app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
source at 0.1.25: the whole crate workspace, the flow, the skills and the docs
f31cc25
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
#!/usr/bin/env python3
"""adom-boardflow: the board loop as one command, with the clock, the negotiation and the finish line.
Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and this run.
See SKILL.md for the flow. Every state-changing command needs --ai-thread.
"""
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from runlog import Run, now, STAGES # noqa: E402
VERSION = "0.1.0"
def ok(msg, *hints):
print("OK: " + msg)
for h in hints:
if h:
print("Hint: " + h)
def err(msg, *hints, code=2):
print("ERROR: " + msg)
for h in hints:
if h:
print("Hint: " + h)
sys.exit(code)
def sh(args, cwd=None, timeout=7200):
r = subprocess.run([sys.executable] + args if args and args[0].endswith(".py") else args, cwd=cwd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
def need_thread(a):
if not a.ai_thread:
err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", "Re-run with --ai-thread \"<your thread name>\".", code=3)
def load_run(a, must=True):
r = Run(a.run)
if must and not r.exists():
err("no run at %s" % a.run, "Start one: adom-boardflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>")
return r
def spec_of(r):
return json.load(open(r.data["spec"], encoding="utf-8"))
# ---------------------------------------------------------------------------------------------
def cmd_start(a):
need_thread(a)
if not Path(a.board).is_file():
err("board not found: %s" % a.board)
if not Path(a.spec).is_file():
err("spec not found: %s" % a.spec, "docs/spec-example.json on the adom-boardflow page is the shape.")
r = Run(a.run)
if r.exists() and not a.force:
err("a run already exists at %s (engine %s, prompt time %s)" % (a.run, r.data["engine"], r.data["clock"]["promptTime"]), "Pick another --run directory, or --force to start over.")
Path(a.run).mkdir(parents=True, exist_ok=True)
board_copy = Path(a.run) / "board-0.kicad_pcb"
board_copy.write_bytes(Path(a.board).read_bytes())
prompt_time = a.prompt_time or now()
r = Run.create(a.run, board_copy, str(Path(a.spec).resolve()), a.engine, prompt_time, a.ai_thread, a.target, a.remote_board)
ok("run started for engine %s at %s; the clock started at the prompt, %s" % (a.engine, a.run, prompt_time),
"Next: adom-boardflow plan --run %s (the stages this board needs and who can take each)." % a.run,
"Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.")
def cmd_plan(a):
r = load_run(a)
spec = spec_of(r)
sys.path.insert(0, str(HERE))
from escboard import Board
b = Board(r.current_board())
parked = 0
x0, y0, x1, y1 = b.bbox
for ref, fp in b.footprints.items():
if fp["x"] > x1:
parked += 1
fixed = len(spec.get("fixedRefs", []))
stages = {
"place": {"engines": ["ai"], "default": "ai", "why": "%d parts parked outside the outline, %d fixed by the interface; the AI places (kicad_placement_state, adom-boardflow place pack/check/land help), the binary checks" % (parked, fixed)},
"route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says --take route=ai"},
"pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},
"gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},
"land": {"engines": ["binary"], "default": "binary", "why": "replay through the bridge as native undo steps, DRC before each"},
"measure": {"engines": ["binary"], "default": "binary", "why": "kicad_zone_state: filled copper per layer, the ablation metric"},
"analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},
"analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},
"finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},
}
r.data["plan"] = {"at": now(), "stages": stages, "board": {"footprints": len(b.footprints), "nets": len(b.nets), "parked": parked, "layers": b.copper_layers}}
r.save()
lines = ["%-16s %-12s %s" % (n, "/".join(s["engines"]), s["why"]) for n, s in stages.items()]
ok("plan for %s: %d footprints, %d nets, %d copper layers, %d parked\n%s" % (r.data["engine"], len(b.footprints), len(b.nets), len(b.copper_layers), parked, "\n".join(lines)),
"Take a stage yourself with: adom-boardflow take route=ai --run %s (default: binary where it is offered)." % a.run,
"Every stage stamps the clock; for a stage you take yourself call `adom-boardflow stage start <name>` and `stage end <name>` around your own work.")
def cmd_take(a):
need_thread(a)
r = load_run(a)
for kv in a.decision:
stage, engine = kv.split("=", 1)
if stage not in STAGES:
err("unknown stage %s" % stage, "Stages: " + ", ".join(STAGES))
r.data["decisions"][stage] = engine
r.save()
ok("decisions recorded: %s" % json.dumps(r.data["decisions"]), "They are part of the run manifest and the comparison chart.")
def cmd_stage(a):
need_thread(a)
r = load_run(a)
if a.action == "start":
r.stage_start(a.name, engine=r.engine_for(a.name), note=a.note)
ok("stage %s started at %s by %s" % (a.name, now(), r.engine_for(a.name)))
else:
result = json.loads(a.result) if a.result else None
r.stage_end(a.name, result=result, note=a.note)
ok("stage %s ended at %s" % (a.name, now()), "Minutes so far per stage: %s; %.1f min since the prompt." % (json.dumps(r.stage_minutes()), r.elapsed_minutes()))
# -- place -------------------------------------------------------------------------------------
def cmd_place(a):
r = load_run(a)
board = r.current_board()
if a.what == "pack":
need_thread(a)
code, out, e = sh([str(HERE / "pack.py"), board, "--wish", a.wish, "--out", a.out, "--edge", "0.5", "--gap", "0.05"])
if code:
err("pack failed: %s" % (e or out)[-400:])
ok("legal spots found:\n" + out.strip(), "Land them live with `adom-boardflow place land --moves %s` or check offline first with `place check --moves %s`." % (a.out, a.out))
elif a.what == "check":
need_thread(a)
n = len(r.data["boards"])
out_board = str(Path(a.run) / ("board-%d.kicad_pcb" % n))
code, out, e = sh([str(HERE / "movefp.py"), board, "--out", out_board, "--json", a.moves, "--check"])
if code and "check:" not in out:
err("move failed: %s" % (e or out)[-400:])
drc = sh(["service-kicad", "pcb", "drc", "--format", "json", "--out", out_board + ".drc.json", out_board])
try:
v = json.load(open(out_board + ".drc.json")).get("violations", [])
except Exception:
v = []
errs = [x for x in v if x.get("severity") == "error"]
base = int((spec_of(r).get("inheritedErrors") or 0))
types = {}
for x in errs:
types[x["type"]] = types.get(x["type"], 0) + 1
if len(errs) > base:
err("the moved board has %d DRC errors (baseline %d): %s" % (len(errs), base, json.dumps(types)), "Courtyard overlaps or edge violations from the moves; change the wishes and pack again. The board copy stays at %s." % out_board)
r.add_board(out_board)
ok("moves applied offline to %s; DRC %d errors, all inherited (%s)" % (out_board, len(errs), json.dumps(types)), "This is now the run's current board. Land the same moves live with `place land`.")
elif a.what == "land":
need_thread(a)
if not r.data.get("target"):
err("the run has no desktop target", "Start the run with --target <box> --remote-board <path on the box>.")
r.stage_start("place-land")
code, out, e = sh([str(HERE / "land_moves.py"), "--target", r.data["target"], "--board", r.data["remoteBoard"], "--moves", a.moves, "--thread", a.ai_thread, "--log", str(Path(a.run) / "place-land-log.json")])
r.stage_end("place-land", result={"ok": code == 0})
if code:
err("live placement refused: %s" % out[-600:], "The bridge checks courtyards and the outline exactly; read offendingRefs and pack again.")
ok("placement landed live as one undo step:\n" + out.strip())
# -- route -------------------------------------------------------------------------------------
def cmd_route(a):
need_thread(a)
r = load_run(a)
spec = spec_of(r)
board = r.current_board()
if r.engine_for("route") == "ai":
err("route is taken by the AI in this run (take route=ai)", "Write your plan in the plan JSON shape and run `adom-boardflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.")
cfg = Path(a.run) / "router-config.json"
json.dump({"wide": spec.get("wideNets", {}), "mid": spec.get("midNets", {}), "planes": spec.get("planes", {}), "kelvin": spec.get("kelvin", {})}, open(cfg, "w"), indent=1)
plan = str(Path(a.run) / "plan-route.json"); report = str(Path(a.run) / "plan-route-report.json"); log = Path(a.run) / "router.log"
r.stage_start("route", engine="binary", note="passes=%d" % a.passes)
with open(log, "w") as lf:
proc = subprocess.run([sys.executable, str(HERE / "router.py"), board, "--out", plan, "--report", report, "--passes", str(a.passes), "--config", str(cfg)], stdout=lf, stderr=subprocess.STDOUT, text=True)
rep = json.load(open(report)) if Path(report).is_file() else {"unrouted": [("?", ("?", "router did not write a report"))]}
unrouted = rep.get("unrouted", [])
r.stage_end("route", result={"unrouted": len(unrouted), "passes": a.passes, "plan": plan})
r.data["outcomes"]["route"] = {"plan": plan, "unrouted": len(unrouted), "detail": unrouted[:20]}
r.save()
if unrouted:
blockers = sorted({b for _, (_, why) in unrouted for b in why.replace("blocked by ", "").strip("[]").replace("'", "").split(", ") if why.startswith("blocked")})
err("routing closed %d connection(s) short after %d passes: %s" % (len(unrouted), a.passes, "; ".join("%s at %s (%s)" % (n, p, why) for n, (p, why) in unrouted[:6])),
"More passes: --passes %d." % (a.passes + 4),
"Or go back to placement: the blockers are %s; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again." % (blockers[:8] or "listed in the report"),
"Or take the stage yourself: `adom-boardflow take route=ai`.")
ok("routing closed: 0 unrouted after %d passes; plan %s" % (a.passes, plan), "Next: adom-boardflow gate (KiCad DRC offline on the plan).")
# -- pour --------------------------------------------------------------------------------------
def cmd_pour(a):
need_thread(a)
r = load_run(a)
plan = a.plan or r.data.get("outcomes", {}).get("route", {}).get("plan")
out = str(Path(a.run) / "plan-pours.json"); vias = str(Path(a.run) / "plan-vias.json")
r.stage_start("pour", engine="binary")
code, o, e = sh([str(HERE / "pourplan.py"), r.current_board(), "--spec", r.data["spec"], "--out", out, "--vias", vias] + (["--plan", plan] if plan else []))
r.stage_end("pour", result={"ok": code == 0, "pours": out, "vias": vias})
if code:
err("pour plan failed: %s" % (e or o)[-400:], "spec.pours entries need net, layers and one of outline, polygon or around.")
r.data["outcomes"]["pour"] = {"pours": out, "vias": vias}
r.save()
ok("pours planned:\n" + o.strip(), "Gate them live: pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `adom-boardflow land pours` does it.")
# -- gate --------------------------------------------------------------------------------------
def cmd_gate(a):
need_thread(a)
r = load_run(a)
plan = a.plan or r.data.get("outcomes", {}).get("route", {}).get("plan")
if not plan:
err("no routing plan to gate", "Run `adom-boardflow route` or pass --plan yourplan.json.")
routed = str(Path(a.run) / "board-routed.kicad_pcb"); report = str(Path(a.run) / "gate.json")
r.stage_start("gate", engine="binary")
code, o, e = sh([str(HERE / "copper.py"), r.current_board(), plan, "--out", routed, "--drc", "--baseline", r.current_board(), "--report", report])
g = json.load(open(report)) if Path(report).is_file() else {}
r.stage_end("gate", result={k: g.get(k) for k in ("newErrors", "inheritedErrors", "unconnected")})
r.data["outcomes"]["gate"] = {"report": report, "newErrors": g.get("newErrors"), "unconnected": g.get("unconnected"), "routedBoard": routed}
r.save()
if g.get("newErrors") or g.get("unconnected"):
err("gate failed: %s new DRC errors, %s unconnected (inherited %s)" % (g.get("newErrors"), g.get("unconnected"), g.get("inheritedErrors")),
"New errors: %s" % json.dumps(g.get("newErrorTypes")), "Unconnected: %s" % json.dumps((g.get("unconnectedList") or [])[:6]),
"Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.")
ok("gate passed: 0 new DRC errors (%s inherited), 0 unconnected; copper %s" % (g.get("inheritedErrors"), json.dumps(g.get("copper"))), "Next: adom-boardflow land route (then land vias, land pours).")
# -- land --------------------------------------------------------------------------------------
def cmd_land(a):
need_thread(a)
r = load_run(a)
if not r.data.get("target"):
err("the run has no desktop target", "Start the run with --target <box> --remote-board <path on the box>.")
T, B = r.data["target"], r.data["remoteBoard"]
if a.what == "route":
plan = r.data.get("outcomes", {}).get("route", {}).get("plan") or a.plan
if not r.data.get("outcomes", {}).get("gate", {}).get("newErrors") == 0:
err("the gate has not passed for this plan", "Run `adom-boardflow gate` first; the finish line needs it.")
r.stage_start("land-route")
code, o, e = sh([str(HERE / "land_route.py"), "--target", T, "--board", B, "--plan", plan, "--pause", "0.6", "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-route-log.json")])
r.stage_end("land-route", result={"ok": code == 0})
tail = "\n".join(o.strip().splitlines()[-3:])
if code:
err("live routing stopped: %s" % tail, "A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.")
r.data["outcomes"]["land-route"] = tail; r.save()
ok("routing landed live, one undo step per net:\n" + tail)
elif a.what == "vias":
vias = r.data.get("outcomes", {}).get("pour", {}).get("vias")
r.stage_start("land-vias")
code, o, e = sh([str(HERE / "land_route.py"), "--target", T, "--board", B, "--plan", vias, "--pause", "0.6", "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-vias-log.json")])
r.stage_end("land-vias", result={"ok": code == 0})
ok("thermal and stitching vias landed:\n" + "\n".join(o.strip().splitlines()[-2:]))
elif a.what == "pours":
pours = r.data.get("outcomes", {}).get("pour", {}).get("pours")
r.stage_start("land-pours")
code, o, e = sh([str(HERE / "land_pours.py"), "--target", T, "--board", B, "--pours", pours, "--local", r.current_board(), "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-pours-log.json")])
r.stage_end("land-pours", result={"ok": code == 0})
log = Path(a.run) / "land-pours-log.json"
L = json.load(open(log)) if log.is_file() else {}
failed = [c["net"] for c in L.get("calls", []) if c["reply"].get("success") is not True]
r.data["outcomes"]["land-pours"] = {"before": L.get("before"), "after": L.get("after", {}).get("filledAreaByLayerMm2") if isinstance(L.get("after"), dict) else None, "failed": failed}
r.save()
if failed:
first = next(c for c in L["calls"] if c["reply"].get("success") is not True)
v = first["reply"].get("violations") or []
types = {}
for x in v:
types[x.get("type")] = types.get(x.get("type"), 0) + 1
err("pour for %s refused by the preflight: %s" % (failed[0], json.dumps(types)),
"starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.")
ok("pours landed: copper per layer before %s, after %s" % (json.dumps(L.get("before")), json.dumps(r.data["outcomes"]["land-pours"]["after"])), "Next: adom-boardflow measure, then analyze current and analyze thermal, then finish.")
# -- measure / analyze / finish ------------------------------------------------------------------
def zone_state(r, ai_thread, refill=True):
from capture import bridge
return bridge(ai_thread, r.data["target"], "kicad_zone_state", {"filePath": r.data["remoteBoard"], "refill": refill})
def cmd_measure(a):
need_thread(a)
r = load_run(a)
if not r.data.get("target"):
err("measure reads the live board", "Start the run with --target and --remote-board.")
r.stage_start("measure")
zs = zone_state(r, a.ai_thread)
r.stage_end("measure", result={"filledAreaByLayerMm2": zs.get("filledAreaByLayerMm2")})
json.dump(zs, open(Path(a.run) / "zone-state.json", "w"), indent=1)
r.data["outcomes"]["measure"] = {"filledAreaByLayerMm2": zs.get("filledAreaByLayerMm2"), "coveragePercent": zs.get("filledCoverageByLayerPercent"), "zones": zs.get("zoneCount")}
r.save()
ok("copper kept per layer (KiCad's fills): %s; coverage %s" % (json.dumps(zs.get("filledAreaByLayerMm2")), json.dumps(zs.get("filledCoverageByLayerPercent"))), "Next: adom-boardflow analyze current, analyze thermal.")
def cmd_analyze(a):
need_thread(a)
r = load_run(a)
from analyze import analyze_current, analyze_thermal
plan = json.load(open(r.data.get("outcomes", {}).get("route", {}).get("plan") or a.plan))
spec = spec_of(r)
zs_path = Path(a.run) / "zone-state.json"
zs = json.load(open(zs_path)) if zs_path.is_file() else None
name = "analyze-" + a.what
r.stage_start(name)
passed, res = (analyze_current if a.what == "current" else analyze_thermal)(plan, spec, zs)
r.stage_end(name, result={"pass": passed})
r.data["outcomes"][name] = {"pass": passed, "results": res}
r.save()
lines = ["%s %s: %s" % (x.get("net") or x.get("ref"), x["status"], x.get("how") or x.get("hint")) for x in res]
if not passed:
err("%s analysis failed:\n%s" % (a.what, "\n".join(lines)), "Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.")
ok("%s analysis passed:\n%s" % (a.what, "\n".join(lines)), "0.1 judges tracks by IPC-2221 and pours by presence and vias; the 0.2 pass computes cross-sections through the filled copper.")
def cmd_finish(a):
need_thread(a)
r = load_run(a)
o = r.data.get("outcomes", {})
missing = []
if (o.get("gate") or {}).get("newErrors") != 0 or (o.get("gate") or {}).get("unconnected") != 0:
missing.append("gate: 0 new DRC errors and 0 unconnected (run gate)")
if "land-route" not in o:
missing.append("land route")
if not o.get("land-pours") or o["land-pours"].get("failed"):
missing.append("land pours (every planned pour accepted)")
if not o.get("measure"):
missing.append("measure")
for k in ("analyze-current", "analyze-thermal"):
if not (o.get(k) or {}).get("pass"):
missing.append(k + " (pass)")
live = None
if r.data.get("target"):
from capture import bridge
live = bridge(a.ai_thread, r.data["target"], "kicad_routing_validate", {"filePath": r.data["remoteBoard"]})
inherited = int(spec_of(r).get("inheritedErrors") or 0)
if live.get("unconnected") != 0 or (live.get("errors") or 0) > inherited:
missing.append("live validate: %s unconnected, %s errors (inherited %d)" % (live.get("unconnected"), live.get("errors"), inherited))
if missing:
err("not finished: " + "; ".join(missing), "A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.")
r.data["clock"]["finishTime"] = now()
r.data["summary"] = {"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "stageMinutes": r.stage_minutes(), "decisions": r.data["decisions"],
"copper": o.get("measure"), "gate": o.get("gate"), "live": {k: live.get(k) for k in ("errors", "unconnected", "warnings")} if live else None,
"current": (o.get("analyze-current") or {}).get("pass"), "thermal": (o.get("analyze-thermal") or {}).get("pass")}
r.save()
ok("finished: %s from the prompt to a qualified board, %.1f min; stages %s" % (r.data["engine"], r.elapsed_minutes(), json.dumps(r.stage_minutes())),
"Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.",
"Cut the comparison with gang-takes split mode from the captures and markers in run.json.")
# -- capture -----------------------------------------------------------------------------------
def cmd_capture(a):
need_thread(a)
r = load_run(a)
from capture import open_board, record_start, record_stop
if not r.data.get("target"):
err("capture needs a desktop target", "Start the run with --target and --remote-board.")
if a.action == "open":
hwnd, rep = open_board(a.ai_thread, r.data["target"], r.data["remoteBoard"], a.reason or "boardflow run %s on the test box" % r.data["engine"])
if not hwnd:
err("could not open the board: %s" % json.dumps(rep)[:300])
r.data["captures"].append({"opened": now(), "hwnd": hwnd}); r.save()
ok("board open, maximised, zoomed to fit (hwnd %s)" % hwnd, "Next: adom-boardflow capture start.")
elif a.action == "start":
rid, rep = record_start(a.ai_thread, r.data["target"], a.reason or "boardflow run %s" % r.data["engine"])
if not rid:
err("recorder did not start: %s" % json.dumps(rep)[:300])
r.data["captures"].append({"recordingId": rid, "start": now()}); r.mark("capture-start"); r.save()
ok("recording %s started at %s" % (rid, now()), "Every stage command writes markers; `capture mark <label>` adds your own.")
elif a.action == "mark":
r.mark(a.label); r.save()
ok("marker %s at %s" % (a.label, now()))
elif a.action == "stop":
cap = next((c for c in reversed(r.data["captures"]) if c.get("recordingId") and not c.get("file")), None)
if not cap:
err("no running recording in this run")
p, rep, pulled = record_stop(a.ai_thread, r.data["target"], cap["recordingId"], str(Path(a.run).resolve()))
cap["stop"] = now(); cap["remoteFile"] = p; cap["file"] = str(Path(a.run) / Path(p).name) if p else None
r.mark("capture-stop"); r.save()
ok("recording stopped; %s pulled to %s" % (p, cap["file"]), "Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.")
def cmd_status(a):
r = load_run(a)
d = r.data
o = d.get("outcomes", {})
ok("run %s, engine %s, %.1f min since the prompt (%s)\nstages (min): %s\ndecisions: %s\nroute: %s unrouted; gate: %s new / %s unconnected; pours: %s; measure: %s; current: %s; thermal: %s; finished: %s" % (
a.run, d["engine"], r.elapsed_minutes(), d["clock"]["promptTime"], json.dumps(r.stage_minutes()), json.dumps(d["decisions"]),
(o.get("route") or {}).get("unrouted"), (o.get("gate") or {}).get("newErrors"), (o.get("gate") or {}).get("unconnected"),
"landed" if o.get("land-pours") and not o["land-pours"].get("failed") else "no", json.dumps((o.get("measure") or {}).get("filledAreaByLayerMm2")),
(o.get("analyze-current") or {}).get("pass"), (o.get("analyze-thermal") or {}).get("pass"), d["clock"]["finishTime"]))
def main(argv=None):
ap = argparse.ArgumentParser(prog="adom-boardflow", description=__doc__.split("\n\n")[0])
ap.add_argument("--ai-thread", help="the name of the AI conversation calling (required for state-changing commands)")
ap.add_argument("--run", default=os.environ.get("BOARDFLOW_RUN", "./boardflow-run"), help="run directory (default ./boardflow-run or $BOARDFLOW_RUN)")
ap.add_argument("--version", action="version", version="adom-boardflow " + VERSION)
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("start", help="start a run: copies the board, stamps the prompt time"); s.add_argument("--board", required=True); s.add_argument("--spec", required=True); s.add_argument("--engine", required=True); s.add_argument("--prompt-time"); s.add_argument("--target"); s.add_argument("--remote-board"); s.add_argument("--force", action="store_true"); s.set_defaults(f=cmd_start)
s = sub.add_parser("plan", help="the stages this board needs and who can take each"); s.set_defaults(f=cmd_plan)
s = sub.add_parser("take", help="record who takes a stage: route=ai pour=binary"); s.add_argument("decision", nargs="+"); s.set_defaults(f=cmd_take)
s = sub.add_parser("stage", help="stamp a stage you do yourself"); s.add_argument("action", choices=["start", "end"]); s.add_argument("name"); s.add_argument("--note"); s.add_argument("--result"); s.set_defaults(f=cmd_stage)
s = sub.add_parser("place", help="placement helpers: pack, check, land"); s.add_argument("what", choices=["pack", "check", "land"]); s.add_argument("--wish"); s.add_argument("--out", default="moves.json"); s.add_argument("--moves"); s.set_defaults(f=cmd_place)
s = sub.add_parser("route", help="the grid router on the current board"); s.add_argument("--passes", type=int, default=5); s.set_defaults(f=cmd_route)
s = sub.add_parser("pour", help="pours and vias from the spec"); s.add_argument("--plan"); s.set_defaults(f=cmd_pour)
s = sub.add_parser("gate", help="KiCad DRC offline on the routing plan"); s.add_argument("--plan"); s.set_defaults(f=cmd_gate)
s = sub.add_parser("land", help="replay live through the bridge"); s.add_argument("what", choices=["route", "vias", "pours"]); s.add_argument("--plan"); s.set_defaults(f=cmd_land)
s = sub.add_parser("measure", help="filled copper per layer from the live board"); s.set_defaults(f=cmd_measure)
s = sub.add_parser("analyze", help="current or thermal"); s.add_argument("what", choices=["current", "thermal"]); s.add_argument("--plan"); s.set_defaults(f=cmd_analyze)
s = sub.add_parser("finish", help="the finish line; refuses until every check passes"); s.set_defaults(f=cmd_finish)
s = sub.add_parser("capture", help="open, start, mark, stop"); s.add_argument("action", choices=["open", "start", "mark", "stop"]); s.add_argument("label", nargs="?"); s.add_argument("--reason"); s.set_defaults(f=cmd_capture)
s = sub.add_parser("status", help="where the run stands"); s.set_defaults(f=cmd_status)
a = ap.parse_args(argv)
a.f(a)
if __name__ == "__main__":
main()