← Commit history

0.2.0: KiCad board from scratch (parts, project page + PCM repo, schematic), step sub-readmes, Antigravity usage, fields EMC beat

John Lauer ·efbfea864f · ·parent 96ef829
9 files changed +841−16
crates/adom-aiflow/src/main.rs+249−15
@@ -121,6 +121,15 @@ enum Cmd {     BindRemoteBoard { #[arg(long)] remote_board: String },     /// Adopt a native-saved board after AI-owned edits, preserving project rules and checking baseline DRC.     AdoptBoard { #[arg(long)] board: String, #[arg(long)] evidence: String },+    /// `parts [--bom <csv>] [--show] [--dwell 4]`: the from-scratch board's parts on the wiki. Every BOM row with an MPN (default: the BOM the sourcing check passed) is matched to its Adom wiki component page; --show walks a Hydrogen tab through each page so the viewer sees the parts being chosen. A part with no page fails: make it (wiki-component skill) and run again.+    Parts { #[arg(long)] bom: Option<String>, #[arg(long)] show: bool, #[arg(long, default_value_t = 4)] dwell: u64 },+    /// `project-page --page <owner/slug> [--create]`: revision 1 of the board's project page before any EDA work: a README that says what the board is with the component table, and a KiCad PCM repository of every part (an Adom Cloud Library, exported and repackaged for KiCad's Plugin and Content Manager). Opens the page in Hydrogen.+    ProjectPage { #[arg(long)] page: String, /// push every visited step's sub-readme and the README's "How this board was built" section now (each step's is also pushed in the background when the run leaves it)+    #[arg(long)] sync: bool, #[arg(long)] create: bool, #[arg(long)] private: bool, #[arg(long)] title: Option<String>, #[arg(long)] brief: Option<String>, #[arg(long)] replace_readme: bool, #[arg(long)] no_push: bool },+    /// `pcm check`: the project page's PCM package is installed in the test box's KiCad (kicad_pcm_list), so the schematic can use every part. Install it first with kicad_pcm_add_repository + kicad_pcm_install (the URLs project-page printed).+    Pcm { #[arg(value_parser = ["check"])] what: String },+    /// `schematic check --sch <file.kicad_sch>`: the from-scratch KiCad schematic, drawn in KiCad: kicad-cli ERC with no errors, every BOM reference on the sheet, every part from the project's PCM library (PCM_<lib>). Records the schematic for the netlist check that follows the board.+    Schematic { #[arg(value_parser = ["check"])] what: String, #[arg(long)] sch: String },     /// Inventory every board reference for the early component quality review; optional MPN marking is an explicit preference.     Components { #[arg(long, value_parser = ["ask", "off", "on"])] etch: Option<String> },     /// Optional Hydrogen progress widget: enable, disable, status, or publish an existing thumbnail.@@ -224,7 +233,7 @@ static DASHBOARD: std::sync::OnceLock<(PathBuf, String)> = std::sync::OnceLock:: fn refresh_dashboard(dir: &Path) {     if let Some(r) = Run::open(dir) {         let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-        let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|v|v.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, sequence::lane(dir, &r.data)));+        let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|v|v.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(dir, &r.data)));         sequence::write_timeline(dir, &sequence::timeline(dir, &r.data, &flow, &seq, &r.step_report()), TIMELINE_PAGE);     } }@@ -588,9 +597,13 @@ fn dt(t: &str) -> String {  /// The engine's plan usage, as the providers report it: percent used per limit window. Claude /// from the Claude Code sign-in (the same call Claude Code's /usage panel makes), Codex from the-/// Codex sign-in (the same call its /status shows). Costs no quota. Returns [{provider, label, pct}].-fn usage_snapshot() -> Vec<Value> {+/// Codex sign-in (the same call its /status shows), Google Antigravity from the running agy hub+/// (`agy`, read by tools/agy-usage.py). Costs no quota. Returns [{provider, label, pct}].+fn usage_snapshot(agy: Option<&Value>) -> Vec<Value> {     let mut out = Vec::new();+    for q in agy.and_then(|a| a["quota"].as_array()).cloned().unwrap_or_default() {+        out.push(json!({"provider": "antigravity", "label": q["label"].clone(), "pct": q["pct"].clone(), "resetsAt": q["resetsAt"].clone()}));+    }     let home = std::env::var("HOME").unwrap_or_default();     let curl = |url: &str, headers: &[String]| -> Option<Value> {         let mut c = std::process::Command::new("curl");@@ -644,37 +657,86 @@ fn usage_snapshot() -> Vec<Value> {     out } -/// Snapshot the plan usage into the ledger and run.json (best effort, never fatal).+/// Google Antigravity's quota and cumulative token counters, read locally (tools/agy-usage.py).+/// None when Antigravity was never used on this machine.+fn agy_usage() -> Option<Value> {+    let home = std::env::var("HOME").unwrap_or_default();+    if !Path::new(&format!("{home}/.gemini/antigravity")).is_dir() { return None; }+    let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/agy-usage.py")).output().ok()?;+    let v: Value = serde_json::from_slice(&o.stdout).ok()?;+    if v["tokens"]["calls"].as_u64().unwrap_or(0) == 0 && v["quota"].as_array().map_or(true, |q| q.is_empty()) { return None; }+    Some(v)+}++/// Snapshot the plan usage (and Antigravity's token counters) into the ledger and run.json at+/// start, every step, finish and deliver (best effort, never fatal). fn record_usage(r: &mut Run, when: &str) -> Vec<Value> {-    let snap = usage_snapshot();-    if !snap.is_empty() {+    let agy = agy_usage();+    let snap = usage_snapshot(agy.as_ref());+    if !snap.is_empty() || agy.is_some() {         r.log("usage", json!({"when": when, "limits": snap}));         if !r.data["usage"].is_array() { r.data["usage"] = json!([]); }-        r.data["usage"].as_array_mut().unwrap().push(json!({"t": now(), "when": when, "limits": snap}));+        let mut e = json!({"t": now(), "when": when, "limits": snap});+        if let Some(a) = &agy { e["agyTokens"] = a["tokens"].clone(); }+        r.data["usage"].as_array_mut().unwrap().push(e);     }     snap } +/// What the run spent on Google Antigravity: model calls and tokens per step (the difference of+/// the cumulative counters between step snapshots; exact when nothing else used Antigravity during+/// the run), the weekly-limit percent it used, and that percent as dollars of the plan price+/// (`AIFLOW_PLAN_USD_MONTH`, default 199.99, Google AI Ultra) at 12/52 months per week.+fn agy_report(r: &Run) -> Option<Value> {+    let snaps: Vec<Value> = r.data["usage"].as_array().cloned().unwrap_or_default().into_iter().filter(|s| s["agyTokens"].is_object()).collect();+    if snaps.len() < 2 { return None; }+    let keys = ["calls", "freshIn", "cachedIn", "out"];+    let diff = |a: &Value, b: &Value| -> Value { let mut m = serde_json::Map::new(); for k in keys { m.insert(k.into(), json!(b[k].as_i64().unwrap_or(0).saturating_sub(a[k].as_i64().unwrap_or(0)).max(0))); } Value::Object(m) };+    let mut steps: Vec<Value> = Vec::new();+    for w in snaps.windows(2) {+        let label = w[0]["when"].as_str().unwrap_or("").trim_start_matches("step:").to_string();+        let mut d = diff(&w[0]["agyTokens"], &w[1]["agyTokens"]);+        if d["calls"].as_i64().unwrap_or(0) == 0 { continue; }+        if let Some(prev) = steps.last_mut().filter(|p| p["step"].as_str() == Some(label.as_str())) {+            for k in keys { prev[k] = json!(prev[k].as_i64().unwrap_or(0) + d[k].as_i64().unwrap_or(0)); }+        } else { d["step"] = json!(label); steps.push(d); }+    }+    let total = diff(&snaps[0]["agyTokens"], &snaps[snaps.len() - 1]["agyTokens"]);+    let week = usage_delta(r).into_iter().find(|x| x["provider"] == "antigravity" && x["label"] == "week").and_then(|x| x["usedPct"].as_f64());+    let month: f64 = std::env::var("AIFLOW_PLAN_USD_MONTH").ok().and_then(|v| v.parse().ok()).unwrap_or(199.99);+    let usd = week.map(|p| (p / 100.0 * month * 12.0 / 52.0 * 100.0).round() / 100.0);+    Some(json!({"total": total, "steps": steps, "weekPct": week, "planUsdMonth": month, "usd": usd}))+}+ /// First and last usage per provider and label: what the run cost in plan percent. fn usage_delta(r: &Run) -> Vec<Value> {     let snaps = r.data["usage"].as_array().cloned().unwrap_or_default();     let mut first: std::collections::BTreeMap<String, (String, String, f64)> = Default::default();     let mut last: std::collections::BTreeMap<String, f64> = Default::default();+    let mut used: std::collections::BTreeMap<String, f64> = Default::default();     for s in &snaps {         for l in s["limits"].as_array().cloned().unwrap_or_default() {             let key = format!("{}:{}", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""));             let pct = l["pct"].as_f64().unwrap_or(0.0);             first.entry(key.clone()).or_insert((l["provider"].as_str().unwrap_or("").to_string(), l["label"].as_str().unwrap_or("").to_string(), pct));+            // a rise is this run's use; a drop is the window resetting, after which the new value is all new use+            if let Some(prev) = last.get(&key).copied() { *used.entry(key.clone()).or_default() += if pct >= prev { pct - prev } else { pct }; }             last.insert(key, pct);         }     }-    first.iter().map(|(k, (p, l, f))| json!({"provider": p, "label": l, "fromPct": f, "toPct": last[k], "usedPct": ((last[k] - f) * 10.0).round() / 10.0})).collect()+    first.iter().map(|(k, (p, l, f))| json!({"provider": p, "label": l, "fromPct": f, "toPct": last[k], "usedPct": (used.get(k).copied().unwrap_or(0.0) * 100.0).round() / 100.0})).collect() }  fn usage_line(r: &Run) -> String {     let d = usage_delta(r);     if d.is_empty() { return "plan usage: no sign-in found to read".into(); }-    format!("plan usage: {}", d.iter().map(|x| format!("{} {} {:.0}% -> {:.0}%", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", "))+    let mut line = format!("plan usage: {}", d.iter().map(|x| format!("{} {} {:.2}% used", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["usedPct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", "));+    if let Some(a) = agy_report(r) {+        let t = &a["total"];+        line.push_str(&format!("; antigravity {} calls, {} fresh + {} cached input, {} output tokens", t["calls"], t["freshIn"], t["cachedIn"], t["out"]));+        if let Some(u) = a["usd"].as_f64() { line.push_str(&format!(", about ${u:.2} of the plan")); }+    }+    line }  @@ -1804,9 +1866,20 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {     let ud = usage_delta(r);     if !ud.is_empty() {         md.push_str("## Plan usage, the second cost line\n\nPercent of each plan limit used, as the provider reports it, at the start of the run and at its end.\n\n| Provider | Limit | At start | At end | Used by this run |\n|---|---|---|---|---|\n");-        for x in &ud { md.push_str(&format!("| {} | {} | {:.0} % | {:.0} % | {:.1} % |\n", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0), x["usedPct"].as_f64().unwrap_or(0.0))); }+        for x in &ud { md.push_str(&format!("| {} | {} | {:.2} % | {:.2} % | {:.2} % |\n", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0), x["usedPct"].as_f64().unwrap_or(0.0))); }         md.push_str("\n*The limits are per account, not per run: every AI thread signed in as this user draws on the same numbers, so a run made while other threads were working shows their usage too. Read it as an upper bound unless this run was the only thread active.*\n\n");     }+    if let Some(a) = agy_report(r) {+        let n = |v: &Value| v.as_i64().unwrap_or(0);+        let t = &a["total"];+        md.push_str(&format!("## Google Antigravity: tokens, quota and cost\n\n{} model calls: {} fresh input, {} cached input and {} output tokens.", n(&t["calls"]), n(&t["freshIn"]), n(&t["cachedIn"]), n(&t["out"])));+        if let (Some(w), Some(u)) = (a["weekPct"].as_f64(), a["usd"].as_f64()) {+            md.push_str(&format!(" The run used **{w:.2} % of the weekly limit**, about **${u:.2}** of a ${:.2}/month plan.", a["planUsdMonth"].as_f64().unwrap_or(199.99)));+        }+        md.push_str("\n\n| Step | Calls | Fresh input | Cached input | Output |\n|---|---|---|---|---|\n");+        for st in a["steps"].as_array().cloned().unwrap_or_default() { md.push_str(&format!("| {} | {} | {} | {} | {} |\n", st["step"].as_str().unwrap_or(""), n(&st["calls"]), n(&st["freshIn"]), n(&st["cachedIn"]), n(&st["out"]))); }+        md.push_str("\n*Token counts are the difference of Antigravity's own per-call records (its conversation files) between step snapshots; exact when nothing else used Antigravity during the run.*\n\n");+    }     let gb = r.data["giveback"].as_array().cloned().unwrap_or_default();     md.push_str(&format!("## Given back\n\n{} issue(s) or PR(s) filed by this run's AI to make the tool better for every Adom user{}\n", gb.len(), if gb.is_empty() { ".".to_string() } else { ":\n\n".to_string() + &gb.iter().map(|g| format!("- {} {}{}", g["kind"].as_str().unwrap_or("issue"), g["url"].as_str().unwrap_or(""), g["note"].as_str().map(|n| format!(": {n}")).unwrap_or_default())).collect::<Vec<_>>().join("\n") }));     md.push_str("\nAdom processes PRs within minutes during business hours: a non-stop PR intake runs all day.\n");@@ -2091,7 +2164,7 @@ fn project_dir_of(r: &Run) -> PathBuf { /// The run's required steps: its recorded sequence, else the lane's sequence from flows/board.json. fn run_sequence(r: &Run, flow: &Value) -> Vec<String> {     let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_default();-    if seq.is_empty() { sequence::sequence_of(flow, sequence::lane(&r.dir, &r.data)) } else { seq }+    if seq.is_empty() { sequence::sequence_of(flow, &sequence::sequence_key(&r.dir, &r.data)) } else { seq } }  /// The over-budget nag for the current step (warn only, never a block). Held while an `exec` of this@@ -2119,7 +2192,7 @@ fn print_next(cli: &Cli) {  fn continuation_report(r: &Run, evidence: Option<&Path>) -> Value {     let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-    let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, sequence::lane(&r.dir, &r.data)));+    let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&r.dir, &r.data)));     let states = sequence::states(&r.dir, &r.data, &flow, &seq);     let pending: Vec<String> = states.iter().filter(|s| {         let failed = flow["proofs"][s.name.as_str()].as_array().is_some_and(|keys| keys.iter().filter_map(Value::as_str).any(|key| continuation::failed_proof(&r.data["outcomes"][key]) || (!r.data["outcomeAt"][key].is_null() && r.data["outcomes"][key].is_null())));@@ -2602,6 +2675,130 @@ fn main() {             r.save().unwrap();             ok("native-saved board adopted; historical snapshots preserved", &["Run plan, then route: it keeps the board's own tracks and vias and plans only the connections they leave open (a fully routed board gets an empty plan that gate, land route and finish accept). Re-solve Fields before finish.".into()]);         }+        Cmd::Parts { bom, show, dwell } => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            let bom = bom.clone().or_else(|| r.data["outcomes"]["sourcing"]["bom"].as_str().map(str::to_string))+                .unwrap_or_else(|| err("no BOM: pass --bom <csv> or pass `sourcing check --bom <csv>` first", &[]));+            let text = std::fs::read_to_string(&bom).unwrap_or_else(|e| err(&format!("{bom}: {e}"), &[]));+            let mut rows = preboard::bom_parts(&text);+            if rows.is_empty() { err(&format!("{bom}: no rows with an MPN"), &[]); }+            let mut missing = Vec::new();+            for row in rows.iter_mut() {+                let mpn = row["mpn"].as_str().unwrap_or("").to_string();+                let o = std::process::Command::new("adom-wiki").args(["search", &mpn, "--json"]).stdin(std::process::Stdio::null()).output().ok();+                let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+                let hits = v["data"]["results"].as_array().or(v["data"].as_array()).or(v["results"].as_array()).cloned().unwrap_or_default();+                let page = hits.iter().find(|h| h["type"] == "hardware" && matches!(h["match"]["tier"].as_str(), Some("exact_slug") | Some("exact_title")))+                    .and_then(|h| h["ref"].as_str().map(str::to_string).or_else(|| Some(format!("{}/{}", h["owner"].as_str()?, h["slug"].as_str()?))));+                if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); }+                row["wiki"] = json!(page);+            }+            let pass = missing.is_empty();+            r.outcome("parts", json!({"pass": pass, "bom": std::fs::canonicalize(&bom).map(|p| p.display().to_string()).unwrap_or(bom.clone()), "parts": rows, "missing": missing}));+            r.save().unwrap();+            if *show {+                // one named tab walks through the pages (never a tab per part)+                let name = format!("Parts · {}", &r.data["id"].as_str().unwrap_or("run")[..8.min(r.data["id"].as_str().unwrap_or("run").len())]);+                for row in rows.iter().filter(|x| x["wiki"].is_string()) {+                    let url = format!("https://wiki.adom.inc/{}", row["wiki"].as_str().unwrap());+                    let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url, "--ai-thread", &t, "--reason", "adom-aiflow: show the chosen parts"]).stdin(std::process::Stdio::null()).output();+                    std::thread::sleep(std::time::Duration::from_secs(*dwell));+                }+            }+            let list: Vec<String> = rows.iter().map(|x| format!("  {:<22} {:<10} {}", x["mpn"].as_str().unwrap_or(""), x["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default(), x["wiki"].as_str().unwrap_or("MISSING"))).collect();+            if !pass {+                err(&format!("{} of {} parts have no Adom wiki component page:\n{}", missing.len(), rows.len(), list.join("\n")), &["Make each missing part's page (the wiki-component skill: symbol, footprint and STEP from the datasheet), then run `parts` again. The project page and the KiCad library are built from these pages.".into()]);+            }+            ok(&format!("every part is on the wiki ({} parts):\n{}", rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]);+        }+        Cmd::ProjectPage { page, sync, create, private, title, brief, replace_readme, no_push } => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            if *sync {+                let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-steps.py")).arg(&dir).arg(page).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));+                let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(json!({"ok": false, "error": String::from_utf8_lossy(&o.stderr).to_string()}));+                if v["ok"] != true { err(&format!("step pages: {}", v["error"].as_str().unwrap_or("failed")), &[]); }+                r.data["projectPage"] = json!(page); r.save().unwrap();+                ok(&format!("{} step page(s) and the README's build section pushed to {page} (commit {})", v["steps"].as_array().map(|a| a.len()).unwrap_or(0), v["commit"].as_str().unwrap_or("?")), &[]);+                return;+            }+            let parts = r.data["outcomes"]["parts"].clone();+            if parts["pass"] != true { err("the parts are not all on the wiki yet: run `parts` until it passes", &[]); }+            if !page.contains('/') { err("--page is <owner>/<slug>", &[]); }+            let (spec, req) = (spec_soft(&r), requirements_of(&r));+            let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::title(&r.data));+            let brief = brief.clone().or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());+            let cfg = json!({"run": dir.display().to_string(), "page": page, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "private": private, "replaceReadme": replace_readme, "version": "1.0.0"});+            let cfgp = dir.join("project-page").join("config.json");+            let _ = std::fs::create_dir_all(cfgp.parent().unwrap());+            std::fs::write(&cfgp, serde_json::to_string_pretty(&cfg).unwrap()).unwrap();+            let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-page.py")).arg(&cfgp).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));+            let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| json!({"ok": false, "error": String::from_utf8_lossy(&o.stderr).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>()}));+            if v["ok"] != true { err(&format!("project page: {}", v["error"].as_str().unwrap_or("failed")), &[]); }+            r.outcome("project-page", v.clone());+            r.data["projectPage"] = json!(page);+            r.save().unwrap();+            if !no_push {+                let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "Project page", "--url", v["url"].as_str().unwrap_or(""), "--ai-thread", &t, "--reason", "adom-aiflow: the board's project page, revision 1"]).stdin(std::process::Stdio::null()).output();+            }+            ok(&format!("project page {} ({}): README revision 1 with {} parts, KiCad PCM repository {}", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"], v["repositoryUrl"].as_str().unwrap_or("")),+               &[format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]);+        }+        Cmd::Pcm { what: _ } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let pp = r.data["outcomes"]["project-page"].clone();+            let Some(id) = pp["identifier"].as_str().map(str::to_string) else { err("no project page yet: `project-page --page <owner/slug> --create` first", &[]) };+            let br = bridge_of(&r, &cli);+            let list = br.raw_call("kicad_pcm_list", &json!({}));+            let text = list.to_string();+            let installed = text.contains(&id);+            r.outcome("library", json!({"pass": installed, "identifier": id, "libName": pp["libName"], "repositoryUrl": pp["repositoryUrl"], "pcmList": list}));+            r.save().unwrap();+            if !installed { err(&format!("{id} is not installed in the test box's KiCad"), &[format!("kicad_pcm_add_repository {{\"url\":\"{}\"}}, then kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{id}\"}}, restart KiCad, `pcm check` again.", pp["repositoryUrl"].as_str().unwrap_or(""), pp["repositoryUrl"].as_str().unwrap_or(""))]); }+            ok(&format!("{id} is installed in KiCad: the schematic uses PCM_{}:<part>", pp["libName"].as_str().unwrap_or("")), &["Next: `step schematic`: draw it in KiCad's schematic editor (kicad_sch_place_symbol, kicad_sch_wire, kicad_sch_label), ERC clean, every BOM reference on the sheet.".into()]);+        }+        Cmd::Schematic { what: _, sch } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if !Path::new(sch).is_file() { err(&format!("no such schematic: {sch}"), &[]); }+            let kc = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+            let sdir = dir.join("schematic"); let _ = std::fs::create_dir_all(&sdir);+            let erc = sdir.join("erc.json");+            let _ = std::process::Command::new(&kc).args(["sch", "erc", "--format", "json", "--severity-error", "--output"]).arg(&erc).arg(sch).stdin(std::process::Stdio::null()).output();+            let ercv: Value = std::fs::read_to_string(&erc).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+            let erc_errors: Vec<String> = ercv["sheets"].as_array().cloned().unwrap_or_default().iter().flat_map(|sh| sh["violations"].as_array().cloned().unwrap_or_default()).filter(|v| v["severity"] == "error").map(|v| v["description"].as_str().unwrap_or("").to_string()).collect();+            let net = sdir.join("netlist.net");+            export_sch_netlist(sch, &net).unwrap_or_else(|e| err(&e, &[]));+            let root = aiflow_board::sx::parse(&std::fs::read_to_string(&net).unwrap_or_default()).unwrap_or_else(|e| err(&format!("netlist: {e}"), &[]));+            let mut on_sheet: BTreeMap<String, (String, String)> = BTreeMap::new();+            for comp in root.find_all("comp") {+                let Some(rf) = comp.find("ref").and_then(|n| n.atom(1)) else { continue };+                let lib = comp.find("libsource").and_then(|l| l.find("lib")).and_then(|n| n.atom(1)).unwrap_or("").to_string();+                let fp = comp.find("footprint").and_then(|n| n.atom(1)).unwrap_or("").to_string();+                on_sheet.insert(rf.to_string(), (lib, fp));+            }+            let lib = r.data["outcomes"]["project-page"]["libName"].as_str().map(|l| format!("PCM_{l}"));+            let bom_refs: Vec<String> = r.data["outcomes"]["parts"]["parts"].as_array().cloned().unwrap_or_default().iter().flat_map(|p| p["refs"].as_array().cloned().unwrap_or_default()).filter_map(|v| v.as_str().map(str::to_string)).collect();+            let missing: Vec<String> = bom_refs.iter().filter(|rf| !on_sheet.contains_key(*rf)).cloned().collect();+            let foreign: Vec<String> = match &lib { Some(l) => on_sheet.iter().filter(|(rf, (sl, fp))| bom_refs.contains(rf) && (sl != l || !fp.starts_with(&format!("{l}:")))).map(|(rf, (sl, fp))| format!("{rf} ({sl} / {fp})")).collect(), None => Vec::new() };+            let extra: Vec<String> = on_sheet.keys().filter(|rf| !bom_refs.contains(rf)).cloned().collect();+            let pass = erc_errors.is_empty() && missing.is_empty() && foreign.is_empty();+            let canon = std::fs::canonicalize(sch).map(|p| p.display().to_string()).unwrap_or(sch.clone());+            r.outcome("schematic", json!({"pass": pass, "schematic": canon, "sha256": sha256_file(sch), "ercErrors": erc_errors, "symbols": on_sheet.len(), "missingRefs": missing, "notFromProjectLibrary": foreign, "notInBom": extra, "library": lib}));+            if pass { r.data["schematic"] = json!(canon); }+            r.save().unwrap();+            if !pass {+                let mut why = Vec::new();+                if !erc_errors.is_empty() { why.push(format!("ERC: {} error(s): {}", erc_errors.len(), erc_errors.iter().take(6).cloned().collect::<Vec<_>>().join("; "))); }+                if !missing.is_empty() { why.push(format!("not on the sheet: {}", missing.join(", "))); }+                if !foreign.is_empty() { why.push(format!("not from the project library {}: {}", lib.clone().unwrap_or_default(), foreign.join(", "))); }+                err(&format!("schematic check failed:\n  {}", why.join("\n  ")), &["Fix it in KiCad's schematic editor on camera (kicad_sch_place_symbol / kicad_sch_wire / kicad_sch_label / kicad_sch_set_property), save, and check again.".into()]);+            }+            ok(&format!("schematic {canon}: {} symbols, ERC clean, every BOM reference placed from {}{}", on_sheet.len(), lib.unwrap_or_else(|| "the project library".into()), if extra.is_empty() { String::new() } else { format!(" ({} not in the BOM: {})", extra.len(), extra.join(", ")) }),+               &["Next: make the board from it (KiCad: Update PCB from Schematic), draw the outline, save, bind it (`bind-remote-board` / `start --board`), then `step components`.".into()]);+        }         Cmd::Components { etch } => {             thread(&cli);             let mut r = load_run(&cli);@@ -3921,10 +4118,10 @@ fn main() {             let mut r = load_run(&cli);             let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();             if r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {-                let want = json!(sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));+                let want = json!(sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));                 if r.data["sequence"] != want { r.data["sequence"] = want; r.save().unwrap(); }             }-            let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));+            let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));             let tl = sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report());             sequence::write_timeline(&dir, &tl, TIMELINE_PAGE);             let tdir = dir.join("timeline");@@ -4402,6 +4599,15 @@ fn main() {                     // this clip is played back at ten times speed                     let tour = std::process::Command::new("adom-fields").args(["tour", "--port", &port.to_string()]).output();                     let tour_ok = tour.as_ref().map(|o| o.status.success()).unwrap_or(false);+                    // the EMC beat after the app's own walkthrough: the hot loop, the return path under it and+                    // the radiated estimate against FCC Part 15 class B, held long enough to read+                    let ps = port.to_string();+                    let emc_ok = std::process::Command::new("adom-fields").args(["ui", "--port", &ps, "view", "--quantity", "emc", "--camera", "iso"]).output().map(|o| o.status.success()).unwrap_or(false);+                    if emc_ok {+                        let _ = std::process::Command::new("adom-fields").args(["ui", "--port", &ps, "toast", "EMC: the switching loop, the ground under its return path, and the radiated estimate against FCC Part 15 class B"]).output();+                        std::thread::sleep(std::time::Duration::from_secs(20));+                        r.log("fields-emc", json!({"step": step, "visit": visit}));+                    }                     std::thread::sleep(std::time::Duration::from_secs(2));                     if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-end")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-end", "file": pth})); }                     let caps = r.data["captures"].as_array().cloned().unwrap_or_default();@@ -4469,6 +4675,19 @@ fn main() {             let known: Vec<&str> = known.iter().map(String::as_str).collect();             let mut clip_stopped = false;             let prev = r.data["currentStep"].as_str().map(str::to_string);+            // the board's project page gets the step just left as its own sub-readme (evidence, screenshots,+            // usage) and a line in the README's build section, in the background so the step is not slowed+            if let (Some(pg), Some(pv)) = (r.data["projectPage"].as_str(), prev.as_deref()) {+                if pv != name.as_str() {+                    let helper = dir.join(".aiflow-project-steps.py");+                    let _ = std::fs::write(&helper, include_str!("../../../tools/project-steps.py"));+                    let log = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("project-steps.log")).ok();+                    let mut c = std::process::Command::new("python3");+                    c.arg(&helper).arg(&dir).arg(pg).arg(pv).stdin(std::process::Stdio::null());+                    if let Some(l) = log { if let Ok(l2) = l.try_clone() { c.stdout(l).stderr(l2); } }+                    let _ = c.spawn();+                }+            }             // a Fusion project simulates the drawn schematic, in front of the human, before the board: placement             // waits until a simulation visit has started after the last schematic visit             if name == "placement" && !*back && aiflow_bridge::eda::project_backend(&dir).is_some() {@@ -4487,7 +4706,7 @@ fn main() {             // the lane can still change while only intake is done (the run learns it is a Fusion board from the             // brief or the requirements written during intake)             if r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {-                let want = json!(sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));+                let want = json!(sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));                 if r.data["sequence"] != want { r.data["sequence"] = want; }             }             let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();@@ -4564,6 +4783,20 @@ fn main() {                 if let Some(h) = cam.hydrogen_for_step(name) {                     let w = if h { br.hydrogen_hwnd() } else { cam.main_window(&tx) };                     br.bring_front(w, if h { "Hydrogen" } else { cam.name() });+                } else if cam.id() == "kicad" && r.data["sequence"].as_array().map_or(false, |s| s.iter().any(|x| x == "project-page")) {+                    // a KiCad board from scratch: Hydrogen is the stage for the steps without KiCad (the parts,+                    // the project page, the analysis apps) and KiCad for the rest; at every new phase the step+                    // dashboard comes up first, so the viewer sees where the run is+                    const HYDROGEN: [&str; 11] = ["intake", "rules", "design", "test-plan", "sourcing", "parts", "project-page", "simulation", "current", "thermal", "fields"];+                    let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+                    let phase_of = |st: &str| flow_full["phases"].as_object().and_then(|ph| ph.iter().find(|(_, v)| v.as_array().map_or(false, |a| a.iter().any(|x| x == st))).map(|(k, _)| k.clone()));+                    let new_phase = prev.as_deref().map_or(true, |p| phase_of(p) != phase_of(name));+                    if new_phase || HYDROGEN.contains(&name.as_str()) {+                        br.bring_front(br.hydrogen_hwnd(), "Hydrogen");+                        if new_phase { ensure_dashboard(&dir, &thread(&cli), true); std::thread::sleep(std::time::Duration::from_secs(6)); }+                    }+                    // the board steps: KiCad's PCB editor in front (the schematic and library steps leave KiCad's own window as the AI opened it)+                    if !HYDROGEN.contains(&name.as_str()) && !matches!(name.as_str(), "library" | "schematic") { br.bring_front(br.pcb_editor_hwnd(), "KiCad"); }                 }                 let cached = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())));                 let hwnd = if name == "fields" { None } else { cam.camera_window(&tx, name, cached) };@@ -4663,6 +4896,7 @@ fn main() {             r.data["clock"]["deliveredTime"] = json!(t);             record_usage(&mut r, "deliver");             r.data["summary"]["planUsage"] = json!(usage_delta(&r));+            if let Some(a) = agy_report(&r) { r.data["summary"]["antigravity"] = a; }             r.data["delivery"] = json!({"at": t, "video": std::fs::canonicalize(vp).map(|p| p.display().to_string()).unwrap_or(video.clone()), "videoBytes": bytes, "message": message});             r.data["summary"]["promptToDeliveredMinutes"] = json!(r.delivered_minutes());             r.data["summary"]["steps"] = Value::Object(r.step_report());
crates/adom-aiflow/src/preboard.rs+32
@@ -468,3 +468,35 @@ mod tests {         std::fs::remove_dir_all(dir).unwrap();     } }++/// The parts a BOM buys, for the parts step: one row per BOM line with an MPN (references, MPN,+/// manufacturer and value as written). Rows without an MPN (`no part`, bare pads) are left out.+pub fn bom_parts(text: &str) -> Vec<Value> {+    let rows = parse_csv(text);+    let Some(header) = rows.first() else { return Vec::new() };+    let c_ref = col(header, &["ref", "refs", "reference", "references", "designator", "designators", "refdes"]);+    let c_mpn = col(header, &["mpn", "mfr_part", "mfr_pn", "manufacturer_part_number", "part_number", "mfr_part_number"]);+    let c_mfr = col(header, &["manufacturer", "mfr", "maker", "brand"]);+    let c_val = col(header, &["value", "val", "description", "desc"]);+    let get = |row: &Vec<String>, c: Option<usize>| c.and_then(|i| row.get(i)).map(|s| s.trim().to_string()).unwrap_or_default();+    rows.iter().skip(1).filter_map(|row| {+        let mpn = get(row, c_mpn);+        if mpn.is_empty() || mpn.eq_ignore_ascii_case("no part") { return None; }+        let refs: Vec<String> = get(row, c_ref).split(|c: char| c == ' ' || c == ',' || c == ';').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();+        Some(json!({"refs": refs, "mpn": mpn, "manufacturer": get(row, c_mfr), "value": get(row, c_val)}))+    }).collect()+}++#[cfg(test)]+mod bom_parts_tests {+    use super::*;+    #[test]+    fn rows_with_an_mpn_become_parts_and_no_part_rows_are_left_out() {+        let csv = "ref,value,mpn,manufacturer,source\n\"C1, C2\",22uF,CL21A226MPQNNNE,Samsung,Mouser\nTP1,,no part,,\nU1,TPS54202,TPS54202DDCR,TI,Mouser\n";+        let p = bom_parts(csv);+        assert_eq!(p.len(), 2);+        assert_eq!(p[0]["refs"], json!(["C1", "C2"]));+        assert_eq!(p[1]["mpn"], "TPS54202DDCR");+        assert_eq!(p[1]["manufacturer"], "TI");+    }+}
crates/adom-aiflow/src/sequence.rs+26
@@ -72,6 +72,15 @@ pub fn title(data: &Value) -> String {     data["title"].as_str().unwrap_or("adom-aiflow").to_string() } +/// Which sequence a run follows: its lane, except a KiCad run that starts with no board (a board+/// from scratch: parts, project page, PCM library and schematic come first) follows `kicad-new`.+/// `scratch: false` in run.json keeps the plain KiCad lane.+pub fn sequence_key(dir: &Path, data: &Value) -> String {+    let lane = lane(dir, data);+    let no_board = data["boards"].as_array().map_or(true, |b| b.is_empty()) && data["remoteBoard"].as_str().map_or(true, |b| b.is_empty());+    if lane == "kicad" && no_board && data["scratch"] != false { "kicad-new".into() } else { lane.into() }+}+ pub fn sequence_of(flow: &Value, lane: &str) -> Vec<String> {     flow["sequence"][lane].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() }@@ -354,3 +363,20 @@ mod tests {         assert!(overdue_warning(&done, &flow, &seq, t0 + 999 * 60).is_none());     } }++#[cfg(test)]+mod sequence_key_tests {+    use super::*;+    #[test]+    fn a_kicad_run_without_a_board_follows_the_from_scratch_sequence() {+        let d = std::env::temp_dir();+        assert_eq!(sequence_key(&d, &serde_json::json!({"eda": "kicad"})), "kicad-new");+        assert_eq!(sequence_key(&d, &serde_json::json!({"eda": "kicad", "boards": ["/x/b.kicad_pcb"]})), "kicad");+        assert_eq!(sequence_key(&d, &serde_json::json!({"eda": "kicad", "scratch": false})), "kicad");+        assert_eq!(sequence_key(&d, &serde_json::json!({"eda": "fusion"})), "fusion");+        let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+        let s = sequence_of(&flow, "kicad-new");+        let pos = |n: &str| s.iter().position(|x| x == n).unwrap();+        assert!(pos("parts") < pos("project-page") && pos("project-page") < pos("library") && pos("library") < pos("schematic") && pos("schematic") < pos("placement"));+    }+}
docs/release-0.2.0.mdadded+18
@@ -0,0 +1,18 @@+# adom-aiflow 0.2.0++## A KiCad board from scratch+A KiCad run that starts with no board follows the new `kicad-new` sequence: intake, rules, design, test-plan, sourcing, **parts**, **project-page**, **library**, **schematic**, simulation, then components, models and the board steps.+- `parts [--bom] [--show]`: every BOM MPN matched to its Adom wiki component page; one Hydrogen tab walks through the pages. A missing page fails the step.+- `project-page --page <owner/slug> --create`: the board's project page, revision 1, before any EDA work: README with the component table, and a KiCad PCM repository of every part (an Adom Cloud Library exported and repackaged; `/api/pages/<owner>/<slug>/files/pcm/repository.json`). The user's active Cloud Library is left as it was.+- `pcm check`: the project's PCM package is installed in the test box's KiCad (after `kicad_pcm_add_repository` + `kicad_pcm_install`). Records the `library` proof.+- `schematic check --sch <file>`: kicad-cli ERC clean, every BOM reference on the sheet, every part from `PCM_<slug>`. Records the `schematic` proof and the run's schematic.+- Hydrogen is in front for the steps without KiCad and KiCad for the board steps; the step dashboard comes up at each new phase.++## The project page grows with the run+Each step the run leaves is pushed (in the background) as `steps/<nn>-<step>/README.md` on the project page, with its proof, visits, screenshots, drawings and AI usage; the main README's "How this board was built" section (between `<!-- aiflow:steps -->` markers) links them. `project-page --sync` pushes all of them.++## Google Antigravity usage+Plan usage now reads Google Antigravity too: the weekly and five-hour percent from the local `agy` hub, and Antigravity's own per-call token counters, at start, every step and finish. The run page and `deliver` show calls and tokens per step, the weekly percent used and its dollar share of the plan (`AIFLOW_PLAN_USD_MONTH`, default 199.99). Plan percent deltas now survive a window reset mid-run (a drop counts as a reset) and show two decimals.++## Fields+`tour fields` ends with an EMC beat: the app's EMC view (hot loop, return path, radiated estimate against FCC Part 15 class B), held for 20 s.
flows/board.json+68−1
@@ -41,6 +41,32 @@         "Stephen's workcell numbers: smallest probing pad 200 um (pads under 300 um need a human crosshair check, so 0.5 mm is the default); two probes can work about 3 mm apart; every probed point within 50 mm of a MachineContactMedium ground contact; parts under a probed point under 18 mm tall."       ]     },+    {+      "name": "parts",+      "who": "ai",+      "does": "The chosen parts on the Adom wiki: every BOM row with an MPN matched to its wiki component page (symbol, footprint, STEP); a missing page is made before going on. Each page is shown in a Hydrogen tab as it is confirmed, so the viewer sees the parts being picked.",+      "binary": [+        "parts --show"+      ],+      "workflow": [+        "Run `parts --show` after the sourcing check passes: it reads that BOM, finds each MPN's wiki page and walks one Hydrogen tab through them.",+        "A part with no page: make it with the wiki-component skill (datasheet symbol, footprint and STEP, verified), then run `parts --show` again. Never substitute a similar part to dodge a missing page."+      ],+      "record": "Hydrogen with the Parts tab stepping through each component's wiki page, a few seconds each, then the parts list in the chat"+    },+    {+      "name": "project-page",+      "who": "ai",+      "does": "Revision 1 of the board's project page before any EDA work: README (what the board is, its requirements) with the component table linking every part's wiki page, and a KiCad PCM repository of all the parts (an Adom Cloud Library exported and repackaged), so KiCad installs them in one go.",+      "binary": [+        "project-page --page <owner/slug> --create"+      ],+      "workflow": [+        "`project-page --page <owner>/<slug> --create` (one page per board: later revisions update this same page; never a second page for the same board).",+        "Read the README it pushed and fix anything wrong in the summary or the table by hand; it opens in a Hydrogen tab."+      ],+      "record": "the project page opening in Hydrogen: the README, the component table, the PCM repository URL"+    },     {       "name": "components",       "who": "ai",@@ -315,7 +341,7 @@   "clips": "every `step <name>` stops the previous step's clip and starts a new window recording tagged with the step, when the board is open on a test box; run.json captures[] carries one entry per clip with its step, start, stop and file, and deliver lists them; the final video is cut from these clips, one segment per step, so two engines' videos line up step for step; a return (step <name> --back --why) is a new visit and gets its own clip, tagged <step>-<visit> with the reason, so the rework is on camera and the final cut can show the loop",   "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one",   "sequence": {-    "_": "The required steps in order, per lane. `step <x>` refuses to skip a required step before x; a return (`step <k> --back`) makes every later required step a redo; finish refuses until the chain is in order and each step's proof is recorded during its last visit.",+    "_": "The required steps in order, per lane. `step <x>` refuses to skip a required step before x; a return (`step <k> --back`) makes every later required step a redo; finish refuses until the chain is in order and each step's proof is recorded during its last visit. kicad-new is a KiCad board from scratch (the run starts with no board): the parts go on the wiki and on the board's project page, KiCad installs them as one PCM library, and the schematic is drawn in KiCad before the board exists.",     "altium": [       "intake",       "rules",@@ -382,10 +408,43 @@       "3d",       "finish",       "export"+    ],+    "kicad-new": [+      "intake",+      "rules",+      "design",+      "test-plan",+      "sourcing",+      "parts",+      "project-page",+      "library",+      "schematic",+      "simulation",+      "components",+      "models",+      "placement",+      "routing",+      "thermal-vias",+      "pours",+      "outline",+      "current",+      "thermal",+      "fields",+      "nets",+      "silkscreen",+      "3d",+      "finish",+      "export"     ]   },   "proofs": {     "_": "outcomes a step must record during its latest visit (any one of the list)",+    "parts": [+      "parts"+    ],+    "project-page": [+      "project-page"+    ],     "rules": [       "rules"     ],@@ -445,6 +504,8 @@     ]   },   "titles": {+    "parts": "Parts on the wiki",+    "project-page": "Project page",     "intake": "Intake",     "rules": "Design rules",     "design": "Circuit design",@@ -471,6 +532,8 @@     "export": "Gerbers, BOM, CPL"   },   "short": {+    "parts": "every part's wiki page, shown as it is picked",+    "project-page": "README rev 1, component table, KiCad PCM repo",     "intake": "read the prompt and the requirements",     "rules": "choose the fab, load its design rules",     "design": "datasheet math and part values",@@ -498,6 +561,8 @@   },   "minutes": {     "_": "Expected wall minutes for one visit of each step. Past twice this, every command and finish-guard warn that the run is still in that step (warn only, never a block): work done with the AI's own scripts still belongs to the step it does. A step not listed here expects 45 minutes.",+    "parts": 15,+    "project-page": 10,     "intake": 20,     "rules": 10,     "design": 45,@@ -534,6 +599,8 @@     ],     "Parts": [       "sourcing",+      "parts",+      "project-page",       "library",       "components",       "libraries",
skills/adom-aiflow/SKILL.md+15
@@ -211,6 +211,21 @@ The clock starts at the prompt even when there is no board yet. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`. +## A KiCad board from scratch (0.2.0)++A KiCad run that starts with no board (`intake` with no `start --board`) follows the `kicad-new` sequence: intake, rules, design, test-plan, sourcing, **parts**, **project-page**, **library**, **schematic**, simulation, then components, models and the board steps as usual. Hydrogen is the stage for the steps without KiCad (the step dashboard comes up at every new phase), KiCad's window for the rest.++1. **`sourcing check --bom design/bom.csv`** as above.+2. **`step parts`, `parts --show`**: every BOM row with an MPN is matched to its Adom wiki component page; one Hydrogen tab ("Parts · <run>") steps through the pages so the viewer sees the parts picked. A part with no page: make it with the wiki-component skill (datasheet symbol, footprint, STEP; verified), run `parts --show` again. Never swap in a similar part to dodge a missing page.+3. **`step project-page`, `project-page --page <owner>/<slug> --create`**: revision 1 of the board's one project page before any EDA work: a README saying what the board is, the component table (references, value, MPN, manufacturer, each part's wiki page) and a KiCad PCM repository of every part (an Adom Cloud Library of the parts, exported and repackaged for KiCad's Plugin and Content Manager, served from the page at `/api/pages/<owner>/<slug>/files/pcm/repository.json`). Later revisions update this same page (aiflow-project-page skill); never a second page for the board.+4. **`step library`**: install the parts in KiCad on the test box: `kicad_pcm_add_repository {"url": <repositoryUrl>}`, `kicad_pcm_install {"repositoryUrl": ..., "identifier": ...}` (both printed by `project-page`), restart KiCad, then **`pcm check`** (the package is in `kicad_pcm_list`). The library is `PCM_<slug>`.+5. **`step schematic`**: draw the schematic in KiCad's schematic editor, on camera: `kicad_open_schematic`, `kicad_sch_place_symbol` (symbols from `PCM_<slug>`), `kicad_sch_wire`, `kicad_sch_label`, `kicad_sch_set_property`; save; **`schematic check --sch <project>.kicad_sch`** passes when kicad-cli ERC has no errors, every BOM reference is on the sheet and every part comes from the project library. Never write the `.kicad_sch` with a script of your own: the viewer is meant to see it drawn.+6. Simulate (as in the Fusion lane), then make the board in KiCad (Update PCB from Schematic, the outline), save it and attach it (`start --board ... --spec ...` on the same `--run`), `step components` and on.++**The project page grows with the run.** Once `project-page` names the page, every step the run leaves is pushed as its own sub-readme, `steps/<nn>-<step>/README.md` (what the step does, each visit and why a return came back, the proof the binary recorded, the screenshots and your registered drawings, the AI usage), and the main README keeps a "How this board was built" section between `<!-- aiflow:steps -->` markers: one intro line per step linking to its page. Keep the main README for people: what the board is, the hero, the component table, then those intros; the evidence lives in the sub-readmes. Register your drawings with `artifact` so they land on their step's page. `project-page --page <owner/slug> --sync` pushes every step at once (do it at finish).++**Antigravity (Google, Gemini) runs**: the run records Antigravity's quota (weekly and five-hour percent, from the local `agy` hub) and its token counters (from its own per-call records) at start, at every step and at finish; the run page and `deliver` show calls and tokens per step, the weekly percent used and its dollar share of the plan (`AIFLOW_PLAN_USD_MONTH`, default 199.99). Exact when nothing else uses Antigravity during the run.+ ## The fields step (adom-fields)  After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. When the Fields URL is a localhost proxy (an Adom Hydrogen desktop container's `VSCODE_PROXY_URI` is `http://{{port}}.localhost:<port>/`, reachable only on the machine hosting the container), the window opens on the user's primary machine (`adom-bridge targets` names it) instead of the test box, and the answer says so; `--on <box>` overrides. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`.
tools/agy-usage.pyadded+98
@@ -0,0 +1,98 @@+#!/usr/bin/env python3+"""Google Antigravity usage, read locally at no quota cost. Prints one JSON object:+  quota:  [{label, pct, resetsAt}] from the running agy hub's RetrieveUserQuotaSummary+          (pct = percent of the window used; labels week, 5h, 3p week, 3p 5h)+  tokens: cumulative model-call counters summed over every conversation in+          ~/.gemini/antigravity/conversations/*.db (gen_metadata field 1.4:+          .2 fresh input, .3 output, .5 cached input), plus per-model totals.+Counters only grow, so the difference between two snapshots is what ran in between.+Undocumented formats (decoded 2026-10-16/17); anything unreadable is skipped, never fatal."""+import glob, json, os, re, sqlite3, urllib.request+++def varint(b, i):+    r = s = 0+    while True:+        c = b[i]; i += 1; r |= (c & 0x7F) << s; s += 7+        if c < 0x80:+            return r, i+++def fields(b):+    i, out = 0, []+    while i < len(b):+        k, i = varint(b, i); f, t = k >> 3, k & 7+        if t == 0: v, i = varint(b, i)+        elif t == 2: n, i = varint(b, i); v = b[i:i + n]; i += n+        elif t == 1: v = b[i:i + 8]; i += 8+        elif t == 5: v = b[i:i + 4]; i += 4+        else: break+        out.append((f, t, v))+    return out+++def tokens():+    tot = {"calls": 0, "freshIn": 0, "cachedIn": 0, "out": 0, "byModel": {}}+    for db in glob.glob(os.path.expanduser("~/.gemini/antigravity/conversations/*.db")):+        try:+            con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2)+            rows = con.execute("select data from gen_metadata").fetchall()+            con.close()+        except Exception:+            continue+        for (blob,) in rows:+            if not isinstance(blob, (bytes, bytearray)):+                continue+            try:+                top = fields(blob)+                for f, t, v in top:+                    if f != 1 or t != 2: continue+                    sub = fields(v)+                    model = next((x.decode(errors="ignore") for f2, t2, x in sub if f2 == 19 and t2 == 2), "")+                    for f2, t2, x in sub:+                        if f2 == 4 and t2 == 2:+                            d = {a: c for a, b2, c in fields(x) if b2 == 0}+                            tot["calls"] += 1; tot["freshIn"] += d.get(2, 0); tot["out"] += d.get(3, 0); tot["cachedIn"] += d.get(5, 0)+                            m = tot["byModel"].setdefault(model or "unknown", {"calls": 0, "freshIn": 0, "cachedIn": 0, "out": 0})+                            m["calls"] += 1; m["freshIn"] += d.get(2, 0); m["out"] += d.get(3, 0); m["cachedIn"] += d.get(5, 0)+            except Exception:+                continue+    return tot+++def hub():+    for p in glob.glob("/proc/[0-9]*/cmdline"):+        try:+            a = open(p, "rb").read().split(b"\0")+        except Exception:+            continue+        s = b" ".join(a).decode(errors="ignore")+        if "--hub" in s and "agy" in s:+            port = re.search(r"--hub-port=(\d+)", s); tok = re.search(r"--csrf_token=(\S+)", s)+            if port and tok:+                return port.group(1), tok.group(1)+    return None+++def quota():+    h = hub()+    if not h:+        return []+    port, tok = h+    req = urllib.request.Request(f"http://127.0.0.1:{port}/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary",+                                 data=b"{}", headers={"content-type": "application/json", "x-codeium-csrf-token": tok})+    try:+        r = json.load(urllib.request.urlopen(req, timeout=8)).get("response", {})+    except Exception:+        return []+    out = []+    for g in r.get("groups", []):+        third = "Claude" in g.get("displayName", "")+        for b in g.get("buckets", []):+            w = {"weekly": "week"}.get(b.get("window"), b.get("window", "limit"))+            out.append({"label": ("3p " if third else "") + w, "pct": round((1 - float(b.get("remainingFraction", 1))) * 100, 4), "resetsAt": b.get("resetTime")})+    return out+++if __name__ == "__main__":+    print(json.dumps({"quota": quota(), "tokens": tokens()}))
tools/project-page.pyadded+202
@@ -0,0 +1,202 @@+#!/usr/bin/env python3+"""The board's project page, revision 1, before any EDA work: the parts the run chose (each one a+wiki component page), a README that says what the board is and lists them, and a KiCad Plugin and+Content Manager (PCM) repository on the page so KiCad installs the whole parts library in one go.++  python3 -c "$(cat project-page.py)" <config.json>++config: {"run": dir, "page": "owner/slug", "title", "brief", "summary", "parts": [{refs, mpn,+manufacturer, value, wiki}], "create": bool, "push": bool, "private": bool, "version": "1.0.0"}++The parts library comes from Adom Cloud Libraries (`adom-wiki library`): one library per board,+the parts added by their wiki pages, exported as a KiCad bundle, then repackaged in the PCM layout+(metadata.json, symbols/<Lib>.kicad_sym, footprints/<Lib>.pretty, 3dmodels/<Lib>.3dshapes) with+the footprints' 3D paths pointed at ${KICAD10_3RD_PARTY}/3dmodels/<pkgid_>/<Lib>.3dshapes/, which is+where PCM puts the files (adom/kicad-pcm documents the layout). Prints one JSON object."""+import hashlib, json, os, re, shutil, subprocess, sys, time, zipfile++API = "https://wiki.adom.inc/api/pages"+++def sh(args, check=True):+    p = subprocess.run(args, capture_output=True, text=True, stdin=subprocess.DEVNULL)+    if check and p.returncode != 0:+        raise SystemExit(json.dumps({"ok": False, "error": f"{' '.join(args[:4])}: {(p.stderr or p.stdout).strip()[-600:]}"}))+    return p.stdout+++def wjson(args):+    out = sh(args + ["--json"])+    try:+        return json.loads(out)+    except Exception:+        return {}+++def sha(path):+    h = hashlib.sha256()+    with open(path, "rb") as f:+        for b in iter(lambda: f.read(1 << 20), b""): h.update(b)+    return h.hexdigest()+++def lib_name(slug):+    return re.sub(r"[^A-Za-z0-9_-]", "_", slug)+++def pkg_id(owner, slug):+    return "inc.adom.wiki." + re.sub(r"[^a-z0-9.-]", "-", f"{owner}.{slug}".lower())+++def page_exists(ref):+    return subprocess.run(["adom-wiki", "repo", "show", ref, "page.json"], capture_output=True, stdin=subprocess.DEVNULL).returncode == 0+++def cloud_library(name, parts, work):+    """Create (or reuse) the board's Cloud Library, add every part page, export the KiCad bundle."""+    libs = wjson(["adom-wiki", "library", "list"])+    items = (libs.get("data") or {}).get("libraries") or libs.get("libraries") or []+    lib = next((l for l in items if l.get("name") == name), None)+    if lib is None:+        # creating a library makes it the user's active one: put their own active library back after+        was_active = next((l.get("ref") for l in items if l.get("active")), None)+        sh(["adom-wiki", "library", "create", name])+        if was_active: sh(["adom-wiki", "library", "activate", was_active], check=False)+        libs = wjson(["adom-wiki", "library", "list"])+        items = (libs.get("data") or {}).get("libraries") or libs.get("libraries") or []+        lib = next((l for l in items if l.get("name") == name), None)+    ref = (lib or {}).get("ref") or (lib or {}).get("id") or name+    for p in parts:+        if p.get("wiki"):+            sh(["adom-wiki", "library", "add", ref, p["wiki"]], check=False)+    bundle = os.path.join(work, "cloud-library.zip")+    report = wjson(["adom-wiki", "library", "export", ref, "-o", bundle])+    if not os.path.isfile(bundle):+        raise SystemExit(json.dumps({"ok": False, "error": "library export wrote no bundle", "report": report}))+    return ref, bundle, report+++def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work):+    """Repackage the Cloud Library bundle in the PCM layout; returns (zip path, install size)."""+    src = os.path.join(work, "bundle"); shutil.rmtree(src, ignore_errors=True); os.makedirs(src)+    with zipfile.ZipFile(bundle) as z: z.extractall(src)+    root = next((os.path.join(src, d) for d in os.listdir(src) if os.path.isdir(os.path.join(src, d))), src)+    stage = os.path.join(work, "pcm-stage"); shutil.rmtree(stage, ignore_errors=True)+    sym_dir, fp_dir, m_dir = (os.path.join(stage, "symbols"), os.path.join(stage, "footprints", f"{lib}.pretty"), os.path.join(stage, "3dmodels", f"{lib}.3dshapes"))+    for d in (sym_dir, fp_dir, m_dir): os.makedirs(d)+    models = os.path.join(root, "3dmodels")+    for f in os.listdir(models) if os.path.isdir(models) else []:+        shutil.copy(os.path.join(models, f), m_dir)+    for f in os.listdir(root):+        if f.endswith(".kicad_sym"): shutil.copy(os.path.join(root, f), os.path.join(sym_dir, f"{lib}.kicad_sym"))+        if f.endswith(".pretty"):+            for m in os.listdir(os.path.join(root, f)):+                t = open(os.path.join(root, f, m)).read()+                # bundle-relative 3D paths -> where PCM installs them+                t = re.sub(r'\(model\s+"?3dmodels/([^")\s]+)"?', lambda mm: f'(model "${{KICAD10_3RD_PARTY}}/3dmodels/{pkgid.replace(".", "_")}/{lib}.3dshapes/{mm.group(1)}"', t)+                open(os.path.join(fp_dir, m), "w").write(t)+    # the symbols point at the footprints by library nickname: PCM installs this library as PCM_<lib>+    sp = os.path.join(sym_dir, f"{lib}.kicad_sym")+    if os.path.isfile(sp):+        t = open(sp).read()+        t = re.sub(r'(\(property\s+"Footprint"\s+")([^":]*):', lambda mm: f'{mm.group(1)}PCM_{lib}:', t)+        open(sp, "w").write(t)+    meta = {"$schema": "https://go.kicad.org/pcm/schemas/v1", "name": title, "description": brief[:150],+            "description_full": f"{brief}\n\nEvery part of this board, from its Adom wiki component page: symbol, footprint and STEP model. Project page: {page_url}",+            "identifier": pkgid, "type": "library", "author": {"name": "Adom wiki", "contact": {"web": page_url}},+            "license": "MIT", "resources": {"homepage": page_url},+            "versions": [{"version": version, "status": "stable", "kicad_version": "9.0"}]}+    json.dump(meta, open(os.path.join(stage, "metadata.json"), "w"), indent=1)+    out = os.path.join(work, "pcm", f"{lib}-pcm-{version}.zip"); os.makedirs(os.path.dirname(out), exist_ok=True)+    size = 0+    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:+        for d, _, fs in os.walk(stage):+            for f in sorted(fs):+                p = os.path.join(d, f); z.write(p, os.path.relpath(p, stage)); size += os.path.getsize(p)+    return out, size, meta+++def readme(cfg, lib, pkgid, repo_url):+    rows, merged = [], {}+    for p in cfg["parts"]:  # one row per MPN, its references together+        m = merged.setdefault(p["mpn"], dict(p, refs=[]))+        m["refs"] = m["refs"] + list(p.get("refs") or [])+    for p in merged.values():+        refs = ", ".join(p.get("refs") or [])+        wiki = f"[{p['wiki']}](https://wiki.adom.inc/{p['wiki']})" if p.get("wiki") else "not on the wiki yet"+        rows.append(f"| {refs} | {len(p.get('refs') or [])} | {p.get('value', '')} | {p['mpn']} | {p.get('manufacturer', '')} | {wiki} |")+    return f"""# {cfg['title']}++{cfg.get('summary') or cfg['brief']}++**Status: revision 1.** The parts are chosen and every one has its Adom wiki page; the schematic and the board come next, built with [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow).++## Components++| References | Qty | Value | MPN | Manufacturer | Wiki page |+|---|---|---|---|---|---|+{chr(10).join(rows)}++## Install every part in KiCad++This page is a KiCad Plugin and Content Manager repository. In KiCad, open **Plugin and Content Manager**, **Manage...**, add this URL, then install **{cfg['title']}** from the Libraries tab:++```+{repo_url}+```++The library appears as `PCM_{lib}` (package `{pkgid}`): every symbol, footprint and 3D model of this board, from the parts' wiki pages.+"""+++def main():+    cfg = json.load(open(sys.argv[1]))+    owner, slug = cfg["page"].split("/", 1)+    work = os.path.join(cfg["run"], "project-page"); os.makedirs(work, exist_ok=True)+    page_url = f"https://wiki.adom.inc/{owner}/{slug}"+    created = False+    if cfg.get("push") and not page_exists(cfg["page"]):  # a dry run (--no-push) builds everything locally+        if not cfg.get("create"):+            raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} does not exist; pass --create to make it"}))+        args = ["adom-wiki", "page", "create", slug, "--type", "hardware", "--kind", "board", "--title", cfg["title"], "--brief", cfg["brief"][:200],+                "--because", "a new board designed by adom-aiflow: its own project page, one page per board",+                "--headline", cfg["title"][:60], "--subhead", cfg["brief"][:100], "--source", "kicad"]+        me = (wjson(["adom-wiki", "whoami"]).get("data") or {}).get("user", {}).get("username") or ""+        if owner != me: args += ["--org", owner]+        if cfg.get("private"): args.append("--private")+        sh(args); created = True+    lib, pkgid = lib_name(slug), pkg_id(owner, slug)+    version = cfg.get("version") or "1.0.0"+    lib_ref, bundle, report = cloud_library(f"{owner}/{slug}", cfg["parts"], work)+    zpath, isize, meta = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work)+    zname = os.path.basename(zpath)+    files_url = f"{API}/{owner}/{slug}/files"+    v = dict(meta["versions"][0]); v.update({"download_url": f"{files_url}/pcm/{zname}", "download_sha256": sha(zpath), "download_size": os.path.getsize(zpath), "install_size": isize})+    pkgs = {"$schema": "https://go.kicad.org/pcm/schemas/v1#/definitions/PackageArray", "packages": [dict(meta, versions=[v])]}+    pj = os.path.join(work, "pcm", "packages.json"); json.dump(pkgs, open(pj, "w"), indent=1)+    now = int(time.time())+    repo = {"$schema": "https://go.kicad.org/pcm/schemas/v1#/definitions/Repository", "name": f"{cfg['title']} (Adom wiki)",+            "maintainer": {"name": owner, "contact": {"web": page_url}},+            "packages": {"url": f"{files_url}/pcm/packages.json", "sha256": sha(pj), "update_timestamp": now, "update_time_utc": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(now))}}+    rp = os.path.join(work, "pcm", "repository.json"); json.dump(repo, open(rp, "w"), indent=1)+    repo_url = f"{files_url}/pcm/repository.json"+    open(os.path.join(work, "README.md"), "w").write(readme(cfg, lib, pkgid, repo_url))+    pushed = None+    if cfg.get("push"):+        files = [f"pcm/{zname}", "pcm/packages.json", "pcm/repository.json"]+        # README rev 1 goes on a page this step made (or when asked); an existing page's README is never replaced unasked+        if created or cfg.get("replaceReadme"): files.insert(0, "README.md")+        p = subprocess.run(["adom-wiki", "repo", "push", cfg["page"], "--files", *files,+                            "-m", "Revision 1: the chosen parts, the component table and the KiCad PCM repository (adom-aiflow)"], capture_output=True, text=True, cwd=work, stdin=subprocess.DEVNULL)+        m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)+        pushed = m.group(1) if m else None+        if p.returncode != 0 or not pushed:+            raise SystemExit(json.dumps({"ok": False, "error": "push failed: " + (p.stderr or p.stdout)[-600:]}))+    print(json.dumps({"ok": True, "page": cfg["page"], "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,+                      "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]),+                      "exportReport": (report.get("data") or report).get("report", report.get("data")) if isinstance(report, dict) else None,+                      "readme": os.path.join(work, "README.md"), "commit": pushed}))+++if __name__ == "__main__":+    main()
tools/project-steps.pyadded+133
@@ -0,0 +1,133 @@+#!/usr/bin/env python3+"""One sub-readme per finished aiflow step on the board's project page, and the main README's+"How this board was built" section (one intro per step, linking to its sub-readme) kept current.++  python3 project-steps.py <run dir> <owner/slug> [<step> ...]     (no step: every step visited so far)++steps/<nn>-<step>/README.md holds the step's evidence: what it does, each visit (and why a return+came back to it), the proof the binary recorded, the screenshots and the AI's drawings, and the+Antigravity tokens the step used. The main README section sits between+<!-- aiflow:steps --> and <!-- /aiflow:steps -->; everything else on the page is left as it is."""+import json, os, re, shutil, subprocess, sys++BEGIN, END = "<!-- aiflow:steps -->", "<!-- /aiflow:steps -->"+++def load(run):+    data = json.load(open(os.path.join(run, "run.json")))+    events = []+    for line in open(os.path.join(run, "run.jsonl")) if os.path.isfile(os.path.join(run, "run.jsonl")) else []:+        try: events.append(json.loads(line))+        except Exception: pass+    return data, events+++def flow_of(data):+    return data.get("flow") or {}+++def steps_done(data):+    seq = data.get("sequence") or []+    seen = [s for s in seq if (data.get("steps", {}).get(s) or {}).get("visits")]+    return seq, seen+++def tokens_by_step(data):+    snaps = [s for s in data.get("usage") or [] if isinstance(s.get("agyTokens"), dict)]+    out = {}+    for a, b in zip(snaps, snaps[1:]):+        st = (a.get("when") or "").replace("step:", "")+        d = {k: max(0, (b["agyTokens"].get(k) or 0) - (a["agyTokens"].get(k) or 0)) for k in ("calls", "freshIn", "cachedIn", "out")}+        if not d["calls"]: continue+        o = out.setdefault(st, {k: 0 for k in d})+        for k in d: o[k] += d[k]+    return out+++def brief(v, depth=0):+    """A short human reading of an outcome: scalars and short lists, nested once."""+    if isinstance(v, dict):+        rows = []+        for k, x in v.items():+            if isinstance(x, (str, int, float, bool)) and len(str(x)) < 160: rows.append(f"- **{k}**: {x}")+            elif isinstance(x, list) and x and len(x) <= 8 and all(isinstance(i, (str, int, float)) for i in x): rows.append(f"- **{k}**: {', '.join(map(str, x))}")+            elif isinstance(x, list): rows.append(f"- **{k}**: {len(x)} item(s)")+            elif isinstance(x, dict) and depth == 0: rows.append(f"- **{k}**: " + "; ".join(f"{a} {b}" for a, b in list(x.items())[:8] if isinstance(b, (str, int, float, bool))))+        return "\n".join(rows)+    return str(v)+++def sub_readme(run, data, events, step, n, stage):+    flow = flow_of(data)+    sdef = next((s for s in flow.get("steps", []) if s.get("name") == step), {})+    title = (flow.get("titles") or {}).get(step, step)+    does = sdef.get("does") or (flow.get("short") or {}).get(step, "")+    visits = (data.get("steps", {}).get(step) or {}).get("visits") or []+    d = os.path.join(stage, "steps", f"{n:02d}-{step}"); os.makedirs(d, exist_ok=True)+    md = [f"# Step {n}: {title}", "", does, ""]+    md += ["## Visits", ""] + [f"- {v.get('at', '')}" + (f" (a return: {v.get('why')})" if v.get("back") else "") for v in visits] + [""]+    proofs = (flow.get("proofs") or {}).get(step) or []+    outs = data.get("outcomes") or {}+    shown = [k for k in proofs if k in outs] + [k for k in outs if k.startswith(step) and k not in proofs]+    if shown:+        md += ["## What the binary checked", ""]+        for k in shown: md += [f"### {k}", "", brief(outs[k]), ""]+    ev = [e for e in data.get("evidence") or [] if e.get("step") == step]+    if ev:+        md += ["## Evidence", ""] + [f"- {e.get('kind')}: `{os.path.basename(e.get('file', ''))}`" + (" (pass)" if e.get("pass") else " (fail)" if e.get("pass") is False else "") for e in ev] + [""]+    pics = []+    for e in events:+        if e.get("event") == "artifact" and e.get("step") == step and str(e.get("file", "")).lower().endswith((".png", ".jpg", ".jpeg", ".webp")):+            f = e["file"] if os.path.isabs(e["file"]) else os.path.join(run, e["file"])+            if os.path.isfile(f) and f not in [p[0] for p in pics]: pics.append((f, e.get("caption") or e.get("when") or e.get("kind") or ""))+    for f in sorted(os.listdir(run)):+        if f.startswith(f"shot-{step}-") and f.endswith(".png") and os.path.join(run, f) not in [p[0] for p in pics]:+            pics.append((os.path.join(run, f), f.replace(".png", "").replace(f"shot-{step}-", "visit ")))+    if pics:+        md += ["## Screenshots and drawings", ""]+        for f, cap in pics[:16]:+            name = os.path.basename(f); shutil.copy(f, os.path.join(d, name))+            md += [f"**{cap}**", "", f"![{cap}]({name})", ""]+    t = tokens_by_step(data).get(step)+    if t:+        md += ["## AI usage (Google Antigravity)", "", f"{t['calls']} model calls: {t['freshIn']:,} fresh input, {t['cachedIn']:,} cached input and {t['out']:,} output tokens.", ""]+    open(os.path.join(d, "README.md"), "w").write("\n".join(md))+    return os.path.relpath(d, stage), title, (flow.get("short") or {}).get(step, does[:140])+++def main():+    run, page = sys.argv[1], sys.argv[2]+    data, events = load(run)+    seq, seen = steps_done(data)+    want = sys.argv[3:] or seen+    stage = os.path.join(run, "project-page", "steps-stage"); shutil.rmtree(stage, ignore_errors=True); os.makedirs(stage)+    files = []+    for st in want:+        if st not in seen: continue+        rel, _, _ = sub_readme(run, data, events, st, seq.index(st) + 1 if st in seq else 0, stage)+        for f in os.listdir(os.path.join(stage, rel)): files.append(os.path.join(rel, f))+    # the main README's section: every step visited so far, one intro each+    cur = subprocess.run(["adom-wiki", "repo", "show", page, "README.md"], capture_output=True, text=True, stdin=subprocess.DEVNULL).stdout+    flow = flow_of(data)+    lines = [BEGIN, "## How this board was built", "", "Every step of the build, in order, each with its own page of evidence (checks, screenshots, the AI's drawings and usage), written by [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow) as the run went.", ""]+    for st in seen:+        n = seq.index(st) + 1 if st in seq else 0+        title = (flow.get("titles") or {}).get(st, st); short = (flow.get("short") or {}).get(st, "")+        lines += [f"**{n}. {title}**: {short}. [The evidence]({'steps/'}{n:02d}-{st}/README.md)", ""]+    lines.append(END)+    block = "\n".join(lines)+    if BEGIN in cur and END in cur:+        new = cur[:cur.index(BEGIN)] + block + cur[cur.index(END) + len(END):]+    else:+        new = cur.rstrip() + "\n\n" + block + "\n"+    open(os.path.join(stage, "README.md"), "w").write(new)+    files.append("README.md")+    if os.environ.get("AIFLOW_PROJECT_STEPS_DRY"):+        print(json.dumps({"ok": True, "dry": True, "stage": stage, "steps": want, "files": len(files)})); return+    p = subprocess.run(["adom-wiki", "repo", "push", page, "--files", *files, "-m", f"adom-aiflow: step evidence ({', '.join(want)})"], capture_output=True, text=True, cwd=stage, stdin=subprocess.DEVNULL)+    m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)+    print(json.dumps({"ok": bool(m), "commit": m.group(1) if m else None, "steps": want, "files": len(files), "error": None if m else (p.stderr or p.stdout)[-400:]}))+++if __name__ == "__main__":+    main()