A PCB as code, and proving its checks work
This page covers how the main board of auspex, an open 5x12 Hall-effect keyboard, is built as generated code instead of drawn in the KiCad GUI, and how the checks on that code were themselves tested. It is part of the series that starts at the auspex hub; the order that shipped the result is the JLC 4-layer PCBA guide, and the case built from the same geometry module is the build123d case guide.
Every number here is the project’s own result, taken from its repo (working name gridlock until the 2026-11-05 rename) between 2026-11-03 and 2026-11-05, on kicad-cli 10.0.8 and the kicad/kicad:10.0.8 CI image. Rev 1 of the board was ordered on 2026-11-04, so the violations quoted for it are on the board as fabricated. KiCad, Freerouting, JLCPCB and kicad-druid behaviour is cited to their public pages; where the project measured something that disagrees with a public page, the evidence table says so.
TL;DR:
- The analog schematic sheets, PCB placement, the switch plate and the case read one geometry module (
geom.py, which re-exports the per-board numbers invariant.py); the power/USB and MCU sheets readvariant.pydirectly. The generators rewrite the committed files byte for byte, and CI fails when they do not (make repro). Routing is the exception: Freerouting is multithreaded, so the routed board is a committed artefact. - Eight gates check the board, plus a
keyboard.jsonschema check and a strict plate DRC: ERC and DRC through one JSON gate with a counted baseline, a netlist contract, a placement verifier plus an independent audit that does not import it and re-derives each expectation fromgeomand the board’s pads, a CPL rotation check against JLC’s own footprints, a live BOM check against JLC’s parts API, and the regeneration diff. - A custom
.kicad_druturned a reviewer’s “USB pair routed ~5 mm apart” into DRC failures: three gap values each reported twice, 32 mm uncoupled and 2.9 mm skew. On kicad-cli 10.0.8 a rule file with any compile error is dropped whole, with no message and an unchanged exit code, so the gate requires an always-firing sentinel rule to fire exactly once. - Every placement, netlist,
keyboard.json, DRC and BOM gate was shown to fail on a fault injected into a copy; the CPL rotation check has unit tests and one manual mutant, and the regeneration diff has no fault test. The first suite collected 88 tests: 53 fault and control tests and 25 geometry/variant invariants passing, plus 10 strict-xfail rows, each a fault some gate missed. After the fixes it ran 88 passed, 0 xfailed; with the DRC-gate rows added later it runs 107 passed. - Five reviewers and a judge raised 17 findings on top of green gates: 10 confirmed, 2 rejected, 1 partly rejected, the rest accepted or downgraded. One rejected blocker was a reviewer repeating a stale doc claim without reading the file it described.
Topology
Section titled “Topology”The same pipeline as a list:
geom.pyholds key centres, the mux assignment, outline, mount holes, MCU and connector positions.variant.pyholds the per-board numbers for the 60-key main board and a 2x4 test board.gen_schem.pywrites the power/USB and MCU sheets,gen_analog.pythe mux and sensor sheets.kicad-cli sch export netlistproduces the netlist;pcb_from_netlist.pycreates footprints, fields and pad nets from it;place.pypositions every part fromgeom.route.pyexports Specctra DSN, runs Freerouting headless, imports the session, fans out the plane nets, writes zones as file text and letskicad-cli pcb drc --refill-zones --save-boardfill them.- The plate, the case and the firmware’s mux matrix are generated from the same sources.
Which do I pick
Section titled “Which do I pick”| You want to | Do this | Not this |
|---|---|---|
| Move a part | Change geom.py or the placer, re-run make pcb (it strips the routing: reroute and re-run the gates) | Nudge it in the GUI: the next placement run recomputes every position |
| Change the circuit | Edit the generator, run make schem analog, then make repro | Edit a .kicad_sch by hand: make repro fails on the diff |
| Encode an electrical intent DRC does not check by default | A rule in the board’s .kicad_dru, before routing | A note for the reviewer |
| Accept a known violation on a board already ordered | A baseline entry with its exact message, items and count | A GUI exclusion: the gate fails on any exclusion |
| Know a new gate works | A fault test that injects the fault into a copy and asserts the gate names it | A green run on the real board |
| Let an agent edit the board | A coding agent with kicad-cli, editing files and running the gates | A plugin whose tools replace direct file edits (one such plugin lost its author’s own A/B test, see lessons) |
Why generate the board
Section titled “Why generate the board”A hand-drawn KiCad project is a set of files nobody can diff meaningfully and no CI job can rebuild. Generating it gives three properties, each checked by a gate:
- One number lives in one place. The PCB placer, the plate generator and the case CAD import
geom.py, so a key pitch or a mount hole cannot disagree between the board and the case. The placer recomputes every position on each run; parts it does not recognise keep their position and are printed as unplaced, never dropped. - Output is deterministic. UUIDs are derived (uuid5 over the symbol link or reference) instead of random, so regenerating an unchanged sheet is a no-op diff, and
make pcbgives a byte-identical file from fresh and on re-run.make reproregenerates the schematic, analog sheets, plate andkeyboard.jsonfor both board variants and fails if anything underhardware/orfirmware/keyboards/changed. - Nothing needs a GUI. KiCad 10 has no
kicad-cli pcb update-from-schematic(itspcbsubcommands are drc, export, import, render and upgrade), sopcb_from_netlist.pybuilds the board with KiCad’spcbnewPython module. Placement, the routing steps, the verifiers and the JLC export use that module too; netlist export, ERC, DRC, plotting and zone fill arekicad-cli. Both ship in thekicad/kicad:10.0.8image the CI job runs.
The boundary of “reproducible” is routing. Freerouting’s optimiser is multithreaded, so two runs can differ; the routed board is committed and CI checks it with strict DRC instead of regenerating it. make pcb strips tracks, vias and zones, because placement invalidates routing.
The pipeline
Section titled “The pipeline”Freerouting reads a Specctra .dsn and writes a .ses session for re-import1; the project runs the 2.5.0 jar headless (--gui.enabled=false) inside an eclipse-temurin:25-jre container, because the jar needs Java 25. Four steps around it are not the autorouter’s:
| Step | What it does | Why |
|---|---|---|
Pre-route escapes (preroute.py) | Fixed stubs out of pads the router cannot leave: the mux end of the select bus at 0.65 mm pitch, one USB-C pad | The router treats the stubs as fixed and routes only the far end of each net |
Plane-net fan-out (gnd_stitch.py) | Every GND and +3V3A pad gets a planned via; those nets never reach the router | They are poured planes on the 4-layer board, not traces |
USB length match (route.py match_usb) | Meanders the shorter USB leg after routing | Freerouting has no length matching; the pair’s length mismatch ranged from 3.5 to 18 mm. With two reserved keep-out corridors alone, before the meander existed, the test board’s total mismatch was 2.64 mm |
| Zones as file text | (zone ...) s-expressions appended to the board file, filled by kicad-cli | A Python-created pcbnew ZONE segfaulted SaveBoard and the zone filler on 10.0.7 |
The main board is 4 layers: F.Cu signal with a GND fill, In1.Cu a +3V3A plane, In2.Cu a GND plane next to the parts layer, B.Cu parts and routing with a GND fill, plus 60 copper-free windows over the Hall sensors on the top three layers. The 2x4 test board stays 2-layer and doubles as a regression run of the whole pipeline.
The gates and what each catches
Section titled “The gates and what each catches”| Gate | Reads | Fails on | A real catch |
|---|---|---|---|
ERC (make erc) | kicad-cli sch erc --format json --severity-all | Any class outside the allow list, any exclusion, a newly ignored class | Library-availability classes are the only allowance, because the dev box has no global KiCad libraries |
| Placement DRC / routed DRC | kicad-cli pcb drc --format json --severity-all + gate-baseline.json | Any violation outside the baseline and a short allow list (missing-library classes; the USB-C silk past the edge in the routed gate; unrouted and cosmetic classes in the placement-stage gate), a stale baseline entry, a missing sentinel | The USB pair rules below |
Netlist contract (netcheck.py) | The exported netlist and keyboard.json | Any sensor, mux, rail, USB-C, PHY, crystal, reset/boot or SWD net off contract; a key the firmware would read from the wrong mux channel; a keyboard.json pin name that is not the MCU pin the net reaches | A transposed mux matrix, which compiles because the main board’s matrix is square |
Placement verify (verify_placement.py) | Board and plate against geom.py | Switch, sensor, cap, mux, USB-C, hole or outline off its computed position | Peg clearance over every non-switch pad |
Placement audit (place_audit.py) | The board only, re-derived from geom and the pads | The same properties, computed without importing the verifier | Mux orientation read from its own pads |
CPL rotation (jlc_rotation_check.py) | Board + JLC’s footprint per LCSC part (EasyEDA API) | A CPL rotation that does not map the KiCad pads onto JLC’s | SOT-23-5/6 parts 90 deg off under a package-name rotation table |
BOM (bom_check.py) | Netlist + JLC parts API | An LCSC number whose live part has the wrong value, the wrong package size (passives), stock below 5 per placed part, or a part with no LCSC field | A capacitor filed as 30 pF that is 330 pF |
Regeneration (make repro) | git status after regenerating | Any change under hardware/ or firmware/keyboards/ | Reproducibility held at review time but was not enforced; now CI runs it |
ERC and DRC through one JSON gate
Section titled “ERC and DRC through one JSON gate”tools/report_gate.py reads kicad-cli’s JSON report, prints a count per class and exits 1 on:
- a class outside
--allow, except violations the baseline accepts and the sentinel; - a report written without
--severity-all(the JSON’sincluded_severitieslacksexclusion), because excluded violations would be invisible; - any violation marked
excluded, and any DRC or ERC exclusion in the.kicad_pro, matching or not; - a check class set to
ignorein the.kicad_pro(KiCad lists those asignored_checks) that the baseline’s frozen list does not name. The frozen list on rev 1 holds 7 DRC and 4 ERC classes, and anything added to it is a reviewed change; - a sentinel that did not fire exactly once;
- an accepted violation that no longer occurs.
An accepted violation matches on type, the full description (KiCad puts the measured value in it) and every item’s description and position to 4 decimal places, and it is consumed up to its count. One more identical hit, a changed value or a moved item is a new violation. A stale entry fails too, because either the violation was fixed or the rule that raised it left the rule file, which the sentinel cannot see.
The .kicad_pro exclusions are why the gate bans them outright. KiCad stores a DRC exclusion as a serialised marker, type|x_nm|y_nm|main_uuid|aux_uuid, and a run without --severity-exclusions (or --severity-all) does not report it2. An exclusion made in the GUI to quieten one marker therefore hides a real violation from every default report.
Netlist contract
Section titled “Netlist contract”netcheck.py asserts the circuit against the written interface document: every sensor on the mux channel geom gives it with its two capacitors, every mux’s COM, select, rail and unused channels, the MCU pins that carry the select bus and the eight ADC inputs, then power, USB-C, the HS PHY, crystal, reset/boot and SWD as a pin table. It runs 907 checks on the main board as of 2026-11-05 (894 when the board contract landed). It also emulates the firmware’s lookup: libhmk writes transpose(matrix) to C and indexes it [channel][input], so the file must hold [input][channel]. The main board’s 8x8 matrix was stored the other way round until 2026-11-03; a square matrix compiles either way, which is how it survived until the gate read it the way the firmware does.
Placement verify and an independent audit
Section titled “Placement verify and an independent audit”verify_placement.py checks the board and plate against geom.py. place_audit.py checks the same board without importing the verifier: it re-derives every expectation from geom and the board itself (pad nets, pad positions, footprint layers), so a bug shared by the placer and its own verifier still shows up. The audit reads the mux orientation from the pads (pin rows along y, COM on the +x row, select pins on the -x row) instead of trusting the footprint’s rotation field, and it bounds the USB-C front copper to 0-0.5 mm inside the board edge and the connector’s overhang past the edge to 2.1 mm.
CPL rotation against JLC’s own footprints
Section titled “CPL rotation against JLC’s own footprints”The CPL rotation correction depends on how JLC’s library footprint for that exact LCSC part is drawn, not on the KiCad package name. On 2026-11-04 a single “SOT-23 -> 180” rule was right for SOT-23-3 and 90 deg wrong for SOT-23-5/6, and JLC’s placement preview showed the two SOT-23-5/6 parts rotated. The check fetches JLC’s footprint per part, finds the rotation that maps the KiCad pads onto it, and checks the exporter’s correction.
It then checks the final CPL angle against geometry computed separately: the part’s pads as they sit on the board, seen from below for a bottom-side part (the board turned over about its Y axis, so x -> -x). Every JLC-placed part on this board is on B.Cu, so the mirror term dominates. The exporter’s bottom formula is 180 - angle; a refactor to angle + 180 agrees at 0 and 180 deg and rotates the 90/270 deg parts, including all 60 sensors, by 180; run against that mutant, the check flagged 65 placements. The review found the first version checked only the correction table; the mirror check was added the same day.
Live BOM check
Section titled “Live BOM check”bom_check.py resolves every LCSC field against JLC’s parts API for value (digit-bounded, so 30 pF does not match 330 pF), package size for passives, and stock of at least 5 per placed part; a part with no LCSC field fails too, except switches, mounting holes and test pads. It caught C1664, entered as 30 pF and actually 330 pF, on 2026-11-03. Stock perishes, so make order runs it again with the rotation check and routed DRC before writing the upload pack.
What KiCad can test that goes underused
Section titled “What KiCad can test that goes underused”Custom rules for the USB pair
Section titled “Custom rules for the USB pair”KiCad stores custom design rules in a .kicad_dru file beside the .kicad_pcb and .kicad_pro3, and kicad-cli pcb drc loads it too (measured here on 10.0.8). The manual says the file is managed by the Board Setup dialog and should not be edited externally; this project generates and commits it as text instead. The constraints that matter for a USB 2.0 High-Speed pair are diff_pair_gap (min/opt/max on parallel portions), diff_pair_uncoupled (max length routed apart, including fanouts) and skew with within_diff_pairs (length difference tested per pair)3. The board’s rules:
(rule "USB HS pair coupled" (condition "A.inDiffPair('USB_D*')") (constraint diff_pair_gap (min 0.1mm) (opt 0.15mm) (max 0.25mm)) (constraint diff_pair_uncoupled (max 5mm)))
(rule "USB HS pair skew" (condition "A.inDiffPair('USB_D*')") (constraint skew (max 1.0mm) (within_diff_pairs)))Rev 1 was routed by Freerouting, which knows nothing about differential pairs. The review judge sampled USB_D+ every 0.1 mm on the U1->U4 leg: 9.8 mm of it at 5 mm or more from USB_D-, 1.2 mm within 0.5 mm. The rules turn that into DRC output: gap violations at 5.1181, 4.1181 and 0.2626 mm, each reported twice (six in total), 31.99 mm uncoupled against 5 mm, and 2.89 mm skew against 1.0 mm. Those exact violations are in the baseline because the board was already ordered; any other hit fails, and the baseline entry is deleted when rev 2 routes the pair coupled. The rules were written only after a reviewer found the problem; written before routing, they would have failed rev 1’s DRC.
A broken rule file passes silently
Section titled “A broken rule file passes silently”kicad-druid’s README describes the same failure (“no message, empty stderr, exit code 0”) and says an unknown property or a unit-less literal drops only that rule and everything after it, so its sentinel goes last4. On 10.0.8 the project did not see partial dropping: a sentinel placed before the bad rule was lost too. The README also says lowercase 'pad' literals never match; on 10.0.8 A.Type == 'pad' matched. Treat both as version-specific.
The sentinel rule
Section titled “The sentinel rule”The board’s rule file ends with a rule that fires on every run that loaded the file:
(rule "GATE SENTINEL" (condition "A.Type == 'Pad' && A.memberOfFootprint('MH1')") (constraint hole_size (min 100mm)))It asks the NPTH pad of mounting hole MH1 (2.2 mm drill) for a hole of at least 100 mm, so it always produces one drill_out_of_range and never a real finding. The baseline declares it, and report_gate.py requires exactly one violation of that type naming rule GATE SENTINEL: zero means the file was dropped, two means a copy. An excluded sentinel still counts as one, and fails through the exclusion checks instead. It sits last because KiCad evaluates rules in reverse order and stops at the first match3, so a later hole_size rule would override it on MH1.
The sentinel shows the file loaded; whether the USB rules are still in it is checked by the baseline. Deleting the skew rule leaves the sentinel firing; the stale baseline entry for the skew violation is what fails then.
Fab rules borrowed from kicad-druid
Section titled “Fab rules borrowed from kicad-druid”The JLCPCB section of the rule file is copied from kicad-druid v1.2.0 (MIT, pinned to a commit, licence text kept in the file)4: drill and via sizes, annular rings, SMD pad size, through-hole-only vias, slot widths, hole and copper clearances, trace width and spacing, and legend size. One rule was removed: “Pad to Silkscreen” (0.15 mm) hit 199 pads on rev 1, all stock KiCad passive footprints with silk 0.10 mm from the pad. Two kinds of its hits are baselined for rev 2: a GND via 0.397 mm from a switch’s NPTH peg against druid’s 0.45 mm, which the project treats as druid’s inference (JLC publishes via hole-to-hole 0.2 mm and pad hole-to-hole 0.45 mm, and nothing for via to NPTH5); and 13 test-pad labels at 0.8 mm height and 0.12 mm stroke against JLC’s 1.0 mm and 0.15 mm legend minimum5.
The kicad-cli flags the gates use
Section titled “The kicad-cli flags the gates use”| Flag | Does | Used for |
|---|---|---|
--format json | Machine-readable report, with ignored_checks, included_severities and an excluded flag per violation | Every gate; no regex over the text report |
--severity-all | Reports errors, warnings and exclusions2 | Required by the gate, so exclusions are visible |
--exit-code-violations | Exit 5 if any violation exists, 0 otherwise2 | The strict make drc; only its plate half can pass, because on the main board the sentinel and rev 1’s baselined violations make it exit 5 by design, so the board’s gate is drc-routed |
--schematic-parity | Tests the PCB against the schematic2 | Run by hand: on this board it reports metadata-only mismatches (Datasheet, Description, exclude-from-BOM fields the importer does not copy), no net, footprint or value mismatches, so it is not in the gate yet |
--refill-zones --save-board | Fills zones and writes the board | The last step of routing; never on a check |
Proving the gates: fault injection
Section titled “Proving the gates: fault injection”A gate that was never seen failing is unverified. The suite in tools/faults/ copies one real input (netlist, keyboard.json, board) to a temp directory, injects one fault, runs the real gate on the copy and asserts exit 1 plus a message that names the fault. Every gate also has a control: the unmodified copy passes, so a red fault test cannot come from a broken copy. The DRC faults run the real make drc-routed target pointed at the copy, so the allow list stays in one place.
| Fault injected | Gate that must name it |
|---|---|
| Switch shifted 0.2 mm, sensor 0.5 mm off its switch | verify, audit |
| Mux rotated 90 deg | verify, audit, routed DRC (shorting_items) |
| Sensor capacitor moved onto a switch peg | verify, audit, routed DRC (hole_clearance) |
| USB-C 1 mm past the top edge | verify, audit |
| USB-C recessed 3 mm | verify, audit, routed DRC (unconnected_items) |
| USB_D- track segment deleted; one GND fan-out via deleted | routed DRC |
| USB pair gap widened 0.1 mm | routed DRC: a new diff_pair_gap_out_of_range plus a stale baseline entry |
| Skew rule deleted from the rule file | routed DRC: stale baseline entry for the skew violation |
| Rule file with a bad layer name, or no rule file | routed DRC: sentinel fired 0 times |
Sentinel excluded in the .kicad_pro; a class newly set to ignore | routed DRC |
Transposed matrix, wrong ADC or select pin name in keyboard.json | netcheck, gen_keyboard_json |
| Wrong LCSC value, missing LCSC field | BOM check |
Two details from writing the faults. A deleted GND via is not a fault on most pads, because the B.Cu pour also reaches them and KiCad correctly reports them connected; the test picks a capacitor pad outside that pour, whose via is its only ground. And a fault test is only proof once the gate it targets has been neutered in a copy and the test went red: the project did that for the verifier, netcheck and report_gate.py, and again for each check added in the gap fixes.
Faults a gate did not catch went in as strict-xfail rows marked gap, each a recorded gate gap. When a gate learns to catch the fault the row turns XPASS, which fails the run, so the mark cannot outlive the gap. The first suite (2026-11-05) collected 88 tests: 53 fault and control tests and 25 geometry/variant invariants passing, and 10 gap rows: mux rotation, a capacitor on a peg in the verifier, USB-C inset bounds, and keyboard.json pin names that netcheck did not read. The fixes gave the verifier peg clearance over every non-switch pad, a mux rotation check and a USB-C recess bound; gave the audit the mux orientation from its pads, the 0-0.5 mm bound on the USB-C front copper and the 2.1 mm overhang cap; and gave netcheck the pin-name check. The suite then ran 88 passed, 0 xfailed, and 107 passed once the DRC-gate rows (sentinel, baseline, exclusion, ignored class) were added; the BOM rows need the live JLC API and skip without it.
The review: five reviewers and a judge
Section titled “The review: five reviewers and a judge”With the gates green (make check exit 0, verify and audit 444/444; at 2026-11-05 HEAD verify runs 416 board and 15 plate checks and the audit 452), five reviewers worked the repo in parallel: electrical, PCB/fab, firmware, mechanical, tooling/docs. A judge then spot-checked the load-bearing findings against the files. Of 17 findings, 10 were confirmed, 2 rejected, 1 partly rejected, 3 accepted as known or rev 2 items and 1 downgraded.
| Finding | Verdict | What the judge checked |
|---|---|---|
| USB pair routed as two traces, not a coupled pair | Confirmed, severity changed | Sampled the trace; the reviewer called it Full-Speed and tolerable, but the interface is High-Speed |
| CNC case corners collide with the board corners | Confirmed (CNC variant only) | Arithmetic: the R3.0 cavity fillet overlaps a sharp board corner by 0.54 mm |
| Case check passes the CNC case anyway | Confirmed | The reviewer’s boolean intersection: 1.96 mm3 of case inside the PCB with every check passing |
| CI ran only five targets, no tests, verify, audit, routed DRC or regeneration diff | Confirmed | The workflow file |
| Rotation check never tests the bottom-side mirror | Confirmed | The check’s source |
| Mux matrix scrambles about 48 keys | Rejected | The matrix equals the one rebuilt from the mux map; the firmware transposes it on build |
| “Zero silkscreen text” | Partly rejected | 13 test-pad labels exist; name, revision, date and licence are missing |
| Sensor windows cut the inner GND plane | Rejected as a defect | Deliberate, surveyed before layout; USB does not cross a window |
The rejected matrix finding shows a doc claim read as evidence about code. The firmware reviewer read a note in the repo’s agent instructions describing the matrix bug as still open and repeated it as a blocker without checking the file against the mux map. A doc claim was treated as evidence about the code, and the doc was stale. Two other reviewer errors were of the same kind: treating the USB interface as Full-Speed, when High-Speed is why the board uses its MCU, and arguing that sequential sampling lowers the regulator load, when every sensor is powered all the time. Most confirmed rows were fixed the same day, the stale doc line among them; the USB pair became the failing rule above.
The review is also why the gates grew. CI now runs verify, audit, routed DRC, the regeneration diff and the test suite; the USB finding became a rule file; the case check gained a boolean collision against the real board file.
Lessons
Section titled “Lessons”- Write the rule file before routing. Two rules with three constraints describe the USB pair’s electrical intent, and the autorouter that ignored it would have failed DRC on its first run.
- Keep an always-firing sentinel in any
.kicad_druand fail when it is missing. On 10.0.8 a typo disables every custom rule with no output. - Use the JSON report with
--severity-all, freeze the ignored classes, and ban exclusions. A baseline entry carries its measured value and an owner, and fails when the violation changes or goes away. - Give every gate a fault test, and run it once with the gate neutered. The suite found 10 gaps in gates that had been passing on the real board.
- Keep the audit independent of the verifier. Here both read
geom, but the audit imports none of the verifier’s code, so a bug in a verifier helper cannot pass both. - pcbnew’s SWIG bindings are removed in KiCad 11. The SWIG
pcbnewmodule still exists in KiCad 9 and 10 and is removed in KiCad 11, which addskicad-cli api-serverfor headless use of the IPC API6. The project writes zones and the post-route repair as file text and fills zones withkicad-cli, because the SWIG layer failed in four ways: on 10.0.7 a Python-createdZONEsegfaultsSaveBoardand the zone filler, some field and outline calls return an untypedSwigPyObject, and repeatedLoadBoard/SaveBoardin one interpreter corrupts the bindings until the next call fails (so best-of-N routing runs in separate processes);board.Remove()on a via also segfaults (recorded without a version). The rest of the tooling still uses the SWIG module, and that is the part a move to KiCad 11 would touch. - A KiCad MCP server was not needed here. The author of a 109-tool KiCad MCP plugin ran it against a plain coding agent with KiCad installed, same model and tasks, judged by KiCad itself: six single edits three times each, 18 of 18 right without the plugin and 3 of 18 with it. In a 20-edit session both got every edit right and the plugin used about 1.8 times the context. The plugin’s failures were missing tools, an instruction never to edit files directly, and a board-sync tool that rewrote almost every footprint. The author calls these small tests on one design, not a verdict on every board or model, and did not publish the test material; they still put the plugin in maintenance mode and recommend an agent that checks its changes with
kicad-cli7. That is the setup this board was built with, with the conventions kept in a skill file as described in the skills-system page. - Check a doc claim against the file it describes before repeating it. The rejected review blocker came from a stale line in the repo’s own agent instructions.
Decision guide
Section titled “Decision guide”The same guide as a list:
- A number (a position, a size, a pitch): change
geom.py, regenerate, and letmake reproconfirm the committed files match. - An electrical intent the default DRC does not check: a rule in the board’s
.kicad_dru, with the sentinel kept last. - A violation on a board that is already ordered: a baseline entry with its exact message, items and count, owned by the work item that fixes it.
- A new gate: a fault test, a control, and one run with the gate neutered to see the test go red.
- Anything else: fix it in the generator, never in the generated file.
Evidence
Section titled “Evidence”All “project result” rows were read from the project’s repo (private) on 2026-11-05.
| Claim | How it was checked | Status |
|---|---|---|
| Generators reproduce committed files byte for byte; routing excluded | The project’s Makefile: the repro target and its comment on the regenerated paths | project result, enforced in CI |
KiCad 10 has no pcb update-from-schematic | kicad-cli pcb --help on 10.0.8; the netlist importer’s docstring | project result |
| Freerouting 2.5.0 headless, Java 25 | The project’s routing script (jar URL, Java 25 note, --gui.enabled=false); Freerouting CLI documentation for the flag and file types1 | project result + documented |
| Test board USB mismatch 2.64 mm with the keep-out corridors, before the meander | The project’s agent notes, routing workflow; the routing script at the commit that reported 2.64 mm has no meander | project result |
| USB rules produce 3 gap values x2, 31.99 mm uncoupled, 2.89 mm skew on rev 1 | The board’s DRC baseline file, first accepted group | measured on rev 1 |
| Judge sample: 9.8 mm at 5 mm or more apart, 1.2 mm within 0.5 mm | The project’s review report, row 1 | measured by the judge |
Bad .kicad_dru dropped whole with no message on 10.0.8 | The project’s KiCad CI verification notes, section 4.1 (11 cases) | measured; kicad-druid README agrees on silence, disagrees on partial dropping4 |
| Rules evaluated last-first, first match wins | KiCad PCB editor manual, custom rules3 | documented |
Exit 5 on violations; --severity-all includes exclusions | KiCad CLI manual2 | documented |
| Sentinel must fire exactly once; any exclusion fails; frozen ignored classes (7 DRC, 4 ERC) | The report gate’s docstring and checks; the board’s DRC baseline file | project result, fault-tested |
| kicad-druid v1.2.0 JLC rules, Pad to Silkscreen removed (199 pads) | Header of the board’s .kicad_dru | project result |
| JLC via 0.2 mm / pad 0.45 mm hole spacing; legend 1.0 mm / 0.15 mm | JLC capabilities page, re-read 2026-11-055 | documented |
| netcheck 907 checks on main (894 when the board contract landed) | make netcheck on 2026-11-05 HEAD; the review report’s status note and the board-contract commit message for 894 | project result |
SOT-23-5/6 90 deg off under a package rule; an angle + 180 mutant flagged 65 placements, all 60 sensors among them | The rotation check’s docstring; the commit message that added the mirror check | project result |
| C1664 filed as 30 pF, is 330 pF | The BOM check’s docstring | project result |
| 88 collected at first (53 fault/control + 25 geometry/variant passing, 10 strict xfail), then 88 passed 0 xfailed, now 107 passed | pytest --collect-only on the first suite’s commit (63 fault + 25 geometry tests); commit messages of the suite and the gap fixes; make faults on 2026-11-05 HEAD | project result |
| 17 review findings: 10 confirmed, 2 rejected, 1 partly rejected | The project’s review report, verdict table | project result |
SWIG pcbnew removed in KiCad 11 | kicad-python README6 | documented, not tested here |
| pcbnew failures on 10.0.7 | The project’s agent notes, routing workflow (via-removal crash recorded without a version) | observed by the project |
| MCP A/B: 18/18 vs 3/18, about 1.8x context | mcp-server-kicad README, “Why maintenance mode”7 | reported by that author, not re-run |
Related docs
Section titled “Related docs”- The auspex hub - the series start, the design basis and the parts
- Ordering a 4-layer PCBA keyboard board from JLC - where the BOM, CPL and rotation gates pay off
- The keyboard case in build123d - the case that imports the same geometry module and the board file
- Loop or graph: a sensor-gated coding-agent loop - the same idea for code: computational sensors decide when work is done
- Subagents, worktrees, and parallel agent work - how parallel reviewers like the five here are run
References
Section titled “References”-
Freerouting, “Command Line Interface (CLI) Documentation,” GitHub. https://github.com/freerouting/freerouting/blob/HEAD/docs/command_line_arguments.md ↩ ↩2
-
KiCad, “Command-Line Interface: pcb drc,” KiCad 10.0 Documentation. https://docs.kicad.org/10.0/en/cli/cli.html ↩ ↩2 ↩3 ↩4 ↩5
-
KiCad, “PCB Editor: custom design rules,” KiCad 10.0 Documentation. https://docs.kicad.org/10.0/en/pcbnew/pcbnew.html ↩ ↩2 ↩3 ↩4
-
Cimos, “kicad-druid: README, Validation,” GitHub. https://github.com/Cimos/kicad-druid ↩ ↩2 ↩3
-
JLCPCB, “PCB Manufacturing and Assembly Capabilities,” JLCPCB. https://jlcpcb.com/capabilities/pcb-capabilities ↩ ↩2 ↩3
-
KiCad, “kicad-python README,” GitLab. https://gitlab.com/kicad/code/kicad-python ↩ ↩2
-
ProductOfAmerica, “mcp-server-kicad: Why maintenance mode,” GitHub. https://github.com/ProductOfAmerica/mcp-server-kicad#why-maintenance-mode ↩ ↩2