main
John Lauer source at 0.1.25: the whole crate workspace, the flow, the skills and the docs f31cc25
#!/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()