main
John Lauer Publish 0.1.0 55a9481 2d ago
//! adom-aiflow-jlcpcb: order a finished board from JLCPCB (PCB + assembly) with every step proven and recorded:
//! parts JLCPCB stocks (substitutes proven from both datasheets), the manufacturing package from the desktop's own
//! KiCad, every part's placement rotation checked against JLCPCB's own footprint, the placement preview captured,
//! the quote, the human's OK before any money moves, the order, the delivered boards, and the rotation corrections
//! shared on the parts' wiki pages so the next board gets them right the first time.
mod bridge;
mod csvio;
mod export;
mod flow;
mod kicad;
mod ledger;
mod out;
mod rotation;
mod swap;

use clap::{Parser, Subcommand};
use out::{err, ok};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};

/// The folder this flow keeps inside the project (state.json, run.jsonl, clips/, fab/, preview/, evidence).
const FLOW_DIR: &str = "jlcpcb-flow";

#[derive(Parser)]
#[command(name = "adom-aiflow-jlcpcb", version, about = "Order a finished board from JLCPCB: proven part swaps, the manufacturing package, the rotation check, the placement preview, quote, the human's OK, order, delivery, and shared rotation corrections")]
struct Cli {
    /// The name of the AI conversation calling (required for commands that change state)
    #[arg(long, global = true)]
    ai_thread: Option<String>,
    /// Project folder (default: the nearest one above the current folder that has jlcpcb-flow/)
    #[arg(long, global = true)]
    project: Option<String>,
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Start on a finished board: `init --release <board-release.json>` or `init --board <B.kicad_pcb> --bom <csv>`, with `--target <desktop>` (the box with KiCad and the browser)
    Init { #[arg(long)] release: Option<String>, #[arg(long)] board: Option<String>, #[arg(long)] bom: Option<String>, #[arg(long)] target: Option<String> },
    /// What to do now: the first step whose artifacts are missing, and what the AI must do in it
    Next,
    /// Every step with its state
    Steps,
    /// The run ledger, one line per command
    Ledger,
    /// `clip start --name <step> --hwnd <window>` / `clip stop`: record one desktop window for the video
    Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },
    /// Prove a substitute part from both datasheets: `swap --ref C6 --facts swaps/C6.json [--apply]` (`--apply` rewrites the BOM rows)
    Swap { #[arg(long = "ref")] reference: String, #[arg(long)] facts: String, #[arg(long)] apply: bool },
    /// Close sourcing: every placed part has an LCSC number; `sourcing --why "<what was swapped and why the rest stay>"`
    Sourcing { #[arg(long)] why: String },
    /// Check the board against JLCPCB's design rules (adom/pcb-design-rules, picked by copper layer count) with the desktop's KiCad DRC, on a copy: the design's own rules file is never touched
    Rules { #[arg(long, default_value = "adom/pcb-design-rules")] from: String },
    /// The manufacturing package plus the rotation check: gerber zip, JLCPCB BOM and CPL (corrected), manifest, rotation record
    Export { #[arg(long)] rotations: Option<String>, #[arg(long)] no_rotation_check: bool, /// export although the JLCPCB rules check has not passed (say why in the ledger)
             #[arg(long)] skip_rules: Option<String>, #[arg(long, default_value_t = 0.15)] tol: f64 },
    /// A JLCPCB placement-preview screenshot: `preview --png <file> --refs U1,L1` (pin 1 visible on each named part)
    Preview { #[arg(long)] png: String, #[arg(long)] refs: String, #[arg(long)] note: Option<String> },
    /// The quote JLCPCB shows before checkout: `quote --total 108.38 [--currency USD] [--quote-id <id>] [--note ...]`
    Quote { #[arg(long)] total: f64, #[arg(long, default_value = "USD")] currency: String, #[arg(long)] quote_id: Option<String>, #[arg(long)] note: Option<String> },
    /// The human's OK to pay, in their own words: `approve --by <name> --amount <total> --words "<what they said>"`
    Approve { #[arg(long)] by: String, #[arg(long)] amount: f64, #[arg(long)] words: String },
    /// The order went in: `ordered --order <JLCPCB order number>`
    Ordered { #[arg(long)] order: String },
    /// The boards came back: `delivered --boards 5 --working 5 [--note ...]`
    Delivered { #[arg(long)] boards: u64, #[arg(long)] working: u64, #[arg(long)] note: Option<String> },
    /// Share a part's rotation correction on its wiki component page: `share --ref U1 --page <owner/slug> [--screenshot <png>] [--dry-run]`; `share --done --why "..."` closes the step
    Share { #[arg(long = "ref")] reference: Option<String>, #[arg(long)] page: Option<String>, #[arg(long)] screenshot: Option<String>, #[arg(long)] dry_run: bool, #[arg(long)] done: bool, #[arg(long)] why: Option<String> },
    /// Write jlcpcb-flow/report.md: what was ordered, the evidence, and what the next stage must know
    Report,
}

pub fn sha256_file(p: &Path) -> String {
    std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()
}

fn thread(cli: &Cli) -> String {
    cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[]))
}

fn project(cli: &Cli) -> ledger::Project {
    let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
    ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-jlcpcb init --release <board-release.json> --target <desktop> --ai-thread <you>".into()]))
}

fn read_json(p: &Path) -> Value {
    std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)
}

fn write_json(p: &Path, v: &Value) {
    if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }
    std::fs::write(p, serde_json::to_string_pretty(v).unwrap() + "\n").unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));
}

fn board_of(p: &ledger::Project) -> PathBuf { PathBuf::from(p.state["release"]["board"]["path"].as_str().unwrap_or("")) }
fn bom_of(p: &ledger::Project) -> PathBuf { PathBuf::from(p.state["release"]["bom"]["path"].as_str().unwrap_or("")) }

fn bridge(p: &ledger::Project, th: &str) -> bridge::Bridge {
    let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));
    bridge::Bridge { target, ai_thread: th.to_string() }
}

/// Refuse when the board changed after the flow started: everything downstream would describe a different board.
fn same_board(p: &ledger::Project) {
    let b = board_of(p);
    let want = p.state["release"]["board"]["sha256"].as_str().unwrap_or("");
    if !want.is_empty() && sha256_file(&b) != want {
        err(&format!("{} changed since init (sha256 differs)", b.display()), &["Run init again on the new board (a new release); the export, preview and quote must all describe the board you order.".into()]);
    }
}

/// Parts the placement preview must show with pin 1 visible: every corrected, hand-set or footprint-less part.
fn flagged(p: &ledger::Project) -> Vec<String> {
    let man = latest_manifest(p).map(|m| read_json(&m)).unwrap_or(Value::Null);
    let rec = man["rotationCheck"]["file"].as_str().map(|f| read_json(Path::new(f))).unwrap_or(Value::Null);
    let mut out: Vec<String> = rec["parts"].as_array().into_iter().flatten()
        .filter(|r| r["delta"].as_i64().unwrap_or(0) != 0 || matches!(r["status"].as_str(), Some("manual") | Some("no-jlc-footprint")))
        .filter_map(|r| r["ref"].as_str().map(str::to_string)).collect();
    out.sort_by_key(|r| kicad::ref_key(r));
    out
}

fn latest_manifest(p: &ledger::Project) -> Option<PathBuf> {
    std::fs::read_dir(p.path("fab")).ok()?.flatten().map(|e| e.path()).find(|x| x.to_string_lossy().ends_with("-fab-manifest.json"))
}

fn after_hint(p: &ledger::Project, fl: &flow::Flow) -> String {
    flow::current(fl, &p.flow_dir).map(|s| format!("next step: {}: {}", s.name, s.todo)).unwrap_or("every step has its artifacts: the flow is done".into())
}

fn main() {
    let cli = Cli::parse();
    let fl = flow::load();
    match &cli.cmd {
        Cmd::Init { release, board, bom, target } => {
            let th = thread(&cli);
            let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
            let mut rel: Value = match release {
                Some(r) => serde_json::from_str(&std::fs::read_to_string(r).unwrap_or_else(|e| err(&format!("{r}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{r}: not JSON: {e}"), &[])),
                None => {
                    let (Some(b), Some(m)) = (board, bom) else { err("init needs --release <board-release.json>, or --board and --bom", &["The board flow (adom-aiflow) writes board-release.json when it finishes (schema adom/board-release@1).".into()]) };
                    json!({"schema": "adom/board-release@1", "board": {"path": b, "sha256": ""}, "bom": {"path": m, "sha256": ""}})
                }
            };
            for k in ["board", "bom"] {
                let raw = rel[k]["path"].as_str().unwrap_or("").to_string();
                let path = Path::new(&raw).canonicalize().unwrap_or_else(|_| err(&format!("the release's {k} is not here: {raw}"), &[]));
                let want = rel[k]["sha256"].as_str().unwrap_or("").to_string();
                let have = sha256_file(&path);
                if !want.is_empty() && want != have { err(&format!("{} changed since the release (sha256 differs)", path.display()), &["Finish the board again so the release names the board you mean to order.".into()]); }
                rel[k] = json!({"path": path.display().to_string(), "sha256": have});
            }
            let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "release": rel, "target": target, "started": ledger::now()}))
                .unwrap_or_else(|e| err(&format!("cannot write {FLOW_DIR}/: {e}"), &[]));
            p.log(&th, "init", json!({"release": release, "board": rel["board"], "bom": rel["bom"]}));
            ok(&format!("JLCPCB flow started in {} for {}", p.flow_dir.display(), rel["board"]["path"].as_str().unwrap_or("")), &[after_hint(&p, &fl),
                if target.is_none() { "No --target: the export, rotation check and clips need the desktop with KiCad 10 and pup (init again with --target <box>).".into() } else { String::new() }]);
        }
        Cmd::Next => {
            let p = project(&cli);
            match flow::current(&fl, &p.flow_dir) {
                Some(s) => ok(&format!("step {}", s.name), &[s.todo.clone()]),
                None => ok("every step has its artifacts: the flow is done", &[]),
            }
        }
        Cmd::Steps => {
            let p = project(&cli);
            let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());
            for s in &fl.steps {
                let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x));
                println!("{} {}", if done { "done" } else if Some(&s.name) == cur.as_ref() { "NOW " } else { "    " }, s.name);
            }
        }
        Cmd::Ledger => { let p = project(&cli); print!("{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default()); }
        Cmd::Clip { what, name, hwnd } => {
            let th = thread(&cli);
            let mut p = project(&cli);
            let br = bridge(&p, &th);
            match what.as_str() {
                "start" => {
                    let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &["Find the window with `adom-bridge desktop_list_windows`. Drive the browser with pup (pup_eval, pup_input_dispatch), never the OS mouse, so the clip shows no moving cursor.".into()]) };
                    if p.state["clip"].is_object() { err("a clip is already recording: clip stop first", &[]); }
                    let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));
                    p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()});
                    p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());
                    ok(&format!("recording {n} (window {h})"), &[]);
                }
                "stop" => {
                    let c = p.state["clip"].clone();
                    let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));
                    let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok();
                    let s = br.record_stop(id, &dir);
                    p.state["clip"] = Value::Null; p.save().ok();
                    p.log(&th, "clip-stop", json!({"name": c["name"], "remote": s.remote, "local": s.local, "pulled": s.pulled}));
                    if !s.pulled { err(&format!("the clip did not land locally: {}", s.reply), &["Pull it with pull_file before the desktop cleans its recordings folder.".into()]); }
                    ok(&format!("clip {} saved to {}", c["name"].as_str().unwrap_or(""), s.local.unwrap().display()), &[]);
                }
                _ => err("clip start|stop", &[]),
            }
        }
        Cmd::Swap { reference, facts, apply } => {
            let th = thread(&cli);
            let p = project(&cli);
            let fpath = PathBuf::from(facts);
            let mut f = read_json(&fpath);
            if f.is_null() { err(&format!("{facts}: missing or not JSON"), &["Write the facts first: both datasheets read, every number with its source (see `adom-aiflow-jlcpcb swap --help` and the skill).".into()]); }
            if f.get("ref").is_none() { f["ref"] = json!(reference); }
            let board = board_of(&p);
            let fps = kicad::footprints(&std::fs::read_to_string(&board).unwrap_or_default());
            let mut rows = swap::check(&f, fps.get(reference.as_str()), true);
            for r in rows.iter_mut() {
                if let Some(w) = f["waive"][&r.check].as_str() { if !r.pass { r.info = true; r.detail = format!("{}; WAIVED: {w}", r.detail); } }
            }
            let pass = !rows.is_empty() && rows.iter().all(|r| r.pass || r.info);
            let cand = &f["candidate"];
            print!("{} -> {}  ({})\n{}", f["original"]["mpn"].as_str().unwrap_or(""), cand["mpn"].as_str().unwrap_or(""), cand["lcsc"].as_str().unwrap_or("no LCSC"), swap::print(&rows));
            println!("verdict: {}", if pass { "PASS" } else { "FAIL" });
            let out = fpath.parent().unwrap_or(Path::new(".")).join(format!("{reference}.result.json"));
            let mut res = json!({"ref": reference, "original": f["original"]["mpn"], "candidate": cand["mpn"], "lcsc": cand["lcsc"], "verdict": if pass { "PASS" } else { "FAIL" },
                "checks": rows.iter().map(|r| json!({"check": r.check, "pass": r.pass, "detail": r.detail, "info": r.info})).collect::<Vec<_>>(),
                "checkedAt": ledger::now(), "facts": facts, "notes": f["notes"]});
            if *apply {
                if !pass { write_json(&out, &res); p.log(&th, "swap", json!({"ref": reference, "verdict": "FAIL", "result": out})); err("not applied: the swap failed its checks", &[]); }
                let changed = swap::apply(&bom_of(&p), &f).unwrap_or_else(|e| err(&format!("BOM not rewritten: {e}"), &[]));
                res["bomRows"] = json!(changed);
                println!("BOM rows now {}: {}", cand["mpn"].as_str().unwrap_or(""), changed.join(", "));
            }
            write_json(&out, &res);
            p.log(&th, "swap", json!({"ref": reference, "verdict": res["verdict"], "applied": apply, "result": out}));
            if !pass { err(&format!("swap {reference}: FAIL (see the checks above); evidence {}", out.display()), &["Fix the facts only with what the datasheets say; a deliberate exception goes in \"waive\" with the reason.".into()]); }
            ok(&format!("swap {reference} verified, evidence {}", out.display()), &[if *apply { String::new() } else { "Re-run with --apply to rewrite the BOM rows.".into() }]);
        }
        Cmd::Sourcing { why } => {
            let th = thread(&cli);
            let p = project(&cli);
            let bom = csvio::Table::read(&bom_of(&p)).unwrap_or_else(|e| err(&format!("BOM: {e}"), &[]));
            let fps = kicad::footprints(&std::fs::read_to_string(board_of(&p)).unwrap_or_default());
            let split = regex::Regex::new(r"[ ,;]+").unwrap();
            let re_c = regex::Regex::new(r"^C\d+$").unwrap();
            let mut missing = Vec::new();
            let mut covered = std::collections::BTreeSet::new();
            for row in &bom.rows {
                let fit = bom.get(row, "fit").trim().to_lowercase();
                let l = { let l = bom.get(row, "lcsc").trim(); if l.is_empty() { bom.get(row, "vendor_pn").trim() } else { l } };
                for rf in split.split(bom.get(row, "ref")).filter(|s| !s.is_empty()) {
                    covered.insert(rf.to_string());
                    if !rotation::SKIP_FIT.contains(&fit.as_str()) && !re_c.is_match(l) { missing.push(rf.to_string()); }
                }
            }
            let mut absent: Vec<String> = fps.keys().filter(|r| !covered.contains(*r) && !r.starts_with('#')).cloned().collect();
            absent.sort_by_key(|r| kicad::ref_key(r));
            if !missing.is_empty() || !absent.is_empty() {
                err(&format!("sourcing is not closed: {}{}", if missing.is_empty() { String::new() } else { format!("no LCSC number for {}", missing.join(", ")) },
                    if absent.is_empty() { String::new() } else { format!("{}board references with no BOM row: {}", if missing.is_empty() { "" } else { "; " }, absent.join(", ")) }),
                    &["Search in Hydrogen (`adom-parts-search show \"<spec>\" --basic`), prove each substitute with `swap`, or mark parts fitted after assembly fit=post.".into()]);
            }
            let swaps: Vec<Value> = std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str::<Value>(l).ok())
                .filter(|e| e["ev"] == "swap" && e["data"]["applied"] == true).map(|e| e["data"].clone()).collect();
            write_json(&p.path("sourcing.json"), &json!({"closed": ledger::now(), "bom": bom_of(&p).display().to_string(), "bomSha256": sha256_file(&bom_of(&p)), "why": why, "swapsApplied": swaps}));
            p.log(&th, "sourcing", json!({"why": why, "swaps": swaps.len()}));
            ok(&format!("sourcing closed: every placed part has an LCSC number ({} swaps applied)", swaps.len()), &[after_hint(&p, &fl)]);
        }
        Cmd::Rules { from } => {
            let th = thread(&cli);
            let p = project(&cli);
            same_board(&p);
            let board = board_of(&p);
            let n = export::copper_layers(&std::fs::read_to_string(&board).unwrap_or_default()).max(2);
            let file = format!("kicad/jlcpcb-{n}L.kicad_dru");
            let o = std::process::Command::new("adom-wiki").args(["repo", "show", from, &file]).output().unwrap_or_else(|e| err(&format!("adom-wiki: {e}"), &[]));
            let dru = String::from_utf8_lossy(&o.stdout).to_string();
            if !o.status.success() || !dru.contains("(version") { err(&format!("no {file} on {from}"), &[format!("JLCPCB rules live on the wiki page {from} (kicad/jlcpcb-2L|4L|6L.kicad_dru); a {n}-layer board needs its file there.")]); }
            // the board's own rules first, JLCPCB's limits last (KiCad gives later rules priority), so a design's
            // deliberate overrides stand and only JLCPCB's minimums are judged on top of them
            let own = std::fs::read_to_string(board.with_extension("kicad_dru")).unwrap_or_default();
            let strip = |t: &str| t.lines().filter(|l| !l.trim_start().starts_with("(version")).collect::<Vec<_>>().join("\n");
            let combined = format!("(version 1)\n\n# the board's own rules\n{}\n\n# JLCPCB's limits ({from}/{file}), last so they take priority\n{}\n", strip(&own), strip(&dru));
            let br = bridge(&p, &th);
            let rep = export::drc_with_rules(&br, &board, &combined).unwrap_or_else(|e| err(&format!("rules check: {e}"), &[]));
            let viol: Vec<Value> = rep["violations"].as_array().cloned().unwrap_or_default();
            let errors: Vec<&Value> = viol.iter().filter(|v| v["severity"] == "error").collect();
            let unconnected = rep["unconnected_items"].as_array().map(|a| a.len()).unwrap_or(0);
            let mut kinds: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
            for v in &errors { *kinds.entry(v["type"].as_str().unwrap_or("?").to_string()).or_default() += 1; }
            let pass = errors.is_empty() && unconnected == 0;
            write_json(&p.path("rules.json"), &json!({"pass": pass, "rules": format!("{from}/{file}"), "ownRules": if own.is_empty() { Value::Null } else { json!(board.with_extension("kicad_dru").display().to_string()) }, "rulesSha256": format!("{:x}", Sha256::digest(dru.as_bytes())), "copperLayers": n,
                "errors": errors.len(), "errorKinds": kinds, "warnings": viol.len() - errors.len(), "unconnected": unconnected, "boardSha256": sha256_file(&board), "at": ledger::now(),
                "violations": errors.iter().take(50).map(|v| json!({"type": v["type"], "description": v["description"], "items": v["items"].as_array().map(|a| a.iter().map(|i| i["description"].clone()).collect::<Vec<_>>())})).collect::<Vec<_>>()}));
            p.log(&th, "rules", json!({"pass": pass, "rules": file, "errors": errors.len(), "unconnected": unconnected}));
            if !pass {
                err(&format!("the board breaks JLCPCB's {n}-layer rules: {} errors ({}), {unconnected} unconnected; details in jlcpcb-flow/rules.json", errors.len(), kinds.iter().map(|(k, c)| format!("{k} x{c}")).collect::<Vec<_>>().join(", ")),
                    &["Fix the board in the board flow (adom-aiflow) and finish it again, then init this flow on the new release. A JLCPCB upgrade (smaller drills, tighter spacing) is a cost decision for the human.".into()]);
            }
            ok(&format!("the board meets JLCPCB's {n}-layer rules ({from}/{file}): 0 errors, 0 unconnected, {} warnings", viol.len()), &[after_hint(&p, &fl)]);
        }
        Cmd::Export { rotations, no_rotation_check, tol, skip_rules } => {
            let th = thread(&cli);
            let p = project(&cli);
            same_board(&p);
            let rules = read_json(&p.path("rules.json"));
            let rules_ok = rules["pass"] == true && rules["boardSha256"].as_str() == Some(sha256_file(&board_of(&p)).as_str());
            match (rules_ok, skip_rules) {
                (true, _) => {}
                (false, Some(why)) => p.log(&th, "rules-skipped", json!({"why": why})),
                (false, None) => err("the board has not passed JLCPCB's design rules yet: run `rules`", &["`rules` runs KiCad DRC with JLCPCB's rules from adom/pcb-design-rules on a copy of the board. To export anyway, pass --skip-rules \"<why>\" (it goes in the ledger).".into()]),
            }
            let br = bridge(&p, &th);
            let ov: Map<String, Value> = rotations.as_ref().map(|r| read_json(Path::new(r))).and_then(|v| v.as_object().cloned()).unwrap_or_default()
                .into_iter().filter(|(k, _)| !k.starts_with('_')).collect();
            let out = p.path("fab");
            let pkg = export::export(&br, &board_of(&p), &bom_of(&p), &out, &ov).unwrap_or_else(|e| { p.log(&th, "export-failed", json!({"why": e})); err(&format!("export: {e}"), &[]) });
            println!("OK: {}", pkg.summary);
            let mut hints = vec![];
            if !*no_rotation_check {
                let bom = csvio::Table::read(&bom_of(&p)).unwrap_or_else(|e| err(&format!("BOM: {e}"), &[]));
                let c = rotation::check(&br, &board_of(&p), &bom, &ov, *tol);
                let rec_path = out.join(format!("{}-jlc-rotation.json", pkg.base));
                write_json(&rec_path, &c.record);
                let applied = c.failed.is_empty();
                if applied { rotation::apply(&pkg.cpl, &c.record).unwrap_or_else(|e| err(&format!("CPL not corrected: {e}"), &[])); }
                println!("{}", rotation::summary(&c, applied));
                for (r, w) in &c.failed { println!("  FAIL {r}: {w}"); }
                for (r, w) in &c.warned { println!("  WARN {r}: {w}"); }
                let parts = c.record["parts"].as_array().cloned().unwrap_or_default();
                let mut man = read_json(&pkg.manifest);
                man["rotationCheck"] = json!({"file": rec_path.display().to_string(), "applied": applied, "parts": parts.len(),
                    "corrected": parts.iter().filter(|r| r["delta"].as_i64().unwrap_or(0) != 0).map(|r| (r["ref"].as_str().unwrap_or("").to_string(), json!(rotation::signed(r["delta"].as_i64().unwrap_or(0))))).collect::<Map<_, _>>(),
                    "failed": c.failed.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>(),
                    "noJlcFootprint": parts.iter().filter(|r| matches!(r["status"].as_str(), Some("manual") | Some("no-jlc-footprint"))).filter_map(|r| r["ref"].as_str()).collect::<Vec<_>>()});
                man["cplJlcpcb"]["sha256"] = json!(sha256_file(&pkg.cpl));
                write_json(&pkg.manifest, &man);
                p.log(&th, "export", json!({"manifest": pkg.manifest, "rotationCheck": man["rotationCheck"]}));
                if !applied { err("the JLCPCB rotation check failed (see above); the CPL was NOT corrected", &["A part whose pads fit no rotation of JLCPCB's footprint has the wrong footprint or LCSC number, or is mirrored: fix the board or the BOM, never the CPL by hand.".into()]); }
                let fl_refs = flagged(&p);
                if !fl_refs.is_empty() { hints.push(format!("In JLCPCB's placement preview, look at pin 1 on {} and save a screenshot of each with `preview --png <file> --refs <refs>`.", fl_refs.join(", "))); }
            } else {
                p.log(&th, "export", json!({"manifest": pkg.manifest, "rotationCheck": "skipped"}));
                hints.push("The rotation check was skipped: every part's pin 1 must then be checked by eye in JLCPCB's preview.".into());
            }
            hints.push(after_hint(&p, &fl));
            ok(&format!("JLCPCB package in {}", out.display()), &hints);
        }
        Cmd::Preview { png, refs, note } => {
            let th = thread(&cli);
            let p = project(&cli);
            let src = Path::new(png);
            if !src.is_file() { err(&format!("{png}: no such file"), &[]); }
            let name = format!("{}-{}.png", refs.replace([',', ' '], "-"), &sha256_file(src)[..8]);
            let dest = p.path("preview").join(&name);
            std::fs::create_dir_all(dest.parent().unwrap()).ok();
            std::fs::copy(src, &dest).unwrap_or_else(|e| err(&format!("{e}"), &[]));
            let mut doc = read_json(&p.path("preview.json"));
            if doc.is_null() { doc = json!({"shots": []}); }
            let list: Vec<String> = refs.split([',', ' ']).filter(|s| !s.is_empty()).map(str::to_string).collect();
            doc["shots"].as_array_mut().unwrap().push(json!({"file": format!("preview/{name}"), "refs": list, "note": note, "at": ledger::now()}));
            let seen: std::collections::BTreeSet<String> = doc["shots"].as_array().unwrap().iter().flat_map(|s| s["refs"].as_array().cloned().unwrap_or_default()).filter_map(|r| r.as_str().map(str::to_string)).collect();
            let left: Vec<String> = flagged(&p).into_iter().filter(|r| !seen.contains(r)).collect();
            doc["missing"] = json!(left);
            write_json(&p.path("preview.json"), &doc);
            p.log(&th, "preview", json!({"file": dest, "refs": list}));
            ok(&format!("preview saved for {}", list.join(", ")), &[if left.is_empty() { "Every flagged part has its preview: get the quote next.".into() } else { format!("Still to capture in the preview: {}", left.join(", ")) }]);
        }
        Cmd::Quote { total, currency, quote_id, note } => {
            let th = thread(&cli);
            let p = project(&cli);
            same_board(&p);
            let doc = read_json(&p.path("preview.json"));
            let seen: std::collections::BTreeSet<String> = doc["shots"].as_array().into_iter().flatten().flat_map(|s| s["refs"].as_array().cloned().unwrap_or_default()).filter_map(|r| r.as_str().map(str::to_string)).collect();
            let left: Vec<String> = flagged(&p).into_iter().filter(|r| !seen.contains(r)).collect();
            if !left.is_empty() { err(&format!("no placement-preview screenshot yet for {}", left.join(", ")), &["These parts were corrected or have no JLCPCB footprint: look at pin 1 on each in JLCPCB's preview and save it with `preview` before quoting.".into()]); }
            write_json(&p.path("quote.json"), &json!({"total": total, "currency": currency, "quoteId": quote_id, "note": note, "at": ledger::now(), "boardSha256": sha256_file(&board_of(&p))}));
            p.log(&th, "quote", json!({"total": total, "currency": currency, "quoteId": quote_id}));
            ok(&format!("quote {total:.2} {currency} recorded"), &["Show the human the total and what it buys, and wait for their explicit OK in their own words before anything is paid. Then record it with `approve`.".into()]);
        }
        Cmd::Approve { by, amount, words } => {
            let th = thread(&cli);
            let p = project(&cli);
            let q = read_json(&p.path("quote.json"));
            let total = q["total"].as_f64().unwrap_or_else(|| err("no quote yet: `quote` first", &[]));
            if (total - amount).abs() > 0.005 { err(&format!("the approval is for {amount:.2} but the quote is {total:.2}"), &["Approval covers one amount. Show the human the current total and ask again.".into()]); }
            if words.trim().len() < 2 { err("record the human's own words", &[]); }
            write_json(&p.path("approval.json"), &json!({"by": by, "amount": amount, "currency": q["currency"], "words": words, "aiThread": th, "at": ledger::now()}));
            p.log(&th, "approve", json!({"by": by, "amount": amount, "words": words}));
            ok(&format!("{by} approved {amount:.2} {}", q["currency"].as_str().unwrap_or("")), &["Place the order and pay at JLCPCB (record the checkout clip privately: it shows addresses), then `ordered --order <number>`.".into()]);
        }
        Cmd::Ordered { order } => {
            let th = thread(&cli);
            let p = project(&cli);
            let (q, a) = (read_json(&p.path("quote.json")), read_json(&p.path("approval.json")));
            if a.is_null() { err("no recorded approval: nothing may be paid without the human's OK (`approve`)", &[]); }
            if (q["total"].as_f64().unwrap_or(-1.0) - a["amount"].as_f64().unwrap_or(-2.0)).abs() > 0.005 { err("the approval does not match the quote", &[]); }
            write_json(&p.path("order.json"), &json!({"order": order, "total": q["total"], "currency": q["currency"], "approvedBy": a["by"], "at": ledger::now(), "boardSha256": sha256_file(&board_of(&p))}));
            p.log(&th, "ordered", json!({"order": order}));
            ok(&format!("order {order} recorded"), &["When the boards arrive and have been tried: `delivered --boards N --working N`.".into()]);
        }
        Cmd::Delivered { boards, working, note } => {
            let th = thread(&cli);
            let p = project(&cli);
            if working > boards { err("more working boards than boards", &[]); }
            let order = read_json(&p.path("order.json"));
            write_json(&p.path("delivered.json"), &json!({"boards": boards, "working": working, "note": note, "order": order["order"], "at": ledger::now()}));
            p.log(&th, "delivered", json!({"boards": boards, "working": working}));
            ok(&format!("{working} of {boards} boards work"), &[if working == boards { "Every board works: the rotation corrections are now proven on real boards. Share them (`share`).".into() } else { "Some boards fail: find out why before sharing any rotation correction as proven (a part turned the wrong way shows up here).".into() }]);
        }
        Cmd::Share { reference, page, screenshot, dry_run, done, why } => {
            let th = thread(&cli);
            let p = project(&cli);
            if *done {
                let why = why.clone().unwrap_or_else(|| err("share --done needs --why \"<what was shared, and why the rest was not>\"", &[]));
                write_json(&p.path("shared.json"), &json!({"why": why, "at": ledger::now()}));
                p.log(&th, "share-done", json!({"why": why}));
                return ok("sharing closed", &[after_hint(&p, &fl)]);
            }
            let (Some(rf), Some(pg)) = (reference, page) else { err("share needs --ref <REF> --page <owner/slug> (or --done --why ...)", &["Share only on pages you own or were asked to update; on anyone else's page, open an issue with the record instead.".into()]) };
            let man = latest_manifest(&p).map(|m| read_json(&m)).unwrap_or(Value::Null);
            let rec = man["rotationCheck"]["file"].as_str().map(|f| read_json(Path::new(f))).unwrap_or_else(|| err("no rotation record: run `export` first", &[]));
            let delivered = read_json(&p.path("delivered.json"));
            let boards = delivered["working"].as_u64().filter(|n| *n > 0 && delivered["working"] == delivered["boards"]);
            let order = delivered["order"].as_str().map(str::to_string);
            let ev = rotation::Evidence { screenshot: screenshot.as_ref().map(Path::new), boards, order: order.as_deref() };
            match rotation::publish(&rec, rf, pg, &ev, *dry_run) {
                Ok(msg) => { if !dry_run { p.log(&th, "share", json!({"ref": rf, "page": pg, "screenshot": screenshot, "boards": boards})); } print!("{}", if *dry_run { format!("{msg}") } else { format!("OK: {msg}\n") }); }
                Err(e) => err(&format!("share {rf}: {e}"), &[]),
            }
        }
        Cmd::Report => {
            let th = thread(&cli);
            let p = project(&cli);
            let man = latest_manifest(&p).map(|m| read_json(&m)).unwrap_or(Value::Null);
            let (q, a, o, d) = (read_json(&p.path("quote.json")), read_json(&p.path("approval.json")), read_json(&p.path("order.json")), read_json(&p.path("delivered.json")));
            let src = read_json(&p.path("sourcing.json"));
            let corrected = man["rotationCheck"]["corrected"].as_object().map(|m| m.iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(", ")).unwrap_or_default();
            let text = format!("# JLCPCB order report\n\nBoard: `{}` (sha256 {})\n\n| Step | Result |\n|---|---|\n| Sourcing | {} swaps applied; {} |\n| Package | {} BOM lines, {} placed parts; left out: {} |\n| Rotation check | {} parts; corrected: {}; no JLCPCB footprint: {} |\n| Quote | {} {} |\n| Approval | {} said \"{}\" |\n| Order | {} |\n| Delivered | {} of {} boards work |\n",
                board_of(&p).display(), &sha256_file(&board_of(&p))[..12],
                src["swapsApplied"].as_array().map(|a| a.len()).unwrap_or(0), src["why"].as_str().unwrap_or("not closed"),
                man["bomJlcpcb"]["lines"], man["cplJlcpcb"]["parts"], man["leftOut"]["fitPost"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" ")).unwrap_or_default(),
                man["rotationCheck"]["parts"], if corrected.is_empty() { "none".into() } else { corrected }, man["rotationCheck"]["noJlcFootprint"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" ")).unwrap_or_default(),
                q["total"], q["currency"].as_str().unwrap_or(""), a["by"].as_str().unwrap_or("nobody yet"), a["words"].as_str().unwrap_or(""),
                o["order"].as_str().unwrap_or("not placed"), d["working"], d["boards"]);
            std::fs::write(p.path("report.md"), &text).unwrap_or_else(|e| err(&format!("{e}"), &[]));
            p.log(&th, "report", json!({}));
            print!("{text}");
            ok(&format!("report written to {}", p.path("report.md").display()), &[]);
        }
    }
}