app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← All Pull Requests
0.2.10: Altium symbol and pin-type check, project-library parts, one desktop folder per run #42
Closes the two Altium gaps 0.2.9 left open, and fixes a bug that let two runs share one desktop folder. Release notes are in docs/release-0.2.10.md.
What's in it
- Symbol and pin-type check on Altium.
altium schematicchecks the drawn sheet after it compiles:- every pin sits on its body edge, in every display mode and as drawn;
- pin types, using the KiCad gate's rules;
- polarity drawings on small parts.
- The result goes into the schematic proof, the verified list reads it, and
finishrefuses on errors. - New command:
altium symbols, which runs the same check again on an existing sheet. - Symbol specs are kept in
<run>/altium/symbols/, so every display mode is checked.
- Project-library parts on Altium. A part with no public wiki page comes from the board's project library (the same folder
parts --project-libvalidates) through the same converters as a wiki part. Wiki parts now clone the pagepartsmatched. - One desktop project folder per run. Every run is named
aiflow-run, so the Altium and Fusion lanes put every project inDocuments/AdomAiFlow/aiflow-run, and a second project overwrote the first one's files. The folder is now<project>-<run>, claimed with a.aiflow-runmarker; a folder another run claimed gets a numbered sibling. - Bridge-version guard. Before Altium Bridge 0.2.52 every library pin was Passive and
symbol_primsanswered any mode's body. On such a bridge, those findings are warnings that say to update the bridge.
Tested
cargo test --workspace: all pass (adom-aiflow 218).tests/altium-lane.py: 15 OK (new).tests/project-page-library.py: 14 OK.- Live on DESKTOP-BAR6S0F, Altium Designer 26.11.1, on the Altium Bridge 0.2.52 dev build:
altium symbolson the 2026-10-16 TPS54202 run: first it surfaced two bridge bugs, which are now fixed in 0.2.52 (a wider display mode's body, a capacitor's single plate). Then it passed, with only the "no kept spec" note for that run's pre-0.2.10 library.- A scratch run with one invented part that has no wiki page (
ADOMTEST-10K0402):parts --project-liblisted it.altium libraryimported it, reporting "from the project library", intoDocuments/AdomAiFlow/gapb-aiflow-run(the folder fix, live).altium schematiccompiled pin for pin with ERC passing; symbols and pin types were checked on 2 symbols with 0 warnings.
- Offline: the folder choice (a claimed folder gets
-2, a run reuses its own), and the version guard.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,648 +1,649 @@⋯ 3 unchanged lines ⋯ [[package]] name = "adom-aiflow"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-analyze", "aiflow-board",⋯ 14 unchanged lines ⋯ [[package]] name = "aiflow-analyze"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "serde",⋯ 3 unchanged lines ⋯ [[package]] name = "aiflow-board"-version = "0.2.9"+version = "0.2.10" dependencies = [ "roxmltree", "serde",⋯ 2 unchanged lines ⋯ [[package]] name = "aiflow-bridge"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "serde",⋯ 2 unchanged lines ⋯ [[package]] name = "aiflow-copper"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "aiflow-grid",⋯ 3 unchanged lines ⋯ [[package]] name = "aiflow-grid"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "serde",⋯ 2 unchanged lines ⋯ [[package]] name = "aiflow-place"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "serde",⋯ 2 unchanged lines ⋯ [[package]] name = "aiflow-pours"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "aiflow-copper",⋯ 3 unchanged lines ⋯ [[package]] name = "aiflow-router"-version = "0.2.9"+version = "0.2.10" dependencies = [ "aiflow-board", "aiflow-grid",⋯ 3 unchanged lines ⋯ [[package]] name = "aiflow-run"-version = "0.2.9"+version = "0.2.10" dependencies = [ "serde", "serde_json",⋯ 538 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,40 +1,41 @@⋯ 13 unchanged lines ⋯ ] [workspace.package]-version = "0.2.9"+version = "0.2.10" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"⋯ 17 unchanged lines ⋯ lto = true codegen-units = 1 strip = true+@@ -1,5481 +1,5485 @@⋯ 175 unchanged lines ⋯ Pour { #[arg(long)] plan: Option<String> }, /// The Fusion side of a project: `library` (one project library with real 3D and part numbers), `reference` (placement and spec from a reference KiCad board, in the Fusion board's frame), `schematic` (the design started with the fab's rules, the schematic drawn live in groups), `export` (Gerbers, pick and place, BOM). `adom-aiflow fusion <step> --help` for each. Fusion { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> },- /// The Altium side of a project: `library` (one .SchLib/.PcbLib from the parts' wiki pages, 3D bound, Mouser/DigiKey/LCSC numbers checked), `reference` (placement and spec from a reference KiCad board, in the Altium board's frame), `schematic` (the schematic drawn from the netlist in a project, compiled and checked pin by pin, then the board in the same project), `board` (the board alone, built from the netlist, every pin on its net), `reset`, `view3d`. `adom-aiflow altium <step> --help` for each.- Altium { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> },- /// The KiCad lane's export step: Gerbers, drill, pick and place (CPL) and BOM written from the board with the native kicad-cli (ADOM_AIFLOW_KICAD_CLI, else kicad-cli; adom-aiflow-kicad-cli-remote in a container), then re-read against the board (copper layer count, drill hits against vias and drilled pads, CPL and BOM references, sides and top rotations). Records the `export` proof only when every file matches. Fusion: `fusion export`.- Export { /// the board (default: the run's current board)- #[arg(long)] board: Option<String>, /// where the set goes (default: <run>/manufacturing)- #[arg(long)] out: Option<PathBuf> },- /// The board twin's native STEP export (kicad-cli pcb export step with models, tracks, pads, zones, mask and silkscreen; never fewer layers). Its output is kept and classified (geometry, model, other) apart from the exit status; geometry warnings block `board-glb copper` and `deliver` until `step-export --ack --why` after a review of the STEP. --log classifies the output of an export run elsewhere (the desktop).- StepExport { /// the board (default: the run's current board)- #[arg(long)] board: Option<String>, /// the STEP to write (default: <run>/twin/<board>.step), or with --log the STEP that export wrote- #[arg(long)] out: Option<PathBuf>, /// classify this saved export output instead of running kicad-cli- #[arg(long)] log: Option<PathBuf>, /// record that the geometry warnings of the last export were reviewed (needs --why)- #[arg(long)] ack: bool, #[arg(long)] why: Option<String> },- /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay- Unroute { #[arg(long, required = true)] net: Vec<String> },- /// KiCad DRC offline on the routing plan- Gate { #[arg(long)] plan: Option<String> },- /// Replay live through the bridge: route, vias, pours, moves- Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64, /// route/vias: start at this trace number (1-based, as the progress lines count); traces already on the live board are skipped anyway- #[arg(long)] from: Option<usize> },- /// Verify existing saved filled zones against an explicit reviewed plan, without landing or claiming ownership (--adopt --why takes ownership; --record-plan records a reviewed plan before the zones change)- VerifyPours {- /// the zone plan: the one `pour` wrote, or a reviewed plan recorded with --record-plan before the zones changed- #[arg(long)] plan: String,- /// take ownership of the matched zones (needs --why); recorded as adopted, not landed- #[arg(long)] adopt: bool,- /// reason for --adopt, or for accepting a plan whose provenance is not established; recorded with the proof- #[arg(long)] why: Option<String>,- /// only record this reviewed plan's hash now, before the zones it describes change- #[arg(long)] record_plan: bool,- },- /// Filled copper per layer from the live board- Measure,- /// current or thermal. `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited peak over the Fields density screen (repeat --ack for more); it covers only that place (the pad or 0.5 mm), keeps the number reported and expires when the copper fingerprint or the spec changes. spec.boardSink declares a board-face heat sink for named parts (thermal).- Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf>, #[arg(long)] ack: Vec<String>, #[arg(long)] why: Option<String> },- /// The finish line; refuses until every check passes- Finish,- /// Record a change to the spec's budgets (loads, hot, rise limits, switch-node length, net widths, fab) after placement began. Budgets are frozen at placement; this is the only way to move one, and it stays on the record.- SpecAmend { #[arg(long)] why: String },- /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.- Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },- /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.- Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message)- #[arg(long)] accept_suspect: bool },- /// Check explicit project hero/datasheet bindings, README screenshots and optional published API readback. Still requires rendered visual review.- ProjectPageCheck { #[arg(long)] project: String, #[arg(long)] live_json: Option<String> },- /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/, --refresh reloads it in the human's browser. Runs by itself at every step change and at deliver once a page is set.- Report { #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)- #[arg(long)] surface: Option<String> },- /// Record an issue or PR your AI filed back on adom/adom-aiflow (or any page) during this run; deliver counts them.- Giveback { url: String, #[arg(long)] kind: Option<String>, #[arg(long)] note: Option<String> },- /// Re-cut every clip of the run (the 10x cut and the motion-only action cut with the real-time clock), for runs recorded before a cut existed or after the cut rules improved.- Recut,- /// Compose the run's final video from the raw clips: only the frames where something moved, in order, paced so the whole video fits --max-seconds (2 minutes by default); the AI's analysis drawings as fullscreen shots; the narration cut to fit each segment, never the other way round. Replaces the delivered video when the run is already delivered.- Compose { /// where to write the video: without --out, final.mp4 in the run directory; a relative --out is resolved against the current working directory (like every other path flag), and its folder is created- #[arg(long)] out: Option<String>, /// the longest the final video may be, in seconds- #[arg(long, default_value_t = 120.0)] max_seconds: f64, /// seconds a fullscreen analysis drawing stays on screen- #[arg(long, default_value_t = 5.0)] still_seconds: f64, /// another run to put beside this one: the side-by-side, this run on the left, step by step in flow order, both runs' own clocks and drawings- #[arg(long)] with: Option<PathBuf>, /// for --with: a narration script (JSON: {"<step>": "words", "drawings:<step>": "words", "drawings:<step>:<n>": "words"}); the words lead and each segment's picture is paced to them, so there is no dead air- #[arg(long)] script: Option<PathBuf> },- /// Register a file the AI made during the current step (an analysis drawing, a plot): it goes on the run page under the step and, for images, into the final video as a fullscreen shot at that point of the step.- Artifact { #[arg(long)] file: String, #[arg(long, default_value = "analysis-image")] kind: String, #[arg(long)] caption: String, #[arg(long)] step: Option<String>, /// an earlier artifact (its file path) this one replaces: the redone drawing takes its place on the page and in the video- #[arg(long)] replaces: Option<String>, #[arg(long)] review: Option<PathBuf> },- /// Snapshot the engine's plan usage (percent used per limit window) into the ledger now; the binary also does this at start, every step, finish and deliver.- Usage,- /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement. Also checks every bound model is right way up (step2glb bbox after the footprint's rotate/offset); warnings must be fixed or acknowledged (--ack/--why) before finish.- Models { /// only the offline 3D orientation check (no live kicad_model_check through the bridge)- #[arg(long)] offline: bool, /// acknowledge orientation warnings for these references (comma separated) after looking at them in the native 3D viewer- #[arg(long)] ack: Option<String>, /// why the acknowledged models are right as they are- #[arg(long)] why: Option<String> },- /// Walkthroughs on camera: `tour 3d` opens the 3D Viewer and runs the scripted tour as this step's clip; `tour nets` selects each key net (loads, wide nets, Kelvin) so the editor highlights its pours and traces together, frames it, and marks the clip.- Tour { what: String, #[arg(long)] nets: Option<String>, /// for `tour fields`: the port adom-fields serves on in this container- #[arg(long, default_value_t = 8874)] port: u16, /// for `tour fields`: the desktop whose pup window is filmed (default: the run's test box)- #[arg(long)] on: Option<String> },- /// Print the append-only ledger (run.jsonl): every turn with its thinking and tool seconds, every step visit and return, every stage and outcome, in order.- Ledger { #[arg(long)] json: bool },- /// open, start, mark <label>, stop- Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String> },- /// The comparison page: a sub-README for this run and another (`compose --with` first), the side-by-side video first, then the numbers from both ledgers, the AIs' drawings side by side and the links to both run pages; --push puts it on the page under docs/comparisons/<a>-vs-<b>/- Compare { #[arg(long)] with: PathBuf, #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool },- /// Where the run stands- Status,- /// A new human prompt on this run (a follow-up: "add the walkthroughs", "recut the video"): the AI's time for it counts from here to its `done`. --at is the paste time when you start late.- Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String> },- /// Stop session accounting after the requested scope is complete or a genuine blocking handoff. This does not complete the engineering run.- Done { #[arg(long)] message: String,- /// JSON evidence of an explicit user pause or an unavoidable external dependency. Never a gate waiver.- #[arg(long)] stop_evidence: Option<PathBuf> },- /// Read-only pre-final check. Exit 2 means continue working; exit 0 means delivered or an evidenced stop. Does not restart an AI runtime.- FinishGuard { #[arg(long)] json: bool, #[arg(long)] stop_evidence: Option<PathBuf> },- /// The run as sessions: each human prompt, the AI's active time on it, the idle gaps cut out (a gap over --idle-minutes with no command running is unobserved activity, not proven human idle).- Sessions { #[arg(long)] json: bool },-}--static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();-static DASHBOARD_ATTEMPTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();-static DASHBOARD: std::sync::OnceLock<(PathBuf, String)> = std::sync::OnceLock::new();--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::sequence_key(dir, &r.data)));- sequence::write_timeline(dir, &sequence::timeline(dir, &r.data, &flow, &seq, &r.step_report()), TIMELINE_PAGE);- }-}--fn ensure_dashboard(dir: &Path, ai_thread: &str, force_show: bool) {- if Run::open(dir).is_none() || std::env::var("AIFLOW_DASHBOARD").as_deref() == Ok("off") { return; }- let _ = DASHBOARD_ATTEMPTED.set(());- refresh_dashboard(dir);- if let Err(e) = dashboard::ensure(dir, ai_thread, None, force_show) {- eprintln!("Dashboard unavailable: {e}. Board results are unchanged. Retry `adom-aiflow timeline open --run {}` when Hydrogen is available.", dir.display());- }-}---/// Close the ledger entry for this command (see Run::turn_begin).-fn close_turn(ok_: bool, first_line: &str) {- if let Some((dir, idx)) = TURN.get() {- if let Some(mut r) = Run::open(dir) {- r.turn_end(*idx, ok_, first_line);- let _ = r.save();- }- }- if let Some((dir, ai_thread)) = DASHBOARD.get() {- // Intake/start create the run during dispatch; existing commands refresh after completion.- if DASHBOARD_ATTEMPTED.get().is_none() { ensure_dashboard(dir, ai_thread, false); }- else { refresh_dashboard(dir); }- }-}--fn ok(msg: &str, hints: &[String]) {- close_turn(true, msg.lines().next().unwrap_or(""));- println!("OK: {msg}");- for h in hints {- if !h.is_empty() {- println!("Hint: {h}");- }- }-}--/// Where this command's input is described (issue #56: the skill file by absolute path and its-/// section), set once in main and printed under every ERROR of the command.-static INPUT_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new();--fn err(msg: &str, hints: &[String]) -> ! {- close_turn(false, msg.lines().next().unwrap_or(""));- println!("ERROR: {msg}");- for h in hints {- if !h.is_empty() {- println!("Hint: {h}");- }- }- if let Some(h) = INPUT_HINT.get() { println!("Hint: {h}"); }- std::process::exit(2)-}--fn thread(cli: &Cli) -> String {- cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()]))-}--fn run_dir(cli: &Cli) -> PathBuf {- cli.run.clone().unwrap_or_else(|| std::env::var("AIFLOW_RUN").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("./aiflow-run")))-}--fn load_run(cli: &Cli) -> Run {- let d = run_dir(cli);- if let Some(r) = Run::open(&d) {- if let Some(b) = r.data["remoteBoard"].as_str() { aiflow_bridge::eda::set_run_board(b); }- // the run's desktop is where a remote KiCad runs, unless the human named another- if let (Some(t), Err(_)) = (r.data["target"].as_str(), std::env::var("KICAD_REMOTE_TARGET")) { std::env::set_var("KICAD_REMOTE_TARGET", t); }- return r;- }- Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()]))-}--fn read_json(p: &str) -> Value {- serde_json::from_str(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{p}: not JSON: {e}"), &[]))-}--/// The `start` hint for the design-rule card (rules_card.rs).-fn rules_card_hint(flags: &[String]) -> String {- if flags.is_empty() { "Design-rule card: every spec size is inside the fab lane's range (drawn to scale on the dashboard; rules-card.json). It changes no gate.".into() }- else { format!("Design-rule card: {} value(s) outside the fab lane's range (the WARNING lines above; rules-card.json and the dashboard). Fix the spec before placement if it is a unit slip; the card changes no gate.", flags.len()) }-}--fn spec_of(r: &Run) -> Value {- if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); }- let spec = read_json(r.data["spec"].as_str().unwrap_or(""));- guard_budgets(r, &spec);- spec_nets::align(spec, &r.current_board())-}--/// Once placement has begun the spec's budgets are frozen (`spec_freeze`): freeze them on the first read, and-/// refuse a spec whose budgets moved since without a `spec-amend` on the record.-fn guard_budgets(r: &Run, spec: &Value) {- let placed = r.data["steps"]["placement"]["visits"].as_array().map(|a| !a.is_empty()).unwrap_or(false);- if !placed { return; }- let Some(rec) = spec_freeze::read(&r.dir) else {- let step = r.data["currentStep"].as_str().unwrap_or("").to_string();- let rec = spec_freeze::frozen(spec, &now(), &step);- spec_freeze::write(&r.dir, &rec);- r.log("spec-budgets-frozen", json!({"step": step, "budgets": rec["budgets"]}));- return;- };- let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(spec));- if changed.is_empty() { return; }- err(&format!("the spec's budgets changed after placement began ({} change(s)): {}. The board changes to meet the budgets; the budgets do not move to meet the board", changed.len(), changed.join("; ")), &[- format!("Put the spec back (the frozen values are in {}) and fix the board: more copper, vias, a better placement.", r.dir.join(spec_freeze::FILE).display()),- "If a budget really is wrong (a misread datasheet value), amend it on the record: `adom-aiflow spec-amend --why \"<datasheet page and what was misread>\" --ai-thread <you>`. Every amendment shows in the run ledger and the finish summary.".into(),- ]);-}--/// The spec when there is one, Null before the board (an intake run) or when it does not read.-fn spec_soft(r: &Run) -> Value {- let spec = r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);- spec_nets::align(spec, &r.current_board())-}--/// Issue #75: a switching converter whose spec lacks Fields' `voltages`/`sources`/`switching` (or whose-/// Fields report modelled no converter). Warns (stderr WARNING and a hint) where the spec is read early;-/// `tour fields` and finish refuse on the same check.-fn converter_warning(data: &Value, spec: &Value, fields: Option<&Value>, board: Option<&Board>) -> Vec<String> {- let calcs = converter::calcs_of_run(data);- let Some((msg, hints)) = converter::check(spec, calcs.as_ref(), fields, board) else { return Vec::new() };- eprintln!("WARNING: {msg}. The fields step will refuse until the spec carries the block and the Fields solve models the converter.");- let mut out = vec![format!("Converter spec for Adom Fields: {msg}; the fields step refuses until it is fixed.")];- out.extend(hints);- out-}--/// requirements.json (aiflow-intake) when the run knows one, else Null.-fn requirements_of(r: &Run) -> Value {- r.data["requirements"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)-}--/// The page the clips go to: `clips --page`, else the run's page (`start --page`, `report --page`).-fn clips_page_of(r: &Run) -> Option<String> {- r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string)-}--/// Rebuild docs/clips.md in <run>/clips-page and, with `push`, put it and the changed clip media-/// on the page (`adom-wiki repo push --files`, batches of about 20 MB). Refuses to push a page-/// with an em-dash or a match of $AIFLOW_BANNED_RE. Returns (what happened, the files).-fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String, Vec<String>), String> {- let notes: Value = std::fs::read_to_string(dir.join("clip-notes.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);- let page_ref = r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string);- let (md, media) = preboard::clips_page(&r.data, dir, ¬es, &now(), page_ref.as_deref());- let stage = dir.join("clips-page");- std::fs::create_dir_all(stage.join("docs").join("clips")).map_err(|e| format!("{}: {e}", stage.display()))?;- // docs/clips.md belongs to the first run that published it (a hidden marker says which). A later- // run on the same page writes docs/clips-<run>.md and adds one link to it in docs/clips.md,- // so a second run never overwrites the first run's clips.- let id = r.data["reportId"].as_str().map(str::to_string).unwrap_or_else(|| report_id(r));- let marker = format!("<!-- aiflow-clips-run: {id} -->");- let remote = std::process::Command::new("adom-wiki").args(["repo", "show", page, "docs/clips.md"]).output().ok()- .filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.trim_start().starts_with('#') || t.contains("aiflow-clips-run"));- let owner = match &remote { None => true, Some(t) => t.contains(&marker) };- let doc = if owner { "docs/clips.md".to_string() } else { format!("docs/clips-{id}.md") };- let md = if owner { format!("{marker}\n{md}") } else { format!("{marker}\n{}", md.replacen("# Build clips", &format!("# Build clips: run {id}"), 1)) };- std::fs::write(stage.join(&doc), &md).map_err(|e| e.to_string())?;- let mut index_update: Option<String> = None;- if let (false, Some(t)) = (owner, &remote) {- let link = format!("clips-{id}.md");- if !t.contains(&link) {- let line = format!("Clips of a later run on this page: [run {id}]({link}).");- let mut lines: Vec<&str> = t.lines().collect();- let at = lines.iter().position(|l| l.starts_with("# ")).map(|i| i + 1).unwrap_or(0);- lines.insert(at, ""); lines.insert(at + 1, &line);- let updated = lines.join("\n") + "\n";- std::fs::write(stage.join("docs").join("clips.md"), &updated).map_err(|e| e.to_string())?;- index_update = Some(updated);- }- }- for (src, rel) in &media {- let dest = stage.join(rel);- let same = std::fs::metadata(&dest).ok().zip(std::fs::metadata(src).ok()).map(|(a, b)| a.len() == b.len()).unwrap_or(false);- if !same { std::fs::copy(src, &dest).map_err(|e| format!("copy {}: {e}", src.display()))?; }- }- let clips = md.matches("\n### Clip ").count();- let mut files: Vec<String> = vec![doc.clone()];- if index_update.is_some() { files.push("docs/clips.md".into()); }- files.extend(media.iter().map(|(_, rel)| rel.clone()));- let banned = std::env::var("AIFLOW_BANNED_RE").ok();- let scrubbed = preboard::scrub(&md, banned.as_deref());- let url = format!("https://wiki.adom.inc/{page}/files/{doc}");- if !push {- return Ok((format!("{doc} rebuilt with {clips} clip(s) and {} media file(s) in {} (dry run, nothing pushed; {}); {url} after --push", media.len(), stage.display(), match &scrubbed { Ok(()) => "the scrub passes".to_string(), Err(e) => format!("a push would be refused: {e}") }), files));- }- scrubbed.map_err(|e| format!("not pushed: {e}"))?;- // only what changed since the last push goes up (docs/clips.md always), in batches the gateway takes- let cache_path = stage.join(".pushed.json");- let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();- let mut todo: Vec<(String, u64, String)> = Vec::new();- for f in &files {- let p = stage.join(f);- let sum = library_review::hash(&p).unwrap_or_default();- if f == &doc || f == "docs/clips.md" || cache.get(f).and_then(|v| v.as_str()) != Some(sum.as_str()) { todo.push((f.clone(), std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0), sum)); }- }- let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];- let mut acc = 0u64;- for f in &todo {- if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }- batches.last_mut().unwrap().push(f); acc += f.1;- }- let (mut pushed, mut failed) = (0usize, Vec::new());- for batch in batches.iter().filter(|b| !b.is_empty()) {- let mut cmd = std::process::Command::new("adom-wiki");- cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("clips: {clips} clip(s), {}", r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);- for f in batch { cmd.arg(&f.0); }- match cmd.output() {- Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }- Ok(o) => failed.push(format!("{} file(s): {}", batch.len(), aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 160))),- Err(e) => failed.push(format!("{} file(s): {e}", batch.len())),- }- }- let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());- r.log("clips-page", json!({"page": page, "clips": clips, "pushed": pushed, "unchanged": files.len() - todo.len(), "failed": failed}));- if !failed.is_empty() { return Err(format!("push failed for {}; the next clip stop (or `clips --push`) sends it again", failed.join("; "))); }- Ok((format!("clips page pushed: {clips} clip(s), {pushed} changed file(s) ({} unchanged skipped): {url}", files.len() - todo.len()), files))-}--/// After a clip stops: when the run has a page, rebuild and push the clips page. Best effort: a-/// failure is said, never a failed step.-fn auto_clips(r: &Run, dir: &Path) -> String {- if std::env::var("AIFLOW_AUTO_PUBLISH_CLIPS").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false) {- return "Automatic raw-clip publication disabled; publish reviewed, narrated clips explicitly.".into();- }- let Some(pg) = clips_page_of(r) else { return String::new() };- match publish_clips(r, dir, &pg, true) { Ok((s, _)) => s, Err(e) => format!("clips page: {e}") }-}--/// The user's primary machine (`adom-bridge targets` names it).-fn primary_machine() -> Option<String> {- let o = std::process::Command::new("adom-bridge").arg("targets").output().ok()?;- preboard::primary_from_targets(&serde_json::from_slice::<Value>(&o.stdout).ok()?)-}--/// The board's own copper as plan-shaped entries (one per net and track width, one per via), so the-/// analyses see a board whose copper is already on it (an adopted, hand-reworked or natively routed-/// board) and not only what a routing plan adds.-fn board_copper_plan(b: &Board) -> Value {- let net_of = |n: &aiflow_board::sx::Node| -> String {- let Some(x) = n.find("net") else { return String::new() };- match x.number(1) { Some(v) => b.net_table.get(&(v as i64)).cloned().unwrap_or_default(), None => x.atom(1).unwrap_or("").to_string() }- };- let mut entries: Vec<Value> = Vec::new();- let mut seen: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();- for s in b.root.find_all("segment") {- let (net, w) = (net_of(s), s.find("width").and_then(|w| w.number(1)).unwrap_or(0.0));- if net.is_empty() || w <= 0.0 { continue; }- if seen.insert((net.clone(), format!("{w:.4}"))) { entries.push(json!({"net": net, "width": w, "source": "board"})); }- }- for v in b.root.find_all("via") {- let net = net_of(v);- if net.is_empty() { continue; }- let at = v.find("at");- let (x, y) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0));- let drill = v.find("drill").and_then(|d| d.number(1)).unwrap_or(0.3);- entries.push(json!({"net": net, "viaDrill": drill, "paths": [[{"layer": "B.Cu", "x": x, "y": y}]], "source": "board"}));- }- json!({"nets": entries})-}--/// The plan, plus the board's own copper for every net the plan carries no copper for.-fn plan_with_board_copper(plan: &Value, b: &Board) -> Value {- let mut entries: Vec<Value> = plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default();- let planned: std::collections::BTreeSet<String> = entries.iter().filter_map(|e| e.get("net").and_then(|n| n.as_str()).map(str::to_string)).collect();- for e in board_copper_plan(b)["nets"].as_array().cloned().unwrap_or_default() {- if !planned.contains(e["net"].as_str().unwrap_or("")) { entries.push(e); }- }- json!({"nets": entries})-}--fn pad_layers_of(b: &Board) -> BTreeMap<String, Vec<String>> {- let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();- for p in &b.pads {- if p.thru || p.net.is_empty() {- continue;- }- let l = if p.on_f { "F.Cu" } else { "B.Cu" };- let e = m.entry(p.net.clone()).or_default();- if !e.iter().any(|x| x == l) {- e.push(l.to_string());- }- }- m-}--/// Zone uuids of the board the run started from (the copy `start` made): those are inherited;-/// every other copper or keepout zone on the live board is the flow's own.-fn inherited_zone_ids(r: &Run) -> Vec<String> {- let Some(p) = r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()) else { return Vec::new() };- let text = std::fs::read_to_string(p).unwrap_or_default();- let mut ids = Vec::new();- let mut from = 0;- while let Some(i) = text[from..].find("(zone") {- let start = from + i;- let end = text[start..].find("(polygon").map(|e| start + e).unwrap_or(text.len());- if let Some(u) = text[start..end].find("(uuid \"") {- let s = start + u + 7;- if let Some(e) = text[s..].find('"') {- ids.push(text[s..s + e].to_string());- }- }- from = start + 5;- }- ids-}--/// The per-step ledger as text: wall, thinking, binary, turns, visits, returns.-fn step_table(r: &Run) -> String {- let rep = r.step_report();- let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];- let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();- for k in rep.keys() {- if !names.contains(k) {- names.push(k.clone());- }- }- let mut lines = vec![format!("{:<11} {:>9} {:>9} {:>9} {:>6} {:>7} {:>8} {}", "step", "wall min", "think min", "tool min", "turns", "visits", "returns", "longest think")];- let (mut tw, mut tt, mut tb, mut tn) = (0u64, 0u64, 0u64, 0u64);- for n in names {- let e = &rep[&n];- let (w, t, b, k) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0), e["turns"].as_u64().unwrap_or(0));- tw += w; tt += t; tb += b; tn += k;- lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8} {:.1} min", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));- }- lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6}", "total", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0, tn));- lines.join("\n")-}----/// Free megabytes on the drive holding the run directory.-fn local_free_mb(dir: &Path) -> Option<u64> {- let out = std::process::Command::new("df").args(["-Pk", dir.to_str().unwrap_or(".")]).output().ok()?;- let text = String::from_utf8_lossy(&out.stdout);- let line = text.lines().last()?;- line.split_whitespace().nth(3)?.parse::<u64>().ok().map(|k| k / 1024)-}--/// The recording budget for a step: measured window captures run about 8 MB per minute of-/// changing screen (H.264 on the box's GPU encoder, frames only when the window changes), so the-/// budget is the flow's expected minutes for the step at 10 MB per minute, doubled for the pull-/// and the 10x cut. Refuses to start a clip when either side has less than the budget plus 1 GB.-fn clip_budget_mb(step: &str) -> u64 {- let minutes = match step { "placement" => 30, "routing" => 20, "pours" => 8, "intake" => 5, _ => 3 };- minutes * 10 * 2-}--fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> {- let need = clip_budget_mb(step);- let local = local_free_mb(dir);- let remote = br.remote_free_mb();- let floor = need + 1024;- let mut msgs = Vec::new();- if let Some(l) = local {- if l < floor {- return Err(format!("the run directory's drive has {l} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. Free space (old runs' raw clips, `*.mp4` without the 10x suffix, are the usual culprit) before starting the clip"));- }- msgs.push(format!("run drive {l} MB free"));- }- if let Some(rm) = remote {- if rm < floor {- return Err(format!("the test box's recording drive has {rm} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. ab prunes its recordings folder after 2 days / 40 files / 3 GB, but not mid-run: clear it or free the drive before starting the clip"));- }- msgs.push(format!("test box {rm} MB free"));- }- Ok(format!("{} for a {step} clip budgeted at {need} MB", msgs.join(", ")))-}---/// The Adom faces, embedded so every caption on every machine is set in them.-fn run_fonts(dir: &Path) -> (String, String) {- let fd = dir.join("fonts");- let _ = std::fs::create_dir_all(&fd);- let files: [(&str, &[u8]); 3] = [("FamiljenGrotesk-Bold.ttf", include_bytes!("../../../assets/fonts/FamiljenGrotesk-Bold.ttf")), ("Satoshi-Regular.ttf", include_bytes!("../../../assets/fonts/Satoshi-Regular.ttf")), ("Satoshi-Medium.ttf", include_bytes!("../../../assets/fonts/Satoshi-Medium.ttf"))];- for (n, b) in files { let p = fd.join(n); if !p.is_file() { let _ = std::fs::write(&p, b); } }- (fd.join("FamiljenGrotesk-Bold.ttf").display().to_string(), fd.join("Satoshi-Medium.ttf").display().to_string())-}--/// drawtext-safe text: no quotes, colons and commas escaped, percent doubled.-fn dt(t: &str) -> String {- t.replace('\\', "").replace('\'', "").replace(':', "\\:").replace(',', "\\,").replace('%', "%%")-}---/// 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), 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");- c.args(["-s", "-m", "12", url]);- for h in headers { c.args(["-H", h]); }- let o = c.output().ok()?;- serde_json::from_slice::<Value>(&o.stdout).ok()- };- if let Ok(t) = std::fs::read_to_string(format!("{home}/.claude/.credentials.json")) {- if let Ok(c) = serde_json::from_str::<Value>(&t) {- let oa = c.get("claudeAiOauth").cloned().unwrap_or(c.clone());- if let Some(tok) = oa.get("accessToken").and_then(|v| v.as_str()) {- if let Some(u) = curl("https://api.anthropic.com/api/oauth/usage", &[format!("Authorization: Bearer {tok}"), "anthropic-beta: oauth-2025-04-20".into(), "User-Agent: adom-aiflow".into()]) {- for lim in u.get("limits").and_then(|l| l.as_array()).cloned().unwrap_or_default() {- let Some(pct) = lim.get("percent").and_then(|p| p.as_f64()) else { continue };- let label = match lim.get("kind").and_then(|k| k.as_str()) {- Some("session") => "5h".to_string(),- Some("weekly_all") => "week".to_string(),- Some("weekly_scoped") => lim["scope"]["model"]["display_name"].as_str().unwrap_or("model").to_string(),- Some(k) => k.to_string(),- None => "limit".to_string(),- };- out.push(json!({"provider": "claude", "label": label, "pct": pct, "resetsAt": lim.get("resets_at").cloned().unwrap_or(Value::Null)}));- }- if out.is_empty() {- for (label, key) in [("5h", "five_hour"), ("week", "seven_day")] {- if let Some(v) = u.get(key).and_then(|x| x.get("utilization")).and_then(|x| x.as_f64()) { out.push(json!({"provider": "claude", "label": label, "pct": v})); }- }- }- }- }- }- }- if let Ok(t) = std::fs::read_to_string(format!("{home}/.codex/auth.json")) {- if let Ok(a) = serde_json::from_str::<Value>(&t) {- let tk = a.get("tokens").cloned().unwrap_or(Value::Null);- if let Some(tok) = tk.get("access_token").and_then(|v| v.as_str()) {- let acct = tk.get("account_id").and_then(|v| v.as_str()).unwrap_or("");- if let Some(u) = curl("https://chatgpt.com/backend-api/wham/usage", &[format!("Authorization: Bearer {tok}"), format!("ChatGPT-Account-Id: {acct}"), "User-Agent: adom-aiflow".into(), "Accept: application/json".into()]) {- let rl = u.get("rate_limit").cloned().unwrap_or(Value::Null);- let label = |w: &Value| -> String { let secs = w.get("limit_window_seconds").and_then(|v| v.as_f64()).unwrap_or(0.0); if secs >= 6.0 * 86400.0 { "week".into() } else if secs >= 4.0 * 3600.0 { "5h".into() } else { format!("{}h", ((secs / 3600.0).floor() as i64).max(1)) } };- for key in ["primary_window", "secondary_window"] {- if let Some(w) = rl.get(key).filter(|w| w.is_object()) {- out.push(json!({"provider": "codex", "label": label(w), "pct": w.get("used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0), "plan": u.get("plan_type").cloned().unwrap_or(Value::Null)}));- }- }- }- }- }- }- out-}--/// 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 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!([]); }- 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"];- // the run's own conversation: the one whose counters grew most over the run (the AI calls aiflow- // from it); other Antigravity conversations on the machine (another project) are left out- let (first, last) = (&snaps[0]["agyTokens"], &snaps[snaps.len() - 1]["agyTokens"]);- let conv: Option<String> = last["byConversation"].as_object().and_then(|m| m.iter().map(|(id, c)| (id.clone(), c["calls"].as_i64().unwrap_or(0) - first["byConversation"][id]["calls"].as_i64().unwrap_or(0))).filter(|(_, d)| *d > 0).max_by_key(|(_, d)| *d).map(|(id, _)| id));- let pick = |s: &Value| -> Value { match &conv { Some(id) => { let c = &s["byConversation"][id]; if c.is_object() { c.clone() } else { json!({"calls": 0, "freshIn": 0, "cachedIn": 0, "out": 0}) } } None => s.clone() } };- let snaps: Vec<Value> = snaps.into_iter().map(|mut s| { s["agyTokens"] = pick(&s["agyTokens"]); s }).collect();- 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, "conversation": conv}))-}--/// 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": (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(); }- 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-}---/// The overlay chain the final video draws on a linear-time (10x) cut: the step chip top left,-/// the engine and board top right, and in the lower right two small tables, "RUN, SINCE YOUR-/// PROMPT" at the far right and "THIS STEP" beside it, headers HR / MIN / SEC over big numbers,-/// the HR column only when the run passed an hour. Same type sizes and row lines, 16 px margins.-/// Drawn after the decimation with t = real seconds inside the clip; `offset` is the clip's start-/// since the prompt. Returns the full -vf string from the 10x cut to the captioned action cut.-/// The overlay filters of the approved layout (step chip top-left, engine and board top-right, the-/// run clock and the step clock lower-right); `offset` is the segment's start on the run clock,-/// `step_offset` its start on the step clock, both in seconds, both advancing with the frame time.-fn overlay_filters(dir: &Path, title: &str, engine_board: &str, offset: f64, step_offset: f64, run_has_hours: bool) -> String {- let (font_display, font_text) = run_fonts(dir);- let off = format!("{offset:.0}");- let m = 16.0; let big = 26.0; let small = 11.0; let lab = 13.0; let pad = 12.0; let gap = 12.0; let colw = 38.0;- let h_box = pad + 16.0 + 6.0 + 14.0 + 6.0 + big + pad;- let run_secs = format!("(t+{off})");- let (run_cols, run_w): (Vec<(String, String)>, f64) = if run_has_hours {- (vec![("HR".into(), format!("%{{eif\\:{run_secs}/3600\\:d}}")), ("MIN".into(), format!("%{{eif\\:mod({run_secs}/60\\,60)\\:d\\:2}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 3.0 * colw + 2.0 * gap)- } else {- (vec![("MIN".into(), format!("%{{eif\\:{run_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 2.0 * colw + gap)- };- let run_inner = run_w.max(162.0);- let step_secs = format!("(t+{step_offset:.0})");- let step_cols: Vec<(String, String)> = vec![("MIN".into(), format!("%{{eif\\:{step_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({step_secs}\\,60)\\:d\\:2}}"))];- let step_inner = (2.0 * colw + gap).max(62.0);- let mut ov: Vec<String> = Vec::new();- let run_box_w = run_inner + 2.0 * pad; let step_box_w = step_inner + 2.0 * pad;- ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={run_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w, m + h_box));- ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={step_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w + m + step_box_w, m + h_box));- let y_lab = format!("h-{}", m + h_box - pad); let y_hdr = format!("h-{}", m + h_box - pad - 16.0 - 6.0); let y_num = format!("h-{}", m + h_box - pad - 16.0 - 6.0 - 14.0 - 6.0);- let run_left = m + pad + run_inner; let step_left = m + run_box_w + m + pad + step_inner;- ov.push(format!("drawtext=fontfile={ft}:text='AI TIME, THIS RUN':x=w-{run_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));- ov.push(format!("drawtext=fontfile={ft}:text='THIS STEP':x=w-{step_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));- for (k, (hdr, expr)) in run_cols.iter().enumerate() {- let cx = format!("w-{}+{}", run_left, k as f64 * (colw + gap) + colw / 2.0);- ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));- ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));- }- for (k, (hdr, expr)) in step_cols.iter().enumerate() {- let cx = format!("w-{}+{}", step_left, k as f64 * (colw + gap) + colw / 2.0);- ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));- ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));- }- ov.push(format!("drawtext=fontfile={fd}:text='{}':x={m}:y={m}:fontsize=24:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=10", dt(title), fd = font_display));- ov.push(format!("drawtext=fontfile={ft}:text='{}':x=w-tw-{m}:y={m}:fontsize=15:fontcolor=0xD9E1E8:box=1:[email protected]:boxborderw=8", dt(engine_board), ft = font_text));- ov.join(",")-}--/// The action cut with overlays: the 10x cut stretched back to real time (so the clocks are-/// honest), near-duplicate frames dropped, the overlays drawn, then the kept frames packed at-/// `rate` frames per second (4 is the pace of one step's clip; compose raises it to fit the budget).-fn overlay_chain(dir: &Path, title: &str, engine_board: &str, offset: f64, run_has_hours: bool, speed: u32, rate: f64) -> String {- format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,{},setpts=N/{rate:.3}/TB", overlay_filters(dir, title, engine_board, offset, 0.0, run_has_hours))-}--/// Drops frames that are almost entirely black: the window recorder's first frame is one, and the-/// scripted pace can stretch a single frame across seconds of narration. Every frame is tagged with-/// its black fraction (amount=0), and only the ones under 95 percent black pass.-const DROP_BLACK: &str = "blackframe=amount=0:threshold=24,metadata=mode=select:key=lavfi.blackframe.pblack:value=95:function=less";--/// A step's display name for chips and cards.-fn step_shown(step: &str) -> String {- match step { "3d" => "3D WALKTHROUGH".into(), "nets" => "NET WALKTHROUGH".into(), "models" => "3D MODELS".into(), "current" => "CURRENT ANALYSIS".into(), "thermal" => "THERMAL ANALYSIS".into(), other => other.to_uppercase() }-}---/// A clip stopped twice loses its file on the second stop (the recorder answers "nothing running"),-/// though the ledger kept the first stop's file under the same recording id. Put it back.-fn recover_capture_files(r: &mut Run, dir: &Path) -> usize {- let mut by_rid: std::collections::HashMap<String, String> = Default::default();- for e in &r.ledger() {- if e["event"] == "clip-stop" {- if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) {- let local = dir.join(Path::new(f).file_name().unwrap_or_default());- if local.is_file() { by_rid.insert(rid.to_string(), local.display().to_string()); }- }- }- }- let mut n = 0;- if let Some(caps) = r.data["captures"].as_array_mut() {- for c in caps.iter_mut() {- if c["file"].as_str().is_some() { continue; }- if let Some(f) = c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned() { c["file"] = json!(f); n += 1; }- }- }- if n > 0 { r.mark(&format!("recovered {n} clip file(s) from the ledger")); }- n-}--/// Put one clip stop on its capture. The remote path and the local file are only ever filled in,-/// never blanked: a second stop of the same recording (the recorder answers "nothing running")-/// keeps what the first stop recorded. A clip whose pull did not land after the retries is-/// flagged `pullFailed` with its desktop path, its `file` stays null, and a WARNING names the-/// remote path; `recut`, `finish` and `deliver` pull it again. Returns the local file.-fn settle_clip_stop(r: &mut Run, pos: usize, stop: &ClipStop, dir: &Path) -> Option<String> {- let cap = &mut r.data["captures"][pos];- let had_file = cap["file"].as_str().map(str::to_string);- if stop.remote.is_some() || cap.get("stop").is_none() { cap["stop"] = json!(now()); }- match &stop.remote {- Some(remote) => {- cap["remoteFile"] = json!(remote);- let kept = had_file.as_deref().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false);- if stop.pulled {- cap["file"] = json!(dir.join(Path::new(remote).file_name().unwrap_or_default()).display().to_string());- if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }- } else if !kept {- cap["file"] = Value::Null;- cap["pullFailed"] = json!(true);- cap["pullAttempts"] = json!(stop.attempts);- eprintln!("WARNING: clip {}-{} did NOT reach the run after {} pull(s): it is still on the desktop at {remote}. Run `adom-aiflow recut` to pull it again before the desktop cleans its recordings folder.", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1), stop.attempts);- }- }- None => {- if had_file.is_none() { cap["file"] = Value::Null; }- if cap.get("remoteFile").is_none() { cap["remoteFile"] = Value::Null; }- }- }- cap["file"].as_str().map(str::to_string)-}--/// The ledger line of a clip stop: `file` is the desktop path (what recover_capture_files reads),-/// `pullFailed` when the pull did not land.-fn clip_stop_event(mut ev: Value, stop: &ClipStop) -> Value {- ev["file"] = json!(stop.remote);- if stop.remote.is_some() && !stop.pulled { ev["pullFailed"] = json!(true); }- ev-}--/// Pull again every stopped clip that is not in the run: flagged `pullFailed`, or recorded with a-/// local file that never arrived. The desktop path comes from the capture or, for older runs,-/// from the ledger's clip-stop lines. Returns (pulled now, still missing).-fn repull_failed_clips(r: &mut Run, br: &Bridge, dir: &Path) -> (usize, usize) {- let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();- let mut by_rid: std::collections::HashMap<String, String> = Default::default();- for e in &r.ledger() {- if e["event"] == "clip-stop" {- if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) { by_rid.insert(rid.to_string(), f.to_string()); }- }- }- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- let (mut pulled, mut missing) = (0, 0);- for (i, c) in caps.iter().enumerate() {- // running clips have no file key yet; discarded ones are never shown- if c.get("file").is_none() || c.get("discarded").is_some() { continue; }- if c["file"].as_str().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false) { continue; }- let Some(remote) = c["remoteFile"].as_str().map(str::to_string).or_else(|| c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned()) else { continue };- let local = dir.join(Path::new(&remote).file_name().unwrap_or_default()).display().to_string();- let tag = format!("{}-{}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1));- let landed = aiflow_bridge::nonempty(Path::new(&local)) || br.pull_verified(&remote, &save_to, aiflow_bridge::PULL_ATTEMPTS).0;- let cap = &mut r.data["captures"][i];- cap["remoteFile"] = json!(remote);- if landed {- cap["file"] = json!(local);- cap["pulledLater"] = json!(now());- if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }- r.log("clip-pulled", json!({"step": tag, "recordingId": c["recordingId"], "file": remote}));- clip_artifacts(r, &json!(tag), &local, 10);- pulled += 1;- } else {- cap["pullFailed"] = json!(true);- eprintln!("WARNING: clip {tag} is still not in the run: pulling {remote} from the desktop failed again (the desktop may have cleaned it). Re-record the step if it matters.");- missing += 1;- }- }- if pulled > 0 { r.mark(&format!("pulled {pulled} clip(s) that had not reached the run")); }- (pulled, missing)-}--/// True when a STEP carries a laser-etched MPN part: `Adom.LaserEtch` (adom-aiflow-etch 0.1.48+, the-/// shared service) or the legacy `laser_etch` part of earlier releases.-fn has_mpn_mark(bytes: &[u8]) -> bool {- bytes.windows(14).any(|w| w == b"Adom.LaserEtch") || bytes.windows(10).any(|w| w == b"laser_etch")-}--/// IC footprints (U*, IC*) whose bound 3D model is a local STEP without a laser-etched MPN part.-/// Models are found beside the source board (${KIPRJMOD}); an unresolvable path is left to `models`.-fn unmarked_ics(r: &Run) -> Vec<String> {- let Ok(b) = Board::load(&r.current_board()) else { return vec![] };- let proj = r.data["sourceBoard"].as_str().map(|p| Path::new(p).parent().map(|d| d.to_path_buf()).unwrap_or_default()).unwrap_or_default();- let mut out = Vec::new();- for fp in b.root.find_all("footprint") {- let Some(reference) = fp.find_all("property").find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).map(str::to_string) else { continue };- let is_ic = reference.starts_with("IC") || (reference.starts_with('U') && reference[1..].chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false));- if !is_ic { continue; }- for m in fp.find_all("model").filter_map(|n| n.value()) {- let path = m.replace("${KIPRJMOD}", &proj.display().to_string());- if !(path.to_ascii_lowercase().ends_with(".step") || path.to_ascii_lowercase().ends_with(".stp")) { continue; }- match std::fs::read(&path) {- Ok(bytes) => { if !has_mpn_mark(&bytes) { out.push(reference.clone()); } }- Err(_) => {}- }- }- }- out-}--/// Stop the running clip of the current step, pull it, cut it. finish and deliver call this so-/// no recorder outlives the run (a clip left running once recorded nine hours of a static editor).-fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option<String> {- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- let pos = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none())?;- let rid = caps[pos]["recordingId"].as_str()?.to_string();- let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();- let stop = br.record_stop(&rid, &save_to);- let local = settle_clip_stop(r, pos, &stop, dir);- let cap = &mut r.data["captures"][pos];- cap["stoppedBy"] = json!(why);- let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));- r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": why}), &stop));- if let Some(f) = local.clone().filter(|f| Path::new(f).is_file()) { clip_artifacts(r, &st, &f, 10); }- let note = auto_clips(r, dir);- if !note.is_empty() { eprintln!("{note}"); }- local-}---/// The tools every clip check and cut runs. Missing ones are an error naming the tool, never a verdict-/// on the footage (issue #48: with no ffprobe every clip read as "no frames" and compose dropped them all).-const MEDIA_TOOLS: &[&str] = &["ffprobe", "ffmpeg"];--/// Err names the first tool of `tools` that cannot be started.-fn media_tools_missing(tools: &[&str]) -> Result<(), String> {- for t in tools {- if let Err(e) = std::process::Command::new(t).arg("-version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status() {- return Err(format!("{t} is not installed or not runnable ({e}); the clips cannot be checked, cut or composed without it"));- }- }- Ok(())-}--/// The clip check: Err when the media tools are missing (the footage is not judged), else the blank verdict.-fn clip_verdict(file: &str, tools: &[&str]) -> Result<Option<String>, String> {- media_tools_missing(tools)?;- Ok(blank_check(file))-}--/// Verdicts that only the blank check gives; a later passing check (recut) clears them. An AI's own rejection stays.-fn blank_check_verdict(why: &str) -> bool {- why == "no frames" || why == "could not read frames" || why.contains("sampled frames are one flat colour") || why.starts_with("truncated")-}--/// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a-/// painted canvas has content and an unpainted one is one flat colour) is read as 96 x 54 grey-/// pixels and binned; when one bin holds over 80 % of the pixels the frame is flat. More than-/// three quarters flat frames means the clip filmed nothing worth keeping. Returns the reason.-fn blank_check(file: &str) -> Option<String> {- let secs = media_seconds(file);- if secs <= 0.0 { return Some("no frames".into()); }- let n = 8usize;- let mut flat = 0usize; let mut total = 0usize; let mut moved = 0usize; let mut prev: Option<Vec<u8>> = None;- for k in 0..n {- let t = secs * (k as f64 + 0.5) / n as f64;- let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;- let px = out.stdout;- if px.len() < 1000 { continue; }- let mut bins = [0usize; 16];- for b in &px { bins[(*b as usize) >> 4] += 1; }- total += 1;- if bins.iter().max().copied().unwrap_or(0) * 100 > px.len() * 80 { flat += 1; }- // a mostly dark canvas that changes between samples is being worked on (a Fusion board is black- // with the copper growing on it), not blank: count the samples that differ from the one before- if let Some(q) = &prev {- let changed = px.iter().zip(q.iter()).filter(|(a, b)| (**a as i32 - **b as i32).abs() > 16).count();- if changed * 200 > px.len() { moved += 1; }- }- prev = Some(px);- }- if total == 0 { return Some("could not read frames".into()); }- if flat * 4 > total * 3 && moved < 2 { Some(format!("{flat} of {total} sampled frames are one flat colour in the middle of the window (an unpainted or covered canvas)")) } else { None }-}--/// A contact sheet: nine frames spread through the clip on one PNG, so the AI looks at what it-/// filmed before it moves on, and the run page shows it under the clip.-fn contact_sheet(file: &str) -> Option<String> {- let secs = media_seconds(file);- if secs <= 0.0 { return None; }- let out = format!("{}-sheet.png", file.trim_end_matches(".mp4"));- let fps = 9.0 / secs;- let ok = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", file, "-vf", &format!("fps={fps:.6},scale=426:-2,tile=3x3:padding=4:margin=4:color=0x0B1420"), "-frames:v", "1", &out]).status().map(|s| s.success()).unwrap_or(false);- if ok && Path::new(&out).is_file() { Some(out) } else { None }-}--/// Seconds of pure black at the head of a clip (the recorder started before the window painted), up to five.-fn leading_black(file: &str) -> f64 {- let out = std::process::Command::new("ffmpeg").args(["-v", "info", "-t", "6", "-i", file, "-an", "-vf", "blackdetect=d=0.05:pic_th=0.98:pix_th=0.10", "-f", "null", "-"]).output();- let Ok(o) = out else { return 0.0 };- let text = String::from_utf8_lossy(&o.stderr);- for line in text.lines() {- if let Some(i) = line.find("black_start:") {- let rest = &line[i + 12..];- let start: f64 = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(1.0);- let end: f64 = line.find("black_end:").and_then(|j| line[j + 10..].split_whitespace().next()).and_then(|v| v.parse().ok()).unwrap_or(0.0);- if start <= 0.05 && end > 0.0 && end <= 5.0 { return end; }- break;- }- }- 0.0-}--/// The clip's seconds, from ffprobe (0.0 when unknown).-fn media_seconds(path: &str) -> f64 {- std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path]).output().ok().and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok()).unwrap_or(0.0)-}--/// Cut a sped-up copy of a pulled clip next to it (`<name>-10x.mp4`) and record both as artifacts-/// of the step. The window recorder only emits frames on change, so the cut is by timestamp.-fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) { clip_artifacts_with(r, step, file, speed, MEDIA_TOOLS) }--fn clip_artifacts_with(r: &mut Run, step: &Value, file: &str, speed: u32, tools: &[&str]) {- // no ffprobe/ffmpeg: say so and keep the clip as it is, unjudged (never flag the footage blank)- let verdict = match clip_verdict(file, tools) {- Ok(v) => v,- Err(e) => {- eprintln!("ERROR clip {} not checked or cut: {e}. Install ffmpeg (it brings ffprobe), then `adom-aiflow recut`.", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));- r.log("clip-check-unavailable", json!({"step": step, "file": file, "why": e}));- return;- }- };- // issue #59: a take that died before it was finalised has no moov atom; say so now, not at deliver- let truncated = clipwatch::truncated(file);- if let Some(t) = &truncated {- eprintln!("WARNING clip {} is {t}. Re-record the step (`step <name> --back --why \"clip truncated\"`) while the work is fresh.", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));- if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["truncated"] = json!(true); }- r.log("clip-truncated", json!({"step": step, "file": file, "why": t}));- }- let verdict = truncated.or(verdict);- let dir = r.dir.clone();- let tag = step.as_str().unwrap_or("step").to_string();- // the clip's offset from the prompt, so the overlay can show the run's own clock- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- let cap = caps.iter().find(|c| c["file"].as_str() == Some(file) || format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag).cloned().unwrap_or(Value::Null);- let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);- let offset = cap["start"].as_str().and_then(aiflow_run::parse).map(|t| t.saturating_sub(t0)).unwrap_or(0) as f64;- let (step_name, visit, back) = (cap["step"].as_str().unwrap_or(&tag).to_string(), cap["visit"].as_u64().unwrap_or(1), cap["back"].as_bool() == Some(true));- let _ = (&step_name, visit, back, offset, &dir);- let secs = media_seconds(file);- // the blank check: a clip whose sampled frames are nearly one colour filmed nothing (an- // unpainted canvas, a covered window); it is flagged, kept out of the video, and said out loud- // a recut that now sees frames clears a blank verdict an earlier check gave (a run on a box without ffprobe)- if verdict.is_none() {- if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) {- if c["suspect"].as_str().map(blank_check_verdict).unwrap_or(false) {- let was = c["suspect"].clone();- c.as_object_mut().map(|o| o.remove("suspect"));- r.log("clip-suspect-cleared", json!({"step": step, "file": file, "was": was}));- }- }- }- if let Some(why) = &verdict {- eprintln!("WARNING clip {} looks blank: {why}", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));- if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["suspect"] = json!(why); }- r.log("clip-suspect", json!({"step": step, "file": file, "why": why}));- }- r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0, "suspect": verdict}));- if let Some(sheet) = contact_sheet(file) {- r.log("artifact", json!({"step": step, "kind": "contact-sheet", "file": sheet}));- eprintln!("contact sheet (look at it before you go on): {sheet}");- }- let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4"));- // the recorder's first frames are black before the window paints: they are cut off the head- // of both cuts (never more than five seconds; longer black is a real clip problem, kept)- let lead = leading_black(file);- let ss = format!("{lead:.3}");- let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},scale=1280:-2"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false);- if lead > 0.0 { r.log("marker", json!({"label": format!("clip {tag}: {lead:.2} s of black recorder startup cut off the head of its cuts")})); }- if cut {- let fast = media_seconds(&out);- r.log("artifact", json!({"step": step, "kind": format!("clip{speed}x"), "file": out, "seconds": (fast * 10.0).round() / 10.0, "speed": speed}));- eprintln!("clip {:.1} s, {speed}x cut {:.1} s: {out}", secs, fast);- // the action cut: only the frames that changed, with the real elapsed time burned in.- // Made from the 10x cut (cheap): stretch its timestamps back to real time so the clock is- // honest, drop near-duplicate frames, then pack what is left at 30 fps.- let action = format!("{}-action.mp4", file.trim_end_matches(".mp4"));- // the action cut stays RAW (no captions): the final video draws its own overlays from the- // ledger, and a side-by-side of two runs must not paint overlays over overlays- let vf = format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,setpts=N/4/TB");- let ok_action = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &out, "-an", "-vf", &vf, "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &action]).status().map(|s| s.success()).unwrap_or(false);- if ok_action {- let a = media_seconds(&action);- r.log("artifact", json!({"step": step, "kind": "clipAction", "file": action, "seconds": (a * 10.0).round() / 10.0, "realSeconds": (secs * 10.0).round() / 10.0}));- eprintln!("action cut {:.1} s of motion out of {:.1} s: {action}", a, secs);- }- }-}---/// What goes into the final video, in run order: a step visit's motion, or a drawing the AI made.-enum Piece { Library { file:String, caption:String, at:u64, seconds:f64 }, Clip { cap: Value, idx: usize, motion: f64 }, Still { file: String, caption: String, step: String, visit: u64, at: u64 } }--/// Say `tiers` (the fullest first) and keep the first that fits `vlen` seconds; the last tier is-/// said faster when even it runs long. The picture sets the length; the words are cut to it.-fn narrate(workdir: &Path, idx: usize, tiers: &[String], vlen: f64, has_tts: bool) -> Option<(PathBuf, f64, String)> {- if !has_tts || vlen < 1.5 { return None; }- let mut best: Option<(PathBuf, f64, String)> = None;- for (k, words) in tiers.iter().enumerate() {- let n = words.split_whitespace().count() as f64;- let last = k + 1 == tiers.len();- if n > vlen * 2.3 && !last { continue; }- let rate = if last && n > vlen * 2.3 { "+30%" } else { "+0%" };- let audio = workdir.join(format!("say-{idx}-{k}.mp3"));- let ok = std::process::Command::new("adom-tts").args(["say", "--rate", rate, "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);- if !ok { continue; }- let alen = media_seconds(audio.to_str().unwrap());- best = Some((audio, alen, words.clone()));- if alen <= vlen + 0.05 { break; }- }- best-}--/// The first clause of a sentence: up to the first semicolon, colon or comma.-fn first_clause(s: &str) -> String {- s.split([';', ':', ',']).next().unwrap_or(s).trim().to_string()-}--/// A step's name in words for the narration.-fn step_said(step: &str) -> String {- step_shown(step).to_lowercase().replace("3d", "3D")-}--/// Compose the final video. Returns (seconds, clip segments, stills, frame rate of motion, narrated).-fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64) -> (f64, usize, usize, f64, bool) {- let led = r.ledger();- let mut act: std::collections::BTreeMap<String, (f64, f64)> = Default::default();- for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), (e["realSeconds"].as_f64().unwrap_or(0.0), e["seconds"].as_f64().unwrap_or(0.0))); } }- let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- // a clip stopped twice loses its file on the second stop (the recorder answers "nothing running");- // the ledger kept the first stop's file under the same recording id, so recover it from there- let mut ledger_file: std::collections::HashMap<String, String> = Default::default();- for e in &led {- if e["event"] == "clip-stop" {- if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) {- let local = dir.join(Path::new(f).file_name().unwrap_or_default());- if local.is_file() { ledger_file.insert(rid.to_string(), local.display().to_string()); }- }- }- }- // the pieces, in the order they happened: clips by their start, drawings by the moment the AI registered them- let mut pieces: Vec<(u64, Piece)> = Vec::new();- for (i, c) in caps.iter().enumerate() {- if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }- let recovered = c["recordingId"].as_str().and_then(|rid| ledger_file.get(rid)).cloned();- let mut c = c.clone();- if c["file"].as_str().is_none() { if let Some(f) = &recovered { c["file"] = json!(f); } }- let c = &c;- let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };- if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }- let visit = c["visit"].as_u64().unwrap_or(1);- let tag = format!("{step}-{visit}");- let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));- if !Path::new(&fast).is_file() { continue; }- let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));- let motion = act.get(&tag).map(|x| x.1).filter(|m| *m > 0.0).unwrap_or_else(|| media_seconds(&action));- // a visit with under a second of motion is nothing to watch: it stays on the page, not in the video- if motion < 1.0 { eprintln!("segment {tag}: {motion:.1} s of motion, left out"); continue; }- let at = c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0);- // a clip that ran while the human was away (a recorder left on) is nothing to watch- let stop = c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(at);- if r.active_between(at, stop) == 0 && stop.saturating_sub(at) > r.idle_minutes() * 60 { eprintln!("segment {tag}: recorded outside the AI's time, left out"); continue; }- pieces.push((at, Piece::Clip { cap: c.clone(), idx: i, motion }));- }- for e in &standing_artifacts(&led) {- if e["kind"]=="library-overview" {- let Some(file)=e["file"].as_str() else {continue};- let Some(review)=e["review"].as_str() else {err("library overview has no review contract", &["Re-register it with artifact --kind library-overview --review <review.json>.".into()])};- library_review::verify(Path::new(review),Path::new(&r.current_board()),Path::new(file)).unwrap_or_else(|e|err(&e,&["Re-review the current native board and selected component variants before composing.".into()]));- let at=e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);- pieces.push((at,Piece::Library{file:file.into(),caption:e["caption"].as_str().unwrap_or("Component library").into(),at,seconds:media_seconds(file).min(5.0)}));- continue;- }- if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }- let Some(f) = e["file"].as_str() else { continue };- if !Path::new(f).is_file() { continue; }- let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);- pieces.push((at, Piece::Still { file: f.to_string(), caption: e["caption"].as_str().unwrap_or("").to_string(), step: e["step"].as_str().unwrap_or("").to_string(), visit: e["visit"].as_u64().unwrap_or(1), at }));- }- pieces.sort_by_key(|p| p.0);- if pieces.is_empty() { err("nothing to compose: no clips with motion and no drawings in this run (run `recut` first)", &[]); }- // the pace: every kept frame of motion plays at 4 per second when the run fits; faster, evenly, when it does not- let n_stills = pieces.iter().filter(|p| matches!(p.1, Piece::Still { .. })).count();- let motion_total: f64 = pieces.iter().map(|p| if let Piece::Clip { motion, .. } = &p.1 { *motion } else { 0.0 }).sum();- let library_seconds: f64=pieces.iter().map(|p|if let Piece::Library{seconds,..}=p.1{seconds}else{0.0}).sum();- let still_seconds=still_seconds.min((max_seconds-library_seconds).max(0.0)*0.5/(n_stills.max(1) as f64));- if library_seconds>=max_seconds {err("component overviews exceed the video budget", &["Replace superseded overviews with artifact --replaces, or raise compose --max-seconds.".into()]);}- let clip_budget = (max_seconds - library_seconds - n_stills as f64 * still_seconds).max(0.1);- let rate = if motion_total > clip_budget { 4.0 * motion_total / clip_budget } else { 4.0 };- let engine = r.data["engine"].as_str().unwrap_or("").to_string();- let minutes = r.active_minutes_total();- let run_has_hours = minutes >= 60.0;- let board = Path::new(r.data["sourceBoard"].as_str().or(r.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();- let engine_board = format!("{engine} · {board}");- let workdir = dir.join("compose");- let _ = std::fs::remove_dir_all(&workdir);- let _ = std::fs::create_dir_all(&workdir);- let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);- let step_does = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };- let visit_start = |step: &str, visit: u64| -> u64 { caps.iter().find(|c| c["step"].as_str() == Some(step) && c["visit"].as_u64() == Some(visit)).and_then(|c| c["start"].as_str()).and_then(aiflow_run::parse).unwrap_or(0) };- let fit = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=0x0B1420";- let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();- let (mut n_clips, mut n_stills_done) = (0usize, 0usize);- for (k, (_, piece)) in pieces.iter().enumerate() {- let seg = workdir.join(format!("seg-{k}.mp4"));- let aseg = workdir.join(format!("aud-{k}.m4a"));- let (vlen, tiers): (f64, Vec<String>) = match piece {- Piece::Library{file,caption,at,seconds}=>{- let offset=r.active_offset(*at) as f64;- let vf=format!("scale=1280:-2,{},{fit}",overlay_filters(dir,"Component library",&engine_board,offset,0.0,run_has_hours));- let good=ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v","error","-y","-i",file,"-t",&format!("{seconds:.3}"),"-vf",&vf,"-r","30","-an","-pix_fmt","yuv420p","-c:v","libx264","-preset","veryfast","-crf","20",seg.to_str().unwrap()]));- if !good{err("component overview failed to render", &["Check the reviewed clip, then compose again.".into()]);}- n_clips+=1;(media_seconds(seg.to_str().unwrap()),vec![caption.clone(),"The reviewed component library.".into()])- }- Piece::Clip { cap: c, idx, motion } => {- let (f, step, visit) = (c["file"].as_str().unwrap_or(""), c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1));- let tag = format!("{step}-{visit}");- let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));- // the run clock is the AI's time: the active seconds from the first prompt to this clip- let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));- let offset = r.active_offset(cs) as f64;- let title = format!("{}{}", step_shown(step), if visit > 1 { format!(" visit {visit}{}", if c["back"].as_bool() == Some(true) { " rework" } else { "" }) } else { String::new() });- let chain = format!("{},{fit}", overlay_chain(dir, &title, &engine_board, offset, run_has_hours, 10, rate));- let okc = ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &fast, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]));- if !okc { eprintln!("segment {tag}: the overlay cut failed; skipped"); continue; }- let vlen = media_seconds(seg.to_str().unwrap());- if vlen < 0.5 { continue; }- let _ = (idx, motion);- let real = if ce > cs { r.active_between(cs, ce) as f64 } else { act.get(&tag).map(|x| x.0).unwrap_or_else(|| media_seconds(f)) };- let said = step_said(step);- let visit_words = if visit > 1 { format!(", visit {visit}") } else { String::new() };- let why_or_does = c["why"].as_str().map(|w| format!("The AI came back here because {w}")).unwrap_or_else(|| step_does(step));- let mins = format!("{:.0} minute{} of real time", (real / 60.0).max(1.0), if real >= 90.0 { "s" } else { "" });- n_clips += 1;- (vlen, vec![format!("{said}{visit_words}. {why_or_does}. {mins}."), format!("{said}{visit_words}. {}.", first_clause(&why_or_does)), format!("{said}{visit_words}.")])- }- Piece::Still { file, caption, step, visit, at } => {- let offset = r.active_offset(*at) as f64;- let step_offset = r.active_between(visit_start(step, *visit), *at) as f64;- let title = format!("{} · the AI's drawing", step_shown(step));- // the drawing is shown at the clips' scale so the overlays match, then fit to 1080p- let vf = format!("scale=1280:-2,{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours));- let oks = ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]));- if !oks { eprintln!("still {file}: could not be rendered; skipped"); continue; }- let vlen = media_seconds(seg.to_str().unwrap());- n_stills_done += 1;- (vlen, vec![caption.clone(), first_clause(caption), format!("{}, the AI's own drawing.", step_said(step))])- }- };- // the words are cut to the picture: the segment is exactly as long as its motion- let said = narrate(&workdir, k, &tiers, vlen, has_tts);- let oka = match &said {- Some((audio, _, _)) => ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()])),- None => ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()])),- };- if let Some((_, alen, words)) = &said { if *alen > vlen + 0.05 { eprintln!("segment {k}: the words ran {alen:.1} s for {vlen:.1} s of picture; cut at the picture: \"{words}\""); } }- if oka { parts.push((seg, aseg)); }- }- if parts.is_empty() { compose_fail("nothing to compose: every segment failed to render", outp); }- // the page takes files up to 10 MB: when the cut is bigger, assemble re-encodes it once at the- // bitrate that fits (screen captures are mostly still, so the picture survives it)- assemble(&workdir, &parts, outp);- let secs = media_seconds(outp.to_str().unwrap());- if secs > max_seconds + 2.0 { eprintln!("WARNING the video runs {secs:.0} s, over the {max_seconds:.0} s cap; the pace ({rate:.1} frames per second) did not hold"); }- (secs, n_clips, n_stills_done, rate, has_tts)-}--/// The AI's artifacts that still stand: an artifact whose file a later `artifact --replaces` names is-/// superseded, as is an earlier event of the same file re-registered later. Returns the ledger events.-fn standing_artifacts(led: &[Value]) -> Vec<Value> {- let mut out: Vec<Value> = Vec::new();- for (i, e) in led.iter().enumerate() {- if e["event"] != "artifact" || e["byAi"].as_bool() != Some(true) { continue; }- let Some(f) = e["file"].as_str() else { continue };- let superseded = led.iter().skip(i + 1).any(|l| l["event"] == "artifact" && l["byAi"].as_bool() == Some(true) && (l["replaces"].as_str() == Some(f) || l["file"].as_str() == Some(f)));- if !superseded { out.push(e.clone()); }- }- out-}--/// The sessions table for the terminal and the run page.-fn sessions_table(r: &Run) -> String {- let ss = r.sessions();- let mut out = String::from("| Session | Prompt at | Done at | AI time min | Unobserved gap min | What |\n|---|---|---|---|---|---|\n");- for x in &ss {- out.push_str(&format!("| {} | {} | {} | {:.1} | {:.0} | {} |\n", x.n, aiflow_run::iso(x.prompt_at), x.done_at.map(aiflow_run::iso).unwrap_or_else(|| if x.open { "(open)".into() } else { "".into() }), x.active_seconds() as f64 / 60.0, x.idle_seconds as f64 / 60.0, x.text.chars().take(90).collect::<String>().replace('|', "/")));- }- out.push_str(&format!("| **all** | | | **{:.1}** | | |\n", r.active_minutes_total()));- out-}--/// One engine's clip of one step visit, ready for the side-by-side.-struct VisitClip { tenx: String, motion: f64, offset: f64, title: String }--/// The 10x cut of a capture, in its own run or, for a run pulled from another machine, by file name in the run directory.-fn tenx_of(run_dir: &Path, file: &str) -> Option<String> {- let fast = format!("{}-10x.mp4", file.trim_end_matches(".mp4"));- if Path::new(&fast).is_file() { return Some(fast); }- let local = run_dir.join(Path::new(&fast).file_name()?);- if local.is_file() { Some(local.display().to_string()) } else { None }-}--/// A run's visits per step (flow order kept by the caller), with their motion seconds and active-time offsets.-fn visits_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<VisitClip>> {- let led = r.ledger();- let mut act: std::collections::BTreeMap<String, f64> = Default::default();- for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), e["seconds"].as_f64().unwrap_or(0.0)); } }- let mut out: std::collections::BTreeMap<String, Vec<VisitClip>> = Default::default();- let mut caps = r.data["captures"].as_array().cloned().unwrap_or_default();- caps.sort_by(|a, b| a["start"].as_str().unwrap_or("").cmp(b["start"].as_str().unwrap_or("")));- for c in &caps {- if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }- let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };- // the 3D step's camera is the viewer; an editor clip under that step is the wait before it opened- if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }- let visit = c["visit"].as_u64().unwrap_or(1);- let Some(tenx) = tenx_of(&r.dir, f) else { continue };- let motion = act.get(&format!("{step}-{visit}")).copied().unwrap_or(0.0);- if motion < 1.0 { continue; }- let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));- if r.active_between(cs, ce) == 0 && ce.saturating_sub(cs) > r.idle_minutes() * 60 { continue; }- let title = format!("{}{}", step_shown(step), if visit > 1 { format!(" visit {visit}{}", if c["back"].as_bool() == Some(true) { " rework" } else { "" }) } else { String::new() });- out.entry(step.to_string()).or_default().push(VisitClip { tenx, motion, offset: r.active_offset(cs) as f64, title });- }- out-}--/// A run's standing drawings per step, as (file, caption, active offset).-fn drawings_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<(String, String, f64)>> {- let mut out: std::collections::BTreeMap<String, Vec<(String, String, f64)>> = Default::default();- for e in standing_artifacts(&r.ledger()) {- if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }- let Some(f) = e["file"].as_str() else { continue };- let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name().unwrap_or_default()); if l.is_file() { l.display().to_string() } else { continue } };- let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);- out.entry(e["step"].as_str().unwrap_or("").to_string()).or_default().push((local, e["caption"].as_str().unwrap_or("").to_string(), r.active_offset(at) as f64));- }- out-}--fn run_ffmpeg(args: &[&str]) -> bool {- ffmpeg_ok(std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args))-}--thread_local! {- /// The stderr of the last ffmpeg run in compose that failed, for the error message (issue #80).- static FFMPEG_LAST_ERROR: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };-}--/// Run an ffmpeg command with its stderr captured; on failure keep the stderr for `compose_error`.-fn ffmpeg_ok(cmd: &mut std::process::Command) -> bool {- let failure = match cmd.stdout(std::process::Stdio::null()).output() {- Ok(o) if o.status.success() => return true,- Ok(o) => { let e = String::from_utf8_lossy(&o.stderr).trim().to_string(); if e.is_empty() { format!("ffmpeg exited with {}", o.status) } else { e } }- Err(e) => format!("ffmpeg could not start: {e}"),- };- FFMPEG_LAST_ERROR.with(|l| *l.borrow_mut() = failure);- false-}--/// Where compose writes (issue #80): without --out, final.mp4 in the run directory; an absolute --out as given;-/// a relative --out against the working directory, like every other path flag.-fn compose_out_path(out: Option<&str>, run_dir: &Path, cwd: &Path) -> PathBuf {- match out {- None => run_dir.join("final.mp4"),- Some(o) if Path::new(o).is_absolute() => PathBuf::from(o),- Some(o) => cwd.join(o),- }-}--/// The compose failure message: what failed, the resolved output path, and ffmpeg's last stderr lines-/// (the last 8 lines, clipped head and tail to 1200 characters).-fn compose_error(what: &str, outp: &Path, stderr: &str) -> String {- let lines: Vec<&str> = stderr.lines().map(str::trim_end).filter(|l| !l.trim().is_empty()).collect();- let last = lines[lines.len().saturating_sub(8)..].join("\n");- let shown = if last.is_empty() { "(ffmpeg printed nothing)".to_string() } else { aiflow_run::clip_text(&last, 1200) };- format!("{what} (output {}); ffmpeg said:\n{shown}", outp.display())-}--fn compose_fail(what: &str, outp: &Path) -> ! {- let stderr = FFMPEG_LAST_ERROR.with(|l| l.borrow().clone());- err(&compose_error(what, outp, &stderr), &[format!("Check that {} can be written (a relative --out is resolved against the current working directory; without --out the video is final.mp4 in the run directory), then compose again.", outp.display())])-}--/// Two runs side by side, step by step in flow order: the left engine's visits of the step play-/// in its column, the right engine's in its column, both paced to the same seconds (the run with-/// more motion sets the pace; the other plays slower, never faster than its own clips); their own-/// clocks in their own corners; the drawings of both side by side; the words cut to the picture.-fn compose_pair(a: &mut Run, b: &Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64, script: Option<&serde_json::Map<String, Value>>) -> (f64, usize, usize, bool) {- let (fd, ft) = run_fonts(dir);- let workdir = dir.join("compose-pair");- let _ = std::fs::remove_dir_all(&workdir);- let _ = std::fs::create_dir_all(&workdir);- let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);- let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());- let board = Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();- let (va, vb) = (visits_by_step(a), visits_by_step(b));- let (da, db) = (drawings_by_step(a), drawings_by_step(b));- let (ra, rb) = (a.step_report(), b.step_report());- let hours = a.active_minutes_total() >= 60.0 || b.active_minutes_total() >= 60.0;- // the steps in flow order, then any the runs added- // the story's order: the build steps, the analyses, the walkthroughs, the finish line last- let mut steps: Vec<String> = ["intake", "models", "placement", "routing", "pours", "current", "thermal", "fields", "nets", "3d", "finish"].iter().map(|s| s.to_string()).collect();- for k in va.keys().chain(vb.keys()).chain(da.keys()).chain(db.keys()) { if !steps.contains(k) { steps.insert(steps.len() - 1, k.clone()); } }- let has = |s: &str| va.contains_key(s) || vb.contains_key(s) || da.contains_key(s) || db.contains_key(s);- let steps: Vec<String> = steps.into_iter().filter(|s| has(s) && s != "capture").collect();- // the pace: each step's seconds is the larger of the two runs' motion; scaled evenly to fit the budget- let frames = |v: Option<&Vec<VisitClip>>| -> f64 { v.map(|x| x.iter().map(|c| c.motion * 4.0).sum()).unwrap_or(0.0) };- let mut raw: Vec<f64> = Vec::new(); let mut pairs_total = 0usize;- for s in &steps {- let (fa, fb) = (frames(va.get(s)), frames(vb.get(s)));- raw.push(fa.max(fb) / 4.0);- pairs_total += da.get(s).map(|x| x.len()).unwrap_or(0).max(db.get(s).map(|x| x.len()).unwrap_or(0));- }- let raw_sum: f64 = raw.iter().sum();- let clip_budget = (max_seconds - pairs_total as f64 * still_seconds).max(max_seconds * 0.5);- let scale = if raw_sum > clip_budget { clip_budget / raw_sum } else { 1.0 };- let mins = |rep: &serde_json::Map<String, Value>, s: &str| -> (f64, u64, u64) { rep.get(s).map(|e| (e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0))).unwrap_or((0.0, 0, 0)) };- let (cw, ch, band) = (960, 540, 110);- // the bars below the columns: this step's AI minutes per engine against the longest step of either run- let max_min = steps.iter().map(|s| mins(&ra, s).0.max(mins(&rb, s).0)).fold(1.0f64, f64::max);- let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));- let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());- let footer = dt(&format!("Delivered: {na} in {d_a:.0} min of AI time with {ret_a} return{} to earlier steps · {nb} in {d_b:.0} min with {ret_b} return{}", if ret_a == 1 { "" } else { "s" }, if ret_b == 1 { "" } else { "s" }));- let frame = |title: &str, sub: &str, bars: Option<((f64, u64), (f64, u64))>| -> String {- let mut f = format!("[0:v][1:v]hstack=inputs=2,pad=1920:1080:0:{band}:color=0x0B1420,drawtext=fontfile={fd}:text='{}':x=(w-tw)/2:y=22:fontsize=40:fontcolor=0xF2E9E1,drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=74:fontsize=20:fontcolor=0xD9E1E8", dt(title), dt(sub));- if let Some(((ma, va), (mb, vb))) = bars {- let (x0, wmax, y1, y2, h) = (420.0, 1180.0, 720.0, 800.0, 40.0);- for (k, (name, m, v, color, y)) in [(&na, ma, va, "0xDB8B58", y1), (&nb, mb, vb, "0x5FB6D6", y2)].into_iter().enumerate() {- let w = ((m / max_min) * wmax).max(4.0);- let _ = k;- f.push_str(&format!(",drawtext=fontfile={ft}:text='{}':x={x0}-tw-24:y={y}+9:fontsize=20:fontcolor=0xF2E9E1", dt(name)));- f.push_str(&format!(",drawbox=x={x0}:y={y}:w={w:.0}:h={h}:color={color}@0.9:t=fill"));- f.push_str(&format!(",drawtext=fontfile={fd}:text='{}':x={x0}+{w:.0}+16:y={y}+6:fontsize=26:fontcolor=0xF2E9E1", dt(&format!("{m:.0} min · {v} visit{}", if v == 1 { "" } else { "s" }))));- }- f.push_str(&format!(",drawtext=fontfile={ft}:text='THIS STEP, AI TIME':x={x0}:y=684:fontsize=13:fontcolor=0xA0AFBE"));- }- f.push_str(&format!(",drawtext=fontfile={ft}:text='{footer}':x=(w-tw)/2:y=1010:fontsize=19:fontcolor=0xD9E1E8"));- f- };- // a clip of any window shape fits the column both ways (a pup window is taller than the editor's)- let fit_col = format!("scale={cw}:{ch}:force_original_aspect_ratio=decrease,pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2:color=0x0B1420");- let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();- let (mut n_steps, mut n_pairs) = (0usize, 0usize);- // with a script the words lead: each step's picture is paced to its narration (slower or faster,- // never held, never cut short), so the voice never stops and never runs past the picture- let scripted = script.is_some();- let say_len = |k: usize, words: &str| -> Option<(PathBuf, f64)> {- if !has_tts || words.trim().is_empty() { return None; }- let audio = workdir.join(format!("script-{k}.mp3"));- let okv = std::process::Command::new("adom-tts").args(["say", "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);- if okv { Some((audio.clone(), media_seconds(audio.to_str().unwrap()))) } else { None }- };- for (si, step) in steps.iter().enumerate() {- let script_words = script.and_then(|m| m.get(step.as_str())).and_then(|v| v.as_str()).map(str::to_string);- let scripted_audio = script_words.as_ref().and_then(|w| say_len(1000 + si, w));- let t_step = match &scripted_audio { Some((_, alen)) => alen + 0.3, None => raw[si] * scale };- if scripted && script_words.is_none() { eprintln!("side by side, step {step}: no words in the script; the picture keeps its own pace"); }- let mut seg_index = parts.len();- // the two columns of clips- if t_step >= 0.5 && (raw[si] > 0.0 || scripted_audio.is_some()) {- let mut cols: Vec<PathBuf> = Vec::new();- for (side, (run, name, vis)) in [(a as &Run, &na, va.get(step)), (b, &nb, vb.get(step))].into_iter().enumerate() {- let col = workdir.join(format!("col-{si}-{side}.mp4"));- let f = frames(vis);- if f > 0.0 {- // scripted: the frames spread over the words (down to one frame every five seconds- // for a step with little motion); unscripted: never slower than one a second- let rate = if scripted_audio.is_some() { (f / t_step).max(0.2) } else { (f / t_step).max(1.0) };- let mut pieces: Vec<PathBuf> = Vec::new();- for (k, v) in vis.unwrap().iter().enumerate() {- let piece = workdir.join(format!("piece-{si}-{side}-{k}.mp4"));- let chain = format!("{},{fit_col}", overlay_chain(dir, &v.title, &format!("{name} · {board}"), v.offset, hours, 10, rate));- if run_ffmpeg(&["-i", &v.tenx, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", piece.to_str().unwrap()]) { pieces.push(piece); }- }- let list = workdir.join(format!("col-{si}-{side}.txt"));- std::fs::write(&list, pieces.iter().map(|p| format!("file '{}'\n", std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display())).collect::<String>()).unwrap();- let cat = workdir.join(format!("cat-{si}-{side}.mp4"));- let _ = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", list.to_str().unwrap(), "-c", "copy", cat.to_str().unwrap()]);- // exactly t_step: the slower column holds its last frame for the rest (a side-by-side has to wait)- let _ = run_ffmpeg(&["-i", cat.to_str().unwrap(), "-vf", &format!("tpad=stop_mode=clone:stop_duration={t_step:.2},trim=0:{t_step:.3}"), "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]);- let _ = run;- } else {- // no clip on this side: the run's own screenshot of the step, or its latest board shot,- // stands in with a caption, so the column is never a dark hole- let shot = latest_shot(&run.dir, step);- let msg = dt(&format!("{name}: no clip for this step{}", if shot.is_some() { ", its board as filmed last" } else { "" }));- let caption = format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=h-th-14:fontsize=20:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=8");- let okp = match &shot {- Some(img) => run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{t_step:.3}"), "-i", img, "-vf", &format!("{fit_col},{caption}"), "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),- None => false,- };- if !okp { let _ = run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={t_step:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE"), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]); }- }- if col.is_file() { cols.push(col); } else { eprintln!("side by side, step {step}, {name}: its column did not render; the step is left out"); }- }- if cols.len() == 2 {- let (ma, mb) = (mins(&ra, step), mins(&rb, step));- let seg = workdir.join(format!("seg-{}.mp4", parts.len()));- let fc = frame(&step_shown(step), &format!("{na} on the left, {nb} on the right; each run's own clocks in its own corner"), Some(((ma.0, ma.1), (mb.0, mb.1))));- if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {- let vlen = media_seconds(seg.to_str().unwrap());- let said = step_said(step);- let tiers = if step == "finish" {- let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));- let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());- vec![format!("Delivered. {na}: {d_a:.0} minutes of AI time from the prompt to done, {ret_a} returns to earlier steps. {nb}: {d_b:.0} minutes, {ret_b} returns."), format!("Delivered. {na} {d_a:.0} minutes, {nb} {d_b:.0}."), "Delivered.".to_string()]- } else {- vec![format!("{said}. {na}: {:.0} minutes of AI time in {} visit{}. {nb}: {:.0} minutes in {} visit{}.", ma.0, ma.1, if ma.1 == 1 { "" } else { "s" }, mb.0, mb.1, if mb.1 == 1 { "" } else { "s" }), format!("{said}. {na} {:.0} minutes, {nb} {:.0}.", ma.0, mb.0), format!("{said}.")]- };- let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));- let oka = match &scripted_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };- if oka { parts.push((seg, aseg)); n_steps += 1; }- }- }- }- // the drawings of both, side by side- let (dra, drb) = (da.get(step).cloned().unwrap_or_default(), db.get(step).cloned().unwrap_or_default());- for k in 0..dra.len().max(drb.len()) {- let pair_words = script.and_then(|m| m.get(&format!("drawings:{step}:{k}")).or_else(|| m.get(&format!("drawings:{step}"))).filter(|_| k == 0 || m.contains_key(&format!("drawings:{step}:{k}")))).and_then(|v| v.as_str()).map(str::to_string);- let pair_audio = pair_words.as_ref().and_then(|w| say_len(2000 + si * 10 + k, w));- let still_len = match &pair_audio { Some((_, alen)) => alen + 0.3, None => still_seconds };- let mut cols: Vec<PathBuf> = Vec::new();- for (side, (name, d)) in [(&na, dra.get(k)), (&nb, drb.get(k))].into_iter().enumerate() {- let col = workdir.join(format!("still-{si}-{k}-{side}.mp4"));- let okc = match d {- Some((file, _, off)) => {- let vf = format!("scale=1280:-2,{},{fit_col}", overlay_filters(dir, &format!("{} · the AI's drawing", step_shown(step)), &format!("{name} · {board}"), *off, 0.0, hours));- run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{still_len:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()])- }- None => run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={still_len:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE", dt(&format!("{name}: no drawing for this step"))), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),- };- if okc { cols.push(col); }- }- if cols.len() != 2 { continue; }- let seg = workdir.join(format!("seg-{}.mp4", parts.len()));- let (ma, mb) = (mins(&ra, step), mins(&rb, step));- let fc = frame(&step_shown(step), &format!("the AIs' own drawings: {na} on the left, {nb} on the right"), Some(((ma.0, ma.1), (mb.0, mb.1))));- if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {- let vlen = media_seconds(seg.to_str().unwrap());- let cap = dra.get(k).map(|x| x.1.clone()).or_else(|| drb.get(k).map(|x| x.1.clone())).unwrap_or_default();- let tiers = vec![format!("The {} drawings, {na} left, {nb} right. {}", step_said(step), first_clause(&cap)), format!("The {} drawings, {na} left, {nb} right.", step_said(step)), format!("{} drawings.", step_said(step))];- let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));- let oka = match &pair_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };- if oka { parts.push((seg, aseg)); n_pairs += 1; }- }- }- let _ = &mut seg_index;- }- if parts.is_empty() { err(&format!("nothing to compose side by side: no step with motion in either run (output {})", outp.display()), &[]); }- assemble(&workdir, &parts, outp);- (media_seconds(outp.to_str().unwrap()), n_steps, n_pairs, has_tts)-}--/// The narration for one segment, exactly `vlen` seconds long (silence when there is nothing to say).-fn audio_for(workdir: &Path, k: usize, tiers: &[String], vlen: f64, has_tts: bool, aseg: &Path) -> bool {- match narrate(workdir, k, tiers, vlen, has_tts) {- Some((audio, _, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]),- None => run_ffmpeg(&["-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]),- }-}--/// Concatenate the segments with their narration into the final file, re-encoded under the page's 10 MB cap when needed.-fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) {- let vlist = workdir.join("video.txt");- let alist = workdir.join("audio.txt");- std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", std::fs::canonicalize(v).unwrap_or_else(|_| v.clone()).display())).collect::<String>()).unwrap();- std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", std::fs::canonicalize(a).unwrap_or_else(|_| a.clone()).display())).collect::<String>()).unwrap();- let vcat = workdir.join("video.mp4");- let acat = workdir.join("audio.m4a");- let okv = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]);- let oka = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]);- let okf = okv && oka && run_ffmpeg(&["-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]);- if !okf { compose_fail("ffmpeg could not assemble the video and its narration", outp); }- let cap_bytes: u64 = 9_800_000;- if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {- let secs = media_seconds(outp.to_str().unwrap()).max(1.0);- let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();- let fitted = workdir.join("fitted.mp4");- if run_ffmpeg(&["-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]) {- let _ = std::fs::copy(&fitted, outp);- eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0));- }- }-}--/// The comparison sub-README: this run (left) against another (right). Returns (rel dir, url, files to push).-fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String, Vec<String>) {- let (ida, idb) = (report_id(a), report_id(b));- let rel = format!("docs/comparisons/{ida}-vs-{idb}");- let stage = dir.join("report");- let out = stage.join(&rel);- let _ = std::fs::remove_dir_all(&out);- let _ = std::fs::create_dir_all(&out);- let slug = page.split('/').nth(1).unwrap_or(page);- let blob = format!("/blob/app/{slug}/{rel}");- let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());- // the board by the spec's name when it has one (ESC G431), else by its file- let board = spec_of(a).get("board").and_then(|v| v.as_str()).map(|v| v.split('(').next().unwrap_or(v).trim().to_string()).filter(|v| !v.is_empty()).unwrap_or_else(|| Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string());- let mut files: Vec<String> = Vec::new();- let mut md = String::new();- md.push_str(&format!("# {na} versus {nb} on the {board}\n\n"));- md.push_str("Two AIs, the same board, the same spec, the same prompt, the same flow (Adom's AI Flow), measured the same way: the AI's own time from the prompt to \"done, here is your video\", every return to an earlier step, every clip, and the analysis each AI did itself. Nobody had put two engines through one measured board flow before; this page is the first pair.\n\n");- // the video first- if let Some(v) = a.data["comparison"]["video"].as_str().filter(|v| Path::new(v).is_file()) {- let stamp: String = a.ledger().iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison").and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());- let name = format!("comparison-{stamp}.mp4");- let _ = std::fs::copy(v, out.join(&name)); files.push(format!("{rel}/{name}"));- md.push_str(&format!("**The side by side, {:.0} s: {na} on the left, {nb} on the right, step by step in the flow's order; each run's own clocks in its own corner (AI time, the idle cut out); both AIs' analysis drawings side by side; each step's AI-minute bars below; the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", a.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));- }- // the number- let (da, db) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));- let (ra, rb) = (a.step_report(), b.step_report());- let turns = |r: &Run| r.data["turns"].as_array().map(|x| x.len()).unwrap_or(0);- let think = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["thinkingSeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;- let tool = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["binarySeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;- md.push_str("## The number: prompt to \"done, here is your video\"\n\n");- md.push_str("| | AI time to delivery | Returns to earlier steps | Turns | AI thinking min | Binary min |\n|---|---|---|---|---|---|\n");- md.push_str(&format!("| **{na}** | **{da:.1} min** | {} | {} | {:.1} | {:.1} |\n", a.returns_before_delivery(), turns(a), think(&ra), tool(&ra)));- md.push_str(&format!("| **{nb}** | **{db:.1} min** | {} | {} | {:.1} | {:.1} |\n\n", b.returns_before_delivery(), turns(b), think(&rb), tool(&rb)));- md.push_str("AI time is the sum of the run's sessions, one per human prompt, from the prompt to the AI's done, with every idle gap over 15 minutes (unobserved, not proven idle) cut out. Follow-up prompts after delivery (the walkthroughs, the drawings, the re-cuts) are on each run's page under Sessions and are not in the delivery number.\n\n");- // per step- let mut steps: Vec<String> = a.data["flow"]["steps"].as_array().map(|x| x.iter().filter_map(|s| s["name"].as_str().map(str::to_string)).collect()).unwrap_or_default();- for k in ra.keys().chain(rb.keys()) { if !steps.contains(k) { steps.push(k.clone()); } }- md.push_str(&format!("## Per step, AI time\n\n| Step | {na} | {nb} |\n|---|---|---|\n"));- for st in &steps {- let cell = |rep: &serde_json::Map<String, Value>| rep.get(st).map(|e| format!("{:.1} min, {} visit{}{}", e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"], if e["visits"].as_u64() == Some(1) { "" } else { "s" }, if e["returns"].as_u64().unwrap_or(0) > 0 { format!(", {} return{}", e["returns"], if e["returns"].as_u64() == Some(1) { "" } else { "s" }) } else { String::new() })).unwrap_or_else(|| "-".into());- if ra.contains_key(st) || rb.contains_key(st) { md.push_str(&format!("| {st} | {} | {} |\n", cell(&ra), cell(&rb))); }- }- md.push_str("\n");- // the drawings side by side- let draw = |r: &Run| -> Vec<(String, String, String)> { standing_artifacts(&r.ledger()).into_iter().filter(|e| matches!(e["kind"].as_str(), Some("analysis-image") | Some("image"))).filter_map(|e| { let f = e["file"].as_str()?; let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name()?); if l.is_file() { l.display().to_string() } else { return None } }; Some((e["step"].as_str().unwrap_or("").to_string(), local, e["caption"].as_str().unwrap_or("").to_string())) }).collect() };- let (dra, drb) = (draw(a), draw(b));- if !dra.is_empty() || !drb.is_empty() {- md.push_str("## The analysis each AI did itself\n\nEach AI solved the current and the heat on its own board and drew it; the pictures are theirs, not the binary's. Left, then right, per step.\n\n");- for st in &steps {- let la: Vec<&(String, String, String)> = dra.iter().filter(|d| d.0 == *st).collect();- let lb: Vec<&(String, String, String)> = drb.iter().filter(|d| d.0 == *st).collect();- for k in 0..la.len().max(lb.len()) {- md.push_str("<table><tr>");- for (side, name, it) in [("a", &na, la.get(k)), ("b", &nb, lb.get(k))] {- match it {- Some((_, file, cap)) => { let fname = format!("{side}-{st}-{k}.{}", Path::new(file).extension().and_then(|x| x.to_str()).unwrap_or("png")); let _ = std::fs::copy(file, out.join(&fname)); files.push(format!("{rel}/{fname}")); md.push_str(&format!("<td width=\"50%\"><b>{name}: {}</b><br><img src=\"{blob}/{fname}\" width=\"100%\"></td>", cap.replace('|', "/"))); }- None => md.push_str(&format!("<td width=\"50%\"><b>{name}: no drawing for this step</b></td>")),- }- }- md.push_str("</tr></table>\n\n");- }- }- }- // the links- md.push_str("## The two runs, in full\n\n");- md.push_str(&format!("- [{na}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{ida}/README.md): every step's clip, screenshots, sessions, returns, decisions, plan usage, what it gave back.\n"));- md.push_str(&format!("- [{nb}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{idb}/README.md): the same for the other engine.\n"));- md.push_str(&format!("- [The flow](https://wiki.adom.inc/{page}): Adom's AI Flow, the tool both ran.\n\n"));- md.push_str("*Plan usage on each run page is per account, not per run: every AI thread signed in as that user draws on the same limits.*\n");- let readme = out.join("README.md");- std::fs::write(&readme, md).unwrap();- files.push(format!("{rel}/README.md"));- (rel.clone(), format!("https://wiki.adom.inc/{page}/files/{rel}/README.md"), files)-}--/// The newest screenshot of a step in a run directory (`shot-<step>-...png`), else the newest screenshot of any step.-fn latest_shot(dir: &Path, step: &str) -> Option<String> {- let mut shots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with("shot-") && e.file_name().to_string_lossy().ends_with(".png")).filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))).collect();- shots.sort_by(|a, b| b.0.cmp(&a.0));- let of_step = shots.iter().find(|(_, p)| p.file_name().map(|n| n.to_string_lossy().starts_with(&format!("shot-{step}-"))).unwrap_or(false));- of_step.or(shots.first()).map(|(_, p)| p.display().to_string())-}--/// The give-back hint: what the AI learned belongs to every Adom user.-fn give_back_hint() -> String {- "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()-}---/// The run id used for its page folder: board stem, engine slug, prompt date.-fn report_id(r: &Run) -> String {- if let Some(id) = r.data["reportId"].as_str() {- return id.to_string();- }- let src = r.data["sourceBoard"].as_str().or_else(|| r.data["boards"][0].as_str()).unwrap_or("board");- let board = Path::new(src).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();- let eng: String = r.data["engine"].as_str().unwrap_or("engine").to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect::<String>().split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");- // prompt date and time, so two runs by one engine on one day get their own folders- let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("");- let date = pt.chars().take(10).filter(|c| *c != '-').collect::<String>();- let hhmm = pt.chars().skip(11).take(5).filter(|c| *c != ':').collect::<String>();- format!("{board}-{eng}-{date}-{hhmm}")-}--/// Build the sub-README and its files under <run>/report/docs/runs/<id>/; returns (staging dir, rel dir, README path).-fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {- let id = report_id(r);- let rel = format!("docs/runs/{id}");- let stage = dir.join("report");- let out = stage.join(&rel);- // the staging folder is rebuilt from nothing every time, so an older cut or clip never rides along- let _ = std::fs::remove_dir_all(&out);- let _ = std::fs::create_dir_all(&out);- let slug = page.split('/').nth(1).unwrap_or(page);- let blob = format!("/blob/app/{slug}/{rel}");- let led = r.ledger();- let engine = r.data["engine"].as_str().unwrap_or("");- let prompt = r.data["clock"]["promptTime"].as_str().unwrap_or("");- let finished = r.data["clock"]["finishTime"].as_str();- let delivered = r.data["clock"]["deliveredTime"].as_str();- let cur = r.data["currentStep"].as_str().unwrap_or("intake");- let now_min = r.elapsed_minutes();- let mut md = String::new();- md.push_str(&format!("# AI Flow run: {} on {}\n\n", engine, Path::new(r.data["boards"][0].as_str().unwrap_or("")).file_stem().and_then(|s| s.to_str()).unwrap_or("the board")));- let ss = r.sessions();- let later: f64 = (r.active_minutes_total() - r.delivered_active_minutes().unwrap_or(0.0)).max(0.0);- let status = match (delivered, finished) {- (Some(d), _) => format!("**Delivered** at {d}: **{:.1} min of AI time** from the prompt to \"done, here is your video\" ({:.1} min on the wall clock). {}", r.delivered_active_minutes().unwrap_or(0.0), r.delivered_minutes().unwrap_or(0.0), if ss.len() > 1 { format!("Since then {later:.1} min more of AI time over {} follow-up prompt(s), listed under Sessions; none of it is in the delivery number.", ss.len() - 1) } else { String::new() }),- (None, Some(f)) => format!("**Finished** (board design checks) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),- _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min),- };- md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (unobserved, not proven idle) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes()));- if let Some(d) = r.data["delivery"].as_object() {- if let Some(v) = d.get("video").and_then(|v| v.as_str()) {- if Path::new(v).is_file() {- // a new name for every cut, so a browser that cached the last one shows this one- let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).filter(|s: &String| !s.is_empty()).unwrap_or_else(|| std::fs::metadata(v).and_then(|m| m.modified()).ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs().to_string()).unwrap_or_else(|| "0".into()));- let name = format!("final-{stamp}.mp4");- let _ = std::fs::copy(v, out.join(&name));- let vs = media_seconds(v);- md.push_str(&format!("## The video\n\n**The final cut of this run, {vs:.0} s: only the moments something moved, every step in order, the AI's analysis drawings fullscreen, the run and step clocks in the corner, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n"));- }- }- }- if let Some(v) = r.data["comparison"]["video"].as_str() {- if Path::new(v).is_file() {- let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());- let name = format!("comparison-{stamp}.mp4");- let _ = std::fs::copy(v, out.join(&name));- md.push_str(&format!("## Side by side with {}\n\n**{} on the left, {} on the right, step by step in flow order, {:.0} s: each run's own clocks in its own corner, both AIs' drawings side by side, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", r.data["comparison"]["withEngine"].as_str().unwrap_or("another run"), engine, r.data["comparison"]["withEngine"].as_str().unwrap_or(""), r.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));- }- }- // sessions- md.push_str("## Sessions\n\nOne row per human prompt: the AI's time on it, unobserved gaps excluded by the heuristic, which may include external AI work.\n\n");- md.push_str(&sessions_table(r));- md.push_str("\n");- // per-step table- let rep = r.step_report();- let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];- let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();- for k in rep.keys() { if !names.contains(k) { names.push(k.clone()); } }- md.push_str("## Per step\n\nAI time only, the idle cut out.\n\n| Step | AI min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");- let (mut tw, mut tt, mut tb) = (0u64, 0u64, 0u64);- for n in &names {- let e = &rep[n];- let (w, t, b) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0));- tw += w; tt += t; tb += b;- md.push_str(&format!("| {} | {:.1} | {:.1} | {:.1} | {} | {} | {} | {:.1} min |\n", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, e["turns"], e["visits"], e["returns"], e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));- }- md.push_str(&format!("| **total** | **{:.1}** | **{:.1}** | **{:.1}** | | | | |\n\n", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0));- // returns- let mut returns: Vec<String> = Vec::new();- for e in &led {- if e["event"] == "step" && e["back"].as_bool() == Some(true) {- returns.push(format!("- {} back to **{}** (visit {}): {}", e["t"].as_str().unwrap_or(""), e["step"].as_str().unwrap_or(""), e["visit"], e["why"].as_str().unwrap_or("")));- }- }- if !returns.is_empty() { md.push_str("## Returns to earlier steps\n\n"); md.push_str(&returns.join("\n")); md.push_str("\n\n"); }- // decisions and markers- let mut marks: Vec<String> = Vec::new();- for e in &led { if e["event"] == "marker" { marks.push(format!("- {} {}", e["t"].as_str().unwrap_or(""), e["label"].as_str().unwrap_or(""))); } }- if !marks.is_empty() { md.push_str("## Decisions along the way\n\n"); md.push_str(&marks.join("\n")); md.push_str("\n\n"); }- // outcomes- let o = r.outcomes();- md.push_str("## Where the board stands\n\n");- if let Some(x) = o.get("route") { md.push_str(&format!("- routing: {} unrouted after {} passes\n", x["unrouted"], x["passes"])); }- if let Some(x) = o.get("gate") { md.push_str(&format!("- gate: {} new DRC errors, {} unconnected, {} inherited\n", x["newErrors"], x["unconnected"], x["inheritedErrors"])); }- if let Some(x) = o.get("land-route") { md.push_str(&format!("- landed live: {} of {} route entries\n", x["accepted"], x["entries"])); }- if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours ({}): {} groups, failed {}\n", pourverify::mode_label(x), x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }- if let Some(x) = o.get("measure") { md.push_str(&format!("- copper kept (mm2 per layer): {}\n", x["filledAreaByLayerMm2"])); }- for k in ["analyze-current", "analyze-thermal"] { if let Some(x) = o.get(k) { md.push_str(&format!("- {k}: {}\n", if x["pass"].as_bool() == Some(true) { "pass" } else { "fail" })); } }- for c in o.get("analyze-current").and_then(|x| x["acknowledgedPeaks"].as_array()).into_iter().flatten() { md.push_str(&format!("- acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) ({}): {} (by {})\n", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))); }- for a in o.get("analyze-thermal").and_then(|x| x["boardSinkAssumptions"].as_array()).into_iter().flatten() { md.push_str(&format!("- board-sink assumption: {}\n", a.as_str().unwrap_or(""))); }- md.push_str("\n");- // clips and screenshots per visit- md.push_str("## The steps on camera\n\nOne clip per step visit, sped up 10x, with the editor at the start and the end of the visit. A caption above every clip and screenshot says what it shows.\n\n");- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- // clip seconds from the ledger's artifacts- let mut secs: std::collections::BTreeMap<String, (f64, f64, f64)> = std::collections::BTreeMap::new(); // real, 10x, action- for e in &led {- if e["event"] == "artifact" {- let key = e["step"].as_str().unwrap_or("").to_string();- let entry = secs.entry(key).or_insert((0.0, 0.0, 0.0));- match e["kind"].as_str() { Some("clip") => entry.0 = e["seconds"].as_f64().unwrap_or(0.0), Some("clip10x") => entry.1 = e["seconds"].as_f64().unwrap_or(0.0), Some("clipAction") => entry.2 = e["seconds"].as_f64().unwrap_or(0.0), _ => {} }- }- }- let standing = standing_artifacts(&led);- let what_of = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };- for c in &caps {- if c.get("discarded").is_some() { continue; }- let Some(step) = c["step"].as_str() else { continue };- let visit = c["visit"].as_u64().unwrap_or(1);- let tag = format!("{step}-{visit}");- let rework = c["back"].as_bool() == Some(true);- let title = if visit > 1 { format!("{step}, visit {visit}{}", if rework { " (rework)" } else { "" }) } else { step.to_string() };- md.push_str(&format!("### {title}\n\n"));- md.push_str(&format!("What this step does: {}\n\n", what_of(step)));- if let Some(why) = c["why"].as_str() { md.push_str(&format!("Why the AI came back here: {why}\n\n")); }- if let Some(sus) = c["suspect"].as_str() { md.push_str(&format!("**This clip filmed nothing usable: {sus}. It is kept as evidence and left out of the video.**\n\n")); }- let (start, stop) = (c["start"].as_str().unwrap_or(""), c["stop"].as_str().unwrap_or("still running"));- // the 10x cut next to the raw clip- let mut have_clip = false;- if let Some(f) = c["file"].as_str() {- let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));- let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));- let (real, quick, act) = secs.get(&tag).copied().unwrap_or((0.0, 0.0, 0.0));- if Path::new(&action).is_file() {- let _ = std::fs::copy(&action, out.join(format!("{tag}.mp4")));- md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}. Motion only: {:.1} min of real time, {:.0} s of it had something moving; the clock in the corner is the real elapsed time.**\n\n", real / 60.0, act));- md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));- have_clip = true;- } else if Path::new(&fast).is_file() {- let _ = std::fs::copy(&fast, out.join(format!("{tag}.mp4")));- md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}, {:.1} min of real time shown at 10x ({:.0} s).**\n\n", real / 60.0, quick));- md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));- have_clip = true;- }- }- if !have_clip { md.push_str(&format!("**Clip: {title}, still recording (started {start}); it appears here when the step ends.**\n\n")); }- if let Some(f) = c["file"].as_str() {- let sheet = format!("{}-sheet.png", f.trim_end_matches(".mp4"));- if Path::new(&sheet).is_file() {- let _ = std::fs::copy(&sheet, out.join(format!("{tag}-sheet.png")));- md.push_str(&format!("**Contact sheet: nine frames spread through the {title} clip, what the camera saw.**\n\n\n\n"));- }- }- for when in ["start", "end"] {- let src = dir.join(format!("shot-{tag}-{when}.png"));- if src.is_file() {- let _ = std::fs::copy(&src, out.join(format!("shot-{tag}-{when}.png")));- md.push_str(&format!("**Screenshot: the board when {title} {}.**\n\n", if when == "start" { "began" } else { "ended" }));- md.push_str(&format!("\n\n", if when == "start" { "began" } else { "ended" }));- }- }- // what the AI made during this visit: its own analysis drawings, with its caption above each- for (n, e) in standing.iter().filter(|e| e["step"].as_str() == Some(step) && e["visit"].as_u64() == Some(visit)).enumerate() {- let Some(f) = e["file"].as_str() else { continue };- if !Path::new(f).is_file() { continue; }- let ext = Path::new(f).extension().and_then(|x| x.to_str()).unwrap_or("png");- let name = format!("{tag}-{}-{n}.{ext}", e["kind"].as_str().unwrap_or("artifact"));- let _ = std::fs::copy(f, out.join(&name));- let cap = e["caption"].as_str().unwrap_or("");- if matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp") {- md.push_str(&format!("**The AI's drawing: {cap}**\n\n\n\n"));- } else {- md.push_str(&format!("**The AI's file: {cap}** [{name}]({blob}/{name})\n\n"));- }- }- }- if let Some(d) = r.data["delivery"].as_object() {- md.push_str("## Delivered\n\n");- md.push_str(&format!("What the AI said when it delivered (the video is at the top of this page):\n\n> {}\n\n", d.get("message").and_then(|m| m.as_str()).unwrap_or("")));- }- 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!("| {} | {} | {:.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");- let readme = out.join("README.md");- let _ = std::fs::write(&readme, md);- (stage, rel, readme)-}--/// Push the report to the page's repo and reload it in the human's browser; returns the page URL.-fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, thread: &str) -> (String, Vec<String>) {- let (stage, rel, _readme) = build_report(r, dir, page);- // the files viewer renders a sub-README as a page; the bare path serves raw markdown- let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");- let mut notes = Vec::new();- if push {- // only the files that changed since the last push go up, in batches the gateway takes- // (about 20 MB each; a 75 MB report in one request came back 502); a failed batch is said- // out loud and the caller turns it into an ERROR- let cache_path = dir.join("report").join(".pushed.json");- let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();- let mut files: Vec<(String, u64, String)> = Vec::new();- if let Ok(d) = std::fs::read_dir(stage.join(&rel)) {- for e in d.filter_map(|e| e.ok()) {- let name = e.file_name().to_string_lossy().to_string();- if name.starts_with('.') { continue; }- let size = e.metadata().map(|m| m.len()).unwrap_or(0);- let sum = std::process::Command::new("sha256sum").arg(e.path()).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();- files.push((format!("{rel}/{name}"), size, sum));- }- }- files.sort();- let changed: Vec<&(String, u64, String)> = files.iter().filter(|(p, _, sum)| cache.get(p).and_then(|v| v.as_str()) != Some(sum.as_str()) || sum.is_empty()).collect();- let (mut pushed, mut failed) = (0usize, 0usize);- let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];- let mut acc = 0u64;- for f in &changed {- if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }- batches.last_mut().unwrap().push(f); acc += f.1;- }- for batch in batches.iter().filter(|b| !b.is_empty()) {- let mut cmd = std::process::Command::new("adom-wiki");- cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("aiflow run {}: {}", report_id(r), r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);- for f in batch { cmd.arg(&f.0); }- match cmd.output() {- Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }- Ok(o) => { failed += batch.len(); notes.push(format!("push failed for {} file(s): {}", batch.len(), aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 160))); }- Err(e) => { failed += batch.len(); notes.push(format!("push failed: {e}")); }- }- }- let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());- notes.insert(0, format!("pushed {pushed} changed file(s) to {page}/{rel} ({} unchanged skipped{})", files.len() - changed.len(), if failed > 0 { format!("; {failed} FAILED") } else { String::new() }));- }- if refresh && r.data["reportSurface"].as_str() == Some("webview") {- // a tab in the Hydrogen workspace this AI runs in: the tab's name is the stable handle- let bust = format!("{url}?v={}", now().replace([':', '-'], ""));- let ws = std::process::Command::new("adom-cli").args(["hydrogen", "workspace", "get"]).output().ok().and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok());- fn leaves(n: &Value, out: &mut Vec<String>) { if let Some(o) = n.as_object() { if o.get("type").and_then(|t| t.as_str()) == Some("leaf") { if let Some(id) = o.get("id").and_then(|i| i.as_str()) { out.push(id.to_string()); } } for v in o.values() { leaves(v, out); } } else if let Some(a) = n.as_array() { for v in a { leaves(v, out); } } }- let mut ids = Vec::new();- if let Some(w) = &ws { leaves(&w["root"], &mut ids); }- let focused = ws.as_ref().and_then(|w| w["focusedPanelId"].as_str()).unwrap_or("").to_string();- let panel = ids.iter().find(|i| **i != focused).or(ids.first()).cloned();- match panel {- Some(pid) => {- let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow run", "--url", &bust, "--panel-id", &pid]).output();- notes.push(match o { Ok(o) if o.status.success() => "refreshed in the Hydrogen webview tab \"AI Flow run\"".to_string(), Ok(o) => format!("webview: {}", String::from_utf8_lossy(&o.stderr).chars().take(160).collect::<String>()), Err(e) => format!("webview: {e}") });- }- None => notes.push("webview: no Hydrogen workspace panel found (is adom-cli hydrogen reachable?)".into()),- }- } else if refresh {- if let Some(m) = r.data["userMachine"].as_str() {- let br = Bridge { ai_thread: thread.to_string(), target: m.to_string() };- let bust = format!("{url}?v={}", now().replace([':', '-'], ""));- let nav = br.call("nbrowser_navigate", &json!({"sessionId": "aiflow-run", "url": bust}));- if !Bridge::ok(&nav) {- let op = br.call("nbrowser_open_window", &json!({"sessionId": "aiflow-run", "url": bust, "thread": thread, "purpose": "show the human the live progress page of this AI Flow run"}));- notes.push(if Bridge::ok(&op) { format!("opened in the browser on {m}") } else { format!("browser on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });- } else {- notes.push(format!("refreshed in the browser on {m}"));- }- let _ = br.call("nbrowser_flash", &json!({"sessionId": "aiflow-run"}));- }- }- (url, notes)-}--fn board_of(r: &Run) -> Board {- if r.board_pending() { err("this run has no board yet (an intake run)", &["Attach it when it exists: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (same run, same clock). Pre-board steps (sourcing, design, simulation, schematic) need no board.".into()]); }- Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[]))-}--/// The run's live board pulled fresh through its EDA backend: (the tool's native file, the plan board-/// rendered from it). None when the run's plan board is itself the board of record (KiCad).-fn live_board(r: &Run, cli: &Cli, dir: &std::path::Path) -> Option<(String, String)> {- let eda = aiflow_bridge::eda::active();- let t = |v: &str, a: &Value| bridge_of(r, cli).raw_call(v, a);- match eda.live_snapshot(&t, dir)? {- Ok(x) => Some(x),- Err(e) => err(&e, &[]),- }-}--/// Why a field-screen neck happens, from the board the solve ran on: a via whose copper ring is the-/// narrowest copper of its net, or another net's track cutting a net's patch pour off from its pin.-/// Each hint names the spec change that fixes it, so a refine pass is a decision, not a search.-/// The copper fingerprint current-peak acknowledgements bind to (issue #70): copper_carry's, the-/// same one that carries Fields evidence over a copper-unchanged adoption.-fn ack_copper(board: &str) -> Result<String, String> { copper_carry::fingerprint(Path::new(board)) }--fn refine_hints(fields: &Value, board_text: &str, spec: &Value) -> Vec<String> {- let num = |s: &str| s.parse::<f64>().unwrap_or(0.0);- let via_re = regex_lite::Regex::new(r#"\(via \(at ([-\d.]+) ([-\d.]+)\) \(size ([\d.]+)\) \(drill ([\d.]+)\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();- let seg_re = regex_lite::Regex::new(r#"\(segment \(start ([-\d.]+) ([-\d.]+)\) \(end ([-\d.]+) ([-\d.]+)\) \(width ([\d.]+)\) \(layer "?([^")]+)"?\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();- let vias: Vec<(f64, f64, f64, f64, String)> = via_re.captures_iter(board_text).map(|c| (num(&c[1]), num(&c[2]), num(&c[3]), num(&c[4]), c[5].to_string())).collect();- let segs: Vec<((f64, f64), (f64, f64), String, String)> = seg_re.captures_iter(board_text).map(|c| ((num(&c[1]), num(&c[2])), (num(&c[3]), num(&c[4])), c[6].to_string(), c[7].to_string())).collect();- let inside = |p: (f64, f64), poly: &[(f64, f64)]| { let mut c = false; let n = poly.len(); for i in 0..n { let (a, b) = (poly[i], poly[(i + n - 1) % n]); if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; } } c };- let cross = |a: (f64, f64), b: (f64, f64), poly: &[(f64, f64)]| { if inside(a, poly) || inside(b, poly) { return true; } let n = poly.len(); let o = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); (0..n).any(|i| { let (c, d) = (poly[i], poly[(i + 1) % n]); o(a, b, c) * o(a, b, d) < 0.0 && o(c, d, a) * o(c, d, b) < 0.0 }) };- let mut out = Vec::new();- for is in fields["issues"].as_array().cloned().unwrap_or_default() {- if is["kind"].as_str() != Some("current-neck") { continue; }- let net = is["net"].as_str().unwrap_or("").to_string();- let at = (is["at"][0].as_f64().unwrap_or(0.0), is["at"][1].as_f64().unwrap_or(0.0));- if let Some(v) = vias.iter().filter(|v| v.4 == net).find(|v| ((v.0 - at.0).powi(2) + (v.1 - at.1).powi(2)).sqrt() <= v.2 / 2.0 + 0.4) {- out.push(format!("{net}: the neck at ({:.1}, {:.1}) is the copper ring around the via at ({:.2}, {:.2}) ({:.2} mm, {:.2} mm drill). Raise `viaPower` in the spec (1.0 mm with a 0.4 mm drill gives the ring 0.3 mm a side), then route, gate and land again.", at.0, at.1, v.0, v.1, v.2, v.3));- continue;- }- let mut said = false;- for z in spec["pours"].as_array().cloned().unwrap_or_default() {- if z["net"].as_str() != Some(net.as_str()) { continue; }- let poly: Vec<(f64, f64)> = z["polygon"].as_array().cloned().unwrap_or_default().iter().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();- if poly.len() < 3 { continue; }- let layers: Vec<String> = z["layers"].as_array().cloned().unwrap_or_default().iter().filter_map(|l| l.as_str().map(str::to_string)).collect();- if let Some(sg) = segs.iter().find(|sg| sg.3 != net && layers.contains(&sg.2) && cross(sg.0, sg.1, &poly)) {- let covered = spec["planeUnder"].as_array().map(|a| a.iter().any(|p| p["net"].as_str() == Some(net.as_str()))).unwrap_or(false);- out.push(format!("{net}: a {} track on {} crosses the pour \"{}\", cutting it off from its pin, so the current takes a narrow track instead. {}", sg.3, sg.2, z["name"].as_str().unwrap_or(""), if covered { "A planeUnder entry for it exists already: check that its polygon covers the whole pour." } else { "Add a `planeUnder` entry {layer, net, polygon: that pour's polygon} so the router keeps other nets out of it, then route, gate and land again." }));- said = true; break;- }- }- if !said { out.push(format!("{net}: neck at ({:.1}, {:.1}) on {}: widen {net} in `wideNets`, or give it a patch pour there.", at.0, at.1, is["layer"].as_str().unwrap_or("?"))); }- }- out-}--fn bridge_of(r: &Run, cli: &Cli) -> Bridge {- let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string();- Bridge { ai_thread: thread(cli), target }-}--fn write_json(p: &Path, v: &Value) {- std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));-}--/// The command as typed, without the thread and run plumbing.-fn command_line() -> String {- let args: Vec<String> = std::env::args().skip(1).collect();- let mut out = Vec::new();- let mut skip = false;- for a in args {- if skip { skip = false; continue; }- if a == "--ai-thread" || a == "--run" { skip = true; continue; }- if a.starts_with("--ai-thread=") || a.starts_with("--run=") { continue; }- out.push(a);- }- out.join(" ")-}--// ----------------------------------------------------------------------------- design gates (checks.rs)--/// True when this fields.json is byte-for-byte the output of an Adom Fields solve aiflow ran or watched-/// (`exec -- adom-fields analyze ...`, or the solve `analyze` starts itself).-fn fields_observed(r: &Run, file: &Path) -> bool {- let Ok(bytes) = std::fs::read(file) else { return false };- let h = provenance::sha256_hex(&bytes);- r.data["fieldsSolves"].as_array().map(|a| a.iter().any(|x| x["sha256"].as_str() == Some(h.as_str()))).unwrap_or(false)-}--/// Record a finished Adom Fields solve by the hash of the fields.json it wrote.-fn record_fields_solve(r: &mut Run, file: &Path, how: &str, args: &[String]) {- if let Ok(bytes) = std::fs::read(file) {- let entry = json!({"sha256": provenance::sha256_hex(&bytes), "file": file.display().to_string(), "how": how, "args": args, "at": aiflow_run::now()});- if !r.data["fieldsSolves"].is_array() { r.data["fieldsSolves"] = json!([]); }- r.data["fieldsSolves"].as_array_mut().unwrap().push(entry);- }-}--fn checked_remote_binding(r:&Run,br:&Bridge,remote:&str,dir:&Path)->Result<Value,String>{- let local=PathBuf::from(r.current_board());- let files=remote_binding::files(&local,remote)?;- let pull=dir.join(format!("native-binding-{}",std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e|e.to_string())?.as_nanos()));- std::fs::create_dir_all(&pull).map_err(|e|e.to_string())?;- let reply=br.call("pull_file",&json!({"filePaths":files.iter().map(|(_,p)|p).collect::<Vec<_>>(),"saveTo":std::fs::canonicalize(&pull).map_err(|e|e.to_string())?}));- if !Bridge::ok(&reply){return Err(format!("native binding download failed: {}",reply["error"]))}- remote_binding::verify(&local,remote,&pull)-}--fn sha256_file(p: &str) -> String {- use sha2::{Digest, Sha256};- std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()-}--/// Export a schematic's netlist (kicadsexpr) with the native kicad-cli: ADOM_AIFLOW_KICAD_CLI, else-/// `kicad-cli`. In a container with no native KiCad, adom-aiflow-kicad-cli-remote runs a desktop's.-fn export_sch_netlist(sch: &str, out: &Path) -> Result<String, String> {- let cli = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());- let o = std::process::Command::new(&cli).args(["sch", "export", "netlist", "--format", "kicadsexpr", "--output"]).arg(out).arg(sch).output()- .map_err(|e| format!("{cli} is not runnable: {e}"))?;- if !o.status.success() || !out.is_file() {- let tail = |b: &[u8]| aiflow_run::clip_text(&String::from_utf8_lossy(b), 500);- return Err(format!("{cli} sch export netlist failed: {} {}", tail(&o.stdout), tail(&o.stderr)));- }- Ok(cli)-}--/// The run's first board (what `start` copied): the reference for fixed parts.-fn first_board(r: &Run) -> Option<Board> {- r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()).and_then(|p| Board::load(p).ok())-}--/// Molecule conformance of a board when the spec declares a molecule; None otherwise.-fn molecule_findings(spec: &Value, board: &Board, r: Option<&Run>) -> Option<checks::Findings> {- if spec.get("molecule").is_none() { return None; }- let before = r.and_then(first_board);- Some(checks::molecule_check(board, spec, before.as_ref()))-}--/// Why the run's schematic and current board are not proven equal, or Ok when they are: the-/// recorded `netlist check` passed on this entire schematic hierarchy, and its exported netlist still-/// matches the current board (placement, routing or an adopted board cannot change a pin's net-/// unnoticed).-fn netlist_gate(r: &Run) -> Result<String, String> {- let Some(sch) = r.data["schematic"].as_str() else { return Ok("no schematic recorded".into()) };- let o = r.outcomes().get("netlist").cloned().ok_or("netlist check (the run has a schematic; run `adom-aiflow netlist check`)")?;- let identity = schematic_identity::capture(Path::new(sch)).map_err(|e| format!("netlist check (schematic hierarchy: {e})"))?;- if o["schematicFiles"] != json!(identity) { return Err("netlist check (schematic hierarchy changed or older check lacks child-sheet hashes; run it again)".into()); }- if o["sha256"].as_str() != Some(sha256_file(sch).as_str()) { return Err("netlist check (the schematic changed since the last check; run it again)".into()); }- let net = o["netlist"].as_str().unwrap_or("");- if o["netlistSha256"].as_str() != Some(sha256_file(net).as_str()) { return Err("netlist check (exported netlist changed or lacks its recorded hash; run it again)".into()); }- let sn = checks::parse_sch_netlist(&std::fs::read_to_string(net).map_err(|e| format!("netlist check ({net}: {e}; run it again)"))?).map_err(|e| format!("netlist check ({e})"))?;- let rep = checks::compare_netlists(&sn, &Board::load(&r.current_board()).map_err(|e| format!("netlist check ({e})"))?);- if rep["ok"] != true { return Err(format!("netlist check (the current board differs from the schematic: {} mismatched pin(s), missing {}, extra {})", rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), rep["missingRefs"], rep["extraRefs"])); }- Ok(format!("{}/{} pins", rep["matchedPins"], rep["connectedPins"]))-}--/// The 3D orientation pass of the models step: every model bound on the board, its bbox from-/// step2glb (or its STEP points), transformed as KiCad places it, judged. Returns-/// (warnings as [{ref, model, finding}], notes on what could not be checked). Bboxes are cached per-/// file (path, size, mtime) in the run directory so reruns do not call the shared service again.-fn model_orientation(board: &Board, project_dir: &Path, cache_path: Option<&Path>) -> (Vec<Value>, Vec<String>) {- let mut cache: serde_json::Map<String, Value> = cache_path.and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();- let (mut warnings, mut notes, mut calls) = (Vec::new(), Vec::new(), 0usize);- let mut unresolved: BTreeMap<String, Vec<String>> = BTreeMap::new();- for m in checks::model_refs(board) {- let Some(file) = checks::resolve_model(&m.path, project_dir) else { unresolved.entry(m.path.clone()).or_default().push(m.reference.clone()); continue };- let meta = std::fs::metadata(&file).ok();- let key = format!("{}|{}|{}", file.display(), meta.as_ref().map(|x| x.len()).unwrap_or(0), meta.and_then(|x| x.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0));- let got = match cache.get(&key) {- Some(v) => serde_json::from_value::<checks::BBox>(v["bbox"].clone()).ok().map(|b| (b, v["source"].as_str().unwrap_or("").to_string())),- None => {- let g = checks::model_bbox(&file, &mut calls);- if let Some((b, src)) = &g { cache.insert(key, json!({"bbox": b, "source": src})); }- g- }- };- let Some((bb, src)) = got else { notes.push(format!("{} ({}): no bbox from step2glb or the STEP points", m.reference, file.display())); continue };- let placed = checks::transform_bbox(&bb, m.offset, m.scale, m.rotate);- for finding in checks::orientation_findings(&m, &placed) {- warnings.push(json!({"ref": m.reference, "model": file.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), "rotate": m.rotate, "offset": m.offset, "bboxSource": src, "finding": finding}));- }- }- for (p, refs) in unresolved { notes.push(format!("not checked here (path does not resolve in this container): {p} ({})", refs.join(","))); }- if let Some(p) = cache_path { let _ = std::fs::write(p, serde_json::to_string_pretty(&Value::Object(cache)).unwrap_or_default()); }- (warnings, notes)-}--/// Orientation warnings the AI has not acknowledged (run.data.modelAcks: {ref: why}).-fn unacked_orientation(r: &Run, warnings: &[Value]) -> Vec<String> {- let mut refs: Vec<String> = warnings.iter().filter_map(|w| w["ref"].as_str()).filter(|x| r.data["modelAcks"].get(*x).is_none()).map(str::to_string).collect();- refs.dedup();- refs-}--fn project_dir_of(r: &Run) -> PathBuf {- let src = r.data["sourceBoard"].as_str().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r.current_board()));- src.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))-}--/// 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::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-/// run is still running: a long solve is the step's own work, not a step left undeclared.-fn overdue_warning(r: &Run) -> Option<String> {- let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- let w = sequence::overdue_warning(&r.data, &flow, &run_sequence(r, &flow), aiflow_run::parse(&now()).unwrap_or(0))?;- if sequence::open_exec(&r.ledger(), &sequence::pid_alive).is_some() { return None; }- Some(w)-}--/// The NEXT line: the exact command for the next required step, with its one-line description.-fn next_step_line(r: &Run, cli: &Cli) -> Option<String> {- let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- let seq = run_sequence(r, &flow);- let next = sequence::next_required(&r.dir, &r.data, &flow, &seq)?;- let thread = cli.ai_thread.clone().or_else(|| r.data["aiThread"].as_str().map(str::to_owned)).unwrap_or_else(|| "<thread>".into());- let does = sequence::short_does(&flow, &next);- Some(format!("NEXT: adom-aiflow --run {} --ai-thread \"{thread}\" step {next}{}", run_dir(cli).display(), if does.is_empty() { String::new() } else { format!(" ({does})") }))-}--fn print_next(cli: &Cli) {- if let Some(r) = Run::open(&run_dir(cli)) { if let Some(l) = next_step_line(&r, cli) { println!("{l}"); } }-}--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::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())));- matches!(s.status, sequence::Status::Todo | sequence::Status::Redo) || !s.unproven.is_empty() || failed- }).map(|s| s.name.clone()).collect();- let stop = evidence.map(|p| std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str::<Value>(&s).map_err(|e| e.to_string())));- continuation::assess(&r.data, &pending, stop)-}--/// A routing plan read and normalised the one way both `gate` and `land` see it (issue #33):-/// repeated waypoints and paths with no length are dropped; the notes name each drop.-fn read_plan_normalised(path: &str, b: &Board) -> (Value, Vec<String>) {- // `{"nets": [...]}` or a bare array of entries: both become the object form first (issue #47)- let mut v = aiflow_copper::normalize_plan(read_json(path));- if let Err(e) = aiflow_copper::plan_entries(&v) { err(&format!("{path}: {e}"), &[]); }- let notes = aiflow_copper::normalise_plan(b, &mut v);- (v, notes)-}--fn print_plan_notes(notes: &[String]) {- if notes.is_empty() { return; }- eprintln!("Note: the plan was normalised before use ({} change(s)); the gate and land route see the same copper:", notes.len());- for n in notes { eprintln!(" {n}"); }-}--/// A lane's Python as a file to run: `python3 -c <script>` passes it as ONE argument, and Linux caps one-/// argument at 128 KiB (MAX_ARG_STRLEN), so a lane past that never started ("Argument list too long",-/// measured 2026-10-16 on the 151 KB Fusion lane). Written once per content (the name carries its hash).-fn lane_script(lane: &str, script: &str) -> std::path::PathBuf {- let dir = std::env::temp_dir().join("adom-aiflow-lanes");- let _ = std::fs::create_dir_all(&dir);- let path = dir.join(format!("{lane}-{}.py", &provenance::sha256_hex(script.as_bytes())[..16]));- if !path.is_file() {- let tmp = path.with_extension(format!("tmp{}", std::process::id()));- std::fs::write(&tmp, script).unwrap_or_else(|e| err(&format!("could not write the {lane} lane script: {e}"), &[]));- let _ = std::fs::rename(&tmp, &path);- }- path-}--fn main() {- let (skill_home, skill_pkg) = skill_hints::here();- let input_hint = skill_hints::command_hint(&command_line(), &skill_home, skill_pkg.as_deref());- let cli = match Cli::try_parse() {- Ok(c) => c,- Err(e) => {- // a malformed command line names the skill section that describes the command's input- if e.use_stderr() { let _ = e.print(); if let Some(h) = &input_hint { eprintln!("Hint: {h}"); } std::process::exit(e.exit_code()); }- e.exit()- }- };- if let Some(h) = input_hint { let _ = INPUT_HINT.set(h); }- // A host stop hook must not open windows, start captures or append timing events.- if let Cmd::FinishGuard { json: as_json, stop_evidence } = &cli.cmd {- let r = load_run(&cli);- let mut report = continuation_report(&r, stop_evidence.as_deref());- let warning = overdue_warning(&r);- if let (Some(w), Some(o)) = (&warning, report.as_object_mut()) { o.insert("warning".into(), json!(w)); }- if *as_json { println!("{}", serde_json::to_string_pretty(&report).unwrap()); }- else { println!("{}: {}", report["decision"].as_str().unwrap_or("continue"), report["reason"].as_str().unwrap_or(""));- println!("Next: {}", report["nextAction"].as_str().unwrap_or("review remaining scope"));- if let Some(w) = &warning { println!("WARNING: {w}"); } }- std::process::exit(if report["mayStop"] == true { 0 } else { 2 });- }- let dir = run_dir(&cli);- // the KiCad DRC gate and kicad-cli steps run a desktop's native KiCad through Adom Bridge when this- // container has none: the run's own desktop, unless the human named another (a fresh thread never- // knew to set KICAD_REMOTE_TARGET, and the helper fell back to one office machine)- if std::env::var("KICAD_REMOTE_TARGET").is_err() {- if let Some(t) = std::fs::read_to_string(dir.join("run.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok())- .and_then(|v| v["target"].as_str().or(v["data"]["target"].as_str()).filter(|t| !t.is_empty()).map(String::from)) {- std::env::set_var("KICAD_REMOTE_TARGET", t);- }- }- // All engines share the same default. Server/write children must never open tabs recursively.- if !matches!(cli.cmd, Cmd::Timeline { .. } | Cmd::Demo { .. }) {- let ai_thread = cli.ai_thread.clone().or_else(|| Run::open(&dir).and_then(|r|r.data["aiThread"].as_str().map(str::to_owned))).unwrap_or_else(|| "adom-aiflow".into());- let _ = DASHBOARD.set((dir.clone(), ai_thread.clone()));- ensure_dashboard(&dir, &ai_thread, matches!(cli.cmd, Cmd::Prompt { .. }));- }- // every command is a turn in the run's ledger; the gap before it is the AI's thinking time- if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. } | Cmd::Demo { .. }) && !matches!(&cli.cmd, Cmd::Timeline { action, .. } if action == "serve") {- if let Some(mut r) = Run::open(&dir) {- let idx = r.turn_begin(&command_line());- let _ = r.save();- let _ = TURN.set((dir.clone(), idx));- // the over-budget nag: a run that sits in one step far past its expected minutes is most- // likely doing later steps' work undeclared (issue #44). Warn only; `step` itself is the fix.- if !matches!(cli.cmd, Cmd::Step { .. }) { if let Some(w) = overdue_warning(&r) { eprintln!("WARNING: {w}"); } }- // the clip guard: a recording that has outrun its step's budget is a mistake, not evidence.- // Stop it here, on whatever command comes next, before it eats the disk (one once ran- // nine hours on a static editor because deliver did not stop it).- if let (Some(target), Some(t)) = (r.data["target"].as_str().map(str::to_string), cli.ai_thread.clone()) {- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {- let started = caps[pos]["start"].as_str().and_then(aiflow_run::parse).unwrap_or(u64::MAX);- let age = aiflow_run::parse(&now()).unwrap_or(0).saturating_sub(started);- let step = caps[pos]["step"].as_str().unwrap_or("").to_string();- let budget_min = match step.as_str() { "placement" => 40, "routing" => 30, "pours" => 15, "intake" => 10, "models" => 20, "sourcing" => 20, "design" | "simulation" | "schematic" => 30, _ => 10 } as u64;- let guard_due = age > budget_min * 60 * 2 || age > 3600;- // issue #59: a take that died (window gone, bridge restarted) is restarted on the step's window now- if !guard_due && !matches!(cli.cmd, Cmd::Step { .. } | Cmd::Capture { .. } | Cmd::Finish | Cmd::Deliver { .. }) {- if let Some(mut r2) = Run::open(&dir) {- let br = Bridge { ai_thread: t.clone(), target: target.clone() };- if let Some(line) = clipwatch::watch(&mut r2, &br, &dir) { eprintln!("WARNING: {line}"); }- let _ = r2.save();- }- }- if guard_due {- let br = Bridge { ai_thread: t, target };- if let Some(mut r2) = Run::open(&dir) {- r2.mark(&format!("clip guard: the {step} clip ran {} min, past its {budget_min} min budget twice over; stopped by the guard", age / 60));- stop_running_clip(&mut r2, &br, &dir, "guard");- let _ = r2.save();- eprintln!("clip guard: stopped the {step} clip after {} min (budget {budget_min} min)", age / 60);- }- }- }- }- }- }- match &cli.cmd {- Cmd::BrowserCheck { input, lane } => {- let data: Value = serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e| err(&e.to_string(), &[]))).unwrap_or_else(|e| err(&e.to_string(), &[]));- let result = browser_access::classify(&data, lane).unwrap_or_else(|e| err(&e, &[]));- println!("{}", serde_json::to_string_pretty(&result).unwrap());- }- Cmd::Exec { command } => {- thread(&cli); let mut r=load_run(&cli);- // an id and this process's pid: other commands see a running exec (the overdue warning waits for it)- let exec_id=format!("{}-{}",std::process::id(),now());- r.log("external-start",json!({"program":command[0],"args":&command[1..],"execId":exec_id,"pid":std::process::id()}));- r.save().unwrap();- let status=std::process::Command::new(&command[0]).args(&command[1..]).status();- let mut r=load_run(&cli);- match status {- Ok(status)=>{- r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success(),"execId":exec_id}));- if status.success() && provenance::is_fields_solve(command) { if let Some(f)=provenance::fields_out(command) { record_fields_solve(&mut r, Path::new(&f), "exec", command); } }- r.save().unwrap();- if !status.success(){err(&format!("external command failed: {} ({status})",command[0]), &["Inspect the command output above, fix the reported failure and retry with adom-aiflow exec -- <program> <args>. The failed command's time is retained.".into()]);}- ok("external command completed; its running time is in this session", &["Use exec for build, browser automation and other work outside AI Flow. Do not start an idle heartbeat; only actual work counts.".into()]);- }- Err(e)=>{ r.log("external-end",json!({"program":command[0],"success":false,"error":e.to_string(),"execId":exec_id})); r.save().unwrap(); err(&format!("could not start external command {}: {e}",command[0]), &["Check the executable path, then retry adom-aiflow exec -- <program> <args>.".into()]) }- }- }- Cmd::SilkscreenSections { input, out } => {- thread(&cli); let _r=load_run(&cli);- let data:serde_json::Value=serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e|err(&e.to_string(), &[]))).unwrap_or_else(|e|err(&e.to_string(), &[]));- let result=silkscreen_sections::solve(&data).unwrap_or_else(|e|err(&e,&[]));- std::fs::write(out,serde_json::to_vec_pretty(&result).unwrap()).unwrap_or_else(|e|err(&e.to_string(), &[]));- if !result["unresolved"].as_array().unwrap().is_empty(){err("Documentation sections remain unresolved", &["Keep rows together; try a shorter line layout, another interior region or a readable font. Do not scatter sentences.".into()]);}- let fallbacks=result["sections"].as_array().unwrap().iter().filter(|s|s["edgeFallback"]==true).count();- ok(&format!("Native Rust placed {} documentation labels; {fallbacks} edge fallbacks",result["placements"].as_array().unwrap().len()), &["Final documentation pass: prefer open board interior, preserve left-aligned sections and include a source-backed board description. Physical pin/contact labels remain local. Review edge fallbacks, font sizes, native DRC and BOTH EDA views.".into()]);- }- Cmd::SilkscreenPreflight { input, out } => {- thread(&cli); let _r=load_run(&cli);- let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-preflight.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("preflight could not run: {e}"), &[]));- if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Repack every reported collision before transferring to the EDA. Include full custom-pad mask geometry, fitted bodies, holes and footprint artwork.".into()]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Preflight passed; confirm with native DRC and inspect both faces. Association quality remains a separate visual gate.".into()]);- }- Cmd::BoardGlb { action, glb, board, out, finish, paste, report } => {- let mut run = Run::open(&dir);- if run.is_some() { thread(&cli); }- if action == "copper" {- if let Some(open) = run.as_ref().and_then(|r| step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null))) {- err(&format!("board twin not delivered: {open}"), &["The STEP behind this GLB carries exporter geometry warnings nobody has reviewed. Review the STEP at those spots, then `adom-aiflow step-export --ack --why \"...\"`; never drop a layer to silence the exporter.".into()]);- }- }- let board_path = board.clone().map(|b| b.display().to_string()).or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("board-glb needs --board <file.kicad_pcb> outside a run", &["Pass the .kicad_pcb the STEP was exported from.".into()]));- let spec_finish = run.as_ref().map(spec_soft).and_then(|v| v["fab"]["finish"].as_str().map(String::from));- let finish = finish.clone().or(spec_finish).unwrap_or_else(|| "copper".into());- let mut command = std::process::Command::new(aiflow_python());- command.arg("-c").arg(include_str!("../../../tools/board-glb-copper.py")).arg(action).arg("--glb").arg(glb).arg("--board").arg(&board_path).arg("--finish").arg(&finish).arg("--paste").arg(paste);- if let Some(v) = out { command.arg("--out").arg(v); }- if let Some(v) = report { command.arg("--report").arg(v); }- let result = command.output().unwrap_or_else(|e| err(&format!("board-glb needs python3 with shapely and numpy: {e}"), &["Reinstall adom-aiflow so its isolated Python environment exists.".into()]));- let text = format!("{}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));- if let Some(r) = run.as_mut() {- r.outcome("boardGlb", json!({"ok": result.status.success(), "action": action, "glb": out.as_ref().unwrap_or(glb), "board": board_path, "finish": finish, "paste": paste}));- r.save().unwrap_or_else(|e| err(&e, &[]));- }- if !result.status.success() {- // the export hint only when the tool found the STEP lacking pads the board has (issue #81)- let export_hint = if text.contains("Export the STEP with --include-pads") { "Use the .kicad_pcb the STEP was exported from, and export the STEP with --include-pads --include-soldermask (skill aiflow-molecule)." } else { "Use the .kicad_pcb the STEP was exported from (skill aiflow-molecule)." };- err(&text, &["Pads and test points are always real copper (or the board's stated finish), and the mask opens exactly where the footprint mask layer says. Fix it by construction with `adom-aiflow board-glb copper`; never hand-recolour a material index or lift the pads.".into(), export_hint.into()]);- }- ok(&text, &["Look at it before shipping: render a close-up of the test points and an under-part pad (skill aiflow-molecule). Grey or pale pads are not done.".into()]);- }- Cmd::SilkscreenMask { board, out } => {- thread(&cli); let _r=load_run(&cli);- let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-kicad-mask.py")).arg("--board").arg(board).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("mask export could not run: {e}"), &[]));- if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &["Unsupported mask geometry is a blocking preflight error, never an anchor-box fallback.".into()]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Use these complete native mask polygons with fitted body, hole and artwork obstacles.".into()]);- }- Cmd::SilkscreenContactPolicy { input, geometry, out, no_contact_borders } => {- thread(&cli); let _r=load_run(&cli);- let mut command=std::process::Command::new(aiflow_python());command.arg("-c").arg(include_str!("../../../tools/silkscreen-contact-policy.py")).arg("--input").arg(input).arg("--geometry").arg(geometry).arg("--out").arg(out);- if *no_contact_borders{command.arg("--no-contact-borders");}- let result=command.output().unwrap_or_else(|e|err(&format!("contact policy could not run: {e}"), &[]));- if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &[]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);- }- Cmd::SilkscreenAudit { input, out, record, board } => {- thread(&cli); let mut r = load_run(&cli);- let result = std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));- if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }- if *record {- // the step proof: this audit passed, on an inventory that covers the board it claims to audit- let board_path = board.clone().unwrap_or_else(|| r.current_board());- let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));- let audit = read_json(&out.display().to_string());- let data = read_json(&input.display().to_string());- let mut proof = silkproof::proof(&audit, &data, &b).unwrap_or_else(|why| {- r.log("silkscreen-proof-refused", json!({"board": board_path, "audit": out, "why": why}));- err(&format!("silkscreen proof not recorded: {}", why.join("; ")), &["Build the inventory from the whole board (every footprint by its reference, every via and hole on both faces) and fix the labels, then `silkscreen-audit --record` again.".into()])- });- proof["board"] = json!(board_path);- proof["boardSha256"] = json!(sha256_file(&board_path));- proof["audit"] = json!(out.display().to_string());- proof["auditSha256"] = json!(sha256_file(&out.display().to_string()));- proof["input"] = json!(input.display().to_string());- proof["inputSha256"] = json!(sha256_file(&input.display().to_string()));- r.outcome("silkscreen", proof.clone());- r.save().unwrap();- ok(&format!("{}silkscreen step proof recorded: {} footprints and {} vias covered, {} labels", String::from_utf8_lossy(&result.stdout), proof["footprints"], proof["vias"], proof["labels"]), &["Geometry audit only; native DRC, label association and both native 2D/3D views remain required. Then `step 3d`.".into()]);- return;- }- ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required. `--record` records the silkscreen step proof from a passing audit that covers the board.".into()]);- }- Cmd::StepExport { board, out, log, ack, why } => {- thread(&cli); let mut r = load_run(&cli);- if *ack {- let why = why.as_deref().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("step-export --ack needs --why \"what you reviewed in the STEP and why it is complete\"", &[]));- let mut rec = r.outcomes().get("step-export").cloned().unwrap_or_else(|| err("no STEP export recorded in this run", &["Run `adom-aiflow step-export` first.".into()]));- if rec["geometryWarnings"].as_u64().unwrap_or(0) == 0 { ok("nothing to acknowledge: the last STEP export has no geometry warnings", &[]); return; }- rec["ack"] = json!({"why": why, "at": now(), "stepSha256": rec["stepSha256"].clone()});- r.outcome("step-export", rec.clone());- r.log("step-export-ack", json!({"why": why, "step": rec["step"], "geometryWarnings": rec["geometryWarnings"]}));- r.save().unwrap();- ok(&format!("acknowledged {} STEP geometry warning(s) for {} (bound to this STEP file; a new export needs a new review)", rec["geometryWarnings"], rec["step"].as_str().unwrap_or("")), &[]);- return;- }- let board_path = board.clone().unwrap_or_else(|| r.current_board());- let stem = Path::new(&board_path).file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();- let step = out.clone().unwrap_or_else(|| dir.join("twin").join(format!("{stem}.step")));- let rec = match log {- Some(l) => { let text = std::fs::read_to_string(l).unwrap_or_else(|e| err(&format!("{}: {e}", l.display()), &[])); let mut v = step_export::record(&board_path, Some(&step), Some(0), &text, &format!("output of an export run elsewhere: {}", l.display())); v["exitCode"] = Value::Null; v["log"] = json!(l.display().to_string()); if !v["stepWritten"].as_bool().unwrap_or(false) { err(&format!("--log needs --out <the STEP that export wrote>; {} is not here", step.display()), &[]); } v["exitCode"] = json!(0); v["exitCodeSource"] = json!("assumed 0: the export ran elsewhere and wrote the STEP"); v }- None => {- let _ = std::fs::create_dir_all(step.parent().unwrap_or(Path::new(".")));- let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());- step_export::run(&board_path, &step, &kicad)- }- };- write_json(&step.with_extension("export.json"), &rec);- r.outcome("step-export", rec.clone());- r.save().unwrap();- let (g, m, o) = (rec["geometryWarnings"].as_u64().unwrap_or(0), rec["modelWarnings"].as_u64().unwrap_or(0), rec["otherWarnings"].as_u64().unwrap_or(0));- if let Some(open) = step_export::open_issue(&rec) {- let spots: Vec<String> = rec["diagnostics"].as_array().into_iter().flatten().map(|d| format!("{}: {}{}", d["kind"].as_str().unwrap_or(""), d["message"].as_str().unwrap_or(""), d["location"].as_object().map(|l| format!(" at z {} box ({}, {})-({}, {}) near {}", l["z"], l["x0"], l["y0"], l["x1"], l["y1"], d["nearbyFootprints"].as_array().map(|n| n.iter().filter_map(|x| x["ref"].as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default())).unwrap_or_default())).take(12).collect();- err(&format!("STEP export recorded with open issues: {open}\n {}", spots.join("\n ")), &[format!("Exit status {} and the file are recorded separately from these diagnostics ({}).", rec["exitCode"], step.with_extension("export.json").display()), "Do not re-export without silkscreen or mask to hide them. Review the STEP at each spot; if it is complete, `step-export --ack --why \"...\"`; if not, fix the board geometry and export again.".into()]);- }- ok(&format!("STEP export {}: exit {}, {g} geometry, {m} model, {o} other warning(s){}", step.display(), rec["exitCode"], if g > 0 { " (acknowledged)" } else { "" }), &[if m > 0 { "Model warnings: a part may be missing from the twin; check the models step.".into() } else { String::new() }, "Next: step2glb convert, then `board-glb copper` (skill aiflow-molecule).".into()]);- }- Cmd::Export { board, out } => {- thread(&cli); let mut r = load_run(&cli);- let lane = sequence::lane(&dir, &r.data);- if lane != "kicad" { err(&format!("this run builds in {}: its export is the tool's own", aiflow_bridge::eda::active().name()), &[format!("`adom-aiflow {lane} export` writes and checks the set from the {lane} design (Gerbers, drill, CPL, BOM).")]); }- let board_path = board.clone().unwrap_or_else(|| r.current_board());- let out_dir = out.clone().unwrap_or_else(|| dir.join("manufacturing"));- let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());- match export::export(&board_path, &out_dir, &kicad) {- Ok(report) => {- write_json(&out_dir.join("export-check.json"), &report);- r.outcome("export", report.clone());- r.save().unwrap();- let mut hints: Vec<String> = report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}")).collect();- hints.extend(report["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str()).map(str::to_string));- hints.push("Tell the human where every file is. Bottom-side CPL rotations follow kicad-cli's convention and were counted, not compared; check them against the fab's preview.".into());- let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));- let checked = verified::run_items(&r, facts.as_ref());- ok(&format!("manufacturing files in {}, checked against the board: Gerbers ({} copper layers of {}, pours present as regions on {} poured layer(s)), drill ({} hits = vias and drilled pads), pick and place ({} parts), BOM ({} lines)\n{}", out_dir.display(), report["gerbers"]["copperLayers"], report["gerbers"]["boardCopperLayers"], report["fillsGerbers"]["layersWithPours"].as_object().map(|o| o.len()).unwrap_or(0), report["drill"]["hits"], report["cpl"]["refs"], report["bom"]["lines"], verified::message(&checked)), &hints);- }- Err((report, problems)) => {- let _ = std::fs::create_dir_all(&out_dir);- write_json(&out_dir.join("export-check.json"), &report);- r.log("export-refused", json!({"board": board_path, "dir": out_dir, "problems": problems}));- r.save().unwrap();- let fill_hint = if problems.iter().any(|p| p.contains("filled polygon") || p.contains("G36")) { fills::HINT.to_string() } else { String::new() };- err(&format!("manufacturing files do not check out against the board: {}", problems.join("; ")), &[fill_hint, "Fix the board or its footprint attributes (DNP, exclude from BOM/position files) and run `export` again; never hand the human files that do not match the board.".into(), "kicad-cli: ADOM_AIFLOW_KICAD_CLI picks it; in a container with no native KiCad, ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>.".into()]);- }- }- }- Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {- let t = thread(&cli); let _r = load_run(&cli);- let mut command = std::process::Command::new("adom-aiflow-silkscreen");- command.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t).arg("--port").arg(port).arg("--surface").arg(surface);- if let Some(v)=url { command.arg("--url").arg(v); }- if let Some(v)=target { command.arg("--target").arg(v); }- if let Some(v)=json { command.arg("--json").arg(v); }- if let Some(v)=reason { command.arg("--reason").arg(v); }- let result=command.output().unwrap_or_else(|e|err(&format!("silkscreen dashboard helper missing: {e}"), &["Install the matching AI Flow package helper.".into()]));- if !result.status.success() {err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &[]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Live feed and replay visualize recorded solver events. Proposed labels do not modify the native board.".into()]);- }- Cmd::SilkscreenLayout { input, out, events } => {- thread(&cli);- let _r = load_run(&cli);- let source = include_str!("../../../tools/silkscreen-layout.py");- let mut command = std::process::Command::new(aiflow_python());- command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out);- if let Some(v)=events {command.arg("--events").arg(v);}- let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));- if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }- ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);- }- Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force, sch, page } => {- let t = thread(&cli);- if !Path::new(board).is_file() {- err(&format!("board not found: {board}"), &[]);- }- if !Path::new(spec).is_file() {- err(&format!("spec not found: {spec}"), &["docs/spec-example.json on the adom-aiflow page is the shape.".into()]);- }- if let Some(s) = sch { if !Path::new(s).is_file() { err(&format!("schematic not found: {s}"), &["--sch names the .kicad_sch the board was made from.".into()]); } }- // a run made by `intake` has no board yet: start attaches it and keeps the clock and the ledger- let intake_run = Run::open(&dir).filter(|r| r.board_pending() && !*force);- if let (Some(r), false, true) = (Run::open(&dir), *force, intake_run.is_none()) {- err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory, or --force to start over.".into()]);- }- std::fs::create_dir_all(&dir).unwrap();- let copy = dir.join("board-0.kicad_pcb");- let src_text = std::fs::read_to_string(board).unwrap_or_default();- // the plan board: rendered by the backend --remote-board names (a Fusion export, an Altium- // read), or the KiCad board copied as it is (eda.rs import_board)- if let Some(text) = aiflow_bridge::eda::import_board(remote_board.as_deref(), &src_text).unwrap_or_else(|e| err(&e, &[])) {- std::fs::write(©, text).unwrap_or_else(|e| err(&format!("write: {e}"), &[]));- // the fab's KiCad rules beside the .brd (`fusion schematic` puts them there) go with the- // plan board, so the gate checks the fab, not KiCad's defaults- let dru = std::path::Path::new(board).with_extension("kicad_dru");- if dru.is_file() { let _ = std::fs::copy(&dru, copy.with_extension("kicad_dru")); }- aiflow_bridge::eda::set_run_board(remote_board.as_deref().unwrap_or(""));- } else {- std::fs::copy(board, ©).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));- aiflow_copper::copy_project_rules(board, ©.display().to_string()).unwrap_or_else(|e|err(&e,&[]));- }- let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone());- if let Some(mut r) = intake_run {- let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("").to_string();- let differs = prompt_time.as_ref().filter(|p| **p != pt).map(|p| format!("--prompt-time {p} ignored: the intake run's clock started at {pt} and stays there. "));- r.attach_board(©.display().to_string(), &specp, engine, target.as_deref(), remote_board.as_deref());- r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));- if let Some(p) = page { r.data["page"] = json!(p); }- if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }- let idx = r.turn_begin(&command_line());- record_usage(&mut r, "start");- r.save().unwrap();- let _ = TURN.set((dir.clone(), idx));- let steps: Vec<String> = r.data["steps"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();- let mut attach_hints = vec![differs.unwrap_or_default() + &format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Re-run `sourcing check --bom <csv>` now: the check before the board could not diff the BOM against the board's references.".into()];- // `rules use` in the intake run recorded fabRules before there was a spec: the gate- // falls back to it when the spec names no fab.rules, and says when they disagree- if let Some((p, layers, warning)) = checks::rules_target(&read_json(spec), &r.data["fabRules"]) {- if let Some(w) = warning { attach_hints.push(format!("Fab rules: {w}.")); }- if let Err((e, hint)) = checks::rules_gate(&p, ©.display().to_string(), layers) { attach_hints.push(format!("Fab rules: {e}. {hint}")); }- }- let rc_flags = rules_card::at_start(&dir, &read_json(spec), ©, &r.data["fabRules"]);- for f in &rc_flags { eprintln!("WARNING: {f}"); }- attach_hints.push(rules_card_hint(&rc_flags));- attach_hints.extend(converter_warning(&r.data, &read_json(spec), None, Board::load(©.display().to_string()).ok().as_ref()));- ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &attach_hints);- print_next(&cli);- return;- }- let pt = prompt_time.clone().unwrap_or_else(now);- let mut r = Run::create(&dir, ©.display().to_string(), &specp, engine, &pt, &t, target.as_deref(), remote_board.as_deref()).unwrap_or_else(|e| err(&e, &[]));- r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));- if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }- if let Some(p) = page { r.data["page"] = json!(p); }- let idx = r.turn_begin(&command_line());- record_usage(&mut r, "start");- r.save().unwrap();- let _ = TURN.set((dir.clone(), idx));- let mut start_hints = vec![format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display())];- if sch.is_some() { start_hints.push("The run has a schematic: `adom-aiflow netlist check` proves the board equals it pin by pin; finish requires it.".into()); }- else { start_hints.push("No --sch: finish will not ask for schematic-to-board netlist equivalence. When the board came from a schematic, start with --sch <file.kicad_sch> (or run `netlist check --sch <file>` to record it).".into()); }- let spec_v = read_json(spec);- if let Some((p, layers, _)) = checks::rules_target(&spec_v, &Value::Null) { if let Err((e, hint)) = checks::rules_gate(&p, board, layers) { start_hints.push(format!("Fab rules: {e}. {hint}")); } }- let rc_flags = rules_card::at_start(&dir, &spec_v, ©, &r.data["fabRules"]);- for f in &rc_flags { eprintln!("WARNING: {f}"); }- start_hints.push(rules_card_hint(&rc_flags));- start_hints.extend(converter_warning(&r.data, &spec_v, None, Board::load(©.display().to_string()).ok().as_ref()));- if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }- start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);- ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);- print_next(&cli);- }- Cmd::Intake { prompt_time, brief, requirements, engine, target, page, eda } => {- let t = thread(&cli);- if aiflow_run::parse(prompt_time).is_none() { err(&format!("--prompt-time {prompt_time} is not a time (RFC 3339 UTC, e.g. 2026-09-29T00:39:00Z)"), &["Use the human message's own timestamp, not the moment you got to it.".into()]); }- if let Some(r) = Run::open(&dir) {- err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory: intake opens a new run. A follow-up prompt on this run is `prompt --text ...`.".into()]);- }- for f in brief.iter().chain(requirements.iter()) { if !Path::new(f).is_file() { err(&format!("not found: {f}"), &[]); } }- let canon = |p: &String| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or(p.clone());- // a brief that is already a requirements file (aiflow-intake's shape) is recorded as both- let req = requirements.as_ref().map(canon).or_else(|| brief.as_ref().filter(|b| std::fs::read_to_string(b).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v.get("fab").is_some() || v.get("sourcing").is_some() || v.get("outputs").is_some()).unwrap_or(false)).map(canon));- let mut r = Run::create_intake(&dir, prompt_time, &t, engine.as_deref(), brief.as_ref().map(canon).as_deref(), req.as_deref(), target.as_deref()).unwrap_or_else(|e| err(&e, &[]));- if let Some(p) = page { r.data["page"] = json!(p); }- // the EDA tool, named: the step sequence is fixed at the first step after intake, before any- // backend project exists, so a brief that never says "Altium" must not fall into kicad-new- if let Some(e) = eda { r.data["eda"] = json!(e); }- let idx = r.turn_begin(&command_line());- record_usage(&mut r, "start");- r.save().unwrap();- let _ = TURN.set((dir.clone(), idx));- let profile = preboard::named_profile(&requirements_of(&r));- ok(&format!("intake run started at {}; the clock started at the prompt, {prompt_time}; step intake is open and there is no board yet", dir.display()), &[- "Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step you declared, and with --target each step before the board films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else your own terminal window (nothing meaningful on screen is a recorded skip): work in a visible window.".into(),- match profile { Some(p) => format!("Sourcing profile {p} (from the requirements): `sourcing check --bom design/bom.csv` gates the BOM; finish requires it."), None => "No sourcing profile named yet: write requirements.json with fab.target `fab` (default) or `jlcpcb` (aiflow-intake), then `sourcing check --bom <csv> --requirements requirements.json`.".into() },- "Record the design record as it lands: `evidence add --kind calcs --file design/calcs.json`, `evidence add --kind simulation --file sim/<results>.json` (a failing simulation is refused), `evidence add --kind vendor-model --file <result>` for a vendor PSpice/LTspice run (adom/adom-spice-skillpack).".into(),- "When the board exists: `start --board B.kicad_pcb --spec spec.json --engine <you> [--target <box> --remote-board <path>]` on this same --run attaches it; the clock stays at the prompt.".into(),- if r.data["page"].is_null() { "Name the run's page once (`clips --page <owner/slug>` or `report --page ...`) so every clip lands on docs/clips.md as it stops.".into() } else { String::new() },- "Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it.".into(),- ]);- print_next(&cli);- }- Cmd::Sourcing { what, bom, profile, qty, board, requirements } => {- thread(&cli);- let mut r = load_run(&cli);- if what != "check" { err(&format!("sourcing {what}: use `sourcing check --bom <csv>`"), &[]); }- if let Some(q) = requirements {- if !Path::new(q).is_file() { err(&format!("not found: {q}"), &[]); }- r.data["requirements"] = json!(std::fs::canonicalize(q).map(|p| p.display().to_string()).unwrap_or(q.clone()));- }- let (spec, req) = (spec_soft(&r), requirements_of(&r));- let named = preboard::named_profile(&spec).map(|p| (p, "the spec")).or_else(|| preboard::named_profile(&req).map(|p| (p, "the requirements")));- let prof = profile.clone().or_else(|| named.as_ref().map(|n| n.0.clone())).unwrap_or_else(|| "fab".into());- let override_note = match (profile, &named) { (Some(p), Some((n, by))) if p != n => format!("--profile {p} overrides {by}, which name {n}; finish checks against the recorded outcome, so re-run without --profile if that was not meant."), _ => String::new() };- let (qty, qty_src) = match qty { Some(q) => (*q, "--qty"), None => match req["build"]["quantity"].as_u64().or(req["buildQuantity"].as_u64()).map(|q| (q, "the requirements")).or_else(|| spec["buildQuantity"].as_u64().map(|q| (q, "the spec"))) { Some(x) => x, None => (10, "the default; say --qty or requirements build.quantity") } };- let text = std::fs::read_to_string(bom).unwrap_or_else(|e| err(&format!("{bom}: {e}"), &[]));- let board_path = board.clone().or_else(|| (!r.board_pending()).then(|| r.current_board()));- let refs = board_path.as_ref().map(|p| Board::load(p).map(|b| preboard::board_refs(&b)).unwrap_or_else(|e| err(&format!("board {p}: {e}"), &[])));- let mut out = preboard::sourcing_check(&text, refs.as_ref(), &prof, qty);- design_evidence::exact_mpn_evidence(&text, design_evidence::require_exact(&spec, &req), &mut out);- let bomp = std::fs::canonicalize(bom).map(|p| p.display().to_string()).unwrap_or(bom.clone());- out["bom"] = json!(bomp);- out["bomSha256"] = json!(library_review::hash(Path::new(bom)).unwrap_or_default());- out["board"] = json!(board_path);- out["checkedAt"] = json!(now());- out["profileNamedBy"] = json!(if profile.is_some() { "--profile" } else { named.as_ref().map(|n| n.1).unwrap_or("default") });- out["qtySource"] = json!(qty_src);- r.outcome("sourcing", out.clone());- r.save().unwrap();- let warnings: Vec<String> = out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("warning: {s}"))).collect();- let rule = if prof == "jlcpcb" { "jlcpcb: every row carries its LCSC number (JLCPCB/LCSC basic parts first)" } else { "fab: every row from Mouser or Adom stock, no LCSC/JLCPCB parts (the 3rd party fab)" };- if out["pass"] != true {- let errs: Vec<String> = out["errors"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!(" {s}"))).collect();- let mut hints = vec![format!("Profile {rule}. Fix the BOM (aiflow-sourcing: search by spec and in stock, record stock and the date), then check again."), "Every electrical reference needs a row with an MPN; contacts and test pads the footprint does not exclude from the BOM take a `no part` row.".into(), override_note];- hints.extend(warnings);- err(&format!("sourcing check failed on the {prof} profile ({} problem(s)):\n{}", errs.len(), errs.join("\n")), &hints);- }- let diff = if out["boardChecked"] == true { format!("every one of the board's {} BOM references is in the BOM ({} excluded by their footprints)", out["boardRefs"], out["excludedByBoard"].as_array().map(|a| a.len()).unwrap_or(0)) } else { "no board yet, so no reference diff: check again after `start --board` (finish requires the diff)".into() };- let mut hints = vec![format!("Profile {rule}; build quantity {qty} ({qty_src})."), override_note];- hints.extend(warnings);- hints.push("Next: `evidence add --kind calcs --file design/calcs.json` once the values are derived (aiflow-circuit-design).".into());- ok(&format!("sourcing check passed on the {prof} profile: {} rows, {} references ({} Adom stock, {} distributor, {} no part), stock checked {}; {diff}", out["rows"], out["refs"], out["adomStockRows"], out["distributorRows"], out["noPartRows"], out["stockCheckDates"].as_array().map(|a| a.iter().filter_map(|d| d.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()), &hints);- }- Cmd::Evidence { what, kind, file, note } => {- thread(&cli);- let mut r = load_run(&cli);- if what != "add" { err(&format!("evidence {what}: use `evidence add --kind calcs|simulation|vendor-model --file <json|md>`"), &[]); }- let src = PathBuf::from(file);- if !src.is_file() { err(&format!("no such file: {file}"), &[]); }- let text = std::fs::read_to_string(&src).unwrap_or_else(|e| err(&format!("{file}: {e}"), &["Evidence is a JSON or Markdown file.".into()]));- let is_json = src.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("json")).unwrap_or(false);- if is_json && serde_json::from_str::<Value>(&text).is_err() { err(&format!("{file}: not JSON"), &[]); }- let sha = library_review::hash(&src).unwrap_or_else(|e| err(&e, &[]));- let (top, checks, failing) = preboard::evidence_checks(&text, is_json);- let pass = if top == Some(false) || !failing.is_empty() { Some(false) } else if top == Some(true) || !checks.is_empty() { Some(true) } else { None };- let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());- let step = r.data["currentStep"].clone();- if kind == "simulation" && pass == Some(false) {- r.log("evidence-refused", json!({"kind": kind, "file": canon.display().to_string(), "sha256": sha, "failing": failing, "pass": top}));- r.save().unwrap();- err(&format!("simulation evidence refused: {} says it fails{}", file, if failing.is_empty() { " (top-level pass: false)".to_string() } else { format!(": {}", failing.iter().map(|c| format!("{} {} against {}", c["name"].as_str().unwrap_or("?"), c["value"], c["limit"])).collect::<Vec<_>>().join("; ")) }), &["A failing simulation is a design result, not evidence of a working design: change the values (aiflow-circuit-design), simulate again, and add the passing result. Obey a datasheet limit over a fitted model (aiflow-simulate).".into()]);- }- // a copy in the run keeps the record when the design folder moves on- let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());- let copy = if canon.starts_with(&rdir) { canon.clone() } else {- let d = dir.join("evidence");- let _ = std::fs::create_dir_all(&d);- let dest = d.join(format!("{kind}-{}", src.file_name().and_then(|n| n.to_str()).unwrap_or("file")));- std::fs::copy(&src, &dest).unwrap_or_else(|e| err(&format!("copy into the run: {e}"), &[]));- std::fs::canonicalize(&dest).unwrap_or(dest)- };- let review: Vec<String> = match (kind.as_str(), serde_json::from_str::<Value>(&text)) { ("calcs", Ok(v)) => design_evidence::calcs_warnings(&v), ("simulation", Ok(v)) => design_evidence::simulation_warnings(&requirements_of(&r), &v), _ => Vec::new() };- let entry = json!({"kind": kind, "file": canon.display().to_string(), "copy": copy.display().to_string(), "sha256": sha, "bytes": text.len(), "note": note, "step": step, "at": now(), "pass": pass, "checks": checks, "warnings": review});- if !r.data["evidence"].is_array() { r.data["evidence"] = json!([]); }- r.data["evidence"].as_array_mut().unwrap().push(entry.clone());- r.log("evidence", entry);- r.save().unwrap();- let n = r.data["evidence"].as_array().map(|a| a.iter().filter(|e| e["kind"] == json!(kind)).count()).unwrap_or(0);- let state = match pass { Some(true) => format!("pass ({} check(s))", checks.len()), Some(false) => format!("FAIL ({} failing check(s))", failing.len()), None => "no pass/fail fields".into() };- let hint = match kind.as_str() {- "calcs" => "Next: simulate the values the equations cannot settle (loop, startup, load step; aiflow-simulate), then `evidence add --kind simulation --file sim/<results>.json` with a top-level \"pass\" or \"checks\": [{\"name\",\"value\",\"limit\",\"pass\"}] so the gate can read it.".to_string(),- "simulation" => if pass.is_none() { "This result states no pass or fail: add a top-level \"pass\" or a \"checks\" list against the requirements so the gate can read it (the file is recorded either way).".to_string() } else { "Next: the vendor model if the brief asks for one (`evidence add --kind vendor-model`), then `step schematic`.".to_string() },- _ => format!("Vendor PSpice/LTspice runs belong to the adom/adom-spice-skillpack (`adom-wiki pkg install adom/adom-spice-skillpack`: PSpice for TI and LTspice on a Windows desktop through Adom Bridge); this records its result{}. Publish results, never solver speed numbers.", if pass == Some(false) { ", which says it fails: write the design conclusion in sim/<vendor>-validation.md" } else { "" }),- };- let mut hints = vec![hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()];- hints.extend(review.iter().map(|w| format!("warning: {w}")));- // issue #75: the calcs say what the converter is; a spec that exists already must carry Fields' block- if kind == "calcs" {- let spec = spec_soft(&r);- if !spec.is_null() { hints.extend(converter_warning(&r.data, &spec, None, Board::load(&r.current_board()).ok().as_ref())); }- }- ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &hints);- }- Cmd::Clips { page, push } => {- thread(&cli);- let mut r = load_run(&cli);- if let Some(p) = page { r.data["clipsPage"] = json!(p); r.save().unwrap(); }- let pg = clips_page_of(&r).unwrap_or_else(|| err("no page for the clips: `clips --page <owner/slug>` once (the project's wiki page)", &[]));- match publish_clips(&r, &dir, &pg, *push) {- Ok((said, files)) => ok(&said, &[if *push { "Look at the rendered page once (a pup tab or a Hydrogen webview on the human's machine): a successful push is not a working page. Link docs/clips.md from the README once (wiki-sub-readme).".to_string() } else { format!("Would push {} file(s): {}", files.len(), files.iter().take(8).cloned().collect::<Vec<_>>().join(", ")) + if files.len() > 8 { ", ..." } else { "" } }, "From now on every clip stop rebuilds and pushes this page by itself; write each clip's note in <run>/clip-notes.json (step, shows, state) after you look at its contact sheet (aiflow-live-clips).".into()]),- Err(e) => err(&e, &["Fix the page text (clip-notes.json is where notes come from), then `clips --push` again.".into()]),- }- }- Cmd::Widget { action, file, label, step, progress } => {- let t = thread(&cli);- let _r = load_run(&cli);- let mut cmd = std::process::Command::new("adom-aiflow-widget");- cmd.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t);- if let Some(v) = file { cmd.arg("--file").arg(v); }- if let Some(v) = label { cmd.arg("--label").arg(v); }- if let Some(v) = step { cmd.arg("--step").arg(v); }- if let Some(v) = progress { cmd.arg("--progress").arg(v.to_string()); }- let status = cmd.status().unwrap_or_else(|e| err(&format!("widget helper unavailable: {e}"), &["Install the package's adom-aiflow-widget helper; the board run is unchanged.".into()]));- if !status.success() { err("widget helper failed", &[]); }- ok("widget preference or artifact updated", &["Optional: open AI Flow progress from Hydrogen Widgets. Reads the run and existing artifacts without AI calls. Disable stops feed updates and new thumbnails.".into()]);- }- Cmd::BindRemoteBoard { remote_board } => {- thread(&cli);let mut r=load_run(&cli);let br=bridge_of(&r,&cli);- let proof=checked_remote_binding(&r,&br,remote_board,&dir).unwrap_or_else(|e|err(&e,&["Transfer the current adopted board and unchanged project/rule sidecars to the intended desktop path, then bind again. This never edits the desktop file or accepts a gate.".into()]));- let old=r.data["remoteBoard"].clone();r.data["remoteBoard"]=proof["remoteBoard"].clone();r.data["remoteBinding"]=proof.clone();r.log("bind-remote-board",json!({"previousRemoteBoard":old,"proof":proof}));r.save().unwrap();- ok("native board and project rules match the adopted snapshot; desktop binding updated",&["Run models and verify the native 2D/3D views. Binding is not engineering acceptance.".into()]);- }- Cmd::AdoptBoard { board, evidence } => {- thread(&cli); let mut r=load_run(&cli);- if evidence.trim().is_empty(){err("adopt-board needs --evidence describing native save/refill/read-back", &["Save and refill the board in its EDA, then pass --board <file> --evidence <review notes or report path>.".into()]);}- Board::load(board).unwrap_or_else(|e|err(&e,&[]));- let base=aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e|err(&e,&[]));- let candidate=aiflow_copper::run_drc(board).unwrap_or_else(|e|err(&e,&[]));- let summary=aiflow_copper::summarize(&candidate,Some(&base));- if summary["newErrors"].as_u64()!=Some(0){err(&format!("adopt-board adds native DRC violations: {}",summary["newErrorList"]), &["Correct the new violations and rerun adopt-board. Existing connectivity may remain for later routing.".into()]);}- let n=r.data["boards"].as_array().map(|a|a.len()).unwrap_or(1);- let dest=dir.join(format!("board-{n}.kicad_pcb"));- std::fs::copy(board,&dest).unwrap_or_else(|e|err(&e.to_string(),&[]));- aiflow_copper::copy_project_rules(board,&dest.display().to_string()).unwrap_or_else(|e|err(&e,&[]));- let (carried, carry_note) = copper_carry::adopt(&mut r, &dest.display().to_string());- r.data["remoteBinding"]=Value::Null;- r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary,"copperCarried":carried}));- r.save().unwrap();- if carried { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["Silkscreen changed: record the silkscreen proof again (`silkscreen-audit --record`) and export again; finish reruns the full native DRC. Bind the desktop board again (bind-remote-board) before live commands.".into()]); }- else { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["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, project_lib } => {- 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();- // the MPN, then the wiki's slug form of it (AP2112K-3.3TRG1 lives at ap2112k-3-3trg1, issue #76)- let mut page = None;- let mut queries = vec![mpn.clone()];- if let Some(w) = preboard::part_slug_candidates(&mpn).into_iter().skip(1).next() { queries.push(w); }- for q in &queries {- let o = std::process::Command::new("adom-wiki").args(["search", q, "--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();- page = preboard::part_page(&hits, &mpn);- if page.is_some() { break; }- }- if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); }- row["wiki"] = json!(page);- }- // parts with no public page may come from the board's own project library (issue #66)- let plib = project_lib.clone().or_else(|| spec_soft(&r)["projectLib"].as_str().or(requirements_of(&r)["projectLib"].as_str()).map(str::to_string));- let mut why_missing: Vec<(String, String)> = Vec::new();- if let Some(d) = &plib {- let lib = project_lib::ProjectLib::load(Path::new(d)).unwrap_or_else(|e| err(&e, &["A project library is a folder with *.kicad_sym, *.pretty/*.kicad_mod, STEP models and provenance.json (see the aiflow-sourcing skill).".into()]));- why_missing = project_lib::resolve(&mut rows, &lib);- missing.retain(|m| why_missing.iter().any(|(w, _)| w == m));- }- let from_lib: Vec<String> = rows.iter().filter(|x| x["projectLib"].is_object()).filter_map(|x| x["mpn"].as_str().map(str::to_string)).collect();- 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, "projectLib": plib, "projectLibParts": from_lib, "projectLibGaps": why_missing.iter().map(|(m, w)| json!({"mpn": m, "why": w})).collect::<Vec<_>>()}));- 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().map(str::to_string).or_else(|| x["projectLib"].is_object().then(|| "project library".to_string())).unwrap_or_else(|| "MISSING".into()))).collect();- if !pass {- let gaps: Vec<String> = why_missing.iter().map(|(m, w)| format!(" {m}: project library: {w}")).collect();- err(&format!("{} of {} parts have no Adom wiki component page{}:\n{}{}", missing.len(), rows.len(), if plib.is_some() { " and no complete project-library entry" } else { "" }, list.join("\n"), if gaps.is_empty() { String::new() } else { format!("\n{}", gaps.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(), "A part whose page cannot be public yet: `parts --project-lib <dir>` with its symbol, footprint, STEP model and a provenance.json entry (aiflow-sourcing skill).".into()]);- }- ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, 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, existing, 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.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());- let lane = sequence::lane(&dir, &r.data);- let cfg = json!({"run": dir.display().to_string(), "eda": lane, "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, "existing": existing, "version": "1.0.0",- "libRef": r.data["cloudLibrary"].as_str().or(r.data["outcomes"]["project-page"]["cloudLibrary"].as_str())});- 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": aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 600)}));- if v["ok"] != true {- if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); r.save().unwrap(); }- err(&format!("project page: {}", v["error"].as_str().unwrap_or("failed")), &["Nothing was published. Rate-limited adds are retried with backoff; rerun `project-page` after the wait and it reuses the same Cloud Library (never a second one).".into()]);- }- r.outcome("project-page", v.clone());- r.data["projectPage"] = json!(page);- if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); }- 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();- }- if lane == "altium" {- ok(&format!("project page {} ({}): README revision 1 with {} parts and the component table (Altium Designer: the library step builds the SchLib and PcbLib from these same wiki pages)", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"]),- &["Next: `step library`, then `adom-aiflow altium library`: one SchLib and one PcbLib from the parts' wiki pages. Every step you leave is pushed to this page as steps/<nn>-<step>/README.md.".into()]);- return;- }- let c = &v["counts"];- let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect();- if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }- ok(&format!("project page {} ({}): README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),- &[hints, vec![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(""))]].concat());- }- Cmd::Pcm { what: _ } => {- thread(&cli);- let mut r = load_run(&cli);- if sequence::lane(&dir, &r.data) != "kicad" { err("pcm check is KiCad's library check (the project page's PCM package installed in KiCad); this run builds in another tool", &["Altium: `adom-aiflow altium library` builds and records the library.".into()]); }- 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 sequence::lane(&dir, &r.data) != "kicad" { err("schematic check reads a KiCad schematic (kicad-cli ERC); this run builds in another tool", &["Altium: `adom-aiflow altium schematic` draws, compiles and checks the schematic pin for pin.".into()]); }- 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 (_, erc_errors) = schematic_check::run_erc(&kc, sch, &erc).unwrap_or_else(|e| err(&format!("schematic check: {e}"), &["ERC must run and its report come back before the schematic can pass. In a container: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> (it runs `sch erc` on the desktop's KiCad).".into()]));- let net = sdir.join("netlist.net");- export_sch_netlist(sch, &net).unwrap_or_else(|e| err(&e, &[]));- let on_sheet = schematic_check::sheet_symbols(&std::fs::read_to_string(&net).unwrap_or_default()).unwrap_or_else(|e| err(&format!("netlist: {e}"), &[]));- 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();- // issue #39: symbol polarity drawings, active-device pin types and readability (schematic_symbols.rs)- let symbols = schematic_symbols::check(Path::new(sch), &spec_soft(&r)).unwrap_or_else(|e| json!({"ok": false, "errors": [format!("could not read the hierarchy: {e}")], "warnings": []}));- schematic_symbols::record(&mut r, sch, &symbols);- let pass = erc_errors.is_empty() && missing.is_empty() && foreign.is_empty() && symbols["ok"] == true;- 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(", "))); }- why.extend(symbols["errors"].as_array().into_iter().flatten().filter_map(|e| e.as_str().map(str::to_string)));- let mut hints = vec!["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.".to_string()];- hints.extend(schematic_symbols::render(&symbols, true).1);- hints.push(schematic_symbols::warning_hint(&symbols));- err(&format!("schematic check failed:\n {}", why.join("\n ")), &hints);- }- 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(", ")) }),- &[schematic_symbols::warning_hint(&symbols), "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);- let b = board_of(&r);- let path = dir.join("components.json");- let old: Value = if path.exists() {- let text = std::fs::read_to_string(&path).unwrap_or_else(|e| err(&format!("read components register: {e}"), &[]));- serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("invalid components register (left untouched): {e}"), &[]))- } else { json!({}) };- let preference = etch.as_deref().or_else(|| old["mpnMarking"].as_str()).unwrap_or("on");- let mut entries = Vec::new();- for fp in b.root.find_all("footprint") {- let props: BTreeMap<String, String> = fp.find_all("property").filter_map(|p| Some((p.atom(1)?.into(), p.atom(2)?.into()))).collect();- let Some(reference) = props.get("Reference") else { continue; };- let mut entry = old["components"].as_array().and_then(|a| a.iter().find(|e| e["reference"].as_str() == Some(reference.as_str()))).cloned().unwrap_or_else(|| json!({"reference": reference, "review": {"status": "pending", "classification": "unreviewed", "wikiPage": null, "geometryEvidence": [], "visualEvidence": [], "redistributionEvidence": [], "assetProvenance": [], "variants": []}}));- let models = json!(fp.find_all("model").filter_map(|n| n.value()).collect::<Vec<_>>());- if entry.get("properties").is_some() && (entry["properties"] != json!(props) || entry["footprint"] != json!(fp.value()) || entry["modelReferences"] != models) {- entry["review"]["status"] = json!("stale");- entry["review"]["staleReason"] = json!("Board properties, footprint or model references changed; re-review retained evidence.");- }- entry["properties"] = json!(props);- entry["footprint"] = json!(fp.value());- entry["modelReferences"] = models;- entries.push(entry);- }- let report = json!({"schemaVersion": 1, "board": b.path, "mpnMarking": preference, "components": entries,- "qualityGate": "AI review required; this inventory does not certify identities, geometry, redistribution rights or native rendering"});- std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()).unwrap_or_else(|e| err(&format!("write component register: {e}"), &[]));- r.data["components"] = json!({"register": path, "mpnMarking": preference, "reviewStatus": "pending"});- r.save().unwrap_or_else(|e| err(&e, &[]));- let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- let step = flow["steps"].as_array().unwrap().iter().find(|s| s["name"] == "components").unwrap();- let mut hints: Vec<String> = step["workflow"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect();- hints.push(format!("MPN marking preference: {preference} (default on: every IC's model carries its laser-etched MPN, and finish checks it). Mark a model with `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>`: the mark planner places it (along the longest usable dimension, between the terminations, as large as fits, upright with pin 1 lower left), the shared service writes it with the model's colours, and the legibility gate must say PASS; look at <etched>.check.png before you report (Marking checklist in the adom-aiflow skill). 'off' keeps plain models; 'ask' offers the option. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));- ok(&format!("component register: {} ({} references); AI review is pending", path.display(), report["components"].as_array().unwrap().len()), &hints);- }- Cmd::Plan => {- let mut r = load_run(&cli);- // the flow is a file: the steps in order, who does each, what the binary offers, what comes later- let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- r.data["flow"] = flow.clone();- let spec = spec_of(&r);- let b = board_of(&r);- let parked = b.footprints.values().filter(|f| f.x > b.bbox.2).count();- let fixed = spec.get("fixedRefs").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0);- let stages = json!({- "place": {"engines": ["ai"], "default": "ai", "why": format!("{parked} parts parked outside the outline, {fixed} fixed by the interface; the AI places (kicad_placement_state, place pack/check/land help), the binary checks")},- "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says take route=ai"},- "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},- "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},- "land": {"engines": ["binary"], "default": "binary", "why": "native bridge undo transactions with candidate DRC; pours use the complete explicitly owned zone plan without a delete/create fallback"},- "measure": {"engines": ["binary"], "default": "binary", "why": "request refill once, then read two delayed matching native revision/copper snapshots; acknowledgement is not measurement and post-commit errors forbid blind replay"},- "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},- "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},- "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},- });- r.data["plan"] = json!({"at": now(), "stages": stages, "board": {"footprints": b.footprints.len(), "nets": b.nets.len(), "parked": parked, "layers": b.copper_layers}});- r.save().unwrap_or_else(|e| err(&e, &[]));- let mut lines = vec![format!("plan for {}: {} footprints, {} nets, {} copper layers, {parked} parked", r.data["engine"].as_str().unwrap_or(""), b.footprints.len(), b.nets.len(), b.copper_layers.len())];- lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));- for st in flow["steps"].as_array().cloned().unwrap_or_default() {- lines.push(format!(" step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));- if let Some(workflow) = st["workflow"].as_array() {- for (i, action) in workflow.iter().filter_map(Value::as_str).enumerate() {- lines.push(format!(" {}. {}", i + 1, action));- }- }- }- lines.push(format!(" later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));- lines.push("stages the binary offers:".into());- for n in STAGES {- let s = &r.data["plan"]["stages"][n];- lines.push(format!("{n:<16} {:<12} {}", s["engines"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default(), s["why"].as_str().unwrap_or("")));- }- ok(&lines.join("\n"), &[format!("Take a stage yourself with: adom-aiflow take route=ai --run {} (default: binary where it is offered).", dir.display()), "Every stage stamps the clock; for a stage you take yourself call `stage start <name>` and `stage end <name>` around your own work.".into()]);- }- Cmd::Take { decisions } => {- thread(&cli);- let mut r = load_run(&cli);- for kv in decisions {- let Some((stage, engine)) = kv.split_once('=') else { err(&format!("bad decision {kv}; use stage=engine"), &[]) };- if stage.is_empty() || !stage.chars().all(|c| c.is_ascii_alphanumeric() || c=='-' || c=='_') || !["ai","binary"].contains(&engine) {- err("invalid stage decision", &["Use adom-aiflow take <stage>=ai or <stage>=binary; custom stage names may contain letters, digits, hyphens and underscores.".into()]);- }- r.data["decisions"][stage] = json!(engine);- }- r.save().unwrap();- ok(&format!("decisions recorded: {}", r.data["decisions"]), &["They are part of the run manifest and the comparison chart.".into()]);- }- Cmd::Stage { action, name, note, result } => {- thread(&cli);- let mut r = load_run(&cli);- if action == "start" {- let e = r.data["decisions"][name].as_str().unwrap_or("ai").to_string();- r.stage_start(name, Some(&e), note.as_deref());- r.save().unwrap();- ok(&format!("stage {name} started at {} by {e}", now()), &[]);- } else {- let res = result.as_deref().map(|s| serde_json::from_str(s).unwrap_or(json!(s)));- r.stage_end(name, res, note.as_deref());- r.save().unwrap();- ok(&format!("stage {name} ended at {}", now()), &[format!("Minutes so far per stage: {}; {:.1} min since the prompt.", Value::Object(r.stage_minutes()), r.elapsed_minutes())]);- print_next(&cli);- }- }- Cmd::Place { what, wish, moves, out, pause_ms } => {- thread(&cli);- let mut r = load_run(&cli);- let b = board_of(&r);- match what.as_str() {- "verify" => {- let board_path=r.current_board();- let spec_path=r.data["spec"].as_str().unwrap_or("").to_string();- let board_hash=placement_verify::digest(Path::new(&board_path)).unwrap_or_else(|e|err(&e,&[]));- let spec_hash=placement_verify::digest(Path::new(&spec_path)).unwrap_or_else(|e|err(&e,&[]));- if let Some(f)=molecule_findings(&spec_of(&r),&b,Some(&r)) {- if !f.errors.is_empty() { err(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); }- }- let spec_i=spec_of(&r);- let intent_lines=placement_intent::check(&mut r,&dir,&spec_i,&b,"place verify").unwrap_or_else(|e|{r.save().ok();err(&e,&[placement_intent::HINT.into()])});- for l in &intent_lines { eprintln!("{l}"); }- let rb=r.data["remoteBoard"].as_str().filter(|s|!s.is_empty()).unwrap_or_else(||err("place verify requires the named native board",&[])).to_string();- let br=bridge_of(&r,&cli);- let before=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));- let validation=br.call("kicad_placement_validate",&json!({"filePath":rb}));- let after=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));- let report=dir.join("placement-verify.json");- write_json(&report,&json!({"before":before,"validation":validation,"after":after}));- let checked=placement_verify::check_with_spec(&b,&before,&validation,&after,&rb,&spec_of(&r));- let stable=placement_verify::digest(Path::new(&board_path)).ok().as_deref()==Some(board_hash.as_str()) && placement_verify::digest(Path::new(&spec_path)).ok().as_deref()==Some(spec_hash.as_str());- let error=if !stable { Some("saved board or spec changed during placement verification".to_string()) } else { checked.as_ref().err().cloned() };- let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()).map(|v|v.to_json());- r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.as_ref().map(|v|v.accepted).unwrap_or(0)}else{0},"excused":excused,"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error}));- r.save().unwrap_or_else(|e|err(&e,&[]));- if let Some(e)=error { err(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); }- let excused_line=excused.as_ref().map(|e|format!("; excused for spec.fixedRefs: {} bridge box overlap(s), {} padless footprint(s) without a courtyard (KiCad's exact placement DRC clean)",e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()))).unwrap_or_default();- ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved{excused_line}",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]);- }- "pack" => {- let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));- let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));- let spec = spec_of(&r);- let courtyard_edge=spec["placement"]["courtyardEdgeClearance"].as_f64().unwrap_or(0.0);- let chains = aiflow_place::chains_from_spec(&spec).unwrap_or_else(|e| err(&format!("spec.chains: {e}"), &[]));- let intent = placement_intent::of_spec(&spec);- let (wishes, anchor_lines) = aiflow_place::intent::apply_anchors(&b, &wishes, &intent);- let (wishes, chain_plans, chain_lines) = aiflow_place::apply_chains(&b, &wishes, &chains, 0.05);- let (mv, mut lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);- lines.extend(anchor_lines);- lines.extend(placement_intent::pack_lines(&b, &mv, &intent));- write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());- let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();- let verdicts = aiflow_place::chain_report(&b, &mv, &chain_plans);- let mut msg = format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n"));- if !chains.is_empty() {- let honoured = verdicts.iter().filter(|v| v.1).count();- msg.push_str(&format!("\nchains: {honoured} of {} honoured", chains.len()));- for l in &chain_lines { msg.push_str(&format!("\n {l}")); }- for (name, _, v) in &verdicts { msg.push_str(&format!("\n chain {name}: {v}")); }- }- let chain_hint = if verdicts.iter().any(|v| !v.1) || chain_lines.iter().any(|l| l.contains("not applied")) { "A chain not honoured: give its first member a wish with room along the chain's direction (or set \"direction\" in spec.chains), put the chain early in the wishes, and pack again.".to_string() } else { String::new() };- ok(&msg, &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }, chain_hint]);- }- "check" => {- let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));- let mv = aiflow_place::moves_from_json(&read_json(m)).unwrap_or_else(|e| err(&e, &[]));- let (text, lines) = aiflow_place::move_in_text(&b, &mv).unwrap_or_else(|e| err(&e, &[]));- let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);- let outp = dir.join(format!("board-{n}.kicad_pcb"));- std::fs::write(&outp, &text).unwrap();- aiflow_copper::copy_project_rules(&r.current_board(), &outp.display().to_string()).unwrap_or_else(|e|err(&e,&[]));- let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[]));- let moved: Vec<String> = mv.keys().cloned().collect();- let problems = aiflow_place::check(&nb, &moved, 0.05);- if let Some(f) = molecule_findings(&spec_of(&r), &nb, Some(&r)) {- if !f.errors.is_empty() {- let mut h = f.hints.clone();- h.push(format!("The moves break the molecule; the run's current board is unchanged. Fix the wishes (never move the interface parts) and check again; see {}.", outp.display()));- err(&format!("the moved board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &h);- }- }- let spec_i = spec_of(&r);- if let Err(e) = placement_intent::check(&mut r, &dir, &spec_i, &nb, "place check") { r.save().ok(); err(&e, &[placement_intent::HINT.into(), format!("The run's current board is unchanged; the moved board is {}.", outp.display())]); }- let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));- let baseline = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));- let s = aiflow_copper::summarize(&drc, Some(&baseline));- let errors = s["errors"].as_u64().unwrap_or(0);- // Bounding boxes are conservative packing hints, not exact courtyard geometry.- // Native KiCad DRC decides whether the actual courtyards overlap.- if !problems.is_empty() { eprintln!("Hint: conservative bounding-box advisory: {}. Native courtyard DRC is authoritative.", problems.join("; ")); }- if s["newErrors"].as_u64() != Some(0) {- err(&format!("the moved board adds {} native DRC violations: {}", s["newErrors"], s["newErrorTypes"]), &[format!("Change the wishes and pack again; inspect {}. Findings are matched by identity, not an inherited-error count.", outp.display())]);- }- // issue #39: courtyards present and clear, part bodies clear, whatever the project severities say- let mech_line = if mechanical::applies(&outp.display().to_string()) {- let mech = mechanical::check_board(&nb, &outp.display().to_string(), &project_dir_of(&r), &spec_soft(&r));- for n in &mech.severity_notes { eprintln!("Warning: {n}"); }- if !mech.errors().is_empty() { err(&format!("the moved board fails the courtyard and body checks:\n {}", mech.errors().join("\n ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); }- format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked)- } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() };- r.add_board(&outp.display().to_string());- r.save().unwrap();- ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line} and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);- }- "land" => {- let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));- let br = bridge_of(&r, &cli);- let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- r.stage_start("place-land", Some("binary"), None);- let mv = read_json(m);- let t0 = std::time::Instant::now();- // one part at a time: the camera sees each part land, not a board that changes at once- let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {- eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });- });- let total = mv.as_object().map(|o| o.len()).unwrap_or(0);- write_json(&dir.join("place-land-log.json"), &json!({"moves":m,"accepted":accepted,"replies":replies,"stoppedAt":stopped}));- r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);- r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));- r.save().unwrap();- if let Some(i) = stopped {- let rep = replies.last().cloned().unwrap_or(Value::Null);- if rep["errorCode"] == "board_mismatch" {- err(&format!("live placement refused before part {}: requested {rb}; IPC openBoards {}",i+1,rep["openBoards"]), &["Save and gracefully close only your own conflicting KiCad editors, then `capture open` to reopen this board first. Never close another user's editor or restart a shared bridge. Inspect place-land-log.json; no failed part was moved.".into()]);- }- err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);- }- ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);- }- other => err(&format!("place {other}: use pack, check, land or verify"), &[]),- }- }- Cmd::Route { passes, rip_per_net, rip_global } => {- thread(&cli);- let mut r = load_run(&cli);- if r.engine_for("route") == "ai" {- err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);- }- // a Fusion run routes around what is in Fusion now (copper already landed, a net taken- // up for a refine), not the placement snapshot: plan on a fresh copy of the live board- if let Some((_, k)) = live_board(&r, &cli, &dir) {- let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);- let dest = dir.join(format!("board-{n}.kicad_pcb"));- std::fs::copy(&k, &dest).unwrap_or_else(|e| err(&e.to_string(), &[]));- aiflow_copper::copy_project_rules(&r.current_board(), &dest.display().to_string()).unwrap_or_else(|e| err(&e, &[]));- r.add_board(&dest.display().to_string());- r.save().unwrap();- eprintln!("routing on the live Fusion board ({})", dest.display());- }- let spec = spec_of(&r);- let b = board_of(&r);- let missing=aiflow_pours::missing_planes(&b,&spec);- if !missing.is_empty(){err(&format!("declared reference planes do not exist: {}",missing.join(", ")), &["Create the listed net/layer zones through the EDA bridge, fill and save them, then `adom-aiflow adopt-board --board <saved.kicad_pcb> --evidence <native review notes>` before route. A spec.planes declaration alone is not copper.".into()]);}- let rules = aiflow_router::Rules::from_spec(&spec);- let regions = aiflow_router::plane_under::regions(&b, &rules.plane_under).unwrap_or_else(|e| err(&format!("spec.planeUnder: {e}"), &["Each entry needs a layer (F.Cu or B.Cu), the net that owns the region, and \"around\" (parts or pads on this board, grown by \"margin\", default 1.0 mm) or a \"polygon\".".into()]));- if spec.get("planeUnder").is_some() && regions.len() != spec["planeUnder"].as_array().map(|a| a.len()).unwrap_or(1) {- err("spec.planeUnder has an entry that does not parse", &["Use {\"layer\": \"B.Cu\", \"net\": \"GND\", \"around\": [\"U1\", \"C2\"], \"margin\": 1.0} or {\"layer\": ..., \"net\": ..., \"polygon\": [[x, y], ...]}.".into()]);- }- let preexisting = aiflow_router::plane_under::issues(&b, ®ions);- decoupling_check::refuse_bad_spec(&spec, &b);- if spec["stub"].as_f64().map(|s|s<rules.stub).unwrap_or(false){eprintln!("Hint: escape width raised to {} mm to satisfy minTrackWidth (default: track width). Declare a smaller manufacturing minimum only when the board process supports it.",rules.stub);}- r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));- let mut router = aiflow_router::Router::new(b, rules, true);- let (mut plan, mut report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);- // issue #39: staircases to clean 45 degree runs before the gate (the router's own clearance check decides)- let simp = aiflow_router::simplify::simplify_plan(&mut router, &mut plan);- report["simplify"] = simp.clone();- let simp_line = format!("simplified: {} staircase(s) replaced, {} kept (no clear 45 degree path), {} collinear point(s) merged; vertices {} -> {} (per net in plan-route-report.json \"simplify\")", simp["staircasesReplaced"], simp["staircasesKept"], simp["collinearMerged"], simp["verticesBefore"], simp["verticesAfter"]);- std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();- let planp = dir.join("plan-route.json");- let bc = report["boardCopper"].clone();- let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) > 0;- let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});- if has_copper {- let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0);- plan_doc["boardCopper"] = bc.clone();- plan_doc["note"] = json!(if plan.is_empty() && failed == 0 {- format!("nothing to route: the board's own copper ({} track pieces, {} vias, filled zones) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"])- } else {- format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias); {complete} nets are complete on it and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["openConnections"])- });- }- if !regions.is_empty() {- plan_doc["planeUnder"] = json!(regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>());- }- write_json(&planp, &plan_doc);- write_json(&dir.join("plan-route-report.json"), &report);- let dec = decoupling_check::review_route(&spec, &router.board, &plan_doc, &report, r.outcomes().get("pour"));- decoupling_check::record(&mut r, &dir, &dec);- let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();- r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);- r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}));- r.save().unwrap();- let policy_hint = if regions.is_empty() { String::new() } else if preexisting.is_empty() {- format!("planeUnder: {} kept free of every other net's copper.", regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>().join("; "))- } else {- format!("planeUnder: the board's own copper already sits inside a region ({}); the gate reports it until that copper is moved (rework it in KiCad, then adopt-board).", preexisting.iter().take(4).cloned().collect::<Vec<_>>().join("; "))- };- if failed > 0 {- let mut blockers: Vec<String> = Vec::new();- for u in &unrouted {- if let Some(why) = u.get(1).and_then(|x| x.get(1)).and_then(|w| w.as_str()) {- if let Some(rest) = why.strip_prefix("blocked by ") {- for b in rest.trim_matches(|c| c == '[' || c == ']').split(", ") {- let b = b.trim_matches('"').trim_matches('\'');- if !b.is_empty() && !blockers.contains(&b.to_string()) {- blockers.push(b.to_string());- }- }- }- }- }- let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect();- let region_hint = if regions.is_empty() { String::new() } else { "A planeUnder region is a keepout for every other net on its layer: a pin inside it can only leave on the other layer. Shrink the margin, list fewer parts, or give the region a polygon that leaves that pin a way out.".to_string() };- err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);- }- decoupling_check::refuse_errors("route", &dec);- if has_copper && plan.is_empty() {- ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias); empty plan {}", bc["trackPieces"], bc["vias"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]);- } else {- let copper_line = if has_copper { format!("; the board's own copper was kept and {} connection(s) it left open were planned", bc["openConnections"]) } else { String::new() };- ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);- }- }- Cmd::Pour { plan } => {- thread(&cli);- let mut r = load_run(&cli);- let spec = spec_of(&r);- // a Fusion run pours around what is in Fusion now (the landed copper and heatsink vias)- let b = live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r));- let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string));- let plan_v = planp.as_deref().map(read_json);- // the heatsink vias planned (landed or not) are holes the stitching vias must keep clear of- let tv_holes = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).filter(|p| std::path::Path::new(p).is_file()).map(|p| aiflow_pours::plan_holes(&read_json(p))).unwrap_or_default();- r.stage_start("pour", Some("binary"), None);- let (zones, vias, keepouts) = aiflow_pours::build_with(&b, &spec, plan_v.as_ref(), &tv_holes).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });- let zp = dir.join("plan-pours.json");- let vp = dir.join("plan-vias.json");- write_json(&zp, &json!({"zones": zones}));- pourverify::record_plan(&mut r.data, &zp, "pour").unwrap_or_else(|e| err(&e, &[]));- write_json(&vp, &json!({"engine": "adom-aiflow pour plan (thermal and stitching vias)", "nets": vias}));- r.stage_end("pour", Some(json!({"zones": zones.len(), "keepouts": keepouts, "vias": vias.len()})), None);- r.outcome("pour", json!({"pours": zp.display().to_string(), "vias": vp.display().to_string(), "zones": zones.len(), "keepouts": keepouts, "viaCount": vias.len()}));- r.save().unwrap();- let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect();- ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]);- }- Cmd::Fusion { args, target } => {- // the Fusion steps run in Python beside the bridge calls; the run gives them their folder,- // the desktop and the thread- let run = Run::open(&dir);- let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();- let status = std::process::Command::new("python3").arg(lane_script("fusion", &format!("{}\n{}", include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/fusion-project.py")))).args(args)- .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())- .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));- std::process::exit(status.code().unwrap_or(1));- }- Cmd::Altium { args, target } => {- // the Altium steps: the shared project half (tools/project-common.py) with the Altium half after it- let run = Run::open(&dir);- let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();- let script = format!("{}\n{}\n{}", include_str!("../../../tools/project-common.py"), include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/altium-project.py"));- let status = std::process::Command::new("python3").arg(lane_script("altium", &script)).args(args)- .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())- .status().unwrap_or_else(|e| err(&format!("altium steps need python3: {e}"), &[]));- std::process::exit(status.code().unwrap_or(1));- }- Cmd::Unroute { net } => {- thread(&cli);- let mut r = load_run(&cli);- let br = bridge_of(&r, &cli);- let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- let live = br.live_copper(&rb).unwrap_or_else(|| err("could not read the live board", &["Open the board in its EDA (or fusion_show_2d_board) and retry.".into()]));- let ids: Vec<String> = live.segments.iter().filter(|s| net.contains(&s.net)).map(|s| s.uuid.clone())- .chain(live.vias.iter().filter(|v| net.contains(&v.net)).map(|v| v.uuid.clone())).filter(|u| !u.is_empty()).collect();- if ids.is_empty() { ok(&format!("nothing to unroute: no tracks or vias on {}", net.join(", ")), &["Next: route, gate, land route.".into()]); return; }- let rep = br.remove_items(&rb, &ids);- r.log("unroute", json!({"nets": net, "items": ids.len(), "reply": rep}));- r.save().unwrap();- if rep["success"].as_bool() != Some(true) { err(&format!("unroute refused: {} {}", rep["errorCode"], rep["error"].as_str().or(rep["message"].as_str()).unwrap_or("")), &["Nothing else was changed. Read the live board state before retrying.".into()]); }- ok(&format!("unrouted {}: {} track(s) and via(s) taken up, pours kept", net.join(", "), ids.len()), &["Change what needs changing (spec wideNets for a wider track), then route, gate and land route again.".into()]);- }- Cmd::Gate { plan } => {- thread(&cli);- let mut r = load_run(&cli);- let b = board_of(&r);- let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| {- // an adopted board that already carries its copper: gate the board as it is- if b.tracks().is_empty() && b.vias().is_empty() {- err("no routing plan to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]);- }- let p = dir.join("plan-route.json");- write_json(&p, &json!({"engine": "none (the board's own copper)", "nets": [], "note": "gate with no routing plan on a board that carries copper: the board is gated as it is"}));- eprintln!("Hint: no routing plan; the board carries copper, so it is gated as it is with an empty plan ({})", p.display());- p.display().to_string()- });- // the same normalisation `land route` applies, so the gate passes only what lands (issue #33)- let (plan_v, plan_notes) = read_plan_normalised(&planp, &b);- print_plan_notes(&plan_notes);- let spec_g = spec_of(&r);- if let Some((p, layers, warning)) = checks::rules_target(&spec_g, &r.data["fabRules"]) {- if let Some(w) = warning { eprintln!("Warning: {w}"); }- if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { err(&format!("gate refused: {e}"), &[hint, "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }- }- if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {- if !f.errors.is_empty() { err(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); }- }- r.stage_start("gate", Some("binary"), None);- // an empty plan adds no copper: the board's own zone fills stand, a raster refill would only invent errors- let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true);- let (text, stats) = aiflow_copper::apply(&b, &plan_v, adds_copper && !aiflow_copper::native_drc_available()).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));- let routed = dir.join("board-routed.kicad_pcb");- std::fs::write(&routed, &text).unwrap();- aiflow_copper::copy_project_rules(&r.current_board(), &routed.display().to_string()).unwrap_or_else(|e|err(&e,&[]));- let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));- let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));- let mut s = aiflow_copper::summarize(&data, Some(&base));- s["copper"] = stats;- s["file"] = json!(routed.display().to_string());- // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error- let spec = spec_of(&r);- let pu = aiflow_router::rules::PlaneUnder::from_spec(&spec);- let pu_issues: Vec<String> = if pu.is_empty() { Vec::new() } else {- let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[]));- match aiflow_router::plane_under::regions(&rb, &pu) { Ok(regs) => aiflow_router::plane_under::issues(&rb, ®s), Err(e) => vec![format!("spec.planeUnder: {e}")] }- };- s["planeUnderIssues"] = json!(pu_issues);- // the switch node: short, and never under another part (switchnode.rs)- let sw_issues = { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); switchnode::findings(&spec_g, &rb) };- s["switchNodeIssues"] = json!(sw_issues);- let dec = decoupling_check::review_text("gate", &spec_g, &routed.display().to_string(), &text, !adds_copper, if adds_copper && !aiflow_copper::native_drc_available() { "an approximate raster fill by the gate, not a native refill" } else { "the zone fills saved in the board file" }, &plan_v, None, r.outcomes().get("pour"));- if let Some(d) = &dec { s["decoupling"] = d.value.clone(); }- // issue #39: courtyards and bodies (aiflow's own geometry), staircases left in the copper (a warning)- let mech = mechanical::applies(&r.current_board()).then(|| { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); mechanical::check_board(&rb, &routed.display().to_string(), &project_dir_of(&r), &spec_g) });- let stairs = Board::from_text(&routed.display().to_string(), text.clone()).map(|rb| aiflow_router::simplify::board_staircases(&rb)).unwrap_or_default();- s["mechanical"] = mech.as_ref().map(|m| m.to_json()).unwrap_or(json!("not checked: Fusion board"));- s["staircases"] = json!(stairs);- decoupling_check::record(&mut r, &dir, &dec);- if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); }- if !plan_notes.is_empty() { s["planNormalised"] = json!(plan_notes); }- write_json(&dir.join("gate.json"), &s);- let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));- let npu = pu_issues.len() as u64;- r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);- r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));- r.save().unwrap();- if !sw_issues.is_empty() {- r.outcome("switch-node", json!({"issues": sw_issues}));- r.save().unwrap();- err(&format!("gate failed: the switch node:\n {}", sw_issues.join("\n ")), &[switchnode::HINT.into()]);- }- if npu > 0 && newe == 0 && unc == 0 {- err(&format!("gate failed: {npu} planeUnder issue(s), other-net copper inside a plane region:\n{}", pu_issues.join("\n")), &["spec.planeUnder keeps these regions for one net's plane. Route again (the router keeps other nets out of them), or move the copper by hand and adopt the board; if the region is wrong, change spec.planeUnder.".into()]);- }- if newe > 0 || unc > 0 {- // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever- let mut hair: Vec<String> = Vec::new();- for v in s["newErrorList"].as_array().cloned().unwrap_or_default() {- if v["type"].as_str() == Some("clearance") {- let d = v["description"].as_str().unwrap_or("");- let actual = d.split("actual ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());- let rule = d.split("clearance ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());- if let (Some(a), Some(r)) = (actual, rule) {- if r - a < 0.02 {- for it in v["items"].as_array().cloned().unwrap_or_default() {- if let Some(t) = it.as_str() {- if t.starts_with("Track [") { if let Some(n) = t.split('[').nth(1).and_then(|x| x.split(']').next()) { hair.push(format!("\"{n}\": {:.2}", r + 0.02)); } }- }- }- }- }- }- }- hair.sort(); hair.dedup();- let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };- let pu_hint = if npu > 0 { format!("Also {npu} planeUnder issue(s): {}", pu_issues.iter().take(4).cloned().collect::<Vec<_>>().join("; ")) } else { String::new() };- err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[pu_hint, format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);- }- decoupling_check::refuse_errors("gate", &dec);- if let Some(m) = &mech {- for n in &m.severity_notes { eprintln!("Warning: {n}"); }- if !m.errors().is_empty() { err(&format!("gate failed: courtyards or part bodies:\n {}", m.errors().join("\n ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); }- } else { eprintln!("Warning: courtyards and part bodies not checked: a Fusion board carries no courtyard geometry aiflow reads; check them in Fusion's DRC"); }- if !stairs.is_empty() { eprintln!("Warning: {} staircase run(s) left in the copper (tiny alternating 0/90 degree steps; redraw as one 45 degree segment):\n {}", stairs.len(), stairs.iter().take(8).cloned().collect::<Vec<_>>().join("\n ")); }- let checked = match &mech { Some(m) => format!("; courtyards present and clear on {} footprint(s), {} part bodies clear", m.with_courtyard, m.bodies_checked), None => "; courtyards and bodies not checked (Fusion board)".into() };- let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false);- ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}{checked}; copper {}\nThis is the routing gate (DRC, connectivity, courtyards, bodies), not a fabrication check: pours in the saved board, Gerbers and human review come later.", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]);- }- Cmd::Land { what, plan, moves, pause_ms, from } => {- let mut r = load_run(&cli);- if what == "route" {- // a gated empty plan (the board's own copper already joins every net): nothing to land, no bridge call- let gate = r.outcomes().get("gate").cloned().unwrap_or(Value::Null);- let gate_ok = gate["newErrors"].as_u64() == Some(0) && gate["unconnected"].as_u64() == Some(0) && gate["planeUnderIssues"].as_u64().unwrap_or(0) == 0 && mechanical::gate_clear(&gate);- let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));- let empty_after_normalising = |p: &String| { let (v, _) = read_plan_normalised(p, &board_of(&r)); v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) };- if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && empty_after_normalising(p)) {- let note = read_json(&planp).get("note").cloned().unwrap_or(json!("empty plan"));- r.stage_start("land-route", Some("binary"), None);- r.stage_end("land-route", Some(json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0})), Some("empty plan: nothing to land"));- r.outcome("land-route", json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0, "stoppedAt": Value::Null, "emptyPlan": true, "note": note}));- r.save().unwrap();- ok(&format!("nothing to land: the gated plan {planp} is empty (the board already carries its copper); recorded as landed"), &["Next: adom-aiflow pour, land vias, land pours.".into()]);- return;- }- }- let br = bridge_of(&r, &cli);- let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- match what.as_str() {- "moves" => {- let m = moves.as_deref().unwrap_or_else(|| err("land moves needs --moves moves.json", &[]));- r.stage_start("place-land", Some("binary"), None);- let mv = read_json(m);- let t0 = std::time::Instant::now();- // one part at a time: the camera sees each part land, not a board that changes at once- let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {- eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });- });- let total = mv.as_object().map(|o| o.len()).unwrap_or(0);- r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);- r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));- r.save().unwrap();- if let Some(i) = stopped {- let rep = replies.last().cloned().unwrap_or(Value::Null);- err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);- }- ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);- }- "route" | "vias" => {- let planp = if what == "route" {- let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("planeUnderIssues")).and_then(|v| v.as_u64()).unwrap_or(0) == 0 && r.outcomes().get("gate").map(mechanical::gate_clear).unwrap_or(false);- if !gate_ok && plan.is_none() {- err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);- }- plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[]))- } else {- plan.clone().or_else(|| r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))- };- // the gate's normalisation (issue #33): no zero-length trace reaches the bridge, and- // trace numbers (`--from`) count the same traces the gate passed- let b = board_of(&r);- let (plan_main, plan_notes) = read_plan_normalised(&planp, &b);- print_plan_notes(&plan_notes);- let entries: Vec<Value> = aiflow_copper::plan_entries(&plan_main).unwrap_or_else(|e| err(&format!("{planp}: {e}"), &[]));- // a via must not touch another net's copper, planned or already on the board: KiCad- // hands a touching via the other net and the bridge's DRC then sees nothing wrong- let pad_copper: Vec<Copper> = b.pads.iter().flat_map(|p| p.polys.iter().map(move |poly| Copper::Poly { poly: poly.clone(), net: p.net.clone(), what: format!("pad {} ({})", p.key, if p.net.is_empty() { "no net" } else { p.net.as_str() }) })).collect();- let mut plans: Vec<Value> = vec![plan_main.clone()];- if what == "vias" {- if let Some(gp) = r.outcomes().get("gate").and_then(|g| g.get("plan")).or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan"))).and_then(|p| p.as_str()) {- if Path::new(gp).is_file() { plans.push(read_plan_normalised(gp, &b).0); }- }- }- let mut copper_cache: Option<Vec<Copper>> = None;- let precheck = |_i: usize, e: &Value, live: &landing::LiveCopper| -> Option<Value> {- let t = landing::resolve(e, &live.pads)?;- if t.vias.is_empty() { return None; }- let copper = copper_cache.get_or_insert_with(|| {- let mut c = pad_copper.clone();- c.extend(landing::live_copper(live));- for p in &plans { c.extend(landing::plan_copper(p, &live.pads)); }- c- });- t.vias.iter().find_map(|v| landing::via_touch(&t.net, *v, t.via_size / 2.0, copper).map(|what| json!({"success": false, "errorCode": "via_touches_other_net", "net": t.net, "at": [v.0, v.1], "touches": what, "message": format!("the planned {} via at ({:.2}, {:.2}) would touch {what}; KiCad would give it that net", t.net, v.0, v.1)})))- };- let stage = format!("land-{what}");- r.stage_start(&stage, Some("binary"), None);- let t0 = std::time::Instant::now();- // one trace at a time: a net with several paths lands as several undo steps- let res = br.land_plan(&rb, &entries, from.unwrap_or(1), *pause_ms, precheck, |i, n, entry, rep| {- if rep["alreadyPresent"] == true { return; }- eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().or(entry["net"].as_str()).unwrap_or("?"), entry["width"], if Bridge::ok(rep) { "ok" } else { rep["errorCode"].as_str().unwrap_or("failed") }, rep["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?"), rep["drc"]["unconnected"]);- });- let (accepted, stopped, traces) = (res.accepted, res.stopped, res.traces);- if res.skipped > 0 {- eprintln!("{} {} already on the live board (same net, same points): not landed again", res.skipped, if what == "vias" { "vias" } else { "traces" });- }- if res.recovered + res.retried > 0 {- eprintln!("transport: {} lost repl(ies) whose trace had landed, {} trace(s) landed on the one retry", res.recovered, res.retried);- }- // read back every landed via: the planned net must be the net KiCad gave it- let mut mismatches: Vec<Value> = Vec::new();- let with_vias: Vec<&(usize, Value, Value)> = res.landed.iter().filter(|(_, e, _)| e["paths"].to_string().contains("\"layer\"") || e["points"].to_string().contains("\"layer\"")).collect();- if !with_vias.is_empty() {- let Some(live) = br.live_copper(&rb) else {- r.stage_end(&stage, Some(json!({"accepted": accepted, "viaReadback": "unavailable"})), None);- r.save().unwrap();- err(&format!("{accepted} trace(s) landed, but the live board could not be read back to check the vias' nets"), &[format!("Read kicad_routing_state for {rb} and compare every via's net with {planp} before going on.")]);- };- let landed: Vec<(usize, landing::Trace)> = with_vias.iter().filter_map(|(n, e, _)| landing::resolve(e, &live.pads).map(|t| (*n, t))).collect();- for m in landing::via_net_mismatches(&landed, &live) {- let reply = with_vias.iter().find(|(n, _, _)| *n == m.trace).map(|(_, _, rep)| rep.clone()).unwrap_or(Value::Null);- let mut ids: Vec<String> = reply["itemIds"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();- if ids.is_empty() && !m.uuid.is_empty() { ids.push(m.uuid.clone()); }- let undo = if ids.is_empty() { json!({"success": false, "errorCode": "no_item_ids"}) } else { br.remove_items(&rb, &ids) };- eprintln!("trace {}: the {} via at ({:.2}, {:.2}) landed as {}; {}", m.trace, m.planned, m.at.0, m.at.1, m.actual, if Bridge::ok(&undo) { "taken back" } else { "NOT taken back" });- mismatches.push(json!({"trace": m.trace, "planned": m.planned, "actual": m.actual, "at": [m.at.0, m.at.1], "viaId": m.uuid, "removedIds": ids, "undone": Bridge::ok(&undo), "undo": undo}));- }- }- let undone = mismatches.iter().filter(|m| m["undone"] == true).count();- let accepted = accepted - undone.min(accepted);- let wall = (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0;- write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "from": from, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "accepted": accepted, "viaNetMismatches": mismatches, "replies": res.replies, "wallSeconds": t0.elapsed().as_secs_f64()}));- r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "wallSeconds": wall})), None);- r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "viaNetMismatches": mismatches.len(), "wallSeconds": wall, "stoppedAt": stopped}));- r.save().unwrap();- if !mismatches.is_empty() {- let lines: Vec<String> = mismatches.iter().map(|m| format!("trace {}: planned {} at {}, landed as {}{}", m["trace"], m["planned"], m["at"], m["actual"], if m["undone"] == true { " (taken back)" } else { " (NOT taken back: remove it by hand)" })).collect();- err(&format!("{} via(s) landed on another net than planned:\n{}", mismatches.len(), lines.join("\n")), &["A via whose copper touches another net's track or pad takes that net in KiCad, and the DRC sees nothing wrong. Move the via clear of that copper (spec thermalVias/stitchVias, then `pour`), or move the track, and land again: the vias already right are skipped.".into()]);- }- if let Some(i) = stopped {- let rep = res.replies.last().cloned().unwrap_or(Value::Null);- if rep["errorCode"] == "via_touches_other_net" {- err(&format!("live {what} refused before landing anything: trace {} of {traces}: {}", i + 1, rep["message"].as_str().unwrap_or("")), &["Move the via clear of that copper (spec thermalVias/stitchVias or the plan), run `pour` or `gate` again, then land. Nothing was landed by this command.".into()]);- }- err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Rerun `land {what}` once fixed: traces already on the live board are skipped. To resume at this trace: `adom-aiflow land {what}{} --from {}`. The log has every reply.", plan.as_ref().map(|p| format!(" --plan {p}")).unwrap_or_default(), i + 1)]);- }- let skipped_note = if res.skipped > 0 { format!(", {} already on the board skipped", res.skipped) } else { String::new() };- ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries{skipped_note}, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);- }- "pours" => {- let zp = r.outcomes().get("pour").and_then(|p| p.get("pours")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no pours plan; run `adom-aiflow pour` first", &[]));- let b = board_of(&r);- let zones = aiflow_pours::resolve_outline(&b, read_json(&zp)["zones"].as_array().unwrap_or(&Vec::new()));- pourverify::landable(&zones).unwrap_or_else(|e| err(&e, &["Write that zone natively in KiCad, save and fill it, then accept it with `verify-pours --plan <plan>` (add --adopt --why to own it). Nothing was landed.".into()]));- r.stage_start("land-pours", Some("binary"), None);- let before = br.zone_state(&rb, true);- if !Bridge::ok(&before) { err("cannot read settled zones before landing", &[before.to_string()]); }- let mut owned: Vec<String> = r.data["ownedZoneIds"].as_array().into_iter().flatten().filter_map(|v|v.as_str().map(str::to_string)).collect();- // Legacy successful operation replies are evidence of creation, not an inference.- if let Some(groups)=r.outcomes().get("land-pours").and_then(|o|o["groups"].as_array()).filter(|_| r.data.get("ownedZoneIds").is_none()) {- for g in groups.iter().filter(|g|g["ok"]==true) {- for id in g["itemIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {- if !owned.iter().any(|x|x==id) { owned.push(id.to_string()); }- }- }- }- let results = br.land_zones(&rb, &zones, *pause_ms, &owned);- for (_,reply) in &results {- if Bridge::ok(reply) || reply["deletionVerified"] == true {- for id in reply["removedIds"].as_array().into_iter().flatten().filter_map(Value::as_str) { owned.retain(|x|x!=id); }- }- if Bridge::ok(reply) || reply["mutated"]==true {- for id in reply["itemIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {- if !owned.iter().any(|x|x==id) { owned.push(id.to_string()); }- }- }- }- r.data["ownedZoneIds"]=json!(owned);- if let Some((_, rep)) = results.iter().find(|(n, _)| n == "removed-stale") {- eprintln!("{}", rep["message"].as_str().unwrap_or(""));- }- let after = br.zone_state(&rb, results.iter().all(|(_, reply)| Bridge::ok(reply)));- let mut results = results;- if !Bridge::ok(&after) { results.push(("post-landing-readback".into(), after.clone())); }- let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();- r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);- r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"]})).collect::<Vec<_>>()}));- write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "results": results, "after": after}));- r.save().unwrap();- if let Some((net, rep)) = failed.first() {- let mut types: BTreeMap<String, usize> = BTreeMap::new();- for v in rep["violations"].as_array().cloned().unwrap_or_default() {- *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;- }- err(&format!("pour for {net} refused by the preflight: {} {:?}", rep["errorCode"], types), &["starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);- }- ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into()]);- }- other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),- }- }- Cmd::VerifyPours { plan, adopt, why, record_plan } => {- if *adopt || *record_plan { thread(&cli); }- let mut r = load_run(&cli);- match pourverify::command(&mut r, &dir, plan, *adopt, why.as_deref(), *record_plan) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }- }- Cmd::Measure => {- let mut r = load_run(&cli);- let br = bridge_of(&r, &cli);- let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- r.stage_start("measure", Some("binary"), None);- let zs = br.zone_state(&rb, true);- r.stage_end("measure", Some(json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"]})), None);- write_json(&dir.join("zone-state.json"), &zs);- r.log("artifact", json!({"step": r.data["currentStep"], "kind": "zone-state", "file": dir.join("zone-state.json").display().to_string()}));- if !Bridge::ok(&zs) {- r.outcome("measure", json!({"failed":true,"errorCode":zs["errorCode"],"reply":zs}));- r.save().unwrap();- err("native copper measurement did not settle", &["The refill request was not replayed. Inspect zone-state.json and the native board before retrying a read.".into()]);- }- r.outcome("measure", json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"], "coveragePercent": zs["filledCoverageByLayerPercent"], "zones": zs["zoneCount"]}));- r.save().unwrap();- ok(&format!("copper kept per layer ({}'s fills): {}; coverage {}", aiflow_bridge::eda::active().name(), zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);- }- Cmd::Analyze { what, plan, fields, ack, why } => {- let t = thread(&cli);- if !ack.is_empty() && what != "current" { err("--ack acknowledges a current-density peak: `analyze current --ack [email protected] --why ...`", &[]); }- let mut r = load_run(&cli);- let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));- // a Fusion run analyzes the board as it is in Fusion now, not the placement snapshot- let live = live_board(&r, &cli, &dir);- let board_now = match &live { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };- let solved_board = live.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());- let plan_v = plan_with_board_copper(&read_json(&planp), &board_now);- let spec = spec_of(&r);- let zsp = dir.join("zone-state.json");- let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None };- let name = format!("analyze-{what}");- r.stage_start(&name, Some("binary"), None);- let (mut heuristic_passed, mut res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal_on_board(&plan_v, &spec, zs.as_ref(), &board_now), other => err(&format!("analyze {other}: use current or thermal"), &[]) };- let mut fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));- // provenance: a fields.json is evidence only when it is the output of a solve aiflow ran or watched;- // any other file (copied, edited, or written to look like one) is solved again here, same inputs- let mut unobserved: Option<String> = None;- if aiflow_analyze::needs_field_screen(&spec,what) && fields_file.is_file() && !fields_observed(&r,&fields_file) {- let out_dir = dir.join("fields");- let args: Vec<String> = vec!["analyze".into(),"--board".into(),solved_board.clone(),"--spec".into(),r.data["spec"].as_str().unwrap_or("").to_string(),"--out".into(),out_dir.display().to_string()];- eprintln!("{} was not produced by an Adom Fields solve this run watched; solving it now: adom-fields {}", fields_file.display(), args.join(" "));- match std::process::Command::new("adom-fields").args(&args).status() {- Ok(st) if st.success() => { fields_file = out_dir.join("fields.json"); let mut full=vec!["adom-fields".to_string()]; full.extend(args.clone()); record_fields_solve(&mut r, &fields_file, "analyze", &full); r.save().unwrap(); }- Ok(st) => { unobserved = Some(format!("the field solve aiflow started failed ({st}); fix what adom-fields printed, then `adom-aiflow exec -- adom-fields {}` and analyze {what} again", args.join(" "))); }- Err(e) => { unobserved = Some(format!("{} was not produced by an Adom Fields solve this run watched, and adom-fields could not start ({e}): install adom-fields, then `adom-aiflow exec -- adom-fields {}`", fields_file.display(), args.join(" "))); }- }- }- if !ack.is_empty() && (unobserved.is_some() || !fields_file.is_file()) { err("--ack needs a same-input Fields solve this run watched to read the peak from; analyze current without --ack first", &[]); }- let mut expired_acks: Vec<Value> = Vec::new();- let mut sink_notes: Vec<String> = Vec::new();- let screen=if let Some(u)=unobserved.clone() { Err(u) } else if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(Vec::new()) } else if fields_file.is_file() {- let f=read_json(&fields_file.display().to_string());- if let Err(e)=std::fs::metadata(&solved_board) { err(&e.to_string(),&[]); }- let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[]));- // issue #70: record reviewed part-limited peaks, then screen with the live ones- let live_acks = if what == "current" {- let copper = ack_copper(live.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board));- if !ack.is_empty() {- let spec_sha = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(&spec_bytes)) };- if f["inputIdentity"]["spec"].as_str() != Some(spec_sha.as_str()) || copper_carry::carried_from(&r.data, f["inputIdentity"]["board"].as_str().unwrap_or(""), Path::new(&solved_board)).is_err() { err("--ack: the Fields report is not of this saved board (or its carried-over copper) and spec; solve again, then acknowledge", &[]); }- let copper = copper.clone().unwrap_or_else(|e| err(&format!("--ack: this board has no copper fingerprint to bind the acknowledgement to ({e})"), &[]));- let w = why.clone().unwrap_or_else(|| err("--ack needs --why \"<what limits the peak and why the board cannot change it>\"", &[]));- let limit = spec["maxCurrentDensityAperMm2"].as_f64().unwrap_or(60.0);- for a in ack.iter() {- let rec = aiflow_analyze::current_acks::record(a, &w, &t, &f, &board_now, &copper, limit, &now()).unwrap_or_else(|e| err(&e, &["The place is a pad of the part that limits the peak (REF.PAD on that net) or the Fields peak location x,y within 0.5 mm.".into()]));- let mut list: Vec<Value> = r.data["currentAcks"].as_array().cloned().unwrap_or_default();- list.retain(|x| x["target"] != rec["target"]);- list.push(rec);- r.data["currentAcks"] = json!(list);- }- r.save().unwrap();- }- let (live_a, expired) = aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &copper.unwrap_or_else(|_| "unavailable".into()), f["inputIdentity"]["spec"].as_str().unwrap_or(""));- expired_acks = expired;- live_a- } else { Vec::new() };- let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e });- // issue #67: the same-input solve with a declared board-sink block stands in for the estimate- if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }- s- } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };- let passed=heuristic_passed && screen.is_ok();- // issue #75: the fields step refuses a converter Fields did not model; say so here already- let converter_hints = converter_warning(&r.data, &spec, if fields_file.is_file() { std::fs::read(&fields_file).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) } else { None }.as_ref(), Some(&board_now));- let covered_acks = screen.as_ref().map(|c| c.clone()).unwrap_or_default();- let sink_assumptions = aiflow_analyze::board_sink::assumptions(&res);- let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into());- r.stage_end(&name, Some(json!({"pass": passed})), None);- r.outcome(&name, json!({"pass": passed, "heuristicPass":heuristic_passed, "fieldReport":fields_file, "fieldNote":field_note, "results": res, "acknowledgedPeaks": covered_acks, "expiredAcks": expired_acks, "boardSinkAssumptions": sink_assumptions, "boardSinkNotes": sink_notes}));- r.save().unwrap();- let mut lines: Vec<String> = res.iter().map(|x| format!("{} {}: {}", x.get("net").or(x.get("ref")).and_then(|v| v.as_str()).unwrap_or("?"), x["status"].as_str().unwrap_or(""), x.get("how").or(x.get("hint")).and_then(|v| v.as_str()).unwrap_or(""))).collect();- for c in &covered_acks { lines.push(format!("acknowledged peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} by --ack {}: \"{}\" ({}); Fields reports one maximum per net, so check its density map for a second spot", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))); }- for a in &expired_acks { lines.push(format!("expired acknowledgement (copper or spec changed since the review; acknowledge again if it still holds): {}", aiflow_analyze::current_acks::describe(a, "expired"))); }- for a in &sink_assumptions { lines.push(format!("board-sink assumption: {a}")); }- for n in &sink_notes { lines.push(format!("board sink: {n}")); }- // the copper this analysis judged: the nets with pours, for the drawing and the walkthrough- let mut pour_nets: Vec<String> = Vec::new();- for x in &res {- let net = x.get("net").or(x.get("tabNet")).and_then(|v| v.as_str()).unwrap_or("");- if !net.is_empty() && x["pourLayers"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !pour_nets.contains(&net.to_string()) { pour_nets.push(net.to_string()); }- }- let draw = match what.as_str() {- "current" => "Now do the analysis yourself, not only this table: draw the current density on the copper (amps per square millimetre on every loaded net's pours and tracks; a Laplace solve on the filled copper between the source pads and the sink pads is enough, the board file has the filled polygons) as a 1920x1080 PNG with a colour scale, then `artifact --kind analysis-image --file <png> --caption \"...\"`. It becomes a fullscreen shot in the final video and a figure on the run page.".to_string(),- _ => "Now do the analysis yourself, not only this table, as two drawings: first the heat flow in the copper, layer by layer like the current picture (a multi-layer solve: each hot tab's watts spreading on its layer and going down through its vias to the other layers, watts per millimetre of width, with the vias drawn and each chip's split between its layer, its vias and the bare dielectric); then the temperature rise over the whole board (the same solve read as temperature, the FR4 spreading). Each a 1920x1080 PNG with a colour scale, registered in that order with `artifact --kind analysis-image --file <png> --caption \"...\"` (`--replaces <old png>` when you redo one). They become fullscreen shots in the final video and figures on the run page.".to_string(),- };- let fields_hint = "Adom Fields 0.4.0 also supports specified conducting heatsinks, per-layer copper thickness, and an optional Signals view for overlap capacitance and differential-pair screening. Read its heatsinks-and-signals guide; use real assembly/material data, never invent a heatsink to pass this gate. Unsupported signal geometry is not a passing impedance result. adom-fields does this solve for you and shows it on the board in 3D: `adom-fields analyze --board <current board> --spec <spec> --out fields` (about two minutes), `adom-fields serve --fields fields &`, then under the fields step `tour fields` films the walkthrough; its screenshots are drawings you can register with `artifact`.".to_string();- let walk = if pour_nets.is_empty() { String::new() } else { format!("Then walk the pours this analysis judged on camera, under this same step: `tour nets --nets {}` (each net lit as a whole, pours and traces together, framed).", pour_nets.join(",")) };- if !passed {- let why = if fields_file.is_file() { let bt = std::fs::read_to_string(live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default(); refine_hints(&read_json(&fields_file.display().to_string()), &bt, &spec) } else { Vec::new() };- if !why.is_empty() { eprintln!("why:\n {}", why.join("\n ")); }- err(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);- }- let mut hints = vec![draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()];- hints.extend(converter_hints);- ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &hints);- }- Cmd::SpecAmend { why } => {- let t = thread(&cli);- let r = load_run(&cli);- if why.trim().chars().count() < spec_freeze::MIN_WHY { err("spec-amend needs a real reason in --why: the datasheet page and what was misread", &[]); }- if r.data["spec"].is_null() { err("this run has no spec yet", &[]); }- let spec = read_json(r.data["spec"].as_str().unwrap_or(""));- let Some(mut rec) = spec_freeze::read(&r.dir) else { err("the budgets are not frozen yet (they freeze once placement begins): edit the spec freely until then", &[]) };- let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(&spec));- if changed.is_empty() { err("the spec's budgets match the frozen ones: nothing to amend", &[]); }- let a = json!({"at": now(), "step": r.data["currentStep"], "why": why.trim(), "changes": changed, "aiThread": t});- rec["budgets"] = spec_freeze::budgets(&spec);- rec["amendments"].as_array_mut().unwrap().push(a.clone());- spec_freeze::write(&r.dir, &rec);- r.log("spec-amend", a);- ok(&format!("spec budgets amended on the record ({} change(s)): {}", changed.len(), changed.join("; ")), &["The amendment is listed in the finish summary; the gates now measure against the new values.".into()]);- }- Cmd::Finish => {- let t = thread(&cli);- let mut r = load_run(&cli);- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t.clone(), target }, &dir);- if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }- }- let o = r.outcomes().clone();- let mut missing = Vec::new();- let placement=o.get("place-land").cloned().unwrap_or(Value::Null);- if placement["mode"]=="verified-existing" && copper_carry::placement_bound(&r.data,&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err() {- missing.push("place verify (saved board or spec changed; verify again)".into());- }- let gate = o.get("gate").cloned().unwrap_or(Value::Null);- if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {- missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());- }- if let Some(m) = decoupling_check::blocks_finish(o.get("decoupling")) { missing.push(m); }- if let Some(m) = placement_intent::blocks_finish(o.get("placement-intent")) { missing.push(m); }- if !mechanical::gate_clear(&gate) {- missing.push("gate: courtyards and part bodies (missing or overlapping; see gate.json \"mechanical\")".to_string());- }- if gate["planeUnderIssues"].as_u64().unwrap_or(0) > 0 {- missing.push(format!("gate: {} planeUnder issue(s) (other-net copper inside a plane region; see gate.json)", gate["planeUnderIssues"]));- }- if !o.contains_key("land-route") {- missing.push("land route".into());- }- if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false) || copper_carry::pours_bound(&r.data, p, Path::new(&r.current_board()), Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err()).unwrap_or(true) {- missing.push("land pours or verify-pours (every planned zone accepted; current inputs required)".into());- }- if !measurement_usable(o.get("measure")) {- missing.push("measure (successful native copper readback)".into());- }- // a Fusion run is judged on the board as it is in Fusion now- let live_board = live_board(&r, &cli, &dir);- let solved_board = live_board.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());- let board_now = || match &live_board { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };- let mut acked_peaks: Vec<Value> = Vec::new();- for k in ["analyze-current", "analyze-thermal"] {- if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {- missing.push(format!("{k} (pass)"));- } else {- if !aiflow_analyze::needs_field_screen(&spec_of(&r),k.trim_start_matches("analyze-")){continue;}- let report=o[k]["fieldReport"].as_str().and_then(|p|std::fs::read(p).ok()).and_then(|b|serde_json::from_slice::<Value>(&b).ok());- let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();- if let Some(report)=report {- let report_path=o[k]["fieldReport"].as_str().unwrap_or("").to_string();- if !fields_observed(&r, Path::new(&report_path)) { missing.push(format!("{k}: the field report was not produced by an Adom Fields solve this run watched (analyze {} again; aiflow solves it)", k.trim_start_matches("analyze-"))); }- // issue #70: only acknowledgements of this copper fingerprint and this Fields spec identity cover a peak- let live_acks = if k == "analyze-current" { aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &ack_copper(live_board.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board)).unwrap_or_else(|_| "unavailable".into()), report["inputIdentity"]["spec"].as_str().unwrap_or("")).0 } else { Vec::new() };- match copper_carry::field_screen_with_acks(&r.data,&report,&spec_of(&r),Path::new(&solved_board),&spec_bytes,k.trim_start_matches("analyze-"),&live_acks) { Err(reason) => missing.push(format!("{k}: {reason}")), Ok((n, c)) => { if let Some(n) = n { eprintln!("{n}"); } acked_peaks.extend(c); } }- } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }- }- }- // issue #75: a switching converter needs Fields' voltages/sources/switching and a solve that modelled it- {- let report = o.get("analyze-current").and_then(|a| a["fieldReport"].as_str()).map(PathBuf::from).filter(|p| p.is_file()).or_else(|| Some(dir.join("fields/fields.json")).filter(|p| p.is_file()))- .and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice::<Value>(&b).ok());- if let Some((msg, hints)) = converter::check(&spec_of(&r), converter::calcs_of_run(&r.data).as_ref(), report.as_ref(), Some(&board_now())) {- missing.push(format!("fields: {msg}. {}", hints.join(" ")));- }- }- if let Err(m) = netlist_gate(&r) { missing.push(m); }- if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); }- let silk_warnings = silk_polarity::warnings(&board_now());- for w in &silk_warnings { eprintln!("Warning: {w}"); }- let spec_f = spec_of(&r);- if let Some((p, layers, warning)) = checks::rules_target(&spec_f, &r.data["fabRules"]) {- if let Some(w) = warning { eprintln!("Warning: {w}"); }- if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { missing.push(format!("fab rules ({e}; {hint})")); }- }- if let Some(f) = molecule_findings(&spec_f, &board_now(), Some(&r)) {- if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }- }- // issue #39: courtyards, part bodies and the pours in the saved board (the board that ships)- // Altium: the live board is a fresh render of the board of record (its courtyards and fab- // outlines from the parts' source footprints, Altium's own fills), so the facts hold for it- let facts = ((live_board.is_none() || aiflow_bridge::eda::active().id() == "altium") && mechanical::applies(&solved_board)).then(|| verified::board_facts(&board_now(), &solved_board, &project_dir_of(&r), &spec_soft(&r)));- match &facts {- Some(f) => {- for n in &f.mech.severity_notes { eprintln!("Warning: {n}"); }- for e in f.mech.errors() { missing.push(format!("mechanical: {e} ({})", f.mech.hint())); }- for p in &f.fill_problems { missing.push(format!("pours in the saved board: {p} ({})", fills::HINT)); }- }- None => eprintln!("Warning: courtyards, bodies and saved pours not checked here (Fusion board); the summary says so"),- }- {- let (labels, fps) = provenance::silk_labels(&board_now());- // On the Fusion lane the board checked here is the KiCad copy converted from the Fusion .brd,- // which now carries its silkscreen text (issue #74); `fusion silkscreen`'s proof is the second source.- if aiflow_bridge::eda::active().id() == "fusion" {- let proof = std::fs::read_to_string(dir.join("proofs").join("silkscreen.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());- let (m, w) = provenance::fusion_silk(labels, fps, proof.as_ref());- missing.extend(m);- if let Some(w) = w { eprintln!("Warning: {w}"); }- }- if fps > 0 && (labels as f64) < provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 {- missing.push(format!("silkscreen: {labels} visible label(s) for {fps} footprints (finished boards carry at least {:.1} per footprint): references, values and contact labels belong on the silkscreen (skill aiflow-silkscreen)", provenance::MIN_SILK_PER_FOOTPRINT));- }- let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));- if !proxies.is_empty() {- missing.push(format!("3D models: {} footprint(s) use the laser-etch proxy box as their model ({}): fetch or build the real part's model (wiki component page, vendor STEP, adom-step)", proxies.len(), proxies.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>().join(", ")));- }- }- if r.outcomes().contains_key("models-orientation") {- let (w, _) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));- let open = unacked_orientation(&r, &w);- if !open.is_empty() { missing.push(format!("3D models right way up (fix or `models --ack {} --why ...`)", open.join(","))); }- }- let mut live = Value::Null;- if let Some(target) = r.data["target"].as_str() {- let br = Bridge { ai_thread: t.clone(), target: target.to_string() };- live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));- let eda = aiflow_bridge::eda::active();- if eda.caps().file_backed_board {- let st = br.status();- let kv = st["kicadVersion"].as_str().or(st["data"]["kicadVersion"].as_str()).unwrap_or("").to_string();- let text = std::fs::read_to_string(r.current_board()).unwrap_or_default();- if !text.contains("(generator \"adom-aiflow-fusion\")") {- if let Err(e) = provenance::saved_by_live_kicad(&text, &kv) { missing.push(format!("board file: {e}")); }- }- }- let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);- if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {- missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));- }- // KiCad's full DRC on the live board, warnings included: the silkscreen findings the fab makes real- // (ink over a mask opening is clipped off, ink on ink, ink past the edge, text under the fab minimum)- // block whatever severity the project gives them; the routing validate above counts errors only- if !eda.caps().full_drc {- eprintln!("finish: {} has no full DRC through its bridge; the silkscreen findings below are not checked (recorded as a gap)", eda.name());- r.log("capability-gap", json!({"step": "finish", "missing": "full_drc", "eda": eda.id()}));- }- let drc = if eda.caps().full_drc { br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"})) } else { Value::Null };- let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).cloned().unwrap_or_default();- let silk: Vec<String> = found.iter().filter(|v| v["type"].as_str().map(|t| aiflow_copper::SILK_BLOCKING.contains(&t)).unwrap_or(false))- .map(|v| format!("{} ({})", v["type"].as_str().unwrap_or(""), v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).collect::<Vec<_>>().join(" / ")).unwrap_or_default())).collect();- if !silk.is_empty() {- missing.push(format!("silkscreen the fab would clip or blur ({}): {}. Move the text (or the pad), never downgrade the rule (skill aiflow-silkscreen)", silk.len(), silk.iter().take(6).cloned().collect::<Vec<_>>().join("; ")));- }- }- // the work before the board: the sourcing check when a profile is named, the evidence the spec asks for- let (spec_v, req_v) = (spec_soft(&r), requirements_of(&r));- if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)) {- let sc = o.get("sourcing").cloned().unwrap_or(Value::Null);- if sc["pass"] != true { missing.push(format!("sourcing check on the {p} profile (sourcing check --bom <csv>)")); }- else if sc["boardChecked"] != true { missing.push("sourcing check against the board's references (run it again now the board exists)".into()); }- else if sc["profile"].as_str() != Some(p.as_str()) { missing.push(format!("sourcing check on the {p} profile the spec or requirements name (the last check used {})", sc["profile"].as_str().unwrap_or("?"))); }- }- let mut required: Vec<String> = Vec::new();- for v in [&spec_v, &req_v] { for k in v["requireEvidence"].as_array().cloned().unwrap_or_default() { if let Some(k) = k.as_str() { if !required.iter().any(|x| x == k) { required.push(k.to_string()); } } } }- for k in &required {- let have = r.data["evidence"].as_array().map(|a| a.iter().any(|e| e["kind"].as_str() == Some(k.as_str()) && e["pass"] != false)).unwrap_or(false);- if !have { missing.push(format!("{k} evidence (evidence add --kind {k} --file <json|md>)")); }- }- // MPN marking: unless it was turned off, every IC (U*, IC*) carries its laser-etched MPN on the- // marking face of its model (an 'Adom.LaserEtch' or legacy 'laser_etch' part in the STEP), so the board and the component- // pages show which chip is which- let marking = r.data["components"]["mpnMarking"].as_str().unwrap_or("on").to_string();- if marking != "off" {- let unmarked = unmarked_ics(&r);- if !unmarked.is_empty() { missing.push(format!("MPN laser etch on the ICs ({}): `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>` (gate must PASS), or `components --etch off` to decline", unmarked.join(", "))); }- }- {- let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();- for (a, h, t) in thermalvias::short(&spec_f, &board_now(), &acked) { missing.push(format!("heatsink vias: {a} has {h} of {t} (thermal-vias plan, land)")); }- }- {- // the outline as the board is now: the live snapshot when the tool has one, else the plan board- let edges = outline::kicad_edges(&std::fs::read_to_string(live_board.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default());- let sharp = outline::sharp_corners(&edges);- if !sharp.is_empty() && r.data["sequence"].is_array() { missing.push(format!("outline: {} sharp corner(s) (outline round)", sharp.len())); }- }- for f in switchnode::findings(&spec_f, &board_now()) { missing.push(format!("switch node: {f} (back to placement)")); }- // a recorded silkscreen proof is re-checked against the board's own pads and vias (the audit's geometry is supplied)- if let Some(inp) = o.get("silkscreen").and_then(|p| p["input"].as_str()) {- if let Ok(t) = std::fs::read_to_string(inp) {- if let Ok(v) = serde_json::from_str::<Value>(&t) {- for g in silkproof::geometry_gaps(&v, &board_now()) { missing.push(format!("silkscreen audit: {g} (silkscreen-audit --record again on real geometry)")); }- }- }- }- if copper_carry::silk_dropped(&r.data) { missing.push("silkscreen proof (adopt-board changed the board file; `silkscreen-audit --record` again)".into()); }- // the test plan again, on the finished board: every measurement still has a landing- if let Some(tp) = r.data["testPlan"].as_str().map(str::to_string) {- let list = testplan::tests_of(&read_json(&tp));- let (_, gaps, _) = testplan::check_board(&list, &board_now());- if !gaps.is_empty() { missing.push(format!("test plan: no probe landing on {} (`testplan check` says why)", gaps.join(", "))); }- }- // every required step before the finish line, done in order and proven- let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).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_default();- if !seq.is_empty() {- let st = sequence::states(&dir, &r.data, &flow_full, &seq);- for b in sequence::blockers(&st, "finish") { missing.push(format!("step {b}")); }- sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE);- }- // parts from the board's own project library stay visible as evidence (issue #66)- let parts_v = o.get("parts").map(|p| p["parts"].clone()).unwrap_or(Value::Null);- let lib_parts = project_lib::report(&parts_v);- for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); }- if !missing.is_empty() {- err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);- }- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let brs = Bridge { ai_thread: t.clone(), target };- stop_running_clip(&mut r, &brs, &dir, "finish");- }- r.data["clock"]["finishTime"] = json!(now());- record_usage(&mut r, "finish");- let sink_assumptions = o.get("analyze-thermal").map(|x| x["boardSinkAssumptions"].clone()).filter(|v| v.is_array()).unwrap_or(json!([]));- let mut reviewed: Vec<String> = acked_peaks.iter().map(|c| format!("acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} ({}): \"{}\" by {}", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))).collect();- reviewed.extend(sink_assumptions.as_array().into_iter().flatten().filter_map(|a| a.as_str()).map(|a| format!("board-sink assumption: {a}")));- let checked = verified::run_items(&r, facts.as_ref());- let summary = json!({"verified": verified::to_json(&checked), "mechanical": facts.as_ref().map(|f| f.mech.to_json()), "poursInSavedBoard": facts.as_ref().map(|f| f.fills.clone()), "engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true, "acknowledgedPeaks": acked_peaks, "boardSinkAssumptions": sink_assumptions});- r.data["summary"] = summary;- if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); }- r.data["summary"]["projectLibraryParts"] = json!(lib_parts);- r.data["summary"]["silkscreenWarnings"] = json!(silk_warnings);- r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));- r.save().unwrap();- ok(&(format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\npours: {}\n{}{}", r.data["engine"], r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n {}", reviewed.join("\n ")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);- }- Cmd::Capture { action, label, reason } => {- let mut r = load_run(&cli);- let br = bridge_of(&r, &cli);- let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- let engine = r.data["engine"].as_str().unwrap_or("").to_string();- match action.as_str() {- "open" => {- let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));- r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));- r.save().unwrap();- let (revision, reply) = br.revision_with_reply(&rb);- write_json(&dir.join("capture-open-ipc.json"), &reply);- if revision.is_none() {- err(&format!("board window opened (hwnd {hwnd}) but IPC is not ready for {rb}: {} {}; openBoards {}",reply["errorCode"],reply["error"],reply["openBoards"]), &["Read capture-open-ipc.json. For board_mismatch, save and gracefully close only your task-owned conflicting editors, then `capture open` so this board owns the IPC socket. Preserve other users' windows and shared bridges.".into()]);- }- let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum();- let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) };- let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };- ok(&format!("board open, maximised, zoomed to fit (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]);- }- "start" => {- let why = reason.clone().unwrap_or_else(|| format!("aiflow run {engine}"));- // the PCB editor window itself, background-capturable; the monitor only when no editor is open- // the editor open NOW first: a stored handle dies when the editor is closed and reopened (an offline edit)- let hwnd = br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))));- let (rid, kind) = match hwnd {- Some(h) => (br.record_window_start(h, &why).unwrap_or_else(|rep| err(&format!("window recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "window", "hwnd": h})),- None => (br.record_start(&why).unwrap_or_else(|rep| err(&format!("recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "monitor"})),- };- let mut entry = json!({"recordingId": rid, "start": now()});- for (k, v) in kind.as_object().unwrap() { entry[k] = v.clone(); }- r.data["captures"].as_array_mut().unwrap().push(entry);- r.mark("capture-start");- r.save().unwrap();- ok(&format!("recording {rid} started at {}", now()), &["Every stage command writes markers; `capture mark <label>` adds your own.".into()]);- }- "mark" => {- let l = label.clone().unwrap_or_else(|| err("capture mark needs a label", &[]));- r.mark(&l);- r.save().unwrap();- ok(&format!("marker {l} at {}", now()), &[]);- }- "stop" => {- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) else { ok("no recording running: nothing to stop (a walkthrough stops its own clip)", &["Next: `done --message ...` when the answer is complete.".into()]); return; };- let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();- let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();- let stop = br.record_stop(&rid, &save_to);- let local = settle_clip_stop(&mut r, pos, &stop, &dir);- let cap = &r.data["captures"][pos];- let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));- let p = stop.remote.clone();- r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": "capture stop"}), &stop));- r.mark("capture-stop");- if let Some(f) = local.filter(|f| Path::new(f).is_file()) {- clip_artifacts(&mut r, &st, &f, 10);- }- r.save().unwrap();- if p.is_some() && !stop.pulled {- err(&format!("recording stopped, but {} did not reach the run after {} pulls", p.clone().unwrap_or_default(), stop.attempts), &["The capture is flagged pullFailed with its desktop path; `adom-aiflow recut` pulls it again. Do it soon: the desktop cleans its recordings folder.".into()]);- }- let clips_note = auto_clips(&r, &dir);- if r.data["captures"][pos]["truncated"] == true { err(&format!("recording stopped, but the clip {} is truncated (no moov atom: the take died before it was finalised)", p.unwrap_or_default()), &["Re-record the step (`step <name> --back --why \"clip truncated\"`) while the work is fresh.".into(), clips_note]); }- ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into(), clips_note]);- }- "reject" => {- // the AI looked at a contact sheet and saw a clip the blank check accepted but a- // human would not (stale pixels after a window resize, a covered canvas)- let tag = label.clone().unwrap_or_else(|| err("capture reject <step>-<visit> --reason \"...\"", &[]));- let why = reason.clone().unwrap_or_else(|| err("say why in --reason", &[]));- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- let pos = caps.iter().rposition(|c| format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag || c["file"].as_str().map(|f| f.ends_with(&tag)).unwrap_or(false)).unwrap_or_else(|| err(&format!("no clip {tag} in this run"), &["Tags are <step>-<visit>, e.g. nets-2; or the clip's file name.".into()]));- r.data["captures"][pos]["suspect"] = json!(format!("rejected by the AI: {why}"));- let st = json!(format!("{}-{}", caps[pos]["step"].as_str().unwrap_or("?"), caps[pos]["visit"].as_u64().unwrap_or(1)));- r.log("clip-suspect", json!({"step": st, "file": caps[pos]["file"], "why": why, "by": "ai"}));- r.save().unwrap();- ok(&format!("clip {tag} rejected: it stays on the page as evidence and out of the video"), &["Re-take the step if the walkthrough matters: `step <name> --back --why ...`.".into()]);- }- other => err(&format!("capture {other}: use open, start, mark, stop or reject"), &[]),- }- }- Cmd::Status => {- let r = load_run(&cli);- let o = r.outcomes();- ok(&format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",- dir.display(), r.data["engine"], r.elapsed_minutes(), r.data["clock"]["promptTime"], Value::Object(r.stage_minutes()), r.data["decisions"],- o.get("route").map(|x| x["unrouted"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),- if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { pourverify::mode_label(&o["land-pours"]) } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),- o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);- }- Cmd::Report { page, push, refresh, user_machine, surface } => {- let t = thread(&cli);- let mut r = load_run(&cli);- if let Some(p) = page { r.data["page"] = json!(p); }- if let Some(m) = user_machine { r.data["userMachine"] = json!(m); }- if let Some(sf) = surface { if !matches!(sf.as_str(), "browser" | "webview") { err("--surface is browser or webview", &[]); } r.data["reportSurface"] = json!(sf); }- r.save().unwrap();- let pg = r.data["page"].as_str().map(str::to_string).unwrap_or_else(|| err("no page for this run: `report --page <owner/slug>` once (the wiki page the run belongs to)", &[]));- if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }- let (url, notes) = publish_report(&r, &dir, &pg, *push, *refresh, &t);- if notes.iter().any(|n| n.contains("FAILED") || n.starts_with("push failed")) { err(&format!("report built but NOT published: {}", notes.join("; ")), &["Run `report --push` again: only the files that failed go up (the pushed ones are cached).".into()]); }- ok(&format!("report built for {pg}: {url}{}", if notes.is_empty() { String::new() } else { format!("; {}", notes.join("; ")) }), &["From now on every `step` and `deliver` rebuilds, pushes and refreshes it by itself.".into()]);- }- Cmd::Giveback { url, kind, note } => {- thread(&cli);- let mut r = load_run(&cli);- if !r.data["giveback"].is_array() { r.data["giveback"] = json!([]); }- r.data["giveback"].as_array_mut().unwrap().push(json!({"url": url, "kind": kind.clone().unwrap_or_else(|| if url.contains("/pulls/") || url.contains("/pr/") { "pr".into() } else { "issue".into() }), "note": note, "at": now()}));- r.log("giveback", json!({"url": url, "kind": kind, "note": note}));- r.save().unwrap();- ok(&format!("given back: {url} ({} so far this run)", r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0)), &["Adom processes PRs within minutes during business hours; the run's summary counts what its AI gave back.".into()]);- }-- Cmd::Recut => {- let t = thread(&cli);- if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then recut again. No clip was judged.".into()]); }- let mut r = load_run(&cli);- // a clip whose pull failed at its stop comes first: the desktop cleans its recordings folder- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t, target }, &dir);- if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }- }- let recovered = recover_capture_files(&mut r, &dir);- if recovered > 0 { eprintln!("recovered {recovered} clip file(s) the manifest had lost"); }- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- let mut n = 0;- for c in &caps {- let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };- if !Path::new(f).is_file() { continue; }- let tag = json!(format!("{step}-{}", c["visit"].as_u64().unwrap_or(1)));- clip_artifacts(&mut r, &tag, f, 10);- n += 1;- }- r.mark(&format!("recut: {n} clips re-cut (10x and action) with this binary's rules"));- r.save().unwrap();- ok(&format!("{n} clips re-cut; the run page uses the action cuts"), &["Next: adom-aiflow compose --out <mp4>, then report --push --refresh.".into()]);- }- Cmd::Compose { out, max_seconds, still_seconds, with, script } => {- thread(&cli);- if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then `adom-aiflow recut` (clips flagged blank only because the tools were missing are re-checked) and compose again.".into()]); }- let mut r = load_run(&cli);- if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }- let cwd = std::env::current_dir().unwrap_or_else(|e| err(&format!("cannot read the working directory: {e}"), &[]));- let outp = compose_out_path(out.as_deref(), &dir, &cwd);- if let Some(parent) = outp.parent() {- if let Err(e) = std::fs::create_dir_all(parent) { err(&format!("compose cannot create the output folder {} for {}: {e}", parent.display(), outp.display()), &["Pass --out <file.mp4> in a folder you can write; a relative --out is resolved against the current working directory, and without --out the video is final.mp4 in the run directory.".into()]); }- }- if let Some(other) = with {- let b = Run::open(other).unwrap_or_else(|| err(&format!("no run at {}", other.display()), &["The other run needs its run.json, run.jsonl and its clips' 10x cuts in that directory.".into()]));- let script: Option<serde_json::Map<String, Value>> = script.as_ref().map(|p| serde_json::from_str::<Value>(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("script {}: {e}", p.display()), &[]))).ok().and_then(|v| v.as_object().cloned()).unwrap_or_else(|| err("the script must be a JSON object of step name to words", &["{\"placement\": \"...\", \"routing\": \"...\", \"drawings:current\": \"...\"}".into()])));- let (secs, steps, pairs, has_tts) = compose_pair(&mut r, &b, &dir, &outp, *max_seconds, *still_seconds, script.as_ref());- r.log("artifact", json!({"step": "finish", "kind": "video-comparison", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "steps": steps, "drawingPairs": pairs, "with": other.display().to_string(), "withEngine": b.data["engine"], "maxSeconds": max_seconds, "narrated": has_tts}));- r.data["comparison"] = json!({"video": outp.display().to_string(), "with": other.display().to_string(), "withEngine": b.data["engine"], "seconds": (secs * 10.0).round() / 10.0});- r.save().unwrap();- ok(&format!("composed {secs:.0} s side by side: {} left, {} right, {steps} steps and {pairs} drawing pair(s){}: {}", r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or(""), if has_tts { ", narrated" } else { ", no narration" }, outp.display()), &["Look at it before you post it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png.".into(), "report --push --refresh puts it on this run's page under the single-run video.".into()]);- return;- }- let (secs, parts, stills, rate, has_tts) = compose_run(&mut r, &dir, &outp, *max_seconds, *still_seconds);- r.log("artifact", json!({"step": "finish", "kind": "video", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "segments": parts, "stills": stills, "rate": rate, "maxSeconds": max_seconds, "narrated": has_tts}));- if r.data["delivery"].is_object() {- r.data["delivery"]["video"] = json!(outp.display().to_string());- r.mark("compose: the delivered video replaced by the composed cut (motion only, the ledger's overlays, the AI's drawings, narration cut to the picture); the clock of record unchanged");- }- r.save().unwrap();- let minutes = r.active_minutes_total();- ok(&format!("composed {secs:.0} s from {parts} step visits and {stills} drawing(s){} for {}, {minutes:.1} min of AI time, motion at {rate:.1} frames per second: {}", if has_tts { ", narrated" } else { ", no narration (adom-tts not reachable)" }, r.data["engine"].as_str().unwrap_or(""), outp.display()), &["Look at it before you deliver it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png gives one page of it.".into(), "Next: report --push --refresh so the run page carries it; deliver --video <this file> if the run is not delivered yet.".into()]);- }- Cmd::Artifact { file, kind, caption, step, replaces, review } => {- thread(&cli);- let mut r = load_run(&cli);- let src = PathBuf::from(file);- if !src.is_file() { err(&format!("no such file: {file}"), &["Make the drawing first (a PNG at 1920x1080 reads best fullscreen), then register it.".into()]); }- let step = step.clone().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err("no current step", &["`step <name>` first: an artifact belongs to a step visit.".into()]));- let visit = r.data["steps"][&step]["visits"].as_array().map(|a| a.len()).unwrap_or(1);- // Snapshot even in-run sources: later edits must not rewrite earlier evidence.- let (dest, artifact_hash) = artifact_store::snapshot(&src, &dir)- .unwrap_or_else(|e| err(&e, &["No artifact was registered. Resolve the storage error and retry; existing evidence is preserved.".into()]));- let is_image = matches!(dest.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("png") | Some("jpg") | Some("jpeg") | Some("webp"));- let review_path=if kind=="library-overview" {- let rp=review.as_ref().unwrap_or_else(||err("library-overview needs --review <review.json>", &["Review exact selected STEP/GLB variants in the native EDA and the overview. See docs/library-overview-review.md.".into()]));- library_review::verify(rp,Path::new(&r.current_board()),&dest).unwrap_or_else(|e|err(&e,&["Update the actual selection/native evidence, then re-register; do not mark pending evidence passed.".into()]));- let seconds=media_seconds(dest.to_str().unwrap());- if !(1.0..=6.0).contains(&seconds){err("library overview must be a moving clip between one and six seconds", &["Keep the full component tour separate; cut a roughly five-second overview.".into()]);}- Some(std::fs::canonicalize(rp).unwrap_or(rp.clone()).display().to_string())- }else{None};- let replaced = replaces.as_ref().map(|p| std::fs::canonicalize(p).unwrap_or(PathBuf::from(p)).display().to_string());- r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced, "review":review_path, "sha256": artifact_hash, "sourceFile": src.display().to_string()}));- r.mark(&format!("artifact {kind} for {step}-{visit}: {caption}"));- r.save().unwrap();- ok(&format!("{kind} registered for {step} (visit {visit}): {}", dest.display()), &[if is_image { "It goes on the run page under this step with your caption above it, and into the final video as a fullscreen shot at this point of the step (the one place the video leaves the EDA window).".to_string() } else if kind=="library-overview" { "The reviewed moving overview goes into compose; the full tour remains separate.".into() } else { "It goes on the run page under this step with your caption; this artifact kind does not automatically enter the final video.".to_string() }, "Next: if this was an analysis, walk the copper it judged on camera under this same step: `tour nets --nets <the pour nets>`.".into()]);- }- Cmd::Compare { with, page, push, refresh } => {- let t = thread(&cli);- let r = load_run(&cli);- let b = Run::open(with).unwrap_or_else(|| err(&format!("no run at {}", with.display()), &[]));- let page = page.clone().or_else(|| r.data["page"].as_str().map(str::to_string)).unwrap_or_else(|| err("no page: --page <owner/slug>", &[]));- let (rel, url, files) = build_comparison(&r, &b, &dir, &page);- let mut notes = Vec::new();- if *push {- let stage = dir.join("report");- let mut cmd = std::process::Command::new("adom-wiki");- cmd.current_dir(&stage).args(["repo", "push", &page, "-m", &format!("aiflow comparison {}: {} versus {}", rel, r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or("")), "--files"]);- for f in &files { cmd.arg(f); }- match cmd.output() { Ok(o) if o.status.success() => notes.push(format!("pushed {} files", files.len())), Ok(o) => err(&format!("push failed: {}", aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 200)), &["Run it again: the files are still staged.".into()]), Err(e) => err(&format!("push failed: {e}"), &[]) }- }- if *refresh {- if let Some(m) = r.data["userMachine"].as_str() {- let br = Bridge { ai_thread: t.clone(), target: m.to_string() };- let bust = format!("{url}?v={}", now().replace([':', '-'], ""));- let op = br.call("pup_open_window", &json!({"sessionId": format!("aiflow-compare-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>()), "url": bust, "reason": "show the human the comparison page"}));- notes.push(if Bridge::ok(&op) { format!("opened in pup on {m}") } else { format!("pup on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });- }- }- ok(&format!("comparison page built: {url}{}", if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }), &["Link it from the page's README so people find it; the two run pages are linked from it.".into()]);- }- Cmd::Prompt { text, at } => {- thread(&cli);- let mut r = load_run(&cli);- let at = at.clone().unwrap_or_else(now);- if aiflow_run::parse(&at).is_none() { err(&format!("--at {at} is not a time (RFC 3339, e.g. 2026-09-14T15:04:05Z)"), &[]); }- let n = r.sessions().len() + 1;- r.log("prompt", json!({"n": n, "text": text, "at": at}));- r.data["clock"]["lastPromptTime"] = json!(at);- r.save().unwrap();- ok(&format!("prompt {n} at {at}: the AI's time on it counts from here"), &["Continue the original requested scope after supporting tasks and status updates. Use `done --message \"...\"` only at completion, an explicit pause, or a genuine blocking handoff; it stops session accounting, not the engineering run.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into(), "Measure external work with adom-aiflow exec -- <program> <args>. Unwrapped long gaps are unobserved and may undercount active work, not proof of idle time.".into()]);- print_next(&cli);- }- Cmd::FinishGuard { .. } => unreachable!("handled before side effects"),- Cmd::Done { message, stop_evidence } => {- thread(&cli);- let mut r = load_run(&cli);- let verdict = continuation_report(&r, stop_evidence.as_deref());- if verdict["mayStop"] != true {- err("finish line not reached; session remains open", &[verdict["reason"].as_str().unwrap_or("").into(), verdict["nextAction"].as_str().unwrap_or("").into(), "A status update, a passing diagnostic, a fixable tool bug or a failed engineering gate is not a stopping point. Run finish-guard before any final answer.".into()]);- }- let ss = r.sessions();- let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };- if cur.done_at.is_some() && !cur.open { err(&format!("session {} is already done", cur.n), &["A new human prompt is `prompt --text \"...\"`; then `done` closes that one.".into()]); }- let t = now();- r.log("done", json!({"n": cur.n, "message": message, "stopDecision": verdict}));- r.save().unwrap();- let ss = r.sessions();- let cur = ss.last().unwrap();- ok(&format!("session {} done at {t}: {:.1} min of AI time on it ({:.0} min unobserved gaps excluded); {:.1} min of AI time on the run so far", cur.n, cur.active_seconds() as f64 / 60.0, cur.idle_seconds as f64 / 60.0, r.active_minutes_total()), &["report --push --refresh puts the session on the run page.".into()]);- }- Cmd::Sessions { json: as_json } => {- thread(&cli);- let r = load_run(&cli);- let ss = r.sessions();- if *as_json {- println!("{}", serde_json::to_string_pretty(&ss.iter().map(|x| json!({"n": x.n, "promptAt": aiflow_run::iso(x.prompt_at), "doneAt": x.done_at.map(aiflow_run::iso), "text": x.text, "activeMinutes": (x.active_seconds() as f64 / 6.0).round() / 10.0, "idleMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "unobservedMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "measurement": "ledger activity with heuristic gap exclusion; not harness-complete", "spans": x.spans.iter().map(|(a, b)| json!([aiflow_run::iso(*a), aiflow_run::iso(*b)])).collect::<Vec<_>>(), "open": x.open})).collect::<Vec<_>>()).unwrap());- return;- }- println!("{}", sessions_table(&r));- ok(&format!("{} session(s), {:.1} min of AI time in all; idle threshold {} min", ss.len(), r.active_minutes_total(), r.idle_minutes()), &["Run finish-guard before a final answer. Intermediate progress stays in commentary; done requires completed delivery or explicit stop evidence. Unobserved time is not automatically charged.".into()]);- }- Cmd::Usage => {- thread(&cli);- let mut r = load_run(&cli);- let snap = record_usage(&mut r, "manual");- r.save().unwrap();- if snap.is_empty() { err("no plan usage readable: no Claude Code (~/.claude/.credentials.json) or Codex (~/.codex/auth.json) sign-in in this container, or the provider did not answer", &["The run still counts time; usage is the second cost line and it needs a sign-in the binary can read.".into()]); }- ok(&format!("{}", snap.iter().map(|l| format!("{} {}: {:.0}% used", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""), l["pct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", ")), &[usage_line(&r)]);- }-- Cmd::Netlist { action: _, sch, board, net } => {- let mut run = Run::open(&dir);- if run.is_some() { thread(&cli); }- let sch = sch.clone().or_else(|| run.as_ref().and_then(|r| r.data["schematic"].as_str().map(str::to_string)))- .unwrap_or_else(|| err("netlist check needs --sch <file.kicad_sch> (or a run started with --sch)", &["The schematic the board was made from; its netlist is exported with kicad-cli and compared pin by pin.".into()]));- if !Path::new(&sch).is_file() { err(&format!("schematic not found: {sch}"), &[]); }- let schematic_files = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy: {e}"), &[]));- let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("netlist check needs --board <file.kicad_pcb> outside a run", &[]));- let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));- let out_dir = if run.is_some() { dir.clone() } else { std::env::temp_dir() };- let (netfile, via) = match net {- Some(n) => (PathBuf::from(n), "given netlist".to_string()),- None => {- let out = out_dir.join(if run.is_some() { "netlist-sch.net".to_string() } else { format!("aiflow-netlist-{}.net", std::process::id()) });- let _ = std::fs::remove_file(&out);- let cli_used = export_sch_netlist(&sch, &out).unwrap_or_else(|e| err(&e, &["Export needs KiCad's own kicad-cli for the schematic's version (a KiCad 7 kicad-cli cannot read a KiCad 10 schematic). In a container with no native KiCad: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> KICAD_REMOTE_THREAD=<your thread>. Or export it yourself (`kicad-cli sch export netlist --format kicadsexpr`) and pass --net <file>.".into()]));- (out, format!("exported by {cli_used}"))- }- };- let text = std::fs::read_to_string(&netfile).unwrap_or_else(|e| err(&format!("{}: {e}", netfile.display()), &[]));- let netlist_hash = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(text.as_bytes())) };- let sn = checks::parse_sch_netlist(&text).unwrap_or_else(|e| err(&e, &[]));- let after_export = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy after export: {e}"), &[]));- if schematic_files != after_export { err("schematic hierarchy changed during netlist export; rerun with stable inputs", &[]); }- let rep = checks::compare_netlists(&sn, &b);- let passed = rep["ok"] == true;- let mut lines = vec![format!("schematic {} ({} refs) vs board {} ({} refs): {}/{} connected pins on the same net ({via})", sch, rep["schematicRefs"], board_path, rep["boardRefs"], rep["matchedPins"], rep["connectedPins"])];- for (k, label) in [("missingRefs", "missing on the board"), ("extraRefs", "on the board, not in the schematic")] {- if let Some(a) = rep[k].as_array().filter(|a| !a.is_empty()) { lines.push(format!(" {label}: {}", a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }- }- for m in rep["mismatches"].as_array().cloned().unwrap_or_default() {- lines.push(format!(" {:<10} schematic {:<12} board {:<12} {}", m["pin"].as_str().unwrap_or(""), m["schematic"].as_str().unwrap_or("-"), m["board"].as_str().unwrap_or("-"), m["why"].as_str().unwrap_or("")));- }- let sch_check = schematic_symbols::check(Path::new(&sch), &run.as_ref().map(spec_soft).unwrap_or(Value::Null));- lines.push(schematic_symbols::summary_line(&sch_check));- if let (Some(r), Ok(sc)) = (run.as_mut(), &sch_check) { schematic_symbols::record(r, &sch, sc); }- let fpd: Vec<String> = rep["footprintDifferences"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();- if let Some(r) = run.as_mut() {- let report = dir.join("netlist.json");- write_json(&report, &rep);- if r.data["schematic"].is_null() { r.data["schematic"] = json!(std::fs::canonicalize(&sch).map(|p| p.display().to_string()).unwrap_or(sch.clone())); }- r.outcome("netlist", json!({"ok": passed, "schematic": sch, "sha256": sha256_file(&sch), "schematicFiles": schematic_files, "netlistSha256": netlist_hash, "netlist": std::fs::canonicalize(&netfile).map(|p| p.display().to_string()).unwrap_or_default(), "board": board_path, "report": report.display().to_string(), "matchedPins": rep["matchedPins"], "connectedPins": rep["connectedPins"], "mismatches": rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), "missingRefs": rep["missingRefs"], "extraRefs": rep["extraRefs"]}));- r.save().unwrap_or_else(|e| err(&e, &[]));- }- let fp_hint = if fpd.is_empty() { String::new() } else { format!("Footprints differ (not blocking, but check they are the parts you meant): {}", fpd.join("; ")) };- if !passed {- err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint]);- }- ok(&lines.join("\n"), &["This is electrical parity between schematic and board only: not ERC, DRC, courtyards, pours or fabrication readiness; never tell the human the board can go to the fab on this result.".into(), if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]);- }- Cmd::Molecule { action: _, board, spec } => {- let mut run = Run::open(&dir);- if run.is_some() && board.is_none() { thread(&cli); }- let spec_v = match spec { Some(p) => read_json(p), None => run.as_ref().map(spec_of).unwrap_or_else(|| err("molecule check needs --spec <spec.json> outside a run", &[])) };- if spec_v.get("molecule").is_none() { eprintln!("Hint: the spec has no \"molecule\" object; checking with the Adom defaults (2 mm grid, 2 mm edge margin, medium pins). Add \"molecule\": {{...}} so place check, gate and finish run it too."); }- let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("molecule check needs --board <file.kicad_pcb> outside a run", &[]));- let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));- let before = if board.is_none() { run.as_ref().and_then(first_board) } else { None };- let f = checks::molecule_check(&b, &spec_v, before.as_ref());- if let (Some(r), None) = (run.as_mut(), board) {- r.outcome("molecule", json!({"ok": f.errors.is_empty(), "board": board_path, "errors": f.errors, "warnings": f.warnings}));- r.save().unwrap_or_else(|e| err(&e, &[]));- }- let text = format!("molecule check on {board_path}: {} error(s), {} warning(s)\n{}", f.errors.len(), f.warnings.len(), f.lines.join("\n"));- if !f.errors.is_empty() { err(&text, &f.hints); }- let mut hints = f.hints.clone();- hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish, then adom-aiflow board-glb copper so pads and test points are real copper (skill aiflow-molecule).".into());- ok(&text, &hints);- }- Cmd::ThermalVias { action, ack, why, pause_ms } => {- thread(&cli);- let mut r = load_run(&cli);- let spec = spec_of(&r);- if let Some(refs) = ack {- let w = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<why this part needs no more vias>\"", &[]));- for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["thermalViaAcks"][x] = json!(w.clone()); }- }- let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();- // a Fusion run plans on the live board (the copper landed since the last snapshot), as the finish step reads it- let b = if action == "plan" { live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))) } else { None }.unwrap_or_else(|| board_of(&r));- let sw = switchnode::nets(&spec, &b);- if action == "plan" {- let (parts, plan) = thermalvias::plan_with_acknowledgements(&spec, &b, &sw, &acked);- let pp = dir.join("plan-thermal-vias.json");- write_json(&pp, &plan);- // issue #40: spec.pours against the board's zones; a stale outline makes the counts wrong- let pours_landed = r.outcomes().get("land-pours").map(|p| p["pass"] == true || p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false);- let freshness = thermalvias::pour_freshness(&spec, &b, pours_landed);- for f in freshness.iter().filter(|f| f.starts_with("WARNING")) { eprintln!("{f}"); }- let mut lines: Vec<String> = parts.iter().map(|p| format!(" {:<5} {:<5} target {:>2} existing {:>2} planned {:>2}{}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note.as_ref().map(|n| format!(" ({n})")).unwrap_or_default())).collect();- lines.extend(freshness.iter().map(|f| format!(" {f}")));- r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note})).collect::<Vec<_>>(), "pourFreshness": freshness}));- r.save().unwrap();- if spec["hot"].as_object().map(|o| o.is_empty()).unwrap_or(true) { err("spec.hot names no hot parts", &["Add every part that dissipates real power: \"hot\": {\"U1\": {\"watts\": 0.25, \"tabNet\": \"GND\"}} (watts from the datasheet's efficiency or Rds(on), the inductor's DCR times I squared).".into()]); }- ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into()]);- } else {- let pp = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).map(str::to_string).unwrap_or_else(|| err("no heatsink via plan; run `thermal-vias plan` first", &[]));- let n = read_json(&pp)["nets"].as_array().map(|a| a.len()).unwrap_or(0);- if n > 0 {- let exe = std::env::current_exe().unwrap();- let st = std::process::Command::new(exe).args(["--run", &dir.display().to_string(), "--ai-thread", &thread(&cli), "land", "vias", "--plan", &pp, "--pause-ms", &pause_ms.to_string()]).status().unwrap_or_else(|e| err(&e.to_string(), &[]));- if !st.success() { err("landing the heatsink vias stopped (see above)", &["Fix what it says, then `thermal-vias plan` again (the plan counts what landed) and land.".into()]); }- }- let r2 = load_run(&cli);- let live_board = live_board(&r2, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r2));- let short = thermalvias::short(&spec, &live_board, &acked);- let mut r = r2;- r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked}));- r.save().unwrap();- if !short.is_empty() { err(&format!("{n} heatsink vias landed, but parts are still short: {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); }- ok(&format!("{n} heatsink vias landed; every hot part at its target"), &["Next: `step pours` (the pours connect the vias), then the analyses read the heat path through them.".into()]);- }- }- Cmd::Outline { action, radius } => {- thread(&cli);- let mut r = load_run(&cli);- let rad = radius.or_else(|| spec_soft(&r)["outline"]["cornerRadius"].as_f64()).unwrap_or(1.0);- let eda = aiflow_bridge::eda::active();- let read = |r: &Run| -> Vec<outline::Edge> {- let board = live_board(r, &cli, &dir).map(|l| l.1).unwrap_or_else(|| r.current_board());- outline::kicad_edges(&std::fs::read_to_string(board).unwrap_or_default())- };- let before = outline::sharp_corners(&read(&r));- if action == "check" || before.is_empty() {- if action == "round" { r.outcome("outline", json!({"radius": rad, "rounded": 0, "sharp": 0})); r.save().unwrap(); }- if before.is_empty() { ok("the board outline has no sharp corners", &[]); return; }- err(&format!("{} sharp outline corner(s): {}", before.len(), before.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["`outline round` turns each into an arc (spec outline.cornerRadius, default 1.0 mm).".into()]);- }- let br = bridge_of(&r, &cli);- let rep = eda.round_corners(&|v: &str, a: &Value| br.raw_call(v, a), &before, rad);- if rep["errorCode"].as_str().map(|c| c.starts_with("unsupported_in_")).unwrap_or(false) {- err(&format!("outline round does not land live in {} yet", eda.name()), &["Round the board-outline corners in the tool (the corner radius), save, then `outline check` and `outline round` again to record it.".into()]);- }- let done = rep["rounded"].as_u64().unwrap_or(0);- let after = outline::sharp_corners(&read(&r));- r.outcome("outline", json!({"radius": rad, "rounded": done, "sharp": after.len()}));- r.save().unwrap();- if !after.is_empty() { err(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); }- ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]);- }- Cmd::Timeline { action, port, json: presentation } => {- 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::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::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");- match action.as_str() {- "write" => ok(&format!("timeline written: step {} of {}, {} done", tl["position"], tl["total"], tl["done"]), &[]),- "serve" => {- timeline_server::serve(&tdir,*port).unwrap_or_else(|e|err(&e,&["Use the existing run server; do not create another dashboard.".into()]));- }- "present" => {- let caller=thread(&cli);- let patch:Value=serde_json::from_str(presentation.as_deref().unwrap_or("{}")).unwrap_or_else(|e|err(&e.to_string(),&[]));- dashboard::ensure(&dir,&caller,None,false).unwrap_or_else(|e|err(&e,&[]));- let config=read_json(&dir.join("dashboard.json").to_string_lossy());- let reply=timeline_server::update(config["port"].as_u64().unwrap() as u16,&caller,&patch).unwrap_or_else(|e|err(&e,&["The existing server must support the current live channel. Restart only this run server after an upgrade.".into()]));- ok(&format!("presentation updated in the existing dashboard: {}",reply["presentation"]), &["SSE updates the open page in place; engineering gates are unchanged.".into()]);- }- _ => {- let ai_thread=cli.ai_thread.clone().or_else(||r.data["aiThread"].as_str().map(str::to_owned)).unwrap_or_else(||"adom-aiflow".into());- let url=dashboard::ensure(&dir,&ai_thread,Some(*port),true).unwrap_or_else(|e|err(&e,&["Dashboard failure does not change any board gate. Retry when Hydrogen is connected.".into()]));- ok(&format!("timeline open in Hydrogen: {url} (step {} of {})", tl["position"], tl["total"]), &["Keep it in the right pane beside the chat (the VS Code tab stays on the left; never widen panes). Every `step` updates it within a second.".into()]);- }- }- }- Cmd::Demo { action, at, port, title } => {- let dir = run_dir(&cli);- if action == "serve" {- let d = dir.join("demo").join("timeline");- if !d.join("timeline.json").exists() { err("nothing to serve yet", &["Run `demo at <step>` first; it writes <run>/demo/timeline/.".into()]); }- timeline_server::serve(&d, *port).unwrap_or_else(|e| err(&e, &["Pick another --port; the run's live dashboard keeps its own.".into()]));- return;- }- let r = load_run(&cli);- let at = at.clone().unwrap_or_else(|| err("demo at needs a moment", &["`demo at placement`, `demo at routing@2+60`, or an ISO time like 2026-10-18T19:50:00Z.".into()]));- let t = demo::resolve(&r.data, &at).unwrap_or_else(|e| err(&e, &["Step names are the run's (`status` lists them); @n picks the nth visit.".into()]));- let state = demo::rewind(&dir, &t).unwrap_or_else(|e| err(&e, &[]));- let rs = Run::open(&state).unwrap_or_else(|| err("the rewound state did not open", &[]));- let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).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::sequence_key(&dir, &r.data)));- let mut tl = sequence::timeline(&state, &rs.data, &flow_full, &seq, &rs.step_report());- let d = demo::write(&dir, &t, &mut tl, TIMELINE_PAGE, title.as_deref()).unwrap_or_else(|e| err(&e, &[]));- ok(&format!("demo at {t}: step {} of {}, {} done, {} loop back(s), current {}; written to {}", tl["position"], tl["total"], tl["done"], tl["loops"].as_array().map(|a| a.len()).unwrap_or(0), tl["current"], d.display()),- &["`demo serve --port 8894` serves it (the run's live dashboard is untouched); an open page updates in place on the next `demo at`.".into()]);- }- Cmd::Testplan { action: _, tests, netlist, add } => {- thread(&cli);- let mut r = load_run(&cli);- let tp = tests.clone().or_else(|| r.data["testPlan"].as_str().map(str::to_string)).unwrap_or_else(|| err("testplan check needs --tests tests.json the first time", &["Write tests.json: [{id, what, net, why, kind: dc|ripple|waveform|camera|ground, expect}], one per measurement you will take on the probing workcell (skill aiflow-test-plan).".into()]));- let tv = read_json(&tp);- let list = testplan::tests_of(&tv);- if list.is_empty() { err(&format!("{tp} has no tests"), &["A board with nothing to measure has not been thought through: list the rails, the switch node, feedback, ripple, enable and every LED.".into()]); }- let tp_abs = std::fs::canonicalize(&tp).map(|p| p.display().to_string()).unwrap_or(tp.clone());- r.data["testPlan"] = json!(tp_abs);- let has_board = r.data["boards"].as_array().map(|a| !a.is_empty()).unwrap_or(false);- let (rows, gaps, notes, added, mode) = if has_board {- let (rows, gaps, warn) = testplan::check_board(&list, &board_of(&r));- (rows, gaps, warn, Vec::new(), "board")- } else {- let np = netlist.clone().unwrap_or_else(|| err("before the board, testplan check needs --netlist <netlist.json>", &[]));- let mut nl = read_json(&np);- let (rows, gaps, unknown) = testplan::check_netlist(&list, &nl);- if !unknown.is_empty() { err(&format!("tests name nets the netlist does not have: {}", unknown.join(", ")), &["Use the netlist's own net names (or rename the net in the design so the measurement has a name).".into()]); }- let added = if *add && !gaps.is_empty() { let a = testplan::add_test_points(&mut nl, &gaps); write_json(Path::new(&np), &nl); a } else { vec![] };- (rows, if *add { vec![] } else { gaps }, vec![], added, "netlist")- };- let table: Vec<String> = rows.iter().map(|x| format!(" {:<10} {:<12} {:<9} {}", x["id"].as_str().unwrap_or(""), x["net"].as_str().unwrap_or(""), x["kind"].as_str().unwrap_or(""), match x["status"].as_str().unwrap_or("") { "gap" => format!("GAP: {}", x["why"].as_str().unwrap_or("no test point")), s => format!("{s} {}", x["spot"].as_object().map(|o| format!("{} at ({}, {})", o["pad"].as_str().unwrap_or(""), o["x"], o["y"])).or_else(|| x["spot"].as_str().map(str::to_string)).unwrap_or_default()) })).collect();- r.outcome("testplan", json!({"mode": mode, "tests": rows, "gaps": gaps, "added": added, "notes": notes, "file": tp_abs}));- r.save().unwrap();- let head = format!("test plan ({mode}): {} measurements, {} probe-able, {} gap(s){}", rows.len(), rows.iter().filter(|x| x["status"] != "gap").count(), gaps.len(), if added.is_empty() { String::new() } else { format!("; added {}", added.join(", ")) });- let body = format!("{head}\n{}", table.join("\n"));- if !gaps.is_empty() {- err(&body, &[if mode == "board" { "Each gap needs a spot: move a part off the pad, add a test point on the net (adom/testpoint-pad-0-5mm, schematic first: `step schematic --back --why \"test point for <net>\"`), or widen the copper so a 0.5 mm landing fits. Then `testplan check` again.".into() } else { "Add a test point per gap: `testplan check --netlist <netlist.json> --add` writes TP<n> (0.5 mm round, adom/testpoint-pad-0-5mm) into the netlist.".into() }]);- }- let mut hints = vec!["Read the table to the human: what you will measure on the workcell and where the probe lands. Every added test point goes into the library and the schematic like any part.".to_string()];- hints.extend(notes.iter().map(|n| format!("Note: {n}")));- if mode == "netlist" { hints.push("Run it again once the board is placed (`testplan check`): a test point under a part or out of tether reach is a gap again. finish runs it too.".into()); }- ok(&body, &hints);- }- Cmd::Rules { action, profile, board, force, layers } if action == "use" || action == "list" => {- let page = "adom/pcb-design-rules";- let list = || -> Vec<String> {- let o = std::process::Command::new("adom-wiki").args(["api", "/api/v1/pages/adom/pcb-design-rules/files?path=kicad"]).output().ok();- let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);- let mut out: Vec<String> = v["files"].as_array().cloned().unwrap_or_default().iter().filter_map(|f| f["name"].as_str()).filter(|n| n.ends_with(".kicad_dru")).map(|n| n.trim_end_matches(".kicad_dru").to_string()).collect();- out.sort(); out- };- if action == "list" {- let l = list();- if l.is_empty() { err(&format!("could not read the rule packs on {page}"), &["Check `adom-wiki whoami` and the wiki's health, then retry.".into()]); }- ok(&format!("{} rule packs on {page} (<fab>[-<process>]-<n>L):\n {}", l.len(), l.join("\n ")), &["`rules use --profile 3rdPartyFab` (the default) or `--profile <fab>[-<process>]`; --layers picks the copper count.".into()]);- return;- }- let mut r = load_run(&cli);- let asked = profile.clone().unwrap_or_else(|| "3rdPartyFab".into());- // `--profile jlcpcb-6L` names the layers too; --layers must agree with it- let (asked_base, asked_layers) = checks::split_profile(&asked);- if let (Some(a), Some(b)) = (asked_layers, *layers) { if a != b { err(&format!("--profile {asked} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }- let cb = board.clone().unwrap_or_else(|| r.current_board());- let layers: u32 = layers.or(asked_layers).or_else(|| checks::board_copper_layers(&cb)).unwrap_or(2);- let slug = match asked_base.as_str() { "fab" | "adom-3rdpartyfab" => "adom-3rdpartyfab".to_string(), o => o.to_string() };- let shown = if slug == "adom-3rdpartyfab" { "3rdPartyFab".to_string() } else { slug.clone() };- let file = format!("{slug}-{layers}L.kicad_dru");- let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, &format!("kicad/{file}")]).output();- let text = o.ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.contains("(rule")).unwrap_or_else(|| {- let l = list();- err(&format!("no {layers}-layer rule pack for {shown} on {page} ({file})"), &[format!("Packs there: {}. Pick one with --profile and --layers.", if l.is_empty() { "(could not list)".into() } else { l.join(", ") })])- });- let marked = format!("# adom-aiflow rules: {slug} {layers}L (from {page})\n{text}");- let rd = dir.join("rules"); let _ = std::fs::create_dir_all(&rd);- std::fs::write(rd.join(&file), &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));- let mut placed = vec![rd.join(&file).display().to_string()];- let mut replaced_note = String::new();- if cb.ends_with(".kicad_pcb") {- let dru = Path::new(&cb).with_extension("kicad_dru");- let cur = std::fs::read_to_string(&dru).ok();- let cur_pack = cur.as_deref().and_then(checks::pack_of);- let same_pack = cur_pack.as_ref().is_some_and(|(s, l)| *s == slug && l.is_none_or(|l| l == layers));- if cur.is_some() && !*force && !same_pack {- let what = cur_pack.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "other rules".into());- err(&format!("{} already carries {what}", dru.display()), &[format!("Pass --force to replace them with {slug}-{layers}L (the old file is kept as .kicad_dru.bak).")]);- }- if cur.is_some() {- let _ = std::fs::copy(&dru, dru.with_extension("kicad_dru.bak"));- if !same_pack { replaced_note = format!(" (replaced {}; the old file is {})", cur_pack.map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into()), dru.with_extension("kicad_dru.bak").display()); }- }- std::fs::write(&dru, &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));- placed.push(dru.display().to_string());- // a plan board rendered from another tool has no KiCad project: give it one carrying the same- // fab floors, so the gate's KiCad DRC judges by the fab's rules, not KiCad's defaults- let pro = Path::new(&cb).with_extension("kicad_pro");- if !aiflow_bridge::eda::active().caps().file_backed_board && !pro.is_file() {- let name = pro.file_name().and_then(|n| n.to_str()).unwrap_or("board.kicad_pro").to_string();- std::fs::write(&pro, aiflow_bridge::eda::plan_project_json(&marked, &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));- placed.push(pro.display().to_string());- }- }- // Fusion: the bridge loads the same pack's Fusion rules into the open design- let mut fusion_note = String::new();- let mut fusion_loaded = Value::Null;- let eda = aiflow_bridge::eda::active();- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let br = Bridge { ai_thread: thread(&cli), target };- if let Some(rep) = eda.load_design_rules(&|v: &str, a: &Value| br.raw_call(v, a), &slug, layers, &text) {- let okk = Bridge::ok(&rep);- fusion_loaded = json!(okk);- fusion_note = if okk { format!("; {}: {shown} {layers}-layer rules loaded into the open design", eda.name()) } else { format!("; {} did not load them ({}): open the design and run `rules use` again", eda.name(), rep["errorCode"].as_str().or(rep["error"].as_str()).unwrap_or("?")) };- }- }- // the choice: in the spec when there is one, and in the run (an intake run has no spec yet)- r.data["fabRules"] = json!({"profile": shown, "slug": slug, "layers": layers, "file": file});- if let Some(sp) = r.data["spec"].as_str().map(str::to_string) {- if let Ok(mut v) = std::fs::read_to_string(&sp).map_err(|_| ()).and_then(|t| serde_json::from_str::<Value>(&t).map_err(|_| ())) {- if !v["fab"].is_object() { v["fab"] = json!({}); }- v["fab"]["rules"] = json!(slug);- write_json(Path::new(&sp), &v);- }- }- let mins: Vec<String> = text.lines().filter(|l| l.starts_with("# Min") || l.starts_with("# Copper to") || l.starts_with("# Annular")).map(|l| l.trim_start_matches("# ").to_string()).take(6).collect();- r.outcome("rules", json!({"profile": shown, "slug": slug, "layers": layers, "kicad": placed, "native": {"eda": eda.id(), "loaded": fusion_loaded}, "fusion": if eda.id() == "fusion" { fusion_loaded.clone() } else { Value::Null }}));- r.save().unwrap();- ok(&format!("design rules: {shown}, {layers} layers{fusion_note}. KiCad rules at {}{replaced_note}", placed.join(", ")), &[- format!("Tell the human in plain words which rules are now enforced: {}.", if mins.is_empty() { "see the file".into() } else { mins.join("; ") }),- "Every later step designs to these: placement checks, routing widths and clearances, the gate's DRC and finish all read them. Design with margin above the minimums.".into()]);- }- Cmd::Rules { action, profile, board, force, layers } => {- let run = Run::open(&dir);- if run.is_some() { thread(&cli); }- let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("rules needs --board <file.kicad_pcb> outside a run", &[]));- // the profile: --profile, else the spec's fab.rules, else the run's fabRules (`rules use`)- let target = run.as_ref().and_then(|r| checks::rules_target(&spec_of(r), &r.data["fabRules"]));- if profile.is_none() { if let Some(w) = target.as_ref().and_then(|t| t.2.clone()) { eprintln!("Warning: {w}"); } }- let recorded_layers = if profile.is_none() { target.as_ref().and_then(|t| t.1) } else { None };- let profile = profile.clone().or_else(|| target.as_ref().map(|t| t.0.clone())).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|jlcpcb-<n>L|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));- match action.as_str() {- "install" => {- let (base, suffix) = checks::split_profile(&profile);- if base != "jlcpcb" {- err(&format!("no shipped rules for profile {profile:?}"), &["The 3rd party fab's rules are private and are not shipped with adom-aiflow: keep the project's own .kicad_dru beside the board (spec \"fab\": {\"rules\": \"fab\"}). `rules install` ships only --profile jlcpcb (2 layers); `rules use --profile <fab> --layers <n>` loads any pack on adom/pcb-design-rules.".into()]);- }- if let (Some(a), Some(b)) = (suffix, *layers) { if a != b { err(&format!("--profile {profile} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }- let board_layers = checks::board_copper_layers(&board_path);- let n = layers.or(suffix).or(board_layers).or(recorded_layers).unwrap_or(2);- if let Some(bl) = board_layers.filter(|bl| *bl != n) {- err(&format!("--layers {n} but {board_path} has {bl} copper layers"), &[format!("Install the pack for the board's stackup: `adom-aiflow rules use --profile jlcpcb --layers {bl}`{}.", if bl == 2 { " or `rules install --profile jlcpcb --layers 2`" } else { "" })]);- }- let shipped = checks::shipped_pack(&base, n).unwrap_or_else(|| err(&format!("no shipped jlcpcb {n}-layer rules: adom-aiflow ships only jlcpcb-2L"), &[format!("Load the {n}-layer pack from adom/pcb-design-rules instead: `adom-aiflow rules use --profile jlcpcb --layers {n}` (`rules list` shows the packs).")]));- let dru = Path::new(&board_path).with_extension("kicad_dru");- let cur = std::fs::read_to_string(&dru).ok();- let cur_pack = cur.as_deref().and_then(checks::pack_of);- let describe = |p: &Option<(String, Option<u32>)>| p.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into());- let mut replaced = String::new();- if let Some(c) = &cur {- if cur_pack == Some(("jlcpcb".into(), Some(n))) { let _ = c; ok(&format!("{} already carries the jlcpcb-{n}L profile", dru.display()), &[]); return; }- if !*force {- err(&format!("{} already exists and carries {}", dru.display(), describe(&cur_pack)), &[format!("Keep it (it may be the 3rd party fab's rules or a pack `rules use` loaded) or pass --force to replace it with the jlcpcb-{n}L profile (the old file is kept as .kicad_dru.bak).")]);- }- let bak = dru.with_extension("kicad_dru.bak");- std::fs::copy(&dru, &bak).unwrap_or_else(|e| err(&format!("{}: {e}", bak.display()), &[]));- replaced = format!("; replaced {} (kept as {})", describe(&cur_pack), bak.display());- }- std::fs::write(&dru, shipped).unwrap_or_else(|e| err(&format!("{}: {e}", dru.display()), &[]));- let mut hints = vec!["DRC loads it with the board: `gate` and `place check` need a native kicad-cli for project rules (ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote in a container).".into(), "Set \"fab\": {\"rules\": \"jlcpcb\"} in the spec so the gate refuses a board without it, and source parts on the jlcpcb profile (skill aiflow-sourcing).".into()];- if run.is_some() && board.is_none() { hints.push("Installed beside the run's current board; later snapshots carry it. Put the same file beside the source board too so the project keeps it.".into()); }- if let Some(fr) = run.as_ref().map(|r| r.data["fabRules"].clone()).filter(|f| f.is_object()) {- let (fs, fl) = (fr["slug"].as_str().unwrap_or(""), fr["layers"].as_u64());- if fs != "jlcpcb" || fl.is_some_and(|l| l as u32 != n) { hints.push(format!("Note: run.json fabRules still records {fs} {}L from `rules use`; the board now carries jlcpcb-{n}L.", fl.unwrap_or(0))); }- }- ok(&format!("installed the jlcpcb-{n}L rules (JLCPCB published {n}-layer capabilities, cited in the file) at {}{replaced}", dru.display()), &hints);- }- _ => {- let fallback_layers = layers.or(recorded_layers);- let result = checks::rules_check_layers(&profile, &board_path, fallback_layers);- // A read-only check of this run's selected project rules is proof too.- // Do not rewrite custom rules, or qualify a different board/profile.- if let Some(mut r) = run {- let same_board = std::fs::canonicalize(&board_path).ok()- .zip(std::fs::canonicalize(r.current_board()).ok())- .is_some_and(|(a, b)| a == b);- let same_profile = checks::rules_target(&spec_of(&r), &r.data["fabRules"])- .is_some_and(|t| t.0 == profile);- let dru = Path::new(&board_path).with_extension("kicad_dru");- let selected_dru = Path::new(&r.current_board()).with_extension("kicad_dru");- let same_rules = dru == selected_dru || std::fs::canonicalize(&dru).ok()- .zip(std::fs::canonicalize(&selected_dru).ok())- .is_some_and(|(a, b)| a == b);- if same_board && same_profile && same_rules {- r.outcome("rules", json!({- "pass": result.is_ok(), "profile": profile,- "method": "existing-project-check", "board": board_path,- "boardSha256": sha256_file(&board_path),- "file": dru.display().to_string(),- "rulesSha256": sha256_file(&dru.display().to_string()),- "note": result.as_ref().map(|m| m.as_str())- .unwrap_or_else(|e| e.as_str())- }));- r.save().unwrap_or_else(|e| err(&e.to_string(), &[]));- }- }- match result {- Ok(m) => ok(&m, &[]),- Err(e) => err(&e, &[checks::rules_hint_layers(&profile, checks::effective_layers(&profile, &board_path, fallback_layers))]),- }- }- }- }- Cmd::Models { offline, ack, why } => {- let t = thread(&cli);- let mut r = load_run(&cli);- if let Some(refs) = ack {- let why = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<what you checked in the native 3D viewer>\"", &[]));- for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["modelAcks"][x] = json!(why.clone()); }- r.log("models-ack", json!({"refs": refs, "why": why}));- }- // the orientation pass: offline, every model bound on the board, right way up- let (orient, orient_notes) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));- let unacked = unacked_orientation(&r, &orient);- r.outcome("models-orientation", json!({"warnings": orient, "notChecked": orient_notes, "unacknowledged": unacked}));- let orient_lines: Vec<String> = orient.iter().map(|w| format!(" {} {}: {}{}", w["ref"].as_str().unwrap_or(""), w["model"].as_str().unwrap_or(""), w["finding"].as_str().unwrap_or(""), if r.data["modelAcks"].get(w["ref"].as_str().unwrap_or("")).is_some() { " (acknowledged)" } else { "" })).collect();- let orient_hint = if unacked.is_empty() { String::new() } else { format!("3D orientation warnings to fix or acknowledge ({}): correct the model's rotate/offset in the footprint and look at it in the native 3D viewer, then `models` again; when the model is right as it is, `models --ack {} --why \"<what you saw>\"`. finish refuses while any stays open.", unacked.join(","), unacked.join(",")) };- if *offline {- r.save().unwrap();- let text = format!("3D orientation (offline): {} warning(s), {} not checked here{}{}", orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }, if orient_notes.is_empty() { String::new() } else { format!("\n {}", orient_notes.join("\n ")) });- if !unacked.is_empty() { err(&text, &[orient_hint]); }- ok(&text, &["The live resolution check is `models` without --offline (kicad_model_check on the test box).".into()]);- return;- }- let br = bridge_of(&r, &cli);- let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- let _ = t;- if aiflow_bridge::eda::active().caps().file_backed_board {- let proof=checked_remote_binding(&r,&br,&rb,&dir).unwrap_or_else(|e|err(&e,&["Use bind-remote-board --remote-board <exact native path> after adopting a different board. Old desktop model results do not qualify the current snapshot.".into()]));- r.data["remoteBinding"]=proof;- }- r.stage_start("models", Some("binary"), None);- let v = br.call("kicad_model_check", &json!({"filePath": rb}));- let d = v.get("data").cloned().unwrap_or(v.clone());- let counts = d["counts"].clone();- let unresolved = counts["unresolvedModel"].as_u64().unwrap_or(0) + counts["missingModel"].as_u64().unwrap_or(0);- // complete modelBindings + viewer classes (KiCad Bridge 1.0.22+); file resolution is not render proof- let mc = aiflow_bridge::model_check_summary(&d);- let missing = mc.missing.clone();- let vis = if mc.hidden.is_empty() { String::new() } else { format!("; NOT visible proof, {}: read/set with kicad_3d_viewer_settings (KiCad closed) and confirm in the 3D frame, never by changing BOM/POS/DNP flags", mc.hidden.join("; ")) };- r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);- r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing, "viewerHidden": mc.hidden, "bindingsComplete": mc.bindings_complete, "evidenceSource": mc.evidence}));- r.log("artifact", json!({"step": "models", "kind": "model-check", "counts": counts}));- r.save().unwrap();- if !Bridge::ok(&v) && d["counts"].is_null() {- err(&format!("model check did not answer: {}", v["errorCode"].as_str().unwrap_or("?")), &["The run needs a live board on the test box (capture open) for kicad_model_check.".into()]);- }- if unresolved > 0 {- err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!(" {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);- }- let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));- if !proxies.is_empty() {- err(&format!("{} footprint model(s) are the laser-etch proxy box, not the part:\n{}", proxies.len(), proxies.iter().map(|(r, m)| format!(" {r}: {m}")).collect::<Vec<_>>().join("\n")), &["A renamed or resized proxy box is a placeholder, not a 3D model. Use the part's real model: its wiki component page, the vendor STEP (adom-parts-search, chip-fetcher), or build one from the datasheet (adom-step); then `models` again.".into()]);- }- if !unacked.is_empty() {- err(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);- }- ok(&format!("every footprint has its 3D model ({} with a model, {} expect none{vis}); 3D orientation: {} warning(s), all acknowledged; {} not checked here{}", counts["withModel"], counts["noModelExpected"], orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }), &["Next: step placement.".into()]);- }- Cmd::Tour { what, nets, port, on } => {- let t = thread(&cli);- let mut r = load_run(&cli);- // a localhost Fields URL ({{port}}.localhost, 127.0.0.1) is reachable only on the machine- // hosting this container: film it there (the user's primary machine), not on the test box- let local_proxy = what == "fields" && std::env::var("VSCODE_PROXY_URI").map(|u| preboard::proxy_is_local(&u)).unwrap_or(false);- let mut on_note = String::new();- let br = match on {- Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() },- _ if local_proxy => match primary_machine() {- Some(m) => { on_note = format!("filmed on {m}, the user's primary machine: the Fields URL is a localhost proxy only the container's host can reach (--on <box> overrides)"); Bridge { ai_thread: t.clone(), target: m } }- None => { on_note = "the Fields URL is a localhost proxy only the container's host can reach, and `adom-bridge targets` named no primary machine: filmed on the test box; say --on <the machine hosting this container> if the window does not appear".into(); bridge_of(&r, &cli) }- },- _ => bridge_of(&r, &cli),- };- let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- let editor = if what == "fields" { 0 } else { br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())))).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) };- match what.as_str() {- "3d" => {- r.stage_start("tour-3d", Some("binary"), None);- // the viewer, its own window, is this step's camera: swap the running clip onto it- let op = br.call("kicad_open_3d_viewer", &json!({"editor": "pcb", "pcbHwnd": editor}));- std::thread::sleep(std::time::Duration::from_secs(6));- let find_viewer = || -> Option<i64> {- let w = br.call("kicad_window_info", &json!({}));- let d = w.get("data").cloned().unwrap_or(w.clone());- d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer"))).and_then(|e| e["hwnd"].as_i64())- };- let viewer = op.get("hwnd").and_then(|h| h.as_i64()).or_else(find_viewer).unwrap_or_else(|| err("the 3D Viewer did not open", &["kicad_open_3d_viewer {editor:\"pcb\"} by hand and look at kicad_screenshot_all.".into()]));- // a viewer opened in the background never paints its GL canvas (a white window with a- // black bar, "last render time 1 ms"): show it once, maximised, and wait until the- // bridge's screenshot says the canvas rendered, or refuse rather than film a blank- let _ = br.call("desktop_ui_window", &json!({"hwnd": viewer, "action": "maximize", "reason": "aiflow 3D walkthrough: the viewer fills the monitor on the test box"}));- let _ = br.call("desktop_bring_to_front", &json!({"hwnd": viewer, "reason": "aiflow 3D walkthrough: the viewer must paint before it is filmed"}));- let mut painted = false;- for _ in 0..12 {- std::thread::sleep(std::time::Duration::from_secs(3));- let sh = br.call("kicad_screenshot_all", &json!({}));- let d = sh.get("data").cloned().unwrap_or(sh.clone());- if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8)).unwrap_or(false) {- painted = true;- break;- }- }- let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();- // the editor clip of this step visit is not the walkthrough: discard it, the viewer is the camera- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("3d").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("3d")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));- if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {- let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();- let stop = br.record_stop(&rid, &save_to);- settle_clip_stop(&mut r, pos, &stop, &dir);- r.data["captures"][pos]["discarded"] = json!("editor clip before the viewer opened; the viewer clip is this visit's camera");- }- if !painted {- let _ = br.call("kicad_close_3d_viewer", &json!({}));- r.log("clip-refused", json!({"step": step, "visit": visit, "why": "the 3D viewer's canvas never painted"}));- r.save().unwrap();- err("the 3D Viewer's canvas never painted (a white window), so nothing was filmed", &["Look at kicad_screenshot_all on the test box: a GPU-less host needs kicad_enable_software_opengl; a hidden window needs to be shown once.".into()]);- }- let rid = br.record_window_start(viewer, &format!("aiflow 3D walkthrough")).unwrap_or_else(|rep| err(&format!("viewer recording did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));- r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"}));- r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));- r.save().unwrap();- let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-start"));- if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-start", "file": pth})); }- let tour = br.call("kicad_3d_view", &json!({"action": "tour", "pauseMs": 700}));- let cmds = tour["commands"].as_u64().unwrap_or(0);- std::thread::sleep(std::time::Duration::from_secs(2));- let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-end"));- if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-end", "file": pth})); }- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {- let stop = br.record_stop(&rid, &save_to);- let local = settle_clip_stop(&mut r, pos, &stop, &dir);- r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));- if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }- }- let _ = br.call("kicad_close_3d_viewer", &json!({}));- let clips_note = auto_clips(&r, &dir);- if !clips_note.is_empty() { eprintln!("{clips_note}"); }- r.stage_end("tour-3d", Some(json!({"commands": cmds, "viewer": viewer})), None);- r.outcome("tour-3d", json!({"commands": cmds}));- r.save().unwrap();- if !Bridge::ok(&tour) { err(&format!("the 3D tour did not run: {}", tour["errorCode"].as_str().unwrap_or("?")), &["kicad_3d_view {action:\"actions\"} lists what the viewer answers to.".into()]); }- ok(&format!("3D walkthrough recorded: {cmds} view commands on the viewer, no mouse, clip and screenshots in the run"), &["Next: step nets, or step capture / finish.".into()]);- }- "nets" => {- let spec = spec_of(&r);- let mut list: Vec<String> = nets.as_deref().map(|n| n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();- if list.is_empty() {- // the pour walkthrough: every poured net first, then the loaded, wide and Kelvin ones- for z in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() { if let Some(n) = z["net"].as_str() { if !list.iter().any(|x| x == n) { list.push(n.to_string()); } } }- for k in spec.get("loads").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }- for k in spec.get("wideNets").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }- for (src, dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {- let b = board_of(&r);- for key in [src.clone(), dst.as_str().unwrap_or("").to_string()] { if let Some(p) = b.pad_by_key(&key) { if !list.contains(&p.net) { list.push(p.net.clone()); } } }- }- }- if list.is_empty() { err("no nets to tour: the spec has no loads, wideNets or kelvin, and --nets was not given", &[]); }- r.stage_start("tour-nets", Some("binary"), None);- let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();- let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("nets").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("nets")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));- let mut toured: Vec<Value> = Vec::new();- let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));- std::thread::sleep(std::time::Duration::from_millis(1500));- for net in &list {- let sel = br.call("kicad_select_net", &json!({"filePath": rb, "net": net}));- if !Bridge::ok(&sel) || sel["selected"].as_u64().unwrap_or(0) == 0 {- let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));- r.stage_end("tour-nets", Some(json!({"failed": net, "error": sel["errorCode"]})), None);- r.save().unwrap();- err(&format!("net {net}: {} selected ({}); the walkthrough would film a static board, so it stops here", sel["selected"].as_u64().unwrap_or(0), sel["errorCode"].as_str().unwrap_or("no error, nothing matched")), &["A loaded net with nothing to select means the editor the bridge talks to is not this run's board: with two boards open (another run's), kicad_routing_state {filePath} answers board_mismatch. Close the other board or open this one alone, then `tour nets` again.".into()]);- }- r.mark(&format!("net {net}: {} items highlighted", sel["selected"]));- let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Selected Objects"}));- std::thread::sleep(std::time::Duration::from_millis(2600));- let safe: String = net.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();- if let Some(pth) = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }- let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));- std::thread::sleep(std::time::Duration::from_millis(1200));- toured.push(json!({"net": net, "selected": sel["selected"], "byKind": sel["byKind"]}));- }- let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));- r.stage_end("tour-nets", Some(json!({"nets": toured.len()})), None);- r.outcome("tour-nets", json!({"nets": toured}));- r.save().unwrap();- ok(&format!("net walkthrough recorded: {} nets, each selected as a whole (pours, tracks, vias, pads) and framed: {}", toured.len(), toured.iter().map(|t| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["selected"])).collect::<Vec<_>>().join(", ")), &["The clip of this step is the walkthrough; markers name each net at its moment.".into()]);- }- "fields" => {- // the fields step: adom-fields solved the board and serves it; a pup window on the test- // box shows it, the recorder films that window while `adom-fields tour` drives it- let health = std::process::Command::new("adom-fields").args(["health", "--port", &port.to_string()]).output().map(|o| o.status.success()).unwrap_or(false);- if !health { err(&format!("adom-fields is not serving on port {port}"), &["Solve and serve first: `adom-fields analyze --board <the current board> --spec <spec> --out fields` then `adom-fields serve --fields fields --port ".to_string() + &port.to_string() + " &` (adom-wiki pkg install adom/adom-fields if it is missing).".into()]); }- // a server left up by an earlier run answers health just as well, and the walkthrough then- // films that run's board (old models, missing parts): the solve served must be this run's- let served: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);- if let Some(sb) = served["source"]["board"].as_str() {- let run_dir = std::fs::canonicalize(&dir).unwrap_or(dir.to_path_buf());- let board = std::fs::canonicalize(sb).unwrap_or_else(|_| PathBuf::from(sb));- if !board.starts_with(&run_dir) {- err(&format!("adom-fields on port {port} serves another run's solve ({sb}), not this run's board"), &[- format!("Stop that server (`pgrep -af \"adom-fields serve\"` names it; kill its pid), then solve this board and serve it: `adom-fields analyze --board <this run's board> --spec <spec> --out {}/fields` and `adom-fields serve --fields {}/fields --port {port} &`.", run_dir.display(), run_dir.display())]);- }- }- // issue #75: a switching converter's walkthrough (the step's proof) needs Fields to have modelled- // the converter: the spec's voltages/sources/switching and a solve whose review.converter is set- {- let spec = spec_soft(&r);- let calcs = converter::calcs_of_run(&r.data);- if let Some((msg, hints)) = converter::check(&spec, calcs.as_ref(), Some(&served), Board::load(&r.current_board()).ok().as_ref()) {- r.log("fields-refused", json!({"why": msg}));- r.save().unwrap();- err(&format!("the fields step cannot pass: {msg}"), &hints);- }- }- let proxy = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| err("no VSCODE_PROXY_URI in this container: the test box cannot reach the app", &["Run inside an Adom container (the proxy URL is how a browser outside reaches a port inside), or set VSCODE_PROXY_URI to https://<slug>.adom.cloud/proxy/{{port}}/.".into()]));- r.stage_start("tour-fields", Some("binary"), None);- // pup's browser on a cold box takes longer than one relay call: warm it, wait until it is ready- let _ = br.call("pup_prewarm", &json!({"reason": "aiflow fields walkthrough: warming pup on the test box"}));- for _ in 0..30 {- let rd = br.call("pup_readiness", &json!({}));- if rd["ready"].as_bool() == Some(true) { break; }- std::thread::sleep(std::time::Duration::from_secs(3));- }- // a fresh pup session every time: a session whose browser died answers browser_gone forever- let sid = format!("aiflow-fields-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>());- let op = br.call("pup_open_window", &json!({"sessionId": sid, "url": proxy, "width": 1600, "height": 1000, "reason": "aiflow fields walkthrough: the AI's current and heat analysis on camera"}));- let open_err = if Bridge::ok(&op) { None } else { Some(op["errorCode"].as_str().unwrap_or("?").to_string()) };- // pup can take a minute to bring a browser up on a cold box: the window is polled for, not assumed- let mut hwnd: Option<i64> = None;- for _ in 0..40 {- std::thread::sleep(std::time::Duration::from_secs(2));- let lw = br.call("pup_list_windows", &json!({}));- let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);- let sessions = lw["sessions"].as_array().or_else(|| inner["sessions"].as_array());- // Only the session created by this invocation owns the recording target.- // A title match can select another user's or another run's Fields window.- hwnd = sessions.and_then(|rows| rows.iter().find(|w| w["sessionId"].as_str() == Some(sid.as_str())))- .and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())))- .filter(|h| *h > 0);- if hwnd.is_some() { break; }- }- let Some(win) = hwnd else { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); err(&format!("the app's window did not appear on {} (no window for this walkthrough session{})", br.target, open_err.map(|e| format!("; pup said {e}")).unwrap_or_default()), &["`adom-bridge --target <box> pup_readiness` says whether pup is ready there; a browser_gone means pup's browser died: `pup_close_window` the old sessions or restart pup on the box.".into(), if preboard::proxy_is_local(&proxy) { format!("The URL {proxy} is a localhost proxy: only the machine hosting this container can open it. `tour fields --on <that machine>`.") } else { String::new() }, on_note.clone()]); };- let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();- let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("fields").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("fields")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));- // the editor clip of this visit is not the walkthrough: the app window is the camera- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {- let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();- let stop = br.record_stop(&rid, &save_to);- settle_clip_stop(&mut r, pos, &stop, &dir);- r.data["captures"][pos]["discarded"] = json!("editor clip before the fields app opened; the app window is this visit's camera");- }- std::thread::sleep(std::time::Duration::from_secs(4));- let rid = br.record_window_start(win, "aiflow fields walkthrough").unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));- r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"}));- r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));- r.save().unwrap();- if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-start")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-start", "file": pth})); }- // no pace of our own: the app holds each beat and drifts the board gently, because- // 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();- if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {- let stop = br.record_stop(&rid, &save_to);- let local = settle_clip_stop(&mut r, pos, &stop, &dir);- r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));- if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }- }- let _ = br.call("pup_close_window", &json!({"sessionId": sid}));- let clips_note = auto_clips(&r, &dir);- if !clips_note.is_empty() { eprintln!("{clips_note}"); }- // the solved numbers ride the run: the issues are what the AI answers for- let fields: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);- let issues = fields["issues"].as_array().cloned().unwrap_or_default();- r.stage_end("tour-fields", Some(json!({"issues": issues.len(), "tour": tour_ok})), None);- if tour_ok {- r.outcome("tour-fields", json!({"issues": issues, "nets": fields["nets"], "chips": fields["chips"], "heat": fields["heat"]}));- }- r.log("artifact", json!({"step": step, "visit": visit, "kind": "fields-summary", "issues": issues.len(), "peakJ": fields["scales"]["jMax"], "peakRiseC": fields["heat"]["peakRiseC"]}));- r.save().unwrap();- if !tour_ok { err("the app's walkthrough did not run (adom-fields tour failed)", &["`adom-fields tour` by hand shows why; the clip of this visit still exists.".into()]); }- ok(&format!("fields walkthrough recorded: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into(), on_note]);- }- other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),- }- }- Cmd::Ledger { json: as_json } => {- let r = load_run(&cli);- let led = r.ledger();- if led.is_empty() {- err("no run.jsonl in this run (started before the ledger existed)", &["run.json still carries turns; start a fresh run to get the append-only ledger.".into()]);- }- if *as_json {- for e in &led {- println!("{e}");- }- } else {- for e in &led {- let t = e["t"].as_str().unwrap_or("");- match e["event"].as_str().unwrap_or("") {- "start" => println!("{t} start engine {} prompt {}", e["engine"], e["promptTime"]),- "turn" => println!("{t} turn {:>3} {:<10} think {:>6.1} min {}", e["n"], e["step"].as_str().unwrap_or(""), e["thinkingSeconds"].as_f64().unwrap_or(0.0) / 60.0, e["command"].as_str().unwrap_or("")),- "turn-end" => println!("{t} end {:>3} {:<10} tool {:>6.1} min {} {}", e["n"], e["step"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, if e["ok"].as_bool() == Some(true) { "OK" } else { "ERROR" }, e["said"].as_str().unwrap_or("")),- "step" => println!("{t} step {}{} visit {}{}", e["step"].as_str().unwrap_or(""), if e["back"].as_bool() == Some(true) { " (back)" } else { "" }, e["visit"], e["why"].as_str().map(|w| format!(": {w}")).unwrap_or_default()),- "stage-end" => println!("{t} stage {} {:.1} min {}", e["stage"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, e["result"].to_string().chars().take(100).collect::<String>()),- "marker" => println!("{t} marker {}", e["label"].as_str().unwrap_or("")),- "finish" | "deliver" => println!("{t} {} {}", e["event"].as_str().unwrap_or(""), e["minutes"]),- "prompt" => println!("{t} prompt {:>2} {}", e["n"], e["text"].as_str().unwrap_or("")),- "board-attached" => println!("{t} board {} attached to the intake run (clock from {})", e["board"].as_str().unwrap_or(""), e["promptTime"].as_str().unwrap_or("")),- "evidence" => println!("{t} evidence {} {} pass {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or(""), e["pass"]),- "evidence-refused" => println!("{t} refused {} {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or("")),- "done" => println!("{t} done {:>2} {}", e["n"], e["message"].as_str().unwrap_or("")),- _ => {}- }- }- }- println!("{}", step_table(&r));- }- Cmd::Step { name, back, why } => {- thread(&cli);- let mut r = load_run(&cli);- let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());- for s in preboard::PREBOARD_STEPS { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } }- 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() {- let last = |s: &str| r.data["steps"][s]["visits"].as_array().and_then(|a| a.iter().filter_map(|v| v["at"].as_str()).last()).map(str::to_string);- if let Some(sch) = last("schematic") {- if last("simulation").map(|sim| sim < sch).unwrap_or(true) {- err("simulate the schematic before the board: no simulation since the schematic was drawn", &[- "`step simulation`, then run the circuit in the Adom SPICE dashboard and show it to the human: `adom-spice start`, `adom-spice study <study.json>`, `adom-spice run ngspice`, `adom-spice show` (a Hydrogen webview in the right pane, beside the chat).".into(),- "Check start-up, ripple and the load step against the requirements; change values in the schematic when a measurement misses, and say what changed. Record it: `evidence add --kind simulation --file <result.json>`. Then `step placement`.".into()]);- }- }- }- // the required steps, in order: a run that starts now carries its lane's sequence, and no step- // in it can be skipped (sequence.rs)- let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- // 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::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();- // --back is a return: it may only go to an earlier step of the flow, never jump ahead past the checks- if !seq.is_empty() && *back {- if let (Some(pos), Some(cp)) = (sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))) {- if pos > cp { err(&format!("--back goes to an earlier step, but {name} comes after {} in the flow", seq[cp]), &[format!("`step {name}` without --back: every step before it must be done first.")]); }- }- }- if !seq.is_empty() && !*back {- if let Some(pos) = sequence::position(&seq, name) {- let cur = r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c));- if let Some(cp) = cur { if pos < cp {- err(&format!("{name} comes before {} in the flow: going back is a return", seq[cp]), &[format!("`step {name} --back --why \"<what has to change>\"`: the return is filmed and shown on the timeline as a loop, and every later step is redone after it.")]);- } }- let st = sequence::states(&dir, &r.data, &flow_full, &seq);- let b = sequence::blockers(&st, name);- if !b.is_empty() {- let first = b[0].split(' ').next().unwrap_or("").to_string();- err(&format!("not yet: {name} needs every required step before it done, and these are not:\n {}", b.join("\n ")), &[- format!("`step {first}` and do it: a step is done when its own command has run during its latest visit (flows/board.json proofs). No step of the flow can be skipped."),- "If a step truly does not apply to this board, say so to the human and ask; never jump past it.".into()]);- }- }- }- record_usage(&mut r, &format!("step:{name}"));- r.step_set(name, *back, why.as_deref());- if !seq.is_empty() { sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE); }- // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)- let mut clip_note = String::new();- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let br = Bridge { ai_thread: thread(&cli), target };- let caps = r.data["captures"].as_array().cloned().unwrap_or_default();- if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {- let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();- let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();- // the step's closing screenshot: what the board looked like when the step ended- if let Some(h) = caps[pos]["hwnd"].as_i64() {- let tag = format!("shot-{}-{}-end", caps[pos]["step"].as_str().unwrap_or("step"), caps[pos]["visit"].as_u64().unwrap_or(1));- if let Some(pth) = br.screenshot_window(h, &save_to, &tag) {- r.log("artifact", json!({"step": caps[pos]["step"], "visit": caps[pos]["visit"], "kind": "screenshot", "when": "end", "file": pth}));- }- }- let stop = br.record_stop(&rid, &save_to);- let local = settle_clip_stop(&mut r, pos, &stop, &dir);- let cap = &r.data["captures"][pos];- let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));- let (cs, cv, cb) = (cap["step"].clone(), cap["visit"].clone(), cap["back"].clone());- r.log("clip-stop", clip_stop_event(json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid}), &stop));- if let Some(f) = local.filter(|f| Path::new(f).is_file()) {- clip_artifacts(&mut r, &st, &f, 10);- }- clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));- if r.data["captures"][pos]["truncated"] == true { clip_note.push_str(&format!("WARNING: clip {} is truncated (no moov atom): re-record it with `step {} --back --why \"clip truncated\"` if it matters; ", st.as_str().unwrap_or(""), cs.as_str().unwrap_or(""))); }- clip_stopped = true;- }- // Recording can be delegated to another host without changing the EDA target.- // Always settle an existing take above, but never start/focus/capture here when off.- if std::env::var("AIFLOW_AUTO_CAPTURE").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false) {- clip_note.push_str("automatic step capture disabled; record reviewed clips on the chosen capture host");- r.log("clip-auto-disabled", json!({"step":name,"reason":"AIFLOW_AUTO_CAPTURE"}));- } else {- // the fields step films the app's window through `tour fields`: no editor clip for it- // a Fusion run films Fusion's own window, the board fitted to it first (a remembered- // hwnd can be a KiCad window from before the board, and Fusion keeps whatever zoom it had)- // the window the step works in, from the run's EDA backend: KiCad's PCB editor for this board,- // a one-window tool (Fusion, Altium) with the board fitted to it, and Hydrogen in front for the- // human-facing steps (the chat, the parts pages, the SPICE dashboard, the fields app). A project- // before its board exists (library, schematic) films its tool, never a KiCad window.- let eda = aiflow_bridge::eda::active();- let pre_board = aiflow_bridge::eda::project_backend(&dir).filter(|_| eda.id() == "kicad")- .map(|b| aiflow_bridge::eda::for_board(&aiflow_bridge::eda::BoardRef::parse(&format!("{b}:"))));- let cam = pre_board.unwrap_or(eda);- let tx = |v: &str, a: &Value| br.raw_call(v, a);- 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())));- // issue #59: a step before the board films Hydrogen's dashboard, the AI's window or the tool's own- // window, never an idle editor; nothing meaningful on screen is a recorded skip- let pre_cam = (name != "fields" && clipwatch::preboard_step(name)).then(|| clipwatch::choose_preboard(&br, &*cam, name, r.data["engine"].as_str().unwrap_or("")));- if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if what.starts_with("Hydrogen") { ensure_dashboard(&dir, &thread(&cli), true); } }- let hwnd = match &pre_cam { Some(clipwatch::Camera::Window { hwnd, .. }) => Some(*hwnd), Some(clipwatch::Camera::Skip(_)) => None, None => if name == "fields" { None } else { cam.camera_window(&tx, name, cached) } };- let disk = disk_check(&dir, &br, name);- if let Err(e) = &disk {- r.log("clip-refused", json!({"step": name, "why": e}));- r.save().unwrap();- err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]);- }- match hwnd {- Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) {- Ok(rid) => {- // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back- let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);- r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h, "step": name, "visit": visit, "back": *back, "why": why}));- if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(what); } clip_note.push_str(&format!("filming {what}; ")); }- r.mark(&format!("clip-start:{name}-{visit}"));- let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();- if let Some(pth) = br.screenshot_window(h, &save_to, &format!("shot-{name}-{visit}-start")) {- r.log("artifact", json!({"step": name, "visit": visit, "kind": "screenshot", "when": "start", "file": pth}));- }- r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid}));- clip_note.push_str(&format!("clip {name}-{visit}{} recording (window {h}; {})", if *back { " (rework)" } else { "" }, disk.as_deref().unwrap_or("")));- }- Err(rep) if rep["errorCode"].as_str() == Some("window_not_found") => {- // the remembered editor window is gone (KiCad closed, the box restarted): look once more, live- match br.pcb_editor_hwnd() {- Some(h2) if h2 != h => match br.record_window_start(h2, &format!("aiflow step {name}")) {- Ok(rid) => { let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h2, "step": name, "visit": visit, "back": *back, "why": why})); r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid})); clip_note.push_str(&format!("clip {name}-{visit} recording (the editor moved to window {h2})")); }- Err(rep2) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep2["errorCode"].as_str().unwrap_or("?"))),- },- _ => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box any more (capture open to bring the board back)")),- }- }- Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),- },- None if name == "fields" => clip_note.push_str("the fields step films the app: `tour fields` brings its own camera"),- // before the board there is no editor to film: the desktop is the camera- None if matches!(pre_cam, Some(clipwatch::Camera::Skip(_))) => {- let why_skip = match &pre_cam { Some(clipwatch::Camera::Skip(w)) => w.clone(), _ => String::new() };- let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);- if !r.data["clipSkips"].is_array() { r.data["clipSkips"] = json!([]); }- r.data["clipSkips"].as_array_mut().unwrap().push(json!({"step": name, "visit": visit, "at": now(), "why": why_skip}));- r.log("clip-skipped", json!({"step": name, "visit": visit, "why": why_skip}));- clip_note.push_str(&format!("no clip for {name}-{visit}: {why_skip}"));- }- None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),- }- }- }- r.save().unwrap();- let clips_note = if clip_stopped { auto_clips(&r, &dir) } else { String::new() };- let mut report_note = String::new();- if let Some(pg) = r.data["page"].as_str().map(str::to_string) {- let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));- report_note = format!("Progress page: {url} ({})", notes.join("; "));- }- let skill_lines = skill_hints::step_hints(&flow_full, name, &skill_home, skill_pkg.as_deref());- let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();- let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };- let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };- ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], skill_lines, vec![back_hint, report_note, clips_note]].concat());- print_next(&cli);- }- Cmd::ProjectPageCheck { project, live_json } => {- let mut command = std::process::Command::new(aiflow_python());- command.arg("-c").arg(include_str!("../../../tools/project-page-check.py")).arg("--project").arg(project);- if let Some(path) = live_json { command.arg("--live-json").arg(path); }- let output = command.output().unwrap_or_else(|e| err(&format!("project page check could not run: {e}"), &[]));- print!("{}", String::from_utf8_lossy(&output.stdout));- if !output.status.success() { err("project page preflight failed", &["Fix explicit board GLB and project PDF bindings, readable datasheet and 5–8 README screenshots. Read skills/aiflow-project-page/SKILL.md. PDF extraction requires pdftotext.".into()]); }- }- Cmd::Deliver { video, message, accept_suspect } => {- thread(&cli);- let mut r = load_run(&cli);- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: thread(&cli), target }, &dir);- if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }- }- let suspects: Vec<String> = r.data["captures"].as_array().cloned().unwrap_or_default().iter().filter(|c| c.get("suspect").is_some() && c.get("discarded").is_none()).map(|c| format!("{}-{}: {}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1), c["suspect"].as_str().unwrap_or(""))).collect();- if !suspects.is_empty() && !*accept_suspect {- err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!(" {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);- }- if r.data["clock"]["finishTime"].is_null() {- err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the checked board design first, then the video and your message. Physical qualification is a separate claim.".into()]);- }- let vp = Path::new(video);- if !vp.is_file() {- err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);- }- if let Some(open) = step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null)) {- err(&format!("not delivered: the board twin's STEP export: {open}"), &["Never drop a layer (silkscreen, mask, zones) to make an exporter warning go away. Review the STEP at the reported spots in a STEP viewer, then acknowledge with a reason, or fix the geometry and export again.".into()]);- }- let board_path = r.current_board();- let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));- let checked = verified::run_items(&r, facts.as_ref());- let bytes = std::fs::metadata(vp).map(|m| m.len()).unwrap_or(0);- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let brs = Bridge { ai_thread: thread(&cli), target };- stop_running_clip(&mut r, &brs, &dir, "deliver");- }- let t = now();- 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"]["verifiedAtDelivery"] = verified::to_json(&checked);- r.data["summary"]["steps"] = Value::Object(r.step_report());- r.data["summary"]["turns"] = json!(r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0));- r.data["summary"]["clips"] = json!(r.data["captures"].as_array().map(|a| a.iter().filter(|c| c.get("file").is_some()).map(|c| json!({"step": c["step"], "visit": c["visit"], "back": c["back"], "why": c["why"], "start": c["start"], "stop": c["stop"], "file": c["file"], "camera": c["camera"], "died": c["died"], "resumedAfterGap": c["resumedAfterGap"], "truncated": c["truncated"]})).collect::<Vec<_>>()).unwrap_or_default());- r.data["summary"]["clipSkips"] = r.data["clipSkips"].clone();- r.log("deliver", json!({"minutes": r.delivered_minutes(), "activeMinutes": r.delivered_active_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));- r.save().unwrap();- let gb = r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0);- let mut report_note = format!("Given back this run: {gb} issue(s) or PR(s). {}.", usage_line(&r));- if let Some(pg) = r.data["page"].as_str().map(str::to_string) {- let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));- report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; ")));- }- let verified_text = verified::message(&checked);- ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);- }- }-}--fn measurement_usable(value: Option<&Value>) -> bool {- value.map(|v| v["failed"] != true && v["filledAreaByLayerMm2"].as_object().map(|a| !a.is_empty() && a.values().all(|n| n.as_f64().map(|x| x.is_finite() && x >= 0.0).unwrap_or(false))).unwrap_or(false)).unwrap_or(false)-}--#[cfg(test)]-mod mpn_mark_tests {- use super::*;- #[test]- fn recognises_service_and_legacy_marks() {- assert!(has_mpn_mark(b"#405 = PRODUCT('Adom.LaserEtch','Adom.LaserEtch','',(#406));"));- assert!(has_mpn_mark(b"#3258 = PRODUCT('laser_etch','laser_etch','',(#1));"));- assert!(!has_mpn_mark(b"#7 = PRODUCT('SOT-23-6','SOT-23-6','',(#8));"));- }-}--#[cfg(test)]-mod netlist_freshness_tests {- use super::*;- #[test]- fn gate_rejects_child_only_edits_old_evidence_and_export_edits() {- let dir=std::env::temp_dir().join(format!("aiflow-net-freshness-{}",std::process::id()));- std::fs::create_dir_all(&dir).unwrap();- let sch=dir.join("root.kicad_sch"); let child=dir.join("child.kicad_sch");- let net=dir.join("export.net"); let board=dir.join("board.kicad_pcb");- std::fs::write(&sch,"(kicad_sch (sheet (property \"Sheetfile\" \"child.kicad_sch\")))").unwrap();- std::fs::write(&child,"(kicad_sch)").unwrap();- std::fs::write(&net,"(export (components) (nets))").unwrap();- std::fs::write(&board,"(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")))").unwrap();- let proof=json!({"ok":true,"sha256":sha256_file(sch.to_str().unwrap()),"schematicFiles":schematic_identity::capture(&sch).unwrap(),"netlist":net,"netlistSha256":sha256_file(net.to_str().unwrap())});- let mut run=Run{dir:dir.clone(),data:json!({"schematic":sch,"boards":[board],"outcomes":{"netlist":proof}})};- assert!(netlist_gate(&run).is_ok());- std::fs::write(&child,"(kicad_sch (changed yes))").unwrap();- assert!(netlist_gate(&run).unwrap_err().contains("hierarchy changed"));- std::fs::write(&child,"(kicad_sch)").unwrap();- std::fs::write(&net,"(export (components) (nets))\n").unwrap();- assert!(netlist_gate(&run).unwrap_err().contains("exported netlist changed"));- run.data["outcomes"]["netlist"].as_object_mut().unwrap().remove("schematicFiles");- assert!(netlist_gate(&run).unwrap_err().contains("older check"));- std::fs::remove_dir_all(dir).unwrap();- }-}--#[cfg(test)]-mod measurement_gate_tests {- use super::*;- #[test]- fn refuses_failed_or_missing_copper_measurement() {- for v in [Value::Null, json!({"failed":true,"filledAreaByLayerMm2":{"F.Cu":12}}), json!({"filledAreaByLayerMm2":null}), json!({"filledAreaByLayerMm2":{}}), json!({"filledAreaByLayerMm2":{"F.Cu":-1}})] {- assert!(!measurement_usable(Some(&v)));- }- assert!(!measurement_usable(None));- assert!(measurement_usable(Some(&json!({"filledAreaByLayerMm2":{"F.Cu":12,"B.Cu":0}}))));- }-}--#[cfg(test)]-mod clip_stop_tests {- use super::*;-- fn run_with_capture(dir: &Path, cap: Value) -> Run {- let _ = std::fs::remove_dir_all(dir);- std::fs::create_dir_all(dir).unwrap();- Run { dir: dir.to_path_buf(), data: json!({"captures": [cap]}) }- }- fn stop(remote: Option<&str>, pulled: bool) -> ClipStop {- ClipStop { remote: remote.map(str::to_string), local: None, pulled, attempts: if remote.is_some() { 3 } else { 0 }, reply: Value::Null }- }-- #[test]- fn a_failed_pull_is_flagged_with_its_remote_path_not_a_local_file() {- let dir = std::env::temp_dir().join(format!("aiflow-clipstop-a-{}", std::process::id()));- let mut r = run_with_capture(&dir, json!({"recordingId": "rec-1", "step": "components", "visit": 1}));- let remote = "C:/Users/j/AppData/Local/Temp/adom-bridge-recordings/window-1.mp4";- assert_eq!(settle_clip_stop(&mut r, 0, &stop(Some(remote), false), &dir), None);- let c = &r.data["captures"][0];- assert!(c["file"].is_null() && c.get("file").is_some(), "stopped, with no local file");- assert_eq!((c["pullFailed"].clone(), c["remoteFile"].clone()), (json!(true), json!(remote)));- assert_eq!(clip_stop_event(json!({}), &stop(Some(remote), false))["pullFailed"], true);- // the pull that lands clears the flag and names the local file- std::fs::write(dir.join("window-1.mp4"), b"mp4").unwrap();- let f = settle_clip_stop(&mut r, 0, &stop(Some(remote), true), &dir).unwrap();- assert!(f.ends_with("window-1.mp4"));- assert!(r.data["captures"][0].get("pullFailed").is_none());- let _ = std::fs::remove_dir_all(&dir);- }-- #[test]- fn a_second_stop_never_blanks_the_first_stops_file() {- let dir = std::env::temp_dir().join(format!("aiflow-clipstop-b-{}", std::process::id()));- let local = dir.join("window-2.mp4").display().to_string();- let mut r = run_with_capture(&dir, json!({"recordingId": "rec-2", "step": "placement", "visit": 1, "stop": "2026-09-29T02:43:48Z", "file": local, "remoteFile": "C:/rec/window-2.mp4"}));- std::fs::write(&local, b"mp4").unwrap();- // the recorder answers "nothing running": no path- assert_eq!(settle_clip_stop(&mut r, 0, &stop(None, false), &dir), Some(local.clone()));- let c = &r.data["captures"][0];- assert_eq!((c["file"].as_str(), c["remoteFile"].as_str(), c["stop"].as_str()), (Some(local.as_str()), Some("C:/rec/window-2.mp4"), Some("2026-09-29T02:43:48Z")));- // a failed re-pull of the same file keeps the good local copy- settle_clip_stop(&mut r, 0, &stop(Some("C:/rec/window-2.mp4"), false), &dir);- assert_eq!(r.data["captures"][0]["file"].as_str(), Some(local.as_str()));- assert!(r.data["captures"][0].get("pullFailed").is_none());- // a first stop with no path still marks the capture stopped- let mut r = run_with_capture(&dir, json!({"recordingId": "rec-3", "step": "nets", "visit": 1}));- settle_clip_stop(&mut r, 0, &stop(None, false), &dir);- assert!(r.data["captures"][0].get("file").is_some());- let _ = std::fs::remove_dir_all(&dir);- }-}--#[cfg(test)]-mod media_tools_tests {- use super::*;-- #[test]- fn a_missing_ffprobe_is_an_error_naming_it_not_a_blank_clip() {- let e = media_tools_missing(&["aiflow-no-such-ffprobe"]).unwrap_err();- assert!(e.starts_with("aiflow-no-such-ffprobe is not installed"), "{e}");- let e = clip_verdict("/nonexistent/clip.mp4", &["aiflow-no-such-ffprobe", "ffmpeg"]).unwrap_err();- assert!(e.contains("aiflow-no-such-ffprobe") && !e.contains("no frames"), "{e}");- }-- #[test]- fn a_clip_is_left_unjudged_when_the_tools_are_missing() {- let dir = std::env::temp_dir().join(format!("aiflow-media-tools-{}", std::process::id()));- let _ = std::fs::remove_dir_all(&dir);- std::fs::create_dir_all(&dir).unwrap();- let file = dir.join("placement-1.mp4").display().to_string();- std::fs::write(&file, b"not really a video").unwrap();- let mut r = Run { dir: dir.clone(), data: json!({"captures": [{"file": file, "step": "placement", "visit": 1}]}) };- clip_artifacts_with(&mut r, &json!("placement-1"), &file, 10, &["aiflow-no-such-ffprobe"]);- assert!(r.data["captures"][0].get("suspect").is_none(), "a missing tool must not flag the footage");- let events: Vec<String> = r.ledger().iter().filter_map(|e| e["event"].as_str().map(str::to_string)).collect();- assert!(events.contains(&"clip-check-unavailable".to_string()) && !events.contains(&"clip-suspect".to_string()), "{events:?}");- assert!(blank_check_verdict("no frames") && !blank_check_verdict("rejected by the AI: covered"));- let _ = std::fs::remove_dir_all(dir);- }-}--#[cfg(test)]-mod compose_out_tests {- use super::*;-- #[test]- fn out_resolves_against_the_working_directory_and_defaults_into_the_run() {- let (run, cwd) = (Path::new("/p/aiflow/run-1mm"), Path::new("/p/aiflow"));- assert_eq!(compose_out_path(None, run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));- // issue #80: run-1mm/final.mp4 from /p/aiflow is the run's own final.mp4, not run-1mm/run-1mm/final.mp4- assert_eq!(compose_out_path(Some("run-1mm/final.mp4"), run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));- assert_eq!(compose_out_path(Some("cut.mp4"), run, cwd), PathBuf::from("/p/aiflow/cut.mp4"));- assert_eq!(compose_out_path(Some("/v/cut.mp4"), run, cwd), PathBuf::from("/v/cut.mp4"));- }-- #[test]- fn the_failure_names_the_output_and_ffmpeg_last_lines() {- let stderr = (1..=20).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n") + "\n/p/run-1mm/run-1mm/final.mp4: No such file or directory\n";- let e = compose_error("ffmpeg could not assemble the video and its narration", Path::new("/p/run-1mm/run-1mm/final.mp4"), &stderr);- assert!(e.contains("(output /p/run-1mm/run-1mm/final.mp4)"), "{e}");- assert!(e.contains("No such file or directory") && e.contains("line 20") && !e.contains("line 12\n"), "{e}");- let e = compose_error("x", Path::new("/o.mp4"), " \n");- assert!(e.contains("(ffmpeg printed nothing)"), "{e}");- // a long line keeps its head and tail- let long = format!("{}MIDDLE{}", "a".repeat(1000), "z".repeat(1000));- let e = compose_error("x", Path::new("/o.mp4"), &long);- assert!(e.contains(" ... ") && e.contains("aaaa") && e.ends_with("zzzz"), "{e}");- }-- #[test]- fn a_failed_ffmpeg_keeps_its_stderr() {- assert!(!ffmpeg_ok(std::process::Command::new("sh").args(["-c", "echo first >&2; echo 'Error opening output file' >&2; exit 1"])));- let kept = FFMPEG_LAST_ERROR.with(|l| l.borrow().clone());- assert!(kept.contains("Error opening output file"), "{kept}");- assert!(ffmpeg_ok(std::process::Command::new("sh").args(["-c", "exit 0"])));- }-}+ /// The Altium side of a project: `library` (one .SchLib/.PcbLib from the parts' wiki pages or the board's project library, 3D bound, Mouser/DigiKey/LCSC numbers checked), `reference` (placement and spec from a reference KiCad board, in the Altium board's frame), `schematic` (the schematic drawn from the netlist in a project, compiled and checked pin by pin, its symbols and pin types checked, then the board in the same project), `symbols` (that symbol check again on the drawn sheet), `board` (the board alone, built from the netlist, every pin on its net), `reset`, `view3d`. `adom-aiflow altium <step> --help` for each.+ Altium { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> },+ /// The KiCad lane's export step: Gerbers, drill, pick and place (CPL) and BOM written from the board with the native kicad-cli (ADOM_AIFLOW_KICAD_CLI, else kicad-cli; adom-aiflow-kicad-cli-remote in a container), then re-read against the board (copper layer count, drill hits against vias and drilled pads, CPL and BOM references, sides and top rotations). Records the `export` proof only when every file matches. Fusion: `fusion export`.+ Export { /// the board (default: the run's current board)+ #[arg(long)] board: Option<String>, /// where the set goes (default: <run>/manufacturing)+ #[arg(long)] out: Option<PathBuf> },+ /// The board twin's native STEP export (kicad-cli pcb export step with models, tracks, pads, zones, mask and silkscreen; never fewer layers). Its output is kept and classified (geometry, model, other) apart from the exit status; geometry warnings block `board-glb copper` and `deliver` until `step-export --ack --why` after a review of the STEP. --log classifies the output of an export run elsewhere (the desktop).+ StepExport { /// the board (default: the run's current board)+ #[arg(long)] board: Option<String>, /// the STEP to write (default: <run>/twin/<board>.step), or with --log the STEP that export wrote+ #[arg(long)] out: Option<PathBuf>, /// classify this saved export output instead of running kicad-cli+ #[arg(long)] log: Option<PathBuf>, /// record that the geometry warnings of the last export were reviewed (needs --why)+ #[arg(long)] ack: bool, #[arg(long)] why: Option<String> },+ /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay+ Unroute { #[arg(long, required = true)] net: Vec<String> },+ /// KiCad DRC offline on the routing plan+ Gate { #[arg(long)] plan: Option<String> },+ /// Replay live through the bridge: route, vias, pours, moves+ Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64, /// route/vias: start at this trace number (1-based, as the progress lines count); traces already on the live board are skipped anyway+ #[arg(long)] from: Option<usize> },+ /// Verify existing saved filled zones against an explicit reviewed plan, without landing or claiming ownership (--adopt --why takes ownership; --record-plan records a reviewed plan before the zones change)+ VerifyPours {+ /// the zone plan: the one `pour` wrote, or a reviewed plan recorded with --record-plan before the zones changed+ #[arg(long)] plan: String,+ /// take ownership of the matched zones (needs --why); recorded as adopted, not landed+ #[arg(long)] adopt: bool,+ /// reason for --adopt, or for accepting a plan whose provenance is not established; recorded with the proof+ #[arg(long)] why: Option<String>,+ /// only record this reviewed plan's hash now, before the zones it describes change+ #[arg(long)] record_plan: bool,+ },+ /// Filled copper per layer from the live board+ Measure,+ /// current or thermal. `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited peak over the Fields density screen (repeat --ack for more); it covers only that place (the pad or 0.5 mm), keeps the number reported and expires when the copper fingerprint or the spec changes. spec.boardSink declares a board-face heat sink for named parts (thermal).+ Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf>, #[arg(long)] ack: Vec<String>, #[arg(long)] why: Option<String> },+ /// The finish line; refuses until every check passes+ Finish,+ /// Record a change to the spec's budgets (loads, hot, rise limits, switch-node length, net widths, fab) after placement began. Budgets are frozen at placement; this is the only way to move one, and it stays on the record.+ SpecAmend { #[arg(long)] why: String },+ /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.+ Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },+ /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.+ Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message)+ #[arg(long)] accept_suspect: bool },+ /// Check explicit project hero/datasheet bindings, README screenshots and optional published API readback. Still requires rendered visual review.+ ProjectPageCheck { #[arg(long)] project: String, #[arg(long)] live_json: Option<String> },+ /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/, --refresh reloads it in the human's browser. Runs by itself at every step change and at deliver once a page is set.+ Report { #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)+ #[arg(long)] surface: Option<String> },+ /// Record an issue or PR your AI filed back on adom/adom-aiflow (or any page) during this run; deliver counts them.+ Giveback { url: String, #[arg(long)] kind: Option<String>, #[arg(long)] note: Option<String> },+ /// Re-cut every clip of the run (the 10x cut and the motion-only action cut with the real-time clock), for runs recorded before a cut existed or after the cut rules improved.+ Recut,+ /// Compose the run's final video from the raw clips: only the frames where something moved, in order, paced so the whole video fits --max-seconds (2 minutes by default); the AI's analysis drawings as fullscreen shots; the narration cut to fit each segment, never the other way round. Replaces the delivered video when the run is already delivered.+ Compose { /// where to write the video: without --out, final.mp4 in the run directory; a relative --out is resolved against the current working directory (like every other path flag), and its folder is created+ #[arg(long)] out: Option<String>, /// the longest the final video may be, in seconds+ #[arg(long, default_value_t = 120.0)] max_seconds: f64, /// seconds a fullscreen analysis drawing stays on screen+ #[arg(long, default_value_t = 5.0)] still_seconds: f64, /// another run to put beside this one: the side-by-side, this run on the left, step by step in flow order, both runs' own clocks and drawings+ #[arg(long)] with: Option<PathBuf>, /// for --with: a narration script (JSON: {"<step>": "words", "drawings:<step>": "words", "drawings:<step>:<n>": "words"}); the words lead and each segment's picture is paced to them, so there is no dead air+ #[arg(long)] script: Option<PathBuf> },+ /// Register a file the AI made during the current step (an analysis drawing, a plot): it goes on the run page under the step and, for images, into the final video as a fullscreen shot at that point of the step.+ Artifact { #[arg(long)] file: String, #[arg(long, default_value = "analysis-image")] kind: String, #[arg(long)] caption: String, #[arg(long)] step: Option<String>, /// an earlier artifact (its file path) this one replaces: the redone drawing takes its place on the page and in the video+ #[arg(long)] replaces: Option<String>, #[arg(long)] review: Option<PathBuf> },+ /// Snapshot the engine's plan usage (percent used per limit window) into the ledger now; the binary also does this at start, every step, finish and deliver.+ Usage,+ /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement. Also checks every bound model is right way up (step2glb bbox after the footprint's rotate/offset); warnings must be fixed or acknowledged (--ack/--why) before finish.+ Models { /// only the offline 3D orientation check (no live kicad_model_check through the bridge)+ #[arg(long)] offline: bool, /// acknowledge orientation warnings for these references (comma separated) after looking at them in the native 3D viewer+ #[arg(long)] ack: Option<String>, /// why the acknowledged models are right as they are+ #[arg(long)] why: Option<String> },+ /// Walkthroughs on camera: `tour 3d` opens the 3D Viewer and runs the scripted tour as this step's clip; `tour nets` selects each key net (loads, wide nets, Kelvin) so the editor highlights its pours and traces together, frames it, and marks the clip.+ Tour { what: String, #[arg(long)] nets: Option<String>, /// for `tour fields`: the port adom-fields serves on in this container+ #[arg(long, default_value_t = 8874)] port: u16, /// for `tour fields`: the desktop whose pup window is filmed (default: the run's test box)+ #[arg(long)] on: Option<String> },+ /// Print the append-only ledger (run.jsonl): every turn with its thinking and tool seconds, every step visit and return, every stage and outcome, in order.+ Ledger { #[arg(long)] json: bool },+ /// open, start, mark <label>, stop+ Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String> },+ /// The comparison page: a sub-README for this run and another (`compose --with` first), the side-by-side video first, then the numbers from both ledgers, the AIs' drawings side by side and the links to both run pages; --push puts it on the page under docs/comparisons/<a>-vs-<b>/+ Compare { #[arg(long)] with: PathBuf, #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool },+ /// Where the run stands+ Status,+ /// A new human prompt on this run (a follow-up: "add the walkthroughs", "recut the video"): the AI's time for it counts from here to its `done`. --at is the paste time when you start late.+ Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String> },+ /// Stop session accounting after the requested scope is complete or a genuine blocking handoff. This does not complete the engineering run.+ Done { #[arg(long)] message: String,+ /// JSON evidence of an explicit user pause or an unavoidable external dependency. Never a gate waiver.+ #[arg(long)] stop_evidence: Option<PathBuf> },+ /// Read-only pre-final check. Exit 2 means continue working; exit 0 means delivered or an evidenced stop. Does not restart an AI runtime.+ FinishGuard { #[arg(long)] json: bool, #[arg(long)] stop_evidence: Option<PathBuf> },+ /// The run as sessions: each human prompt, the AI's active time on it, the idle gaps cut out (a gap over --idle-minutes with no command running is unobserved activity, not proven human idle).+ Sessions { #[arg(long)] json: bool },+}++static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();+static DASHBOARD_ATTEMPTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();+static DASHBOARD: std::sync::OnceLock<(PathBuf, String)> = std::sync::OnceLock::new();++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::sequence_key(dir, &r.data)));+ sequence::write_timeline(dir, &sequence::timeline(dir, &r.data, &flow, &seq, &r.step_report()), TIMELINE_PAGE);+ }+}++fn ensure_dashboard(dir: &Path, ai_thread: &str, force_show: bool) {+ if Run::open(dir).is_none() || std::env::var("AIFLOW_DASHBOARD").as_deref() == Ok("off") { return; }+ let _ = DASHBOARD_ATTEMPTED.set(());+ refresh_dashboard(dir);+ if let Err(e) = dashboard::ensure(dir, ai_thread, None, force_show) {+ eprintln!("Dashboard unavailable: {e}. Board results are unchanged. Retry `adom-aiflow timeline open --run {}` when Hydrogen is available.", dir.display());+ }+}+++/// Close the ledger entry for this command (see Run::turn_begin).+fn close_turn(ok_: bool, first_line: &str) {+ if let Some((dir, idx)) = TURN.get() {+ if let Some(mut r) = Run::open(dir) {+ r.turn_end(*idx, ok_, first_line);+ let _ = r.save();+ }+ }+ if let Some((dir, ai_thread)) = DASHBOARD.get() {+ // Intake/start create the run during dispatch; existing commands refresh after completion.+ if DASHBOARD_ATTEMPTED.get().is_none() { ensure_dashboard(dir, ai_thread, false); }+ else { refresh_dashboard(dir); }+ }+}++fn ok(msg: &str, hints: &[String]) {+ close_turn(true, msg.lines().next().unwrap_or(""));+ println!("OK: {msg}");+ for h in hints {+ if !h.is_empty() {+ println!("Hint: {h}");+ }+ }+}++/// Where this command's input is described (issue #56: the skill file by absolute path and its+/// section), set once in main and printed under every ERROR of the command.+static INPUT_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new();++fn err(msg: &str, hints: &[String]) -> ! {+ close_turn(false, msg.lines().next().unwrap_or(""));+ println!("ERROR: {msg}");+ for h in hints {+ if !h.is_empty() {+ println!("Hint: {h}");+ }+ }+ if let Some(h) = INPUT_HINT.get() { println!("Hint: {h}"); }+ std::process::exit(2)+}++fn thread(cli: &Cli) -> String {+ cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()]))+}++fn run_dir(cli: &Cli) -> PathBuf {+ cli.run.clone().unwrap_or_else(|| std::env::var("AIFLOW_RUN").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("./aiflow-run")))+}++fn load_run(cli: &Cli) -> Run {+ let d = run_dir(cli);+ if let Some(r) = Run::open(&d) {+ if let Some(b) = r.data["remoteBoard"].as_str() { aiflow_bridge::eda::set_run_board(b); }+ // the run's desktop is where a remote KiCad runs, unless the human named another+ if let (Some(t), Err(_)) = (r.data["target"].as_str(), std::env::var("KICAD_REMOTE_TARGET")) { std::env::set_var("KICAD_REMOTE_TARGET", t); }+ return r;+ }+ Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()]))+}++fn read_json(p: &str) -> Value {+ serde_json::from_str(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{p}: not JSON: {e}"), &[]))+}++/// The `start` hint for the design-rule card (rules_card.rs).+fn rules_card_hint(flags: &[String]) -> String {+ if flags.is_empty() { "Design-rule card: every spec size is inside the fab lane's range (drawn to scale on the dashboard; rules-card.json). It changes no gate.".into() }+ else { format!("Design-rule card: {} value(s) outside the fab lane's range (the WARNING lines above; rules-card.json and the dashboard). Fix the spec before placement if it is a unit slip; the card changes no gate.", flags.len()) }+}++fn spec_of(r: &Run) -> Value {+ if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); }+ let spec = read_json(r.data["spec"].as_str().unwrap_or(""));+ guard_budgets(r, &spec);+ spec_nets::align(spec, &r.current_board())+}++/// Once placement has begun the spec's budgets are frozen (`spec_freeze`): freeze them on the first read, and+/// refuse a spec whose budgets moved since without a `spec-amend` on the record.+fn guard_budgets(r: &Run, spec: &Value) {+ let placed = r.data["steps"]["placement"]["visits"].as_array().map(|a| !a.is_empty()).unwrap_or(false);+ if !placed { return; }+ let Some(rec) = spec_freeze::read(&r.dir) else {+ let step = r.data["currentStep"].as_str().unwrap_or("").to_string();+ let rec = spec_freeze::frozen(spec, &now(), &step);+ spec_freeze::write(&r.dir, &rec);+ r.log("spec-budgets-frozen", json!({"step": step, "budgets": rec["budgets"]}));+ return;+ };+ let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(spec));+ if changed.is_empty() { return; }+ err(&format!("the spec's budgets changed after placement began ({} change(s)): {}. The board changes to meet the budgets; the budgets do not move to meet the board", changed.len(), changed.join("; ")), &[+ format!("Put the spec back (the frozen values are in {}) and fix the board: more copper, vias, a better placement.", r.dir.join(spec_freeze::FILE).display()),+ "If a budget really is wrong (a misread datasheet value), amend it on the record: `adom-aiflow spec-amend --why \"<datasheet page and what was misread>\" --ai-thread <you>`. Every amendment shows in the run ledger and the finish summary.".into(),+ ]);+}++/// The spec when there is one, Null before the board (an intake run) or when it does not read.+fn spec_soft(r: &Run) -> Value {+ let spec = r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+ spec_nets::align(spec, &r.current_board())+}++/// Issue #75: a switching converter whose spec lacks Fields' `voltages`/`sources`/`switching` (or whose+/// Fields report modelled no converter). Warns (stderr WARNING and a hint) where the spec is read early;+/// `tour fields` and finish refuse on the same check.+fn converter_warning(data: &Value, spec: &Value, fields: Option<&Value>, board: Option<&Board>) -> Vec<String> {+ let calcs = converter::calcs_of_run(data);+ let Some((msg, hints)) = converter::check(spec, calcs.as_ref(), fields, board) else { return Vec::new() };+ eprintln!("WARNING: {msg}. The fields step will refuse until the spec carries the block and the Fields solve models the converter.");+ let mut out = vec![format!("Converter spec for Adom Fields: {msg}; the fields step refuses until it is fixed.")];+ out.extend(hints);+ out+}++/// requirements.json (aiflow-intake) when the run knows one, else Null.+fn requirements_of(r: &Run) -> Value {+ r.data["requirements"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)+}++/// The page the clips go to: `clips --page`, else the run's page (`start --page`, `report --page`).+fn clips_page_of(r: &Run) -> Option<String> {+ r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string)+}++/// Rebuild docs/clips.md in <run>/clips-page and, with `push`, put it and the changed clip media+/// on the page (`adom-wiki repo push --files`, batches of about 20 MB). Refuses to push a page+/// with an em-dash or a match of $AIFLOW_BANNED_RE. Returns (what happened, the files).+fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String, Vec<String>), String> {+ let notes: Value = std::fs::read_to_string(dir.join("clip-notes.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+ let page_ref = r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string);+ let (md, media) = preboard::clips_page(&r.data, dir, ¬es, &now(), page_ref.as_deref());+ let stage = dir.join("clips-page");+ std::fs::create_dir_all(stage.join("docs").join("clips")).map_err(|e| format!("{}: {e}", stage.display()))?;+ // docs/clips.md belongs to the first run that published it (a hidden marker says which). A later+ // run on the same page writes docs/clips-<run>.md and adds one link to it in docs/clips.md,+ // so a second run never overwrites the first run's clips.+ let id = r.data["reportId"].as_str().map(str::to_string).unwrap_or_else(|| report_id(r));+ let marker = format!("<!-- aiflow-clips-run: {id} -->");+ let remote = std::process::Command::new("adom-wiki").args(["repo", "show", page, "docs/clips.md"]).output().ok()+ .filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.trim_start().starts_with('#') || t.contains("aiflow-clips-run"));+ let owner = match &remote { None => true, Some(t) => t.contains(&marker) };+ let doc = if owner { "docs/clips.md".to_string() } else { format!("docs/clips-{id}.md") };+ let md = if owner { format!("{marker}\n{md}") } else { format!("{marker}\n{}", md.replacen("# Build clips", &format!("# Build clips: run {id}"), 1)) };+ std::fs::write(stage.join(&doc), &md).map_err(|e| e.to_string())?;+ let mut index_update: Option<String> = None;+ if let (false, Some(t)) = (owner, &remote) {+ let link = format!("clips-{id}.md");+ if !t.contains(&link) {+ let line = format!("Clips of a later run on this page: [run {id}]({link}).");+ let mut lines: Vec<&str> = t.lines().collect();+ let at = lines.iter().position(|l| l.starts_with("# ")).map(|i| i + 1).unwrap_or(0);+ lines.insert(at, ""); lines.insert(at + 1, &line);+ let updated = lines.join("\n") + "\n";+ std::fs::write(stage.join("docs").join("clips.md"), &updated).map_err(|e| e.to_string())?;+ index_update = Some(updated);+ }+ }+ for (src, rel) in &media {+ let dest = stage.join(rel);+ let same = std::fs::metadata(&dest).ok().zip(std::fs::metadata(src).ok()).map(|(a, b)| a.len() == b.len()).unwrap_or(false);+ if !same { std::fs::copy(src, &dest).map_err(|e| format!("copy {}: {e}", src.display()))?; }+ }+ let clips = md.matches("\n### Clip ").count();+ let mut files: Vec<String> = vec![doc.clone()];+ if index_update.is_some() { files.push("docs/clips.md".into()); }+ files.extend(media.iter().map(|(_, rel)| rel.clone()));+ let banned = std::env::var("AIFLOW_BANNED_RE").ok();+ let scrubbed = preboard::scrub(&md, banned.as_deref());+ let url = format!("https://wiki.adom.inc/{page}/files/{doc}");+ if !push {+ return Ok((format!("{doc} rebuilt with {clips} clip(s) and {} media file(s) in {} (dry run, nothing pushed; {}); {url} after --push", media.len(), stage.display(), match &scrubbed { Ok(()) => "the scrub passes".to_string(), Err(e) => format!("a push would be refused: {e}") }), files));+ }+ scrubbed.map_err(|e| format!("not pushed: {e}"))?;+ // only what changed since the last push goes up (docs/clips.md always), in batches the gateway takes+ let cache_path = stage.join(".pushed.json");+ let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+ let mut todo: Vec<(String, u64, String)> = Vec::new();+ for f in &files {+ let p = stage.join(f);+ let sum = library_review::hash(&p).unwrap_or_default();+ if f == &doc || f == "docs/clips.md" || cache.get(f).and_then(|v| v.as_str()) != Some(sum.as_str()) { todo.push((f.clone(), std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0), sum)); }+ }+ let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];+ let mut acc = 0u64;+ for f in &todo {+ if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }+ batches.last_mut().unwrap().push(f); acc += f.1;+ }+ let (mut pushed, mut failed) = (0usize, Vec::new());+ for batch in batches.iter().filter(|b| !b.is_empty()) {+ let mut cmd = std::process::Command::new("adom-wiki");+ cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("clips: {clips} clip(s), {}", r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);+ for f in batch { cmd.arg(&f.0); }+ match cmd.output() {+ Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }+ Ok(o) => failed.push(format!("{} file(s): {}", batch.len(), aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 160))),+ Err(e) => failed.push(format!("{} file(s): {e}", batch.len())),+ }+ }+ let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());+ r.log("clips-page", json!({"page": page, "clips": clips, "pushed": pushed, "unchanged": files.len() - todo.len(), "failed": failed}));+ if !failed.is_empty() { return Err(format!("push failed for {}; the next clip stop (or `clips --push`) sends it again", failed.join("; "))); }+ Ok((format!("clips page pushed: {clips} clip(s), {pushed} changed file(s) ({} unchanged skipped): {url}", files.len() - todo.len()), files))+}++/// After a clip stops: when the run has a page, rebuild and push the clips page. Best effort: a+/// failure is said, never a failed step.+fn auto_clips(r: &Run, dir: &Path) -> String {+ if std::env::var("AIFLOW_AUTO_PUBLISH_CLIPS").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false) {+ return "Automatic raw-clip publication disabled; publish reviewed, narrated clips explicitly.".into();+ }+ let Some(pg) = clips_page_of(r) else { return String::new() };+ match publish_clips(r, dir, &pg, true) { Ok((s, _)) => s, Err(e) => format!("clips page: {e}") }+}++/// The user's primary machine (`adom-bridge targets` names it).+fn primary_machine() -> Option<String> {+ let o = std::process::Command::new("adom-bridge").arg("targets").output().ok()?;+ preboard::primary_from_targets(&serde_json::from_slice::<Value>(&o.stdout).ok()?)+}++/// The board's own copper as plan-shaped entries (one per net and track width, one per via), so the+/// analyses see a board whose copper is already on it (an adopted, hand-reworked or natively routed+/// board) and not only what a routing plan adds.+fn board_copper_plan(b: &Board) -> Value {+ let net_of = |n: &aiflow_board::sx::Node| -> String {+ let Some(x) = n.find("net") else { return String::new() };+ match x.number(1) { Some(v) => b.net_table.get(&(v as i64)).cloned().unwrap_or_default(), None => x.atom(1).unwrap_or("").to_string() }+ };+ let mut entries: Vec<Value> = Vec::new();+ let mut seen: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();+ for s in b.root.find_all("segment") {+ let (net, w) = (net_of(s), s.find("width").and_then(|w| w.number(1)).unwrap_or(0.0));+ if net.is_empty() || w <= 0.0 { continue; }+ if seen.insert((net.clone(), format!("{w:.4}"))) { entries.push(json!({"net": net, "width": w, "source": "board"})); }+ }+ for v in b.root.find_all("via") {+ let net = net_of(v);+ if net.is_empty() { continue; }+ let at = v.find("at");+ let (x, y) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0));+ let drill = v.find("drill").and_then(|d| d.number(1)).unwrap_or(0.3);+ entries.push(json!({"net": net, "viaDrill": drill, "paths": [[{"layer": "B.Cu", "x": x, "y": y}]], "source": "board"}));+ }+ json!({"nets": entries})+}++/// The plan, plus the board's own copper for every net the plan carries no copper for.+fn plan_with_board_copper(plan: &Value, b: &Board) -> Value {+ let mut entries: Vec<Value> = plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default();+ let planned: std::collections::BTreeSet<String> = entries.iter().filter_map(|e| e.get("net").and_then(|n| n.as_str()).map(str::to_string)).collect();+ for e in board_copper_plan(b)["nets"].as_array().cloned().unwrap_or_default() {+ if !planned.contains(e["net"].as_str().unwrap_or("")) { entries.push(e); }+ }+ json!({"nets": entries})+}++fn pad_layers_of(b: &Board) -> BTreeMap<String, Vec<String>> {+ let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();+ for p in &b.pads {+ if p.thru || p.net.is_empty() {+ continue;+ }+ let l = if p.on_f { "F.Cu" } else { "B.Cu" };+ let e = m.entry(p.net.clone()).or_default();+ if !e.iter().any(|x| x == l) {+ e.push(l.to_string());+ }+ }+ m+}++/// Zone uuids of the board the run started from (the copy `start` made): those are inherited;+/// every other copper or keepout zone on the live board is the flow's own.+fn inherited_zone_ids(r: &Run) -> Vec<String> {+ let Some(p) = r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()) else { return Vec::new() };+ let text = std::fs::read_to_string(p).unwrap_or_default();+ let mut ids = Vec::new();+ let mut from = 0;+ while let Some(i) = text[from..].find("(zone") {+ let start = from + i;+ let end = text[start..].find("(polygon").map(|e| start + e).unwrap_or(text.len());+ if let Some(u) = text[start..end].find("(uuid \"") {+ let s = start + u + 7;+ if let Some(e) = text[s..].find('"') {+ ids.push(text[s..s + e].to_string());+ }+ }+ from = start + 5;+ }+ ids+}++/// The per-step ledger as text: wall, thinking, binary, turns, visits, returns.+fn step_table(r: &Run) -> String {+ let rep = r.step_report();+ let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];+ let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();+ for k in rep.keys() {+ if !names.contains(k) {+ names.push(k.clone());+ }+ }+ let mut lines = vec![format!("{:<11} {:>9} {:>9} {:>9} {:>6} {:>7} {:>8} {}", "step", "wall min", "think min", "tool min", "turns", "visits", "returns", "longest think")];+ let (mut tw, mut tt, mut tb, mut tn) = (0u64, 0u64, 0u64, 0u64);+ for n in names {+ let e = &rep[&n];+ let (w, t, b, k) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0), e["turns"].as_u64().unwrap_or(0));+ tw += w; tt += t; tb += b; tn += k;+ lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8} {:.1} min", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+ }+ lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6}", "total", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0, tn));+ lines.join("\n")+}++++/// Free megabytes on the drive holding the run directory.+fn local_free_mb(dir: &Path) -> Option<u64> {+ let out = std::process::Command::new("df").args(["-Pk", dir.to_str().unwrap_or(".")]).output().ok()?;+ let text = String::from_utf8_lossy(&out.stdout);+ let line = text.lines().last()?;+ line.split_whitespace().nth(3)?.parse::<u64>().ok().map(|k| k / 1024)+}++/// The recording budget for a step: measured window captures run about 8 MB per minute of+/// changing screen (H.264 on the box's GPU encoder, frames only when the window changes), so the+/// budget is the flow's expected minutes for the step at 10 MB per minute, doubled for the pull+/// and the 10x cut. Refuses to start a clip when either side has less than the budget plus 1 GB.+fn clip_budget_mb(step: &str) -> u64 {+ let minutes = match step { "placement" => 30, "routing" => 20, "pours" => 8, "intake" => 5, _ => 3 };+ minutes * 10 * 2+}++fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> {+ let need = clip_budget_mb(step);+ let local = local_free_mb(dir);+ let remote = br.remote_free_mb();+ let floor = need + 1024;+ let mut msgs = Vec::new();+ if let Some(l) = local {+ if l < floor {+ return Err(format!("the run directory's drive has {l} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. Free space (old runs' raw clips, `*.mp4` without the 10x suffix, are the usual culprit) before starting the clip"));+ }+ msgs.push(format!("run drive {l} MB free"));+ }+ if let Some(rm) = remote {+ if rm < floor {+ return Err(format!("the test box's recording drive has {rm} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. ab prunes its recordings folder after 2 days / 40 files / 3 GB, but not mid-run: clear it or free the drive before starting the clip"));+ }+ msgs.push(format!("test box {rm} MB free"));+ }+ Ok(format!("{} for a {step} clip budgeted at {need} MB", msgs.join(", ")))+}+++/// The Adom faces, embedded so every caption on every machine is set in them.+fn run_fonts(dir: &Path) -> (String, String) {+ let fd = dir.join("fonts");+ let _ = std::fs::create_dir_all(&fd);+ let files: [(&str, &[u8]); 3] = [("FamiljenGrotesk-Bold.ttf", include_bytes!("../../../assets/fonts/FamiljenGrotesk-Bold.ttf")), ("Satoshi-Regular.ttf", include_bytes!("../../../assets/fonts/Satoshi-Regular.ttf")), ("Satoshi-Medium.ttf", include_bytes!("../../../assets/fonts/Satoshi-Medium.ttf"))];+ for (n, b) in files { let p = fd.join(n); if !p.is_file() { let _ = std::fs::write(&p, b); } }+ (fd.join("FamiljenGrotesk-Bold.ttf").display().to_string(), fd.join("Satoshi-Medium.ttf").display().to_string())+}++/// drawtext-safe text: no quotes, colons and commas escaped, percent doubled.+fn dt(t: &str) -> String {+ t.replace('\\', "").replace('\'', "").replace(':', "\\:").replace(',', "\\,").replace('%', "%%")+}+++/// 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), 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");+ c.args(["-s", "-m", "12", url]);+ for h in headers { c.args(["-H", h]); }+ let o = c.output().ok()?;+ serde_json::from_slice::<Value>(&o.stdout).ok()+ };+ if let Ok(t) = std::fs::read_to_string(format!("{home}/.claude/.credentials.json")) {+ if let Ok(c) = serde_json::from_str::<Value>(&t) {+ let oa = c.get("claudeAiOauth").cloned().unwrap_or(c.clone());+ if let Some(tok) = oa.get("accessToken").and_then(|v| v.as_str()) {+ if let Some(u) = curl("https://api.anthropic.com/api/oauth/usage", &[format!("Authorization: Bearer {tok}"), "anthropic-beta: oauth-2025-04-20".into(), "User-Agent: adom-aiflow".into()]) {+ for lim in u.get("limits").and_then(|l| l.as_array()).cloned().unwrap_or_default() {+ let Some(pct) = lim.get("percent").and_then(|p| p.as_f64()) else { continue };+ let label = match lim.get("kind").and_then(|k| k.as_str()) {+ Some("session") => "5h".to_string(),+ Some("weekly_all") => "week".to_string(),+ Some("weekly_scoped") => lim["scope"]["model"]["display_name"].as_str().unwrap_or("model").to_string(),+ Some(k) => k.to_string(),+ None => "limit".to_string(),+ };+ out.push(json!({"provider": "claude", "label": label, "pct": pct, "resetsAt": lim.get("resets_at").cloned().unwrap_or(Value::Null)}));+ }+ if out.is_empty() {+ for (label, key) in [("5h", "five_hour"), ("week", "seven_day")] {+ if let Some(v) = u.get(key).and_then(|x| x.get("utilization")).and_then(|x| x.as_f64()) { out.push(json!({"provider": "claude", "label": label, "pct": v})); }+ }+ }+ }+ }+ }+ }+ if let Ok(t) = std::fs::read_to_string(format!("{home}/.codex/auth.json")) {+ if let Ok(a) = serde_json::from_str::<Value>(&t) {+ let tk = a.get("tokens").cloned().unwrap_or(Value::Null);+ if let Some(tok) = tk.get("access_token").and_then(|v| v.as_str()) {+ let acct = tk.get("account_id").and_then(|v| v.as_str()).unwrap_or("");+ if let Some(u) = curl("https://chatgpt.com/backend-api/wham/usage", &[format!("Authorization: Bearer {tok}"), format!("ChatGPT-Account-Id: {acct}"), "User-Agent: adom-aiflow".into(), "Accept: application/json".into()]) {+ let rl = u.get("rate_limit").cloned().unwrap_or(Value::Null);+ let label = |w: &Value| -> String { let secs = w.get("limit_window_seconds").and_then(|v| v.as_f64()).unwrap_or(0.0); if secs >= 6.0 * 86400.0 { "week".into() } else if secs >= 4.0 * 3600.0 { "5h".into() } else { format!("{}h", ((secs / 3600.0).floor() as i64).max(1)) } };+ for key in ["primary_window", "secondary_window"] {+ if let Some(w) = rl.get(key).filter(|w| w.is_object()) {+ out.push(json!({"provider": "codex", "label": label(w), "pct": w.get("used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0), "plan": u.get("plan_type").cloned().unwrap_or(Value::Null)}));+ }+ }+ }+ }+ }+ }+ out+}++/// 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 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!([]); }+ 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"];+ // the run's own conversation: the one whose counters grew most over the run (the AI calls aiflow+ // from it); other Antigravity conversations on the machine (another project) are left out+ let (first, last) = (&snaps[0]["agyTokens"], &snaps[snaps.len() - 1]["agyTokens"]);+ let conv: Option<String> = last["byConversation"].as_object().and_then(|m| m.iter().map(|(id, c)| (id.clone(), c["calls"].as_i64().unwrap_or(0) - first["byConversation"][id]["calls"].as_i64().unwrap_or(0))).filter(|(_, d)| *d > 0).max_by_key(|(_, d)| *d).map(|(id, _)| id));+ let pick = |s: &Value| -> Value { match &conv { Some(id) => { let c = &s["byConversation"][id]; if c.is_object() { c.clone() } else { json!({"calls": 0, "freshIn": 0, "cachedIn": 0, "out": 0}) } } None => s.clone() } };+ let snaps: Vec<Value> = snaps.into_iter().map(|mut s| { s["agyTokens"] = pick(&s["agyTokens"]); s }).collect();+ 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, "conversation": conv}))+}++/// 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": (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(); }+ 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+}+++/// The overlay chain the final video draws on a linear-time (10x) cut: the step chip top left,+/// the engine and board top right, and in the lower right two small tables, "RUN, SINCE YOUR+/// PROMPT" at the far right and "THIS STEP" beside it, headers HR / MIN / SEC over big numbers,+/// the HR column only when the run passed an hour. Same type sizes and row lines, 16 px margins.+/// Drawn after the decimation with t = real seconds inside the clip; `offset` is the clip's start+/// since the prompt. Returns the full -vf string from the 10x cut to the captioned action cut.+/// The overlay filters of the approved layout (step chip top-left, engine and board top-right, the+/// run clock and the step clock lower-right); `offset` is the segment's start on the run clock,+/// `step_offset` its start on the step clock, both in seconds, both advancing with the frame time.+fn overlay_filters(dir: &Path, title: &str, engine_board: &str, offset: f64, step_offset: f64, run_has_hours: bool) -> String {+ let (font_display, font_text) = run_fonts(dir);+ let off = format!("{offset:.0}");+ let m = 16.0; let big = 26.0; let small = 11.0; let lab = 13.0; let pad = 12.0; let gap = 12.0; let colw = 38.0;+ let h_box = pad + 16.0 + 6.0 + 14.0 + 6.0 + big + pad;+ let run_secs = format!("(t+{off})");+ let (run_cols, run_w): (Vec<(String, String)>, f64) = if run_has_hours {+ (vec![("HR".into(), format!("%{{eif\\:{run_secs}/3600\\:d}}")), ("MIN".into(), format!("%{{eif\\:mod({run_secs}/60\\,60)\\:d\\:2}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 3.0 * colw + 2.0 * gap)+ } else {+ (vec![("MIN".into(), format!("%{{eif\\:{run_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 2.0 * colw + gap)+ };+ let run_inner = run_w.max(162.0);+ let step_secs = format!("(t+{step_offset:.0})");+ let step_cols: Vec<(String, String)> = vec![("MIN".into(), format!("%{{eif\\:{step_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({step_secs}\\,60)\\:d\\:2}}"))];+ let step_inner = (2.0 * colw + gap).max(62.0);+ let mut ov: Vec<String> = Vec::new();+ let run_box_w = run_inner + 2.0 * pad; let step_box_w = step_inner + 2.0 * pad;+ ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={run_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w, m + h_box));+ ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={step_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w + m + step_box_w, m + h_box));+ let y_lab = format!("h-{}", m + h_box - pad); let y_hdr = format!("h-{}", m + h_box - pad - 16.0 - 6.0); let y_num = format!("h-{}", m + h_box - pad - 16.0 - 6.0 - 14.0 - 6.0);+ let run_left = m + pad + run_inner; let step_left = m + run_box_w + m + pad + step_inner;+ ov.push(format!("drawtext=fontfile={ft}:text='AI TIME, THIS RUN':x=w-{run_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));+ ov.push(format!("drawtext=fontfile={ft}:text='THIS STEP':x=w-{step_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));+ for (k, (hdr, expr)) in run_cols.iter().enumerate() {+ let cx = format!("w-{}+{}", run_left, k as f64 * (colw + gap) + colw / 2.0);+ ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));+ ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));+ }+ for (k, (hdr, expr)) in step_cols.iter().enumerate() {+ let cx = format!("w-{}+{}", step_left, k as f64 * (colw + gap) + colw / 2.0);+ ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));+ ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));+ }+ ov.push(format!("drawtext=fontfile={fd}:text='{}':x={m}:y={m}:fontsize=24:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=10", dt(title), fd = font_display));+ ov.push(format!("drawtext=fontfile={ft}:text='{}':x=w-tw-{m}:y={m}:fontsize=15:fontcolor=0xD9E1E8:box=1:[email protected]:boxborderw=8", dt(engine_board), ft = font_text));+ ov.join(",")+}++/// The action cut with overlays: the 10x cut stretched back to real time (so the clocks are+/// honest), near-duplicate frames dropped, the overlays drawn, then the kept frames packed at+/// `rate` frames per second (4 is the pace of one step's clip; compose raises it to fit the budget).+fn overlay_chain(dir: &Path, title: &str, engine_board: &str, offset: f64, run_has_hours: bool, speed: u32, rate: f64) -> String {+ format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,{},setpts=N/{rate:.3}/TB", overlay_filters(dir, title, engine_board, offset, 0.0, run_has_hours))+}++/// Drops frames that are almost entirely black: the window recorder's first frame is one, and the+/// scripted pace can stretch a single frame across seconds of narration. Every frame is tagged with+/// its black fraction (amount=0), and only the ones under 95 percent black pass.+const DROP_BLACK: &str = "blackframe=amount=0:threshold=24,metadata=mode=select:key=lavfi.blackframe.pblack:value=95:function=less";++/// A step's display name for chips and cards.+fn step_shown(step: &str) -> String {+ match step { "3d" => "3D WALKTHROUGH".into(), "nets" => "NET WALKTHROUGH".into(), "models" => "3D MODELS".into(), "current" => "CURRENT ANALYSIS".into(), "thermal" => "THERMAL ANALYSIS".into(), other => other.to_uppercase() }+}+++/// A clip stopped twice loses its file on the second stop (the recorder answers "nothing running"),+/// though the ledger kept the first stop's file under the same recording id. Put it back.+fn recover_capture_files(r: &mut Run, dir: &Path) -> usize {+ let mut by_rid: std::collections::HashMap<String, String> = Default::default();+ for e in &r.ledger() {+ if e["event"] == "clip-stop" {+ if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) {+ let local = dir.join(Path::new(f).file_name().unwrap_or_default());+ if local.is_file() { by_rid.insert(rid.to_string(), local.display().to_string()); }+ }+ }+ }+ let mut n = 0;+ if let Some(caps) = r.data["captures"].as_array_mut() {+ for c in caps.iter_mut() {+ if c["file"].as_str().is_some() { continue; }+ if let Some(f) = c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned() { c["file"] = json!(f); n += 1; }+ }+ }+ if n > 0 { r.mark(&format!("recovered {n} clip file(s) from the ledger")); }+ n+}++/// Put one clip stop on its capture. The remote path and the local file are only ever filled in,+/// never blanked: a second stop of the same recording (the recorder answers "nothing running")+/// keeps what the first stop recorded. A clip whose pull did not land after the retries is+/// flagged `pullFailed` with its desktop path, its `file` stays null, and a WARNING names the+/// remote path; `recut`, `finish` and `deliver` pull it again. Returns the local file.+fn settle_clip_stop(r: &mut Run, pos: usize, stop: &ClipStop, dir: &Path) -> Option<String> {+ let cap = &mut r.data["captures"][pos];+ let had_file = cap["file"].as_str().map(str::to_string);+ if stop.remote.is_some() || cap.get("stop").is_none() { cap["stop"] = json!(now()); }+ match &stop.remote {+ Some(remote) => {+ cap["remoteFile"] = json!(remote);+ let kept = had_file.as_deref().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false);+ if stop.pulled {+ cap["file"] = json!(dir.join(Path::new(remote).file_name().unwrap_or_default()).display().to_string());+ if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }+ } else if !kept {+ cap["file"] = Value::Null;+ cap["pullFailed"] = json!(true);+ cap["pullAttempts"] = json!(stop.attempts);+ eprintln!("WARNING: clip {}-{} did NOT reach the run after {} pull(s): it is still on the desktop at {remote}. Run `adom-aiflow recut` to pull it again before the desktop cleans its recordings folder.", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1), stop.attempts);+ }+ }+ None => {+ if had_file.is_none() { cap["file"] = Value::Null; }+ if cap.get("remoteFile").is_none() { cap["remoteFile"] = Value::Null; }+ }+ }+ cap["file"].as_str().map(str::to_string)+}++/// The ledger line of a clip stop: `file` is the desktop path (what recover_capture_files reads),+/// `pullFailed` when the pull did not land.+fn clip_stop_event(mut ev: Value, stop: &ClipStop) -> Value {+ ev["file"] = json!(stop.remote);+ if stop.remote.is_some() && !stop.pulled { ev["pullFailed"] = json!(true); }+ ev+}++/// Pull again every stopped clip that is not in the run: flagged `pullFailed`, or recorded with a+/// local file that never arrived. The desktop path comes from the capture or, for older runs,+/// from the ledger's clip-stop lines. Returns (pulled now, still missing).+fn repull_failed_clips(r: &mut Run, br: &Bridge, dir: &Path) -> (usize, usize) {+ let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();+ let mut by_rid: std::collections::HashMap<String, String> = Default::default();+ for e in &r.ledger() {+ if e["event"] == "clip-stop" {+ if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) { by_rid.insert(rid.to_string(), f.to_string()); }+ }+ }+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let (mut pulled, mut missing) = (0, 0);+ for (i, c) in caps.iter().enumerate() {+ // running clips have no file key yet; discarded ones are never shown+ if c.get("file").is_none() || c.get("discarded").is_some() { continue; }+ if c["file"].as_str().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false) { continue; }+ let Some(remote) = c["remoteFile"].as_str().map(str::to_string).or_else(|| c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned()) else { continue };+ let local = dir.join(Path::new(&remote).file_name().unwrap_or_default()).display().to_string();+ let tag = format!("{}-{}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1));+ let landed = aiflow_bridge::nonempty(Path::new(&local)) || br.pull_verified(&remote, &save_to, aiflow_bridge::PULL_ATTEMPTS).0;+ let cap = &mut r.data["captures"][i];+ cap["remoteFile"] = json!(remote);+ if landed {+ cap["file"] = json!(local);+ cap["pulledLater"] = json!(now());+ if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }+ r.log("clip-pulled", json!({"step": tag, "recordingId": c["recordingId"], "file": remote}));+ clip_artifacts(r, &json!(tag), &local, 10);+ pulled += 1;+ } else {+ cap["pullFailed"] = json!(true);+ eprintln!("WARNING: clip {tag} is still not in the run: pulling {remote} from the desktop failed again (the desktop may have cleaned it). Re-record the step if it matters.");+ missing += 1;+ }+ }+ if pulled > 0 { r.mark(&format!("pulled {pulled} clip(s) that had not reached the run")); }+ (pulled, missing)+}++/// True when a STEP carries a laser-etched MPN part: `Adom.LaserEtch` (adom-aiflow-etch 0.1.48+, the+/// shared service) or the legacy `laser_etch` part of earlier releases.+fn has_mpn_mark(bytes: &[u8]) -> bool {+ bytes.windows(14).any(|w| w == b"Adom.LaserEtch") || bytes.windows(10).any(|w| w == b"laser_etch")+}++/// IC footprints (U*, IC*) whose bound 3D model is a local STEP without a laser-etched MPN part.+/// Models are found beside the source board (${KIPRJMOD}); an unresolvable path is left to `models`.+fn unmarked_ics(r: &Run) -> Vec<String> {+ let Ok(b) = Board::load(&r.current_board()) else { return vec![] };+ let proj = r.data["sourceBoard"].as_str().map(|p| Path::new(p).parent().map(|d| d.to_path_buf()).unwrap_or_default()).unwrap_or_default();+ let mut out = Vec::new();+ for fp in b.root.find_all("footprint") {+ let Some(reference) = fp.find_all("property").find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).map(str::to_string) else { continue };+ let is_ic = reference.starts_with("IC") || (reference.starts_with('U') && reference[1..].chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false));+ if !is_ic { continue; }+ for m in fp.find_all("model").filter_map(|n| n.value()) {+ let path = m.replace("${KIPRJMOD}", &proj.display().to_string());+ if !(path.to_ascii_lowercase().ends_with(".step") || path.to_ascii_lowercase().ends_with(".stp")) { continue; }+ match std::fs::read(&path) {+ Ok(bytes) => { if !has_mpn_mark(&bytes) { out.push(reference.clone()); } }+ Err(_) => {}+ }+ }+ }+ out+}++/// Stop the running clip of the current step, pull it, cut it. finish and deliver call this so+/// no recorder outlives the run (a clip left running once recorded nine hours of a static editor).+fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option<String> {+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let pos = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none())?;+ let rid = caps[pos]["recordingId"].as_str()?.to_string();+ let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();+ let stop = br.record_stop(&rid, &save_to);+ let local = settle_clip_stop(r, pos, &stop, dir);+ let cap = &mut r.data["captures"][pos];+ cap["stoppedBy"] = json!(why);+ let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+ r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": why}), &stop));+ if let Some(f) = local.clone().filter(|f| Path::new(f).is_file()) { clip_artifacts(r, &st, &f, 10); }+ let note = auto_clips(r, dir);+ if !note.is_empty() { eprintln!("{note}"); }+ local+}+++/// The tools every clip check and cut runs. Missing ones are an error naming the tool, never a verdict+/// on the footage (issue #48: with no ffprobe every clip read as "no frames" and compose dropped them all).+const MEDIA_TOOLS: &[&str] = &["ffprobe", "ffmpeg"];++/// Err names the first tool of `tools` that cannot be started.+fn media_tools_missing(tools: &[&str]) -> Result<(), String> {+ for t in tools {+ if let Err(e) = std::process::Command::new(t).arg("-version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status() {+ return Err(format!("{t} is not installed or not runnable ({e}); the clips cannot be checked, cut or composed without it"));+ }+ }+ Ok(())+}++/// The clip check: Err when the media tools are missing (the footage is not judged), else the blank verdict.+fn clip_verdict(file: &str, tools: &[&str]) -> Result<Option<String>, String> {+ media_tools_missing(tools)?;+ Ok(blank_check(file))+}++/// Verdicts that only the blank check gives; a later passing check (recut) clears them. An AI's own rejection stays.+fn blank_check_verdict(why: &str) -> bool {+ why == "no frames" || why == "could not read frames" || why.contains("sampled frames are one flat colour") || why.starts_with("truncated")+}++/// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a+/// painted canvas has content and an unpainted one is one flat colour) is read as 96 x 54 grey+/// pixels and binned; when one bin holds over 80 % of the pixels the frame is flat. More than+/// three quarters flat frames means the clip filmed nothing worth keeping. Returns the reason.+fn blank_check(file: &str) -> Option<String> {+ let secs = media_seconds(file);+ if secs <= 0.0 { return Some("no frames".into()); }+ let n = 8usize;+ let mut flat = 0usize; let mut total = 0usize; let mut moved = 0usize; let mut prev: Option<Vec<u8>> = None;+ for k in 0..n {+ let t = secs * (k as f64 + 0.5) / n as f64;+ let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;+ let px = out.stdout;+ if px.len() < 1000 { continue; }+ let mut bins = [0usize; 16];+ for b in &px { bins[(*b as usize) >> 4] += 1; }+ total += 1;+ if bins.iter().max().copied().unwrap_or(0) * 100 > px.len() * 80 { flat += 1; }+ // a mostly dark canvas that changes between samples is being worked on (a Fusion board is black+ // with the copper growing on it), not blank: count the samples that differ from the one before+ if let Some(q) = &prev {+ let changed = px.iter().zip(q.iter()).filter(|(a, b)| (**a as i32 - **b as i32).abs() > 16).count();+ if changed * 200 > px.len() { moved += 1; }+ }+ prev = Some(px);+ }+ if total == 0 { return Some("could not read frames".into()); }+ if flat * 4 > total * 3 && moved < 2 { Some(format!("{flat} of {total} sampled frames are one flat colour in the middle of the window (an unpainted or covered canvas)")) } else { None }+}++/// A contact sheet: nine frames spread through the clip on one PNG, so the AI looks at what it+/// filmed before it moves on, and the run page shows it under the clip.+fn contact_sheet(file: &str) -> Option<String> {+ let secs = media_seconds(file);+ if secs <= 0.0 { return None; }+ let out = format!("{}-sheet.png", file.trim_end_matches(".mp4"));+ let fps = 9.0 / secs;+ let ok = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", file, "-vf", &format!("fps={fps:.6},scale=426:-2,tile=3x3:padding=4:margin=4:color=0x0B1420"), "-frames:v", "1", &out]).status().map(|s| s.success()).unwrap_or(false);+ if ok && Path::new(&out).is_file() { Some(out) } else { None }+}++/// Seconds of pure black at the head of a clip (the recorder started before the window painted), up to five.+fn leading_black(file: &str) -> f64 {+ let out = std::process::Command::new("ffmpeg").args(["-v", "info", "-t", "6", "-i", file, "-an", "-vf", "blackdetect=d=0.05:pic_th=0.98:pix_th=0.10", "-f", "null", "-"]).output();+ let Ok(o) = out else { return 0.0 };+ let text = String::from_utf8_lossy(&o.stderr);+ for line in text.lines() {+ if let Some(i) = line.find("black_start:") {+ let rest = &line[i + 12..];+ let start: f64 = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(1.0);+ let end: f64 = line.find("black_end:").and_then(|j| line[j + 10..].split_whitespace().next()).and_then(|v| v.parse().ok()).unwrap_or(0.0);+ if start <= 0.05 && end > 0.0 && end <= 5.0 { return end; }+ break;+ }+ }+ 0.0+}++/// The clip's seconds, from ffprobe (0.0 when unknown).+fn media_seconds(path: &str) -> f64 {+ std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path]).output().ok().and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok()).unwrap_or(0.0)+}++/// Cut a sped-up copy of a pulled clip next to it (`<name>-10x.mp4`) and record both as artifacts+/// of the step. The window recorder only emits frames on change, so the cut is by timestamp.+fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) { clip_artifacts_with(r, step, file, speed, MEDIA_TOOLS) }++fn clip_artifacts_with(r: &mut Run, step: &Value, file: &str, speed: u32, tools: &[&str]) {+ // no ffprobe/ffmpeg: say so and keep the clip as it is, unjudged (never flag the footage blank)+ let verdict = match clip_verdict(file, tools) {+ Ok(v) => v,+ Err(e) => {+ eprintln!("ERROR clip {} not checked or cut: {e}. Install ffmpeg (it brings ffprobe), then `adom-aiflow recut`.", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));+ r.log("clip-check-unavailable", json!({"step": step, "file": file, "why": e}));+ return;+ }+ };+ // issue #59: a take that died before it was finalised has no moov atom; say so now, not at deliver+ let truncated = clipwatch::truncated(file);+ if let Some(t) = &truncated {+ eprintln!("WARNING clip {} is {t}. Re-record the step (`step <name> --back --why \"clip truncated\"`) while the work is fresh.", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));+ if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["truncated"] = json!(true); }+ r.log("clip-truncated", json!({"step": step, "file": file, "why": t}));+ }+ let verdict = truncated.or(verdict);+ let dir = r.dir.clone();+ let tag = step.as_str().unwrap_or("step").to_string();+ // the clip's offset from the prompt, so the overlay can show the run's own clock+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let cap = caps.iter().find(|c| c["file"].as_str() == Some(file) || format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag).cloned().unwrap_or(Value::Null);+ let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ let offset = cap["start"].as_str().and_then(aiflow_run::parse).map(|t| t.saturating_sub(t0)).unwrap_or(0) as f64;+ let (step_name, visit, back) = (cap["step"].as_str().unwrap_or(&tag).to_string(), cap["visit"].as_u64().unwrap_or(1), cap["back"].as_bool() == Some(true));+ let _ = (&step_name, visit, back, offset, &dir);+ let secs = media_seconds(file);+ // the blank check: a clip whose sampled frames are nearly one colour filmed nothing (an+ // unpainted canvas, a covered window); it is flagged, kept out of the video, and said out loud+ // a recut that now sees frames clears a blank verdict an earlier check gave (a run on a box without ffprobe)+ if verdict.is_none() {+ if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) {+ if c["suspect"].as_str().map(blank_check_verdict).unwrap_or(false) {+ let was = c["suspect"].clone();+ c.as_object_mut().map(|o| o.remove("suspect"));+ r.log("clip-suspect-cleared", json!({"step": step, "file": file, "was": was}));+ }+ }+ }+ if let Some(why) = &verdict {+ eprintln!("WARNING clip {} looks blank: {why}", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));+ if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["suspect"] = json!(why); }+ r.log("clip-suspect", json!({"step": step, "file": file, "why": why}));+ }+ r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0, "suspect": verdict}));+ if let Some(sheet) = contact_sheet(file) {+ r.log("artifact", json!({"step": step, "kind": "contact-sheet", "file": sheet}));+ eprintln!("contact sheet (look at it before you go on): {sheet}");+ }+ let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4"));+ // the recorder's first frames are black before the window paints: they are cut off the head+ // of both cuts (never more than five seconds; longer black is a real clip problem, kept)+ let lead = leading_black(file);+ let ss = format!("{lead:.3}");+ let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},scale=1280:-2"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false);+ if lead > 0.0 { r.log("marker", json!({"label": format!("clip {tag}: {lead:.2} s of black recorder startup cut off the head of its cuts")})); }+ if cut {+ let fast = media_seconds(&out);+ r.log("artifact", json!({"step": step, "kind": format!("clip{speed}x"), "file": out, "seconds": (fast * 10.0).round() / 10.0, "speed": speed}));+ eprintln!("clip {:.1} s, {speed}x cut {:.1} s: {out}", secs, fast);+ // the action cut: only the frames that changed, with the real elapsed time burned in.+ // Made from the 10x cut (cheap): stretch its timestamps back to real time so the clock is+ // honest, drop near-duplicate frames, then pack what is left at 30 fps.+ let action = format!("{}-action.mp4", file.trim_end_matches(".mp4"));+ // the action cut stays RAW (no captions): the final video draws its own overlays from the+ // ledger, and a side-by-side of two runs must not paint overlays over overlays+ let vf = format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,setpts=N/4/TB");+ let ok_action = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &out, "-an", "-vf", &vf, "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &action]).status().map(|s| s.success()).unwrap_or(false);+ if ok_action {+ let a = media_seconds(&action);+ r.log("artifact", json!({"step": step, "kind": "clipAction", "file": action, "seconds": (a * 10.0).round() / 10.0, "realSeconds": (secs * 10.0).round() / 10.0}));+ eprintln!("action cut {:.1} s of motion out of {:.1} s: {action}", a, secs);+ }+ }+}+++/// What goes into the final video, in run order: a step visit's motion, or a drawing the AI made.+enum Piece { Library { file:String, caption:String, at:u64, seconds:f64 }, Clip { cap: Value, idx: usize, motion: f64 }, Still { file: String, caption: String, step: String, visit: u64, at: u64 } }++/// Say `tiers` (the fullest first) and keep the first that fits `vlen` seconds; the last tier is+/// said faster when even it runs long. The picture sets the length; the words are cut to it.+fn narrate(workdir: &Path, idx: usize, tiers: &[String], vlen: f64, has_tts: bool) -> Option<(PathBuf, f64, String)> {+ if !has_tts || vlen < 1.5 { return None; }+ let mut best: Option<(PathBuf, f64, String)> = None;+ for (k, words) in tiers.iter().enumerate() {+ let n = words.split_whitespace().count() as f64;+ let last = k + 1 == tiers.len();+ if n > vlen * 2.3 && !last { continue; }+ let rate = if last && n > vlen * 2.3 { "+30%" } else { "+0%" };+ let audio = workdir.join(format!("say-{idx}-{k}.mp3"));+ let ok = std::process::Command::new("adom-tts").args(["say", "--rate", rate, "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);+ if !ok { continue; }+ let alen = media_seconds(audio.to_str().unwrap());+ best = Some((audio, alen, words.clone()));+ if alen <= vlen + 0.05 { break; }+ }+ best+}++/// The first clause of a sentence: up to the first semicolon, colon or comma.+fn first_clause(s: &str) -> String {+ s.split([';', ':', ',']).next().unwrap_or(s).trim().to_string()+}++/// A step's name in words for the narration.+fn step_said(step: &str) -> String {+ step_shown(step).to_lowercase().replace("3d", "3D")+}++/// Compose the final video. Returns (seconds, clip segments, stills, frame rate of motion, narrated).+fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64) -> (f64, usize, usize, f64, bool) {+ let led = r.ledger();+ let mut act: std::collections::BTreeMap<String, (f64, f64)> = Default::default();+ for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), (e["realSeconds"].as_f64().unwrap_or(0.0), e["seconds"].as_f64().unwrap_or(0.0))); } }+ let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ // a clip stopped twice loses its file on the second stop (the recorder answers "nothing running");+ // the ledger kept the first stop's file under the same recording id, so recover it from there+ let mut ledger_file: std::collections::HashMap<String, String> = Default::default();+ for e in &led {+ if e["event"] == "clip-stop" {+ if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) {+ let local = dir.join(Path::new(f).file_name().unwrap_or_default());+ if local.is_file() { ledger_file.insert(rid.to_string(), local.display().to_string()); }+ }+ }+ }+ // the pieces, in the order they happened: clips by their start, drawings by the moment the AI registered them+ let mut pieces: Vec<(u64, Piece)> = Vec::new();+ for (i, c) in caps.iter().enumerate() {+ if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }+ let recovered = c["recordingId"].as_str().and_then(|rid| ledger_file.get(rid)).cloned();+ let mut c = c.clone();+ if c["file"].as_str().is_none() { if let Some(f) = &recovered { c["file"] = json!(f); } }+ let c = &c;+ let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+ if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }+ let visit = c["visit"].as_u64().unwrap_or(1);+ let tag = format!("{step}-{visit}");+ let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+ if !Path::new(&fast).is_file() { continue; }+ let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));+ let motion = act.get(&tag).map(|x| x.1).filter(|m| *m > 0.0).unwrap_or_else(|| media_seconds(&action));+ // a visit with under a second of motion is nothing to watch: it stays on the page, not in the video+ if motion < 1.0 { eprintln!("segment {tag}: {motion:.1} s of motion, left out"); continue; }+ let at = c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ // a clip that ran while the human was away (a recorder left on) is nothing to watch+ let stop = c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(at);+ if r.active_between(at, stop) == 0 && stop.saturating_sub(at) > r.idle_minutes() * 60 { eprintln!("segment {tag}: recorded outside the AI's time, left out"); continue; }+ pieces.push((at, Piece::Clip { cap: c.clone(), idx: i, motion }));+ }+ for e in &standing_artifacts(&led) {+ if e["kind"]=="library-overview" {+ let Some(file)=e["file"].as_str() else {continue};+ let Some(review)=e["review"].as_str() else {err("library overview has no review contract", &["Re-register it with artifact --kind library-overview --review <review.json>.".into()])};+ library_review::verify(Path::new(review),Path::new(&r.current_board()),Path::new(file)).unwrap_or_else(|e|err(&e,&["Re-review the current native board and selected component variants before composing.".into()]));+ let at=e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ pieces.push((at,Piece::Library{file:file.into(),caption:e["caption"].as_str().unwrap_or("Component library").into(),at,seconds:media_seconds(file).min(5.0)}));+ continue;+ }+ if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }+ let Some(f) = e["file"].as_str() else { continue };+ if !Path::new(f).is_file() { continue; }+ let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ pieces.push((at, Piece::Still { file: f.to_string(), caption: e["caption"].as_str().unwrap_or("").to_string(), step: e["step"].as_str().unwrap_or("").to_string(), visit: e["visit"].as_u64().unwrap_or(1), at }));+ }+ pieces.sort_by_key(|p| p.0);+ if pieces.is_empty() { err("nothing to compose: no clips with motion and no drawings in this run (run `recut` first)", &[]); }+ // the pace: every kept frame of motion plays at 4 per second when the run fits; faster, evenly, when it does not+ let n_stills = pieces.iter().filter(|p| matches!(p.1, Piece::Still { .. })).count();+ let motion_total: f64 = pieces.iter().map(|p| if let Piece::Clip { motion, .. } = &p.1 { *motion } else { 0.0 }).sum();+ let library_seconds: f64=pieces.iter().map(|p|if let Piece::Library{seconds,..}=p.1{seconds}else{0.0}).sum();+ let still_seconds=still_seconds.min((max_seconds-library_seconds).max(0.0)*0.5/(n_stills.max(1) as f64));+ if library_seconds>=max_seconds {err("component overviews exceed the video budget", &["Replace superseded overviews with artifact --replaces, or raise compose --max-seconds.".into()]);}+ let clip_budget = (max_seconds - library_seconds - n_stills as f64 * still_seconds).max(0.1);+ let rate = if motion_total > clip_budget { 4.0 * motion_total / clip_budget } else { 4.0 };+ let engine = r.data["engine"].as_str().unwrap_or("").to_string();+ let minutes = r.active_minutes_total();+ let run_has_hours = minutes >= 60.0;+ let board = Path::new(r.data["sourceBoard"].as_str().or(r.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+ let engine_board = format!("{engine} · {board}");+ let workdir = dir.join("compose");+ let _ = std::fs::remove_dir_all(&workdir);+ let _ = std::fs::create_dir_all(&workdir);+ let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);+ let step_does = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };+ let visit_start = |step: &str, visit: u64| -> u64 { caps.iter().find(|c| c["step"].as_str() == Some(step) && c["visit"].as_u64() == Some(visit)).and_then(|c| c["start"].as_str()).and_then(aiflow_run::parse).unwrap_or(0) };+ let fit = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=0x0B1420";+ let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();+ let (mut n_clips, mut n_stills_done) = (0usize, 0usize);+ for (k, (_, piece)) in pieces.iter().enumerate() {+ let seg = workdir.join(format!("seg-{k}.mp4"));+ let aseg = workdir.join(format!("aud-{k}.m4a"));+ let (vlen, tiers): (f64, Vec<String>) = match piece {+ Piece::Library{file,caption,at,seconds}=>{+ let offset=r.active_offset(*at) as f64;+ let vf=format!("scale=1280:-2,{},{fit}",overlay_filters(dir,"Component library",&engine_board,offset,0.0,run_has_hours));+ let good=ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v","error","-y","-i",file,"-t",&format!("{seconds:.3}"),"-vf",&vf,"-r","30","-an","-pix_fmt","yuv420p","-c:v","libx264","-preset","veryfast","-crf","20",seg.to_str().unwrap()]));+ if !good{err("component overview failed to render", &["Check the reviewed clip, then compose again.".into()]);}+ n_clips+=1;(media_seconds(seg.to_str().unwrap()),vec![caption.clone(),"The reviewed component library.".into()])+ }+ Piece::Clip { cap: c, idx, motion } => {+ let (f, step, visit) = (c["file"].as_str().unwrap_or(""), c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1));+ let tag = format!("{step}-{visit}");+ let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+ // the run clock is the AI's time: the active seconds from the first prompt to this clip+ let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));+ let offset = r.active_offset(cs) as f64;+ let title = format!("{}{}", step_shown(step), if visit > 1 { format!(" visit {visit}{}", if c["back"].as_bool() == Some(true) { " rework" } else { "" }) } else { String::new() });+ let chain = format!("{},{fit}", overlay_chain(dir, &title, &engine_board, offset, run_has_hours, 10, rate));+ let okc = ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &fast, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]));+ if !okc { eprintln!("segment {tag}: the overlay cut failed; skipped"); continue; }+ let vlen = media_seconds(seg.to_str().unwrap());+ if vlen < 0.5 { continue; }+ let _ = (idx, motion);+ let real = if ce > cs { r.active_between(cs, ce) as f64 } else { act.get(&tag).map(|x| x.0).unwrap_or_else(|| media_seconds(f)) };+ let said = step_said(step);+ let visit_words = if visit > 1 { format!(", visit {visit}") } else { String::new() };+ let why_or_does = c["why"].as_str().map(|w| format!("The AI came back here because {w}")).unwrap_or_else(|| step_does(step));+ let mins = format!("{:.0} minute{} of real time", (real / 60.0).max(1.0), if real >= 90.0 { "s" } else { "" });+ n_clips += 1;+ (vlen, vec![format!("{said}{visit_words}. {why_or_does}. {mins}."), format!("{said}{visit_words}. {}.", first_clause(&why_or_does)), format!("{said}{visit_words}.")])+ }+ Piece::Still { file, caption, step, visit, at } => {+ let offset = r.active_offset(*at) as f64;+ let step_offset = r.active_between(visit_start(step, *visit), *at) as f64;+ let title = format!("{} · the AI's drawing", step_shown(step));+ // the drawing is shown at the clips' scale so the overlays match, then fit to 1080p+ let vf = format!("scale=1280:-2,{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours));+ let oks = ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]));+ if !oks { eprintln!("still {file}: could not be rendered; skipped"); continue; }+ let vlen = media_seconds(seg.to_str().unwrap());+ n_stills_done += 1;+ (vlen, vec![caption.clone(), first_clause(caption), format!("{}, the AI's own drawing.", step_said(step))])+ }+ };+ // the words are cut to the picture: the segment is exactly as long as its motion+ let said = narrate(&workdir, k, &tiers, vlen, has_tts);+ let oka = match &said {+ Some((audio, _, _)) => ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()])),+ None => ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()])),+ };+ if let Some((_, alen, words)) = &said { if *alen > vlen + 0.05 { eprintln!("segment {k}: the words ran {alen:.1} s for {vlen:.1} s of picture; cut at the picture: \"{words}\""); } }+ if oka { parts.push((seg, aseg)); }+ }+ if parts.is_empty() { compose_fail("nothing to compose: every segment failed to render", outp); }+ // the page takes files up to 10 MB: when the cut is bigger, assemble re-encodes it once at the+ // bitrate that fits (screen captures are mostly still, so the picture survives it)+ assemble(&workdir, &parts, outp);+ let secs = media_seconds(outp.to_str().unwrap());+ if secs > max_seconds + 2.0 { eprintln!("WARNING the video runs {secs:.0} s, over the {max_seconds:.0} s cap; the pace ({rate:.1} frames per second) did not hold"); }+ (secs, n_clips, n_stills_done, rate, has_tts)+}++/// The AI's artifacts that still stand: an artifact whose file a later `artifact --replaces` names is+/// superseded, as is an earlier event of the same file re-registered later. Returns the ledger events.+fn standing_artifacts(led: &[Value]) -> Vec<Value> {+ let mut out: Vec<Value> = Vec::new();+ for (i, e) in led.iter().enumerate() {+ if e["event"] != "artifact" || e["byAi"].as_bool() != Some(true) { continue; }+ let Some(f) = e["file"].as_str() else { continue };+ let superseded = led.iter().skip(i + 1).any(|l| l["event"] == "artifact" && l["byAi"].as_bool() == Some(true) && (l["replaces"].as_str() == Some(f) || l["file"].as_str() == Some(f)));+ if !superseded { out.push(e.clone()); }+ }+ out+}++/// The sessions table for the terminal and the run page.+fn sessions_table(r: &Run) -> String {+ let ss = r.sessions();+ let mut out = String::from("| Session | Prompt at | Done at | AI time min | Unobserved gap min | What |\n|---|---|---|---|---|---|\n");+ for x in &ss {+ out.push_str(&format!("| {} | {} | {} | {:.1} | {:.0} | {} |\n", x.n, aiflow_run::iso(x.prompt_at), x.done_at.map(aiflow_run::iso).unwrap_or_else(|| if x.open { "(open)".into() } else { "".into() }), x.active_seconds() as f64 / 60.0, x.idle_seconds as f64 / 60.0, x.text.chars().take(90).collect::<String>().replace('|', "/")));+ }+ out.push_str(&format!("| **all** | | | **{:.1}** | | |\n", r.active_minutes_total()));+ out+}++/// One engine's clip of one step visit, ready for the side-by-side.+struct VisitClip { tenx: String, motion: f64, offset: f64, title: String }++/// The 10x cut of a capture, in its own run or, for a run pulled from another machine, by file name in the run directory.+fn tenx_of(run_dir: &Path, file: &str) -> Option<String> {+ let fast = format!("{}-10x.mp4", file.trim_end_matches(".mp4"));+ if Path::new(&fast).is_file() { return Some(fast); }+ let local = run_dir.join(Path::new(&fast).file_name()?);+ if local.is_file() { Some(local.display().to_string()) } else { None }+}++/// A run's visits per step (flow order kept by the caller), with their motion seconds and active-time offsets.+fn visits_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<VisitClip>> {+ let led = r.ledger();+ let mut act: std::collections::BTreeMap<String, f64> = Default::default();+ for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), e["seconds"].as_f64().unwrap_or(0.0)); } }+ let mut out: std::collections::BTreeMap<String, Vec<VisitClip>> = Default::default();+ let mut caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ caps.sort_by(|a, b| a["start"].as_str().unwrap_or("").cmp(b["start"].as_str().unwrap_or("")));+ for c in &caps {+ if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }+ let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+ // the 3D step's camera is the viewer; an editor clip under that step is the wait before it opened+ if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }+ let visit = c["visit"].as_u64().unwrap_or(1);+ let Some(tenx) = tenx_of(&r.dir, f) else { continue };+ let motion = act.get(&format!("{step}-{visit}")).copied().unwrap_or(0.0);+ if motion < 1.0 { continue; }+ let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));+ if r.active_between(cs, ce) == 0 && ce.saturating_sub(cs) > r.idle_minutes() * 60 { continue; }+ let title = format!("{}{}", step_shown(step), if visit > 1 { format!(" visit {visit}{}", if c["back"].as_bool() == Some(true) { " rework" } else { "" }) } else { String::new() });+ out.entry(step.to_string()).or_default().push(VisitClip { tenx, motion, offset: r.active_offset(cs) as f64, title });+ }+ out+}++/// A run's standing drawings per step, as (file, caption, active offset).+fn drawings_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<(String, String, f64)>> {+ let mut out: std::collections::BTreeMap<String, Vec<(String, String, f64)>> = Default::default();+ for e in standing_artifacts(&r.ledger()) {+ if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }+ let Some(f) = e["file"].as_str() else { continue };+ let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name().unwrap_or_default()); if l.is_file() { l.display().to_string() } else { continue } };+ let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ out.entry(e["step"].as_str().unwrap_or("").to_string()).or_default().push((local, e["caption"].as_str().unwrap_or("").to_string(), r.active_offset(at) as f64));+ }+ out+}++fn run_ffmpeg(args: &[&str]) -> bool {+ ffmpeg_ok(std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args))+}++thread_local! {+ /// The stderr of the last ffmpeg run in compose that failed, for the error message (issue #80).+ static FFMPEG_LAST_ERROR: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };+}++/// Run an ffmpeg command with its stderr captured; on failure keep the stderr for `compose_error`.+fn ffmpeg_ok(cmd: &mut std::process::Command) -> bool {+ let failure = match cmd.stdout(std::process::Stdio::null()).output() {+ Ok(o) if o.status.success() => return true,+ Ok(o) => { let e = String::from_utf8_lossy(&o.stderr).trim().to_string(); if e.is_empty() { format!("ffmpeg exited with {}", o.status) } else { e } }+ Err(e) => format!("ffmpeg could not start: {e}"),+ };+ FFMPEG_LAST_ERROR.with(|l| *l.borrow_mut() = failure);+ false+}++/// Where compose writes (issue #80): without --out, final.mp4 in the run directory; an absolute --out as given;+/// a relative --out against the working directory, like every other path flag.+fn compose_out_path(out: Option<&str>, run_dir: &Path, cwd: &Path) -> PathBuf {+ match out {+ None => run_dir.join("final.mp4"),+ Some(o) if Path::new(o).is_absolute() => PathBuf::from(o),+ Some(o) => cwd.join(o),+ }+}++/// The compose failure message: what failed, the resolved output path, and ffmpeg's last stderr lines+/// (the last 8 lines, clipped head and tail to 1200 characters).+fn compose_error(what: &str, outp: &Path, stderr: &str) -> String {+ let lines: Vec<&str> = stderr.lines().map(str::trim_end).filter(|l| !l.trim().is_empty()).collect();+ let last = lines[lines.len().saturating_sub(8)..].join("\n");+ let shown = if last.is_empty() { "(ffmpeg printed nothing)".to_string() } else { aiflow_run::clip_text(&last, 1200) };+ format!("{what} (output {}); ffmpeg said:\n{shown}", outp.display())+}++fn compose_fail(what: &str, outp: &Path) -> ! {+ let stderr = FFMPEG_LAST_ERROR.with(|l| l.borrow().clone());+ err(&compose_error(what, outp, &stderr), &[format!("Check that {} can be written (a relative --out is resolved against the current working directory; without --out the video is final.mp4 in the run directory), then compose again.", outp.display())])+}++/// Two runs side by side, step by step in flow order: the left engine's visits of the step play+/// in its column, the right engine's in its column, both paced to the same seconds (the run with+/// more motion sets the pace; the other plays slower, never faster than its own clips); their own+/// clocks in their own corners; the drawings of both side by side; the words cut to the picture.+fn compose_pair(a: &mut Run, b: &Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64, script: Option<&serde_json::Map<String, Value>>) -> (f64, usize, usize, bool) {+ let (fd, ft) = run_fonts(dir);+ let workdir = dir.join("compose-pair");+ let _ = std::fs::remove_dir_all(&workdir);+ let _ = std::fs::create_dir_all(&workdir);+ let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);+ let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());+ let board = Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+ let (va, vb) = (visits_by_step(a), visits_by_step(b));+ let (da, db) = (drawings_by_step(a), drawings_by_step(b));+ let (ra, rb) = (a.step_report(), b.step_report());+ let hours = a.active_minutes_total() >= 60.0 || b.active_minutes_total() >= 60.0;+ // the steps in flow order, then any the runs added+ // the story's order: the build steps, the analyses, the walkthroughs, the finish line last+ let mut steps: Vec<String> = ["intake", "models", "placement", "routing", "pours", "current", "thermal", "fields", "nets", "3d", "finish"].iter().map(|s| s.to_string()).collect();+ for k in va.keys().chain(vb.keys()).chain(da.keys()).chain(db.keys()) { if !steps.contains(k) { steps.insert(steps.len() - 1, k.clone()); } }+ let has = |s: &str| va.contains_key(s) || vb.contains_key(s) || da.contains_key(s) || db.contains_key(s);+ let steps: Vec<String> = steps.into_iter().filter(|s| has(s) && s != "capture").collect();+ // the pace: each step's seconds is the larger of the two runs' motion; scaled evenly to fit the budget+ let frames = |v: Option<&Vec<VisitClip>>| -> f64 { v.map(|x| x.iter().map(|c| c.motion * 4.0).sum()).unwrap_or(0.0) };+ let mut raw: Vec<f64> = Vec::new(); let mut pairs_total = 0usize;+ for s in &steps {+ let (fa, fb) = (frames(va.get(s)), frames(vb.get(s)));+ raw.push(fa.max(fb) / 4.0);+ pairs_total += da.get(s).map(|x| x.len()).unwrap_or(0).max(db.get(s).map(|x| x.len()).unwrap_or(0));+ }+ let raw_sum: f64 = raw.iter().sum();+ let clip_budget = (max_seconds - pairs_total as f64 * still_seconds).max(max_seconds * 0.5);+ let scale = if raw_sum > clip_budget { clip_budget / raw_sum } else { 1.0 };+ let mins = |rep: &serde_json::Map<String, Value>, s: &str| -> (f64, u64, u64) { rep.get(s).map(|e| (e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0))).unwrap_or((0.0, 0, 0)) };+ let (cw, ch, band) = (960, 540, 110);+ // the bars below the columns: this step's AI minutes per engine against the longest step of either run+ let max_min = steps.iter().map(|s| mins(&ra, s).0.max(mins(&rb, s).0)).fold(1.0f64, f64::max);+ let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+ let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());+ let footer = dt(&format!("Delivered: {na} in {d_a:.0} min of AI time with {ret_a} return{} to earlier steps · {nb} in {d_b:.0} min with {ret_b} return{}", if ret_a == 1 { "" } else { "s" }, if ret_b == 1 { "" } else { "s" }));+ let frame = |title: &str, sub: &str, bars: Option<((f64, u64), (f64, u64))>| -> String {+ let mut f = format!("[0:v][1:v]hstack=inputs=2,pad=1920:1080:0:{band}:color=0x0B1420,drawtext=fontfile={fd}:text='{}':x=(w-tw)/2:y=22:fontsize=40:fontcolor=0xF2E9E1,drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=74:fontsize=20:fontcolor=0xD9E1E8", dt(title), dt(sub));+ if let Some(((ma, va), (mb, vb))) = bars {+ let (x0, wmax, y1, y2, h) = (420.0, 1180.0, 720.0, 800.0, 40.0);+ for (k, (name, m, v, color, y)) in [(&na, ma, va, "0xDB8B58", y1), (&nb, mb, vb, "0x5FB6D6", y2)].into_iter().enumerate() {+ let w = ((m / max_min) * wmax).max(4.0);+ let _ = k;+ f.push_str(&format!(",drawtext=fontfile={ft}:text='{}':x={x0}-tw-24:y={y}+9:fontsize=20:fontcolor=0xF2E9E1", dt(name)));+ f.push_str(&format!(",drawbox=x={x0}:y={y}:w={w:.0}:h={h}:color={color}@0.9:t=fill"));+ f.push_str(&format!(",drawtext=fontfile={fd}:text='{}':x={x0}+{w:.0}+16:y={y}+6:fontsize=26:fontcolor=0xF2E9E1", dt(&format!("{m:.0} min · {v} visit{}", if v == 1 { "" } else { "s" }))));+ }+ f.push_str(&format!(",drawtext=fontfile={ft}:text='THIS STEP, AI TIME':x={x0}:y=684:fontsize=13:fontcolor=0xA0AFBE"));+ }+ f.push_str(&format!(",drawtext=fontfile={ft}:text='{footer}':x=(w-tw)/2:y=1010:fontsize=19:fontcolor=0xD9E1E8"));+ f+ };+ // a clip of any window shape fits the column both ways (a pup window is taller than the editor's)+ let fit_col = format!("scale={cw}:{ch}:force_original_aspect_ratio=decrease,pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2:color=0x0B1420");+ let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();+ let (mut n_steps, mut n_pairs) = (0usize, 0usize);+ // with a script the words lead: each step's picture is paced to its narration (slower or faster,+ // never held, never cut short), so the voice never stops and never runs past the picture+ let scripted = script.is_some();+ let say_len = |k: usize, words: &str| -> Option<(PathBuf, f64)> {+ if !has_tts || words.trim().is_empty() { return None; }+ let audio = workdir.join(format!("script-{k}.mp3"));+ let okv = std::process::Command::new("adom-tts").args(["say", "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);+ if okv { Some((audio.clone(), media_seconds(audio.to_str().unwrap()))) } else { None }+ };+ for (si, step) in steps.iter().enumerate() {+ let script_words = script.and_then(|m| m.get(step.as_str())).and_then(|v| v.as_str()).map(str::to_string);+ let scripted_audio = script_words.as_ref().and_then(|w| say_len(1000 + si, w));+ let t_step = match &scripted_audio { Some((_, alen)) => alen + 0.3, None => raw[si] * scale };+ if scripted && script_words.is_none() { eprintln!("side by side, step {step}: no words in the script; the picture keeps its own pace"); }+ let mut seg_index = parts.len();+ // the two columns of clips+ if t_step >= 0.5 && (raw[si] > 0.0 || scripted_audio.is_some()) {+ let mut cols: Vec<PathBuf> = Vec::new();+ for (side, (run, name, vis)) in [(a as &Run, &na, va.get(step)), (b, &nb, vb.get(step))].into_iter().enumerate() {+ let col = workdir.join(format!("col-{si}-{side}.mp4"));+ let f = frames(vis);+ if f > 0.0 {+ // scripted: the frames spread over the words (down to one frame every five seconds+ // for a step with little motion); unscripted: never slower than one a second+ let rate = if scripted_audio.is_some() { (f / t_step).max(0.2) } else { (f / t_step).max(1.0) };+ let mut pieces: Vec<PathBuf> = Vec::new();+ for (k, v) in vis.unwrap().iter().enumerate() {+ let piece = workdir.join(format!("piece-{si}-{side}-{k}.mp4"));+ let chain = format!("{},{fit_col}", overlay_chain(dir, &v.title, &format!("{name} · {board}"), v.offset, hours, 10, rate));+ if run_ffmpeg(&["-i", &v.tenx, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", piece.to_str().unwrap()]) { pieces.push(piece); }+ }+ let list = workdir.join(format!("col-{si}-{side}.txt"));+ std::fs::write(&list, pieces.iter().map(|p| format!("file '{}'\n", std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display())).collect::<String>()).unwrap();+ let cat = workdir.join(format!("cat-{si}-{side}.mp4"));+ let _ = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", list.to_str().unwrap(), "-c", "copy", cat.to_str().unwrap()]);+ // exactly t_step: the slower column holds its last frame for the rest (a side-by-side has to wait)+ let _ = run_ffmpeg(&["-i", cat.to_str().unwrap(), "-vf", &format!("tpad=stop_mode=clone:stop_duration={t_step:.2},trim=0:{t_step:.3}"), "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]);+ let _ = run;+ } else {+ // no clip on this side: the run's own screenshot of the step, or its latest board shot,+ // stands in with a caption, so the column is never a dark hole+ let shot = latest_shot(&run.dir, step);+ let msg = dt(&format!("{name}: no clip for this step{}", if shot.is_some() { ", its board as filmed last" } else { "" }));+ let caption = format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=h-th-14:fontsize=20:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=8");+ let okp = match &shot {+ Some(img) => run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{t_step:.3}"), "-i", img, "-vf", &format!("{fit_col},{caption}"), "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),+ None => false,+ };+ if !okp { let _ = run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={t_step:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE"), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]); }+ }+ if col.is_file() { cols.push(col); } else { eprintln!("side by side, step {step}, {name}: its column did not render; the step is left out"); }+ }+ if cols.len() == 2 {+ let (ma, mb) = (mins(&ra, step), mins(&rb, step));+ let seg = workdir.join(format!("seg-{}.mp4", parts.len()));+ let fc = frame(&step_shown(step), &format!("{na} on the left, {nb} on the right; each run's own clocks in its own corner"), Some(((ma.0, ma.1), (mb.0, mb.1))));+ if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {+ let vlen = media_seconds(seg.to_str().unwrap());+ let said = step_said(step);+ let tiers = if step == "finish" {+ let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+ let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());+ vec![format!("Delivered. {na}: {d_a:.0} minutes of AI time from the prompt to done, {ret_a} returns to earlier steps. {nb}: {d_b:.0} minutes, {ret_b} returns."), format!("Delivered. {na} {d_a:.0} minutes, {nb} {d_b:.0}."), "Delivered.".to_string()]+ } else {+ vec![format!("{said}. {na}: {:.0} minutes of AI time in {} visit{}. {nb}: {:.0} minutes in {} visit{}.", ma.0, ma.1, if ma.1 == 1 { "" } else { "s" }, mb.0, mb.1, if mb.1 == 1 { "" } else { "s" }), format!("{said}. {na} {:.0} minutes, {nb} {:.0}.", ma.0, mb.0), format!("{said}.")]+ };+ let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));+ let oka = match &scripted_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };+ if oka { parts.push((seg, aseg)); n_steps += 1; }+ }+ }+ }+ // the drawings of both, side by side+ let (dra, drb) = (da.get(step).cloned().unwrap_or_default(), db.get(step).cloned().unwrap_or_default());+ for k in 0..dra.len().max(drb.len()) {+ let pair_words = script.and_then(|m| m.get(&format!("drawings:{step}:{k}")).or_else(|| m.get(&format!("drawings:{step}"))).filter(|_| k == 0 || m.contains_key(&format!("drawings:{step}:{k}")))).and_then(|v| v.as_str()).map(str::to_string);+ let pair_audio = pair_words.as_ref().and_then(|w| say_len(2000 + si * 10 + k, w));+ let still_len = match &pair_audio { Some((_, alen)) => alen + 0.3, None => still_seconds };+ let mut cols: Vec<PathBuf> = Vec::new();+ for (side, (name, d)) in [(&na, dra.get(k)), (&nb, drb.get(k))].into_iter().enumerate() {+ let col = workdir.join(format!("still-{si}-{k}-{side}.mp4"));+ let okc = match d {+ Some((file, _, off)) => {+ let vf = format!("scale=1280:-2,{},{fit_col}", overlay_filters(dir, &format!("{} · the AI's drawing", step_shown(step)), &format!("{name} · {board}"), *off, 0.0, hours));+ run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{still_len:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()])+ }+ None => run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={still_len:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE", dt(&format!("{name}: no drawing for this step"))), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),+ };+ if okc { cols.push(col); }+ }+ if cols.len() != 2 { continue; }+ let seg = workdir.join(format!("seg-{}.mp4", parts.len()));+ let (ma, mb) = (mins(&ra, step), mins(&rb, step));+ let fc = frame(&step_shown(step), &format!("the AIs' own drawings: {na} on the left, {nb} on the right"), Some(((ma.0, ma.1), (mb.0, mb.1))));+ if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {+ let vlen = media_seconds(seg.to_str().unwrap());+ let cap = dra.get(k).map(|x| x.1.clone()).or_else(|| drb.get(k).map(|x| x.1.clone())).unwrap_or_default();+ let tiers = vec![format!("The {} drawings, {na} left, {nb} right. {}", step_said(step), first_clause(&cap)), format!("The {} drawings, {na} left, {nb} right.", step_said(step)), format!("{} drawings.", step_said(step))];+ let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));+ let oka = match &pair_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };+ if oka { parts.push((seg, aseg)); n_pairs += 1; }+ }+ }+ let _ = &mut seg_index;+ }+ if parts.is_empty() { err(&format!("nothing to compose side by side: no step with motion in either run (output {})", outp.display()), &[]); }+ assemble(&workdir, &parts, outp);+ (media_seconds(outp.to_str().unwrap()), n_steps, n_pairs, has_tts)+}++/// The narration for one segment, exactly `vlen` seconds long (silence when there is nothing to say).+fn audio_for(workdir: &Path, k: usize, tiers: &[String], vlen: f64, has_tts: bool, aseg: &Path) -> bool {+ match narrate(workdir, k, tiers, vlen, has_tts) {+ Some((audio, _, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]),+ None => run_ffmpeg(&["-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]),+ }+}++/// Concatenate the segments with their narration into the final file, re-encoded under the page's 10 MB cap when needed.+fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) {+ let vlist = workdir.join("video.txt");+ let alist = workdir.join("audio.txt");+ std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", std::fs::canonicalize(v).unwrap_or_else(|_| v.clone()).display())).collect::<String>()).unwrap();+ std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", std::fs::canonicalize(a).unwrap_or_else(|_| a.clone()).display())).collect::<String>()).unwrap();+ let vcat = workdir.join("video.mp4");+ let acat = workdir.join("audio.m4a");+ let okv = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]);+ let oka = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]);+ let okf = okv && oka && run_ffmpeg(&["-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]);+ if !okf { compose_fail("ffmpeg could not assemble the video and its narration", outp); }+ let cap_bytes: u64 = 9_800_000;+ if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {+ let secs = media_seconds(outp.to_str().unwrap()).max(1.0);+ let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();+ let fitted = workdir.join("fitted.mp4");+ if run_ffmpeg(&["-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]) {+ let _ = std::fs::copy(&fitted, outp);+ eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0));+ }+ }+}++/// The comparison sub-README: this run (left) against another (right). Returns (rel dir, url, files to push).+fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String, Vec<String>) {+ let (ida, idb) = (report_id(a), report_id(b));+ let rel = format!("docs/comparisons/{ida}-vs-{idb}");+ let stage = dir.join("report");+ let out = stage.join(&rel);+ let _ = std::fs::remove_dir_all(&out);+ let _ = std::fs::create_dir_all(&out);+ let slug = page.split('/').nth(1).unwrap_or(page);+ let blob = format!("/blob/app/{slug}/{rel}");+ let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());+ // the board by the spec's name when it has one (ESC G431), else by its file+ let board = spec_of(a).get("board").and_then(|v| v.as_str()).map(|v| v.split('(').next().unwrap_or(v).trim().to_string()).filter(|v| !v.is_empty()).unwrap_or_else(|| Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string());+ let mut files: Vec<String> = Vec::new();+ let mut md = String::new();+ md.push_str(&format!("# {na} versus {nb} on the {board}\n\n"));+ md.push_str("Two AIs, the same board, the same spec, the same prompt, the same flow (Adom's AI Flow), measured the same way: the AI's own time from the prompt to \"done, here is your video\", every return to an earlier step, every clip, and the analysis each AI did itself. Nobody had put two engines through one measured board flow before; this page is the first pair.\n\n");+ // the video first+ if let Some(v) = a.data["comparison"]["video"].as_str().filter(|v| Path::new(v).is_file()) {+ let stamp: String = a.ledger().iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison").and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());+ let name = format!("comparison-{stamp}.mp4");+ let _ = std::fs::copy(v, out.join(&name)); files.push(format!("{rel}/{name}"));+ md.push_str(&format!("**The side by side, {:.0} s: {na} on the left, {nb} on the right, step by step in the flow's order; each run's own clocks in its own corner (AI time, the idle cut out); both AIs' analysis drawings side by side; each step's AI-minute bars below; the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", a.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));+ }+ // the number+ let (da, db) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+ let (ra, rb) = (a.step_report(), b.step_report());+ let turns = |r: &Run| r.data["turns"].as_array().map(|x| x.len()).unwrap_or(0);+ let think = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["thinkingSeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;+ let tool = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["binarySeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;+ md.push_str("## The number: prompt to \"done, here is your video\"\n\n");+ md.push_str("| | AI time to delivery | Returns to earlier steps | Turns | AI thinking min | Binary min |\n|---|---|---|---|---|---|\n");+ md.push_str(&format!("| **{na}** | **{da:.1} min** | {} | {} | {:.1} | {:.1} |\n", a.returns_before_delivery(), turns(a), think(&ra), tool(&ra)));+ md.push_str(&format!("| **{nb}** | **{db:.1} min** | {} | {} | {:.1} | {:.1} |\n\n", b.returns_before_delivery(), turns(b), think(&rb), tool(&rb)));+ md.push_str("AI time is the sum of the run's sessions, one per human prompt, from the prompt to the AI's done, with every idle gap over 15 minutes (unobserved, not proven idle) cut out. Follow-up prompts after delivery (the walkthroughs, the drawings, the re-cuts) are on each run's page under Sessions and are not in the delivery number.\n\n");+ // per step+ let mut steps: Vec<String> = a.data["flow"]["steps"].as_array().map(|x| x.iter().filter_map(|s| s["name"].as_str().map(str::to_string)).collect()).unwrap_or_default();+ for k in ra.keys().chain(rb.keys()) { if !steps.contains(k) { steps.push(k.clone()); } }+ md.push_str(&format!("## Per step, AI time\n\n| Step | {na} | {nb} |\n|---|---|---|\n"));+ for st in &steps {+ let cell = |rep: &serde_json::Map<String, Value>| rep.get(st).map(|e| format!("{:.1} min, {} visit{}{}", e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"], if e["visits"].as_u64() == Some(1) { "" } else { "s" }, if e["returns"].as_u64().unwrap_or(0) > 0 { format!(", {} return{}", e["returns"], if e["returns"].as_u64() == Some(1) { "" } else { "s" }) } else { String::new() })).unwrap_or_else(|| "-".into());+ if ra.contains_key(st) || rb.contains_key(st) { md.push_str(&format!("| {st} | {} | {} |\n", cell(&ra), cell(&rb))); }+ }+ md.push_str("\n");+ // the drawings side by side+ let draw = |r: &Run| -> Vec<(String, String, String)> { standing_artifacts(&r.ledger()).into_iter().filter(|e| matches!(e["kind"].as_str(), Some("analysis-image") | Some("image"))).filter_map(|e| { let f = e["file"].as_str()?; let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name()?); if l.is_file() { l.display().to_string() } else { return None } }; Some((e["step"].as_str().unwrap_or("").to_string(), local, e["caption"].as_str().unwrap_or("").to_string())) }).collect() };+ let (dra, drb) = (draw(a), draw(b));+ if !dra.is_empty() || !drb.is_empty() {+ md.push_str("## The analysis each AI did itself\n\nEach AI solved the current and the heat on its own board and drew it; the pictures are theirs, not the binary's. Left, then right, per step.\n\n");+ for st in &steps {+ let la: Vec<&(String, String, String)> = dra.iter().filter(|d| d.0 == *st).collect();+ let lb: Vec<&(String, String, String)> = drb.iter().filter(|d| d.0 == *st).collect();+ for k in 0..la.len().max(lb.len()) {+ md.push_str("<table><tr>");+ for (side, name, it) in [("a", &na, la.get(k)), ("b", &nb, lb.get(k))] {+ match it {+ Some((_, file, cap)) => { let fname = format!("{side}-{st}-{k}.{}", Path::new(file).extension().and_then(|x| x.to_str()).unwrap_or("png")); let _ = std::fs::copy(file, out.join(&fname)); files.push(format!("{rel}/{fname}")); md.push_str(&format!("<td width=\"50%\"><b>{name}: {}</b><br><img src=\"{blob}/{fname}\" width=\"100%\"></td>", cap.replace('|', "/"))); }+ None => md.push_str(&format!("<td width=\"50%\"><b>{name}: no drawing for this step</b></td>")),+ }+ }+ md.push_str("</tr></table>\n\n");+ }+ }+ }+ // the links+ md.push_str("## The two runs, in full\n\n");+ md.push_str(&format!("- [{na}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{ida}/README.md): every step's clip, screenshots, sessions, returns, decisions, plan usage, what it gave back.\n"));+ md.push_str(&format!("- [{nb}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{idb}/README.md): the same for the other engine.\n"));+ md.push_str(&format!("- [The flow](https://wiki.adom.inc/{page}): Adom's AI Flow, the tool both ran.\n\n"));+ md.push_str("*Plan usage on each run page is per account, not per run: every AI thread signed in as that user draws on the same limits.*\n");+ let readme = out.join("README.md");+ std::fs::write(&readme, md).unwrap();+ files.push(format!("{rel}/README.md"));+ (rel.clone(), format!("https://wiki.adom.inc/{page}/files/{rel}/README.md"), files)+}++/// The newest screenshot of a step in a run directory (`shot-<step>-...png`), else the newest screenshot of any step.+fn latest_shot(dir: &Path, step: &str) -> Option<String> {+ let mut shots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with("shot-") && e.file_name().to_string_lossy().ends_with(".png")).filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))).collect();+ shots.sort_by(|a, b| b.0.cmp(&a.0));+ let of_step = shots.iter().find(|(_, p)| p.file_name().map(|n| n.to_string_lossy().starts_with(&format!("shot-{step}-"))).unwrap_or(false));+ of_step.or(shots.first()).map(|(_, p)| p.display().to_string())+}++/// The give-back hint: what the AI learned belongs to every Adom user.+fn give_back_hint() -> String {+ "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()+}+++/// The run id used for its page folder: board stem, engine slug, prompt date.+fn report_id(r: &Run) -> String {+ if let Some(id) = r.data["reportId"].as_str() {+ return id.to_string();+ }+ let src = r.data["sourceBoard"].as_str().or_else(|| r.data["boards"][0].as_str()).unwrap_or("board");+ let board = Path::new(src).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+ let eng: String = r.data["engine"].as_str().unwrap_or("engine").to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect::<String>().split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");+ // prompt date and time, so two runs by one engine on one day get their own folders+ let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("");+ let date = pt.chars().take(10).filter(|c| *c != '-').collect::<String>();+ let hhmm = pt.chars().skip(11).take(5).filter(|c| *c != ':').collect::<String>();+ format!("{board}-{eng}-{date}-{hhmm}")+}++/// Build the sub-README and its files under <run>/report/docs/runs/<id>/; returns (staging dir, rel dir, README path).+fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {+ let id = report_id(r);+ let rel = format!("docs/runs/{id}");+ let stage = dir.join("report");+ let out = stage.join(&rel);+ // the staging folder is rebuilt from nothing every time, so an older cut or clip never rides along+ let _ = std::fs::remove_dir_all(&out);+ let _ = std::fs::create_dir_all(&out);+ let slug = page.split('/').nth(1).unwrap_or(page);+ let blob = format!("/blob/app/{slug}/{rel}");+ let led = r.ledger();+ let engine = r.data["engine"].as_str().unwrap_or("");+ let prompt = r.data["clock"]["promptTime"].as_str().unwrap_or("");+ let finished = r.data["clock"]["finishTime"].as_str();+ let delivered = r.data["clock"]["deliveredTime"].as_str();+ let cur = r.data["currentStep"].as_str().unwrap_or("intake");+ let now_min = r.elapsed_minutes();+ let mut md = String::new();+ md.push_str(&format!("# AI Flow run: {} on {}\n\n", engine, Path::new(r.data["boards"][0].as_str().unwrap_or("")).file_stem().and_then(|s| s.to_str()).unwrap_or("the board")));+ let ss = r.sessions();+ let later: f64 = (r.active_minutes_total() - r.delivered_active_minutes().unwrap_or(0.0)).max(0.0);+ let status = match (delivered, finished) {+ (Some(d), _) => format!("**Delivered** at {d}: **{:.1} min of AI time** from the prompt to \"done, here is your video\" ({:.1} min on the wall clock). {}", r.delivered_active_minutes().unwrap_or(0.0), r.delivered_minutes().unwrap_or(0.0), if ss.len() > 1 { format!("Since then {later:.1} min more of AI time over {} follow-up prompt(s), listed under Sessions; none of it is in the delivery number.", ss.len() - 1) } else { String::new() }),+ (None, Some(f)) => format!("**Finished** (board design checks) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),+ _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min),+ };+ md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (unobserved, not proven idle) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes()));+ if let Some(d) = r.data["delivery"].as_object() {+ if let Some(v) = d.get("video").and_then(|v| v.as_str()) {+ if Path::new(v).is_file() {+ // a new name for every cut, so a browser that cached the last one shows this one+ let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).filter(|s: &String| !s.is_empty()).unwrap_or_else(|| std::fs::metadata(v).and_then(|m| m.modified()).ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs().to_string()).unwrap_or_else(|| "0".into()));+ let name = format!("final-{stamp}.mp4");+ let _ = std::fs::copy(v, out.join(&name));+ let vs = media_seconds(v);+ md.push_str(&format!("## The video\n\n**The final cut of this run, {vs:.0} s: only the moments something moved, every step in order, the AI's analysis drawings fullscreen, the run and step clocks in the corner, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n"));+ }+ }+ }+ if let Some(v) = r.data["comparison"]["video"].as_str() {+ if Path::new(v).is_file() {+ let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());+ let name = format!("comparison-{stamp}.mp4");+ let _ = std::fs::copy(v, out.join(&name));+ md.push_str(&format!("## Side by side with {}\n\n**{} on the left, {} on the right, step by step in flow order, {:.0} s: each run's own clocks in its own corner, both AIs' drawings side by side, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", r.data["comparison"]["withEngine"].as_str().unwrap_or("another run"), engine, r.data["comparison"]["withEngine"].as_str().unwrap_or(""), r.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));+ }+ }+ // sessions+ md.push_str("## Sessions\n\nOne row per human prompt: the AI's time on it, unobserved gaps excluded by the heuristic, which may include external AI work.\n\n");+ md.push_str(&sessions_table(r));+ md.push_str("\n");+ // per-step table+ let rep = r.step_report();+ let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];+ let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();+ for k in rep.keys() { if !names.contains(k) { names.push(k.clone()); } }+ md.push_str("## Per step\n\nAI time only, the idle cut out.\n\n| Step | AI min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");+ let (mut tw, mut tt, mut tb) = (0u64, 0u64, 0u64);+ for n in &names {+ let e = &rep[n];+ let (w, t, b) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0));+ tw += w; tt += t; tb += b;+ md.push_str(&format!("| {} | {:.1} | {:.1} | {:.1} | {} | {} | {} | {:.1} min |\n", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, e["turns"], e["visits"], e["returns"], e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+ }+ md.push_str(&format!("| **total** | **{:.1}** | **{:.1}** | **{:.1}** | | | | |\n\n", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0));+ // returns+ let mut returns: Vec<String> = Vec::new();+ for e in &led {+ if e["event"] == "step" && e["back"].as_bool() == Some(true) {+ returns.push(format!("- {} back to **{}** (visit {}): {}", e["t"].as_str().unwrap_or(""), e["step"].as_str().unwrap_or(""), e["visit"], e["why"].as_str().unwrap_or("")));+ }+ }+ if !returns.is_empty() { md.push_str("## Returns to earlier steps\n\n"); md.push_str(&returns.join("\n")); md.push_str("\n\n"); }+ // decisions and markers+ let mut marks: Vec<String> = Vec::new();+ for e in &led { if e["event"] == "marker" { marks.push(format!("- {} {}", e["t"].as_str().unwrap_or(""), e["label"].as_str().unwrap_or(""))); } }+ if !marks.is_empty() { md.push_str("## Decisions along the way\n\n"); md.push_str(&marks.join("\n")); md.push_str("\n\n"); }+ // outcomes+ let o = r.outcomes();+ md.push_str("## Where the board stands\n\n");+ if let Some(x) = o.get("route") { md.push_str(&format!("- routing: {} unrouted after {} passes\n", x["unrouted"], x["passes"])); }+ if let Some(x) = o.get("gate") { md.push_str(&format!("- gate: {} new DRC errors, {} unconnected, {} inherited\n", x["newErrors"], x["unconnected"], x["inheritedErrors"])); }+ if let Some(x) = o.get("land-route") { md.push_str(&format!("- landed live: {} of {} route entries\n", x["accepted"], x["entries"])); }+ if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours ({}): {} groups, failed {}\n", pourverify::mode_label(x), x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }+ if let Some(x) = o.get("measure") { md.push_str(&format!("- copper kept (mm2 per layer): {}\n", x["filledAreaByLayerMm2"])); }+ for k in ["analyze-current", "analyze-thermal"] { if let Some(x) = o.get(k) { md.push_str(&format!("- {k}: {}\n", if x["pass"].as_bool() == Some(true) { "pass" } else { "fail" })); } }+ for c in o.get("analyze-current").and_then(|x| x["acknowledgedPeaks"].as_array()).into_iter().flatten() { md.push_str(&format!("- acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) ({}): {} (by {})\n", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))); }+ for a in o.get("analyze-thermal").and_then(|x| x["boardSinkAssumptions"].as_array()).into_iter().flatten() { md.push_str(&format!("- board-sink assumption: {}\n", a.as_str().unwrap_or(""))); }+ md.push_str("\n");+ // clips and screenshots per visit+ md.push_str("## The steps on camera\n\nOne clip per step visit, sped up 10x, with the editor at the start and the end of the visit. A caption above every clip and screenshot says what it shows.\n\n");+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ // clip seconds from the ledger's artifacts+ let mut secs: std::collections::BTreeMap<String, (f64, f64, f64)> = std::collections::BTreeMap::new(); // real, 10x, action+ for e in &led {+ if e["event"] == "artifact" {+ let key = e["step"].as_str().unwrap_or("").to_string();+ let entry = secs.entry(key).or_insert((0.0, 0.0, 0.0));+ match e["kind"].as_str() { Some("clip") => entry.0 = e["seconds"].as_f64().unwrap_or(0.0), Some("clip10x") => entry.1 = e["seconds"].as_f64().unwrap_or(0.0), Some("clipAction") => entry.2 = e["seconds"].as_f64().unwrap_or(0.0), _ => {} }+ }+ }+ let standing = standing_artifacts(&led);+ let what_of = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };+ for c in &caps {+ if c.get("discarded").is_some() { continue; }+ let Some(step) = c["step"].as_str() else { continue };+ let visit = c["visit"].as_u64().unwrap_or(1);+ let tag = format!("{step}-{visit}");+ let rework = c["back"].as_bool() == Some(true);+ let title = if visit > 1 { format!("{step}, visit {visit}{}", if rework { " (rework)" } else { "" }) } else { step.to_string() };+ md.push_str(&format!("### {title}\n\n"));+ md.push_str(&format!("What this step does: {}\n\n", what_of(step)));+ if let Some(why) = c["why"].as_str() { md.push_str(&format!("Why the AI came back here: {why}\n\n")); }+ if let Some(sus) = c["suspect"].as_str() { md.push_str(&format!("**This clip filmed nothing usable: {sus}. It is kept as evidence and left out of the video.**\n\n")); }+ let (start, stop) = (c["start"].as_str().unwrap_or(""), c["stop"].as_str().unwrap_or("still running"));+ // the 10x cut next to the raw clip+ let mut have_clip = false;+ if let Some(f) = c["file"].as_str() {+ let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));+ let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+ let (real, quick, act) = secs.get(&tag).copied().unwrap_or((0.0, 0.0, 0.0));+ if Path::new(&action).is_file() {+ let _ = std::fs::copy(&action, out.join(format!("{tag}.mp4")));+ md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}. Motion only: {:.1} min of real time, {:.0} s of it had something moving; the clock in the corner is the real elapsed time.**\n\n", real / 60.0, act));+ md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));+ have_clip = true;+ } else if Path::new(&fast).is_file() {+ let _ = std::fs::copy(&fast, out.join(format!("{tag}.mp4")));+ md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}, {:.1} min of real time shown at 10x ({:.0} s).**\n\n", real / 60.0, quick));+ md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));+ have_clip = true;+ }+ }+ if !have_clip { md.push_str(&format!("**Clip: {title}, still recording (started {start}); it appears here when the step ends.**\n\n")); }+ if let Some(f) = c["file"].as_str() {+ let sheet = format!("{}-sheet.png", f.trim_end_matches(".mp4"));+ if Path::new(&sheet).is_file() {+ let _ = std::fs::copy(&sheet, out.join(format!("{tag}-sheet.png")));+ md.push_str(&format!("**Contact sheet: nine frames spread through the {title} clip, what the camera saw.**\n\n\n\n"));+ }+ }+ for when in ["start", "end"] {+ let src = dir.join(format!("shot-{tag}-{when}.png"));+ if src.is_file() {+ let _ = std::fs::copy(&src, out.join(format!("shot-{tag}-{when}.png")));+ md.push_str(&format!("**Screenshot: the board when {title} {}.**\n\n", if when == "start" { "began" } else { "ended" }));+ md.push_str(&format!("\n\n", if when == "start" { "began" } else { "ended" }));+ }+ }+ // what the AI made during this visit: its own analysis drawings, with its caption above each+ for (n, e) in standing.iter().filter(|e| e["step"].as_str() == Some(step) && e["visit"].as_u64() == Some(visit)).enumerate() {+ let Some(f) = e["file"].as_str() else { continue };+ if !Path::new(f).is_file() { continue; }+ let ext = Path::new(f).extension().and_then(|x| x.to_str()).unwrap_or("png");+ let name = format!("{tag}-{}-{n}.{ext}", e["kind"].as_str().unwrap_or("artifact"));+ let _ = std::fs::copy(f, out.join(&name));+ let cap = e["caption"].as_str().unwrap_or("");+ if matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp") {+ md.push_str(&format!("**The AI's drawing: {cap}**\n\n\n\n"));+ } else {+ md.push_str(&format!("**The AI's file: {cap}** [{name}]({blob}/{name})\n\n"));+ }+ }+ }+ if let Some(d) = r.data["delivery"].as_object() {+ md.push_str("## Delivered\n\n");+ md.push_str(&format!("What the AI said when it delivered (the video is at the top of this page):\n\n> {}\n\n", d.get("message").and_then(|m| m.as_str()).unwrap_or("")));+ }+ 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!("| {} | {} | {:.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");+ let readme = out.join("README.md");+ let _ = std::fs::write(&readme, md);+ (stage, rel, readme)+}++/// Push the report to the page's repo and reload it in the human's browser; returns the page URL.+fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, thread: &str) -> (String, Vec<String>) {+ let (stage, rel, _readme) = build_report(r, dir, page);+ // the files viewer renders a sub-README as a page; the bare path serves raw markdown+ let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");+ let mut notes = Vec::new();+ if push {+ // only the files that changed since the last push go up, in batches the gateway takes+ // (about 20 MB each; a 75 MB report in one request came back 502); a failed batch is said+ // out loud and the caller turns it into an ERROR+ let cache_path = dir.join("report").join(".pushed.json");+ let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+ let mut files: Vec<(String, u64, String)> = Vec::new();+ if let Ok(d) = std::fs::read_dir(stage.join(&rel)) {+ for e in d.filter_map(|e| e.ok()) {+ let name = e.file_name().to_string_lossy().to_string();+ if name.starts_with('.') { continue; }+ let size = e.metadata().map(|m| m.len()).unwrap_or(0);+ let sum = std::process::Command::new("sha256sum").arg(e.path()).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();+ files.push((format!("{rel}/{name}"), size, sum));+ }+ }+ files.sort();+ let changed: Vec<&(String, u64, String)> = files.iter().filter(|(p, _, sum)| cache.get(p).and_then(|v| v.as_str()) != Some(sum.as_str()) || sum.is_empty()).collect();+ let (mut pushed, mut failed) = (0usize, 0usize);+ let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];+ let mut acc = 0u64;+ for f in &changed {+ if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }+ batches.last_mut().unwrap().push(f); acc += f.1;+ }+ for batch in batches.iter().filter(|b| !b.is_empty()) {+ let mut cmd = std::process::Command::new("adom-wiki");+ cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("aiflow run {}: {}", report_id(r), r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);+ for f in batch { cmd.arg(&f.0); }+ match cmd.output() {+ Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }+ Ok(o) => { failed += batch.len(); notes.push(format!("push failed for {} file(s): {}", batch.len(), aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 160))); }+ Err(e) => { failed += batch.len(); notes.push(format!("push failed: {e}")); }+ }+ }+ let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());+ notes.insert(0, format!("pushed {pushed} changed file(s) to {page}/{rel} ({} unchanged skipped{})", files.len() - changed.len(), if failed > 0 { format!("; {failed} FAILED") } else { String::new() }));+ }+ if refresh && r.data["reportSurface"].as_str() == Some("webview") {+ // a tab in the Hydrogen workspace this AI runs in: the tab's name is the stable handle+ let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+ let ws = std::process::Command::new("adom-cli").args(["hydrogen", "workspace", "get"]).output().ok().and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok());+ fn leaves(n: &Value, out: &mut Vec<String>) { if let Some(o) = n.as_object() { if o.get("type").and_then(|t| t.as_str()) == Some("leaf") { if let Some(id) = o.get("id").and_then(|i| i.as_str()) { out.push(id.to_string()); } } for v in o.values() { leaves(v, out); } } else if let Some(a) = n.as_array() { for v in a { leaves(v, out); } } }+ let mut ids = Vec::new();+ if let Some(w) = &ws { leaves(&w["root"], &mut ids); }+ let focused = ws.as_ref().and_then(|w| w["focusedPanelId"].as_str()).unwrap_or("").to_string();+ let panel = ids.iter().find(|i| **i != focused).or(ids.first()).cloned();+ match panel {+ Some(pid) => {+ let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow run", "--url", &bust, "--panel-id", &pid]).output();+ notes.push(match o { Ok(o) if o.status.success() => "refreshed in the Hydrogen webview tab \"AI Flow run\"".to_string(), Ok(o) => format!("webview: {}", String::from_utf8_lossy(&o.stderr).chars().take(160).collect::<String>()), Err(e) => format!("webview: {e}") });+ }+ None => notes.push("webview: no Hydrogen workspace panel found (is adom-cli hydrogen reachable?)".into()),+ }+ } else if refresh {+ if let Some(m) = r.data["userMachine"].as_str() {+ let br = Bridge { ai_thread: thread.to_string(), target: m.to_string() };+ let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+ let nav = br.call("nbrowser_navigate", &json!({"sessionId": "aiflow-run", "url": bust}));+ if !Bridge::ok(&nav) {+ let op = br.call("nbrowser_open_window", &json!({"sessionId": "aiflow-run", "url": bust, "thread": thread, "purpose": "show the human the live progress page of this AI Flow run"}));+ notes.push(if Bridge::ok(&op) { format!("opened in the browser on {m}") } else { format!("browser on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });+ } else {+ notes.push(format!("refreshed in the browser on {m}"));+ }+ let _ = br.call("nbrowser_flash", &json!({"sessionId": "aiflow-run"}));+ }+ }+ (url, notes)+}++fn board_of(r: &Run) -> Board {+ if r.board_pending() { err("this run has no board yet (an intake run)", &["Attach it when it exists: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (same run, same clock). Pre-board steps (sourcing, design, simulation, schematic) need no board.".into()]); }+ Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[]))+}++/// The run's live board pulled fresh through its EDA backend: (the tool's native file, the plan board+/// rendered from it). None when the run's plan board is itself the board of record (KiCad).+fn live_board(r: &Run, cli: &Cli, dir: &std::path::Path) -> Option<(String, String)> {+ let eda = aiflow_bridge::eda::active();+ let t = |v: &str, a: &Value| bridge_of(r, cli).raw_call(v, a);+ match eda.live_snapshot(&t, dir)? {+ Ok(x) => Some(x),+ Err(e) => err(&e, &[]),+ }+}++/// Why a field-screen neck happens, from the board the solve ran on: a via whose copper ring is the+/// narrowest copper of its net, or another net's track cutting a net's patch pour off from its pin.+/// Each hint names the spec change that fixes it, so a refine pass is a decision, not a search.+/// The copper fingerprint current-peak acknowledgements bind to (issue #70): copper_carry's, the+/// same one that carries Fields evidence over a copper-unchanged adoption.+fn ack_copper(board: &str) -> Result<String, String> { copper_carry::fingerprint(Path::new(board)) }++fn refine_hints(fields: &Value, board_text: &str, spec: &Value) -> Vec<String> {+ let num = |s: &str| s.parse::<f64>().unwrap_or(0.0);+ let via_re = regex_lite::Regex::new(r#"\(via \(at ([-\d.]+) ([-\d.]+)\) \(size ([\d.]+)\) \(drill ([\d.]+)\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();+ let seg_re = regex_lite::Regex::new(r#"\(segment \(start ([-\d.]+) ([-\d.]+)\) \(end ([-\d.]+) ([-\d.]+)\) \(width ([\d.]+)\) \(layer "?([^")]+)"?\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();+ let vias: Vec<(f64, f64, f64, f64, String)> = via_re.captures_iter(board_text).map(|c| (num(&c[1]), num(&c[2]), num(&c[3]), num(&c[4]), c[5].to_string())).collect();+ let segs: Vec<((f64, f64), (f64, f64), String, String)> = seg_re.captures_iter(board_text).map(|c| ((num(&c[1]), num(&c[2])), (num(&c[3]), num(&c[4])), c[6].to_string(), c[7].to_string())).collect();+ let inside = |p: (f64, f64), poly: &[(f64, f64)]| { let mut c = false; let n = poly.len(); for i in 0..n { let (a, b) = (poly[i], poly[(i + n - 1) % n]); if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; } } c };+ let cross = |a: (f64, f64), b: (f64, f64), poly: &[(f64, f64)]| { if inside(a, poly) || inside(b, poly) { return true; } let n = poly.len(); let o = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); (0..n).any(|i| { let (c, d) = (poly[i], poly[(i + 1) % n]); o(a, b, c) * o(a, b, d) < 0.0 && o(c, d, a) * o(c, d, b) < 0.0 }) };+ let mut out = Vec::new();+ for is in fields["issues"].as_array().cloned().unwrap_or_default() {+ if is["kind"].as_str() != Some("current-neck") { continue; }+ let net = is["net"].as_str().unwrap_or("").to_string();+ let at = (is["at"][0].as_f64().unwrap_or(0.0), is["at"][1].as_f64().unwrap_or(0.0));+ if let Some(v) = vias.iter().filter(|v| v.4 == net).find(|v| ((v.0 - at.0).powi(2) + (v.1 - at.1).powi(2)).sqrt() <= v.2 / 2.0 + 0.4) {+ out.push(format!("{net}: the neck at ({:.1}, {:.1}) is the copper ring around the via at ({:.2}, {:.2}) ({:.2} mm, {:.2} mm drill). Raise `viaPower` in the spec (1.0 mm with a 0.4 mm drill gives the ring 0.3 mm a side), then route, gate and land again.", at.0, at.1, v.0, v.1, v.2, v.3));+ continue;+ }+ let mut said = false;+ for z in spec["pours"].as_array().cloned().unwrap_or_default() {+ if z["net"].as_str() != Some(net.as_str()) { continue; }+ let poly: Vec<(f64, f64)> = z["polygon"].as_array().cloned().unwrap_or_default().iter().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+ if poly.len() < 3 { continue; }+ let layers: Vec<String> = z["layers"].as_array().cloned().unwrap_or_default().iter().filter_map(|l| l.as_str().map(str::to_string)).collect();+ if let Some(sg) = segs.iter().find(|sg| sg.3 != net && layers.contains(&sg.2) && cross(sg.0, sg.1, &poly)) {+ let covered = spec["planeUnder"].as_array().map(|a| a.iter().any(|p| p["net"].as_str() == Some(net.as_str()))).unwrap_or(false);+ out.push(format!("{net}: a {} track on {} crosses the pour \"{}\", cutting it off from its pin, so the current takes a narrow track instead. {}", sg.3, sg.2, z["name"].as_str().unwrap_or(""), if covered { "A planeUnder entry for it exists already: check that its polygon covers the whole pour." } else { "Add a `planeUnder` entry {layer, net, polygon: that pour's polygon} so the router keeps other nets out of it, then route, gate and land again." }));+ said = true; break;+ }+ }+ if !said { out.push(format!("{net}: neck at ({:.1}, {:.1}) on {}: widen {net} in `wideNets`, or give it a patch pour there.", at.0, at.1, is["layer"].as_str().unwrap_or("?"))); }+ }+ out+}++fn bridge_of(r: &Run, cli: &Cli) -> Bridge {+ let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string();+ Bridge { ai_thread: thread(cli), target }+}++fn write_json(p: &Path, v: &Value) {+ std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));+}++/// The command as typed, without the thread and run plumbing.+fn command_line() -> String {+ let args: Vec<String> = std::env::args().skip(1).collect();+ let mut out = Vec::new();+ let mut skip = false;+ for a in args {+ if skip { skip = false; continue; }+ if a == "--ai-thread" || a == "--run" { skip = true; continue; }+ if a.starts_with("--ai-thread=") || a.starts_with("--run=") { continue; }+ out.push(a);+ }+ out.join(" ")+}++// ----------------------------------------------------------------------------- design gates (checks.rs)++/// True when this fields.json is byte-for-byte the output of an Adom Fields solve aiflow ran or watched+/// (`exec -- adom-fields analyze ...`, or the solve `analyze` starts itself).+fn fields_observed(r: &Run, file: &Path) -> bool {+ let Ok(bytes) = std::fs::read(file) else { return false };+ let h = provenance::sha256_hex(&bytes);+ r.data["fieldsSolves"].as_array().map(|a| a.iter().any(|x| x["sha256"].as_str() == Some(h.as_str()))).unwrap_or(false)+}++/// Record a finished Adom Fields solve by the hash of the fields.json it wrote.+fn record_fields_solve(r: &mut Run, file: &Path, how: &str, args: &[String]) {+ if let Ok(bytes) = std::fs::read(file) {+ let entry = json!({"sha256": provenance::sha256_hex(&bytes), "file": file.display().to_string(), "how": how, "args": args, "at": aiflow_run::now()});+ if !r.data["fieldsSolves"].is_array() { r.data["fieldsSolves"] = json!([]); }+ r.data["fieldsSolves"].as_array_mut().unwrap().push(entry);+ }+}++fn checked_remote_binding(r:&Run,br:&Bridge,remote:&str,dir:&Path)->Result<Value,String>{+ let local=PathBuf::from(r.current_board());+ let files=remote_binding::files(&local,remote)?;+ let pull=dir.join(format!("native-binding-{}",std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e|e.to_string())?.as_nanos()));+ std::fs::create_dir_all(&pull).map_err(|e|e.to_string())?;+ let reply=br.call("pull_file",&json!({"filePaths":files.iter().map(|(_,p)|p).collect::<Vec<_>>(),"saveTo":std::fs::canonicalize(&pull).map_err(|e|e.to_string())?}));+ if !Bridge::ok(&reply){return Err(format!("native binding download failed: {}",reply["error"]))}+ remote_binding::verify(&local,remote,&pull)+}++fn sha256_file(p: &str) -> String {+ use sha2::{Digest, Sha256};+ std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()+}++/// Export a schematic's netlist (kicadsexpr) with the native kicad-cli: ADOM_AIFLOW_KICAD_CLI, else+/// `kicad-cli`. In a container with no native KiCad, adom-aiflow-kicad-cli-remote runs a desktop's.+fn export_sch_netlist(sch: &str, out: &Path) -> Result<String, String> {+ let cli = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+ let o = std::process::Command::new(&cli).args(["sch", "export", "netlist", "--format", "kicadsexpr", "--output"]).arg(out).arg(sch).output()+ .map_err(|e| format!("{cli} is not runnable: {e}"))?;+ if !o.status.success() || !out.is_file() {+ let tail = |b: &[u8]| aiflow_run::clip_text(&String::from_utf8_lossy(b), 500);+ return Err(format!("{cli} sch export netlist failed: {} {}", tail(&o.stdout), tail(&o.stderr)));+ }+ Ok(cli)+}++/// The run's first board (what `start` copied): the reference for fixed parts.+fn first_board(r: &Run) -> Option<Board> {+ r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()).and_then(|p| Board::load(p).ok())+}++/// Molecule conformance of a board when the spec declares a molecule; None otherwise.+fn molecule_findings(spec: &Value, board: &Board, r: Option<&Run>) -> Option<checks::Findings> {+ if spec.get("molecule").is_none() { return None; }+ let before = r.and_then(first_board);+ Some(checks::molecule_check(board, spec, before.as_ref()))+}++/// Why the run's schematic and current board are not proven equal, or Ok when they are: the+/// recorded `netlist check` passed on this entire schematic hierarchy, and its exported netlist still+/// matches the current board (placement, routing or an adopted board cannot change a pin's net+/// unnoticed).+fn netlist_gate(r: &Run) -> Result<String, String> {+ let Some(sch) = r.data["schematic"].as_str() else { return Ok("no schematic recorded".into()) };+ let o = r.outcomes().get("netlist").cloned().ok_or("netlist check (the run has a schematic; run `adom-aiflow netlist check`)")?;+ let identity = schematic_identity::capture(Path::new(sch)).map_err(|e| format!("netlist check (schematic hierarchy: {e})"))?;+ if o["schematicFiles"] != json!(identity) { return Err("netlist check (schematic hierarchy changed or older check lacks child-sheet hashes; run it again)".into()); }+ if o["sha256"].as_str() != Some(sha256_file(sch).as_str()) { return Err("netlist check (the schematic changed since the last check; run it again)".into()); }+ let net = o["netlist"].as_str().unwrap_or("");+ if o["netlistSha256"].as_str() != Some(sha256_file(net).as_str()) { return Err("netlist check (exported netlist changed or lacks its recorded hash; run it again)".into()); }+ let sn = checks::parse_sch_netlist(&std::fs::read_to_string(net).map_err(|e| format!("netlist check ({net}: {e}; run it again)"))?).map_err(|e| format!("netlist check ({e})"))?;+ let rep = checks::compare_netlists(&sn, &Board::load(&r.current_board()).map_err(|e| format!("netlist check ({e})"))?);+ if rep["ok"] != true { return Err(format!("netlist check (the current board differs from the schematic: {} mismatched pin(s), missing {}, extra {})", rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), rep["missingRefs"], rep["extraRefs"])); }+ Ok(format!("{}/{} pins", rep["matchedPins"], rep["connectedPins"]))+}++/// The 3D orientation pass of the models step: every model bound on the board, its bbox from+/// step2glb (or its STEP points), transformed as KiCad places it, judged. Returns+/// (warnings as [{ref, model, finding}], notes on what could not be checked). Bboxes are cached per+/// file (path, size, mtime) in the run directory so reruns do not call the shared service again.+fn model_orientation(board: &Board, project_dir: &Path, cache_path: Option<&Path>) -> (Vec<Value>, Vec<String>) {+ let mut cache: serde_json::Map<String, Value> = cache_path.and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();+ let (mut warnings, mut notes, mut calls) = (Vec::new(), Vec::new(), 0usize);+ let mut unresolved: BTreeMap<String, Vec<String>> = BTreeMap::new();+ for m in checks::model_refs(board) {+ let Some(file) = checks::resolve_model(&m.path, project_dir) else { unresolved.entry(m.path.clone()).or_default().push(m.reference.clone()); continue };+ let meta = std::fs::metadata(&file).ok();+ let key = format!("{}|{}|{}", file.display(), meta.as_ref().map(|x| x.len()).unwrap_or(0), meta.and_then(|x| x.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0));+ let got = match cache.get(&key) {+ Some(v) => serde_json::from_value::<checks::BBox>(v["bbox"].clone()).ok().map(|b| (b, v["source"].as_str().unwrap_or("").to_string())),+ None => {+ let g = checks::model_bbox(&file, &mut calls);+ if let Some((b, src)) = &g { cache.insert(key, json!({"bbox": b, "source": src})); }+ g+ }+ };+ let Some((bb, src)) = got else { notes.push(format!("{} ({}): no bbox from step2glb or the STEP points", m.reference, file.display())); continue };+ let placed = checks::transform_bbox(&bb, m.offset, m.scale, m.rotate);+ for finding in checks::orientation_findings(&m, &placed) {+ warnings.push(json!({"ref": m.reference, "model": file.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), "rotate": m.rotate, "offset": m.offset, "bboxSource": src, "finding": finding}));+ }+ }+ for (p, refs) in unresolved { notes.push(format!("not checked here (path does not resolve in this container): {p} ({})", refs.join(","))); }+ if let Some(p) = cache_path { let _ = std::fs::write(p, serde_json::to_string_pretty(&Value::Object(cache)).unwrap_or_default()); }+ (warnings, notes)+}++/// Orientation warnings the AI has not acknowledged (run.data.modelAcks: {ref: why}).+fn unacked_orientation(r: &Run, warnings: &[Value]) -> Vec<String> {+ let mut refs: Vec<String> = warnings.iter().filter_map(|w| w["ref"].as_str()).filter(|x| r.data["modelAcks"].get(*x).is_none()).map(str::to_string).collect();+ refs.dedup();+ refs+}++fn project_dir_of(r: &Run) -> PathBuf {+ let src = r.data["sourceBoard"].as_str().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r.current_board()));+ src.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))+}++/// 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::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+/// run is still running: a long solve is the step's own work, not a step left undeclared.+fn overdue_warning(r: &Run) -> Option<String> {+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let w = sequence::overdue_warning(&r.data, &flow, &run_sequence(r, &flow), aiflow_run::parse(&now()).unwrap_or(0))?;+ if sequence::open_exec(&r.ledger(), &sequence::pid_alive).is_some() { return None; }+ Some(w)+}++/// The NEXT line: the exact command for the next required step, with its one-line description.+fn next_step_line(r: &Run, cli: &Cli) -> Option<String> {+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let seq = run_sequence(r, &flow);+ let next = sequence::next_required(&r.dir, &r.data, &flow, &seq)?;+ let thread = cli.ai_thread.clone().or_else(|| r.data["aiThread"].as_str().map(str::to_owned)).unwrap_or_else(|| "<thread>".into());+ let does = sequence::short_does(&flow, &next);+ Some(format!("NEXT: adom-aiflow --run {} --ai-thread \"{thread}\" step {next}{}", run_dir(cli).display(), if does.is_empty() { String::new() } else { format!(" ({does})") }))+}++fn print_next(cli: &Cli) {+ if let Some(r) = Run::open(&run_dir(cli)) { if let Some(l) = next_step_line(&r, cli) { println!("{l}"); } }+}++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::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())));+ matches!(s.status, sequence::Status::Todo | sequence::Status::Redo) || !s.unproven.is_empty() || failed+ }).map(|s| s.name.clone()).collect();+ let stop = evidence.map(|p| std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str::<Value>(&s).map_err(|e| e.to_string())));+ continuation::assess(&r.data, &pending, stop)+}++/// A routing plan read and normalised the one way both `gate` and `land` see it (issue #33):+/// repeated waypoints and paths with no length are dropped; the notes name each drop.+fn read_plan_normalised(path: &str, b: &Board) -> (Value, Vec<String>) {+ // `{"nets": [...]}` or a bare array of entries: both become the object form first (issue #47)+ let mut v = aiflow_copper::normalize_plan(read_json(path));+ if let Err(e) = aiflow_copper::plan_entries(&v) { err(&format!("{path}: {e}"), &[]); }+ let notes = aiflow_copper::normalise_plan(b, &mut v);+ (v, notes)+}++fn print_plan_notes(notes: &[String]) {+ if notes.is_empty() { return; }+ eprintln!("Note: the plan was normalised before use ({} change(s)); the gate and land route see the same copper:", notes.len());+ for n in notes { eprintln!(" {n}"); }+}++/// A lane's Python as a file to run: `python3 -c <script>` passes it as ONE argument, and Linux caps one+/// argument at 128 KiB (MAX_ARG_STRLEN), so a lane past that never started ("Argument list too long",+/// measured 2026-10-16 on the 151 KB Fusion lane). Written once per content (the name carries its hash).+fn lane_script(lane: &str, script: &str) -> std::path::PathBuf {+ let dir = std::env::temp_dir().join("adom-aiflow-lanes");+ let _ = std::fs::create_dir_all(&dir);+ let path = dir.join(format!("{lane}-{}.py", &provenance::sha256_hex(script.as_bytes())[..16]));+ if !path.is_file() {+ let tmp = path.with_extension(format!("tmp{}", std::process::id()));+ std::fs::write(&tmp, script).unwrap_or_else(|e| err(&format!("could not write the {lane} lane script: {e}"), &[]));+ let _ = std::fs::rename(&tmp, &path);+ }+ path+}++fn main() {+ let (skill_home, skill_pkg) = skill_hints::here();+ let input_hint = skill_hints::command_hint(&command_line(), &skill_home, skill_pkg.as_deref());+ let cli = match Cli::try_parse() {+ Ok(c) => c,+ Err(e) => {+ // a malformed command line names the skill section that describes the command's input+ if e.use_stderr() { let _ = e.print(); if let Some(h) = &input_hint { eprintln!("Hint: {h}"); } std::process::exit(e.exit_code()); }+ e.exit()+ }+ };+ if let Some(h) = input_hint { let _ = INPUT_HINT.set(h); }+ // A host stop hook must not open windows, start captures or append timing events.+ if let Cmd::FinishGuard { json: as_json, stop_evidence } = &cli.cmd {+ let r = load_run(&cli);+ let mut report = continuation_report(&r, stop_evidence.as_deref());+ let warning = overdue_warning(&r);+ if let (Some(w), Some(o)) = (&warning, report.as_object_mut()) { o.insert("warning".into(), json!(w)); }+ if *as_json { println!("{}", serde_json::to_string_pretty(&report).unwrap()); }+ else { println!("{}: {}", report["decision"].as_str().unwrap_or("continue"), report["reason"].as_str().unwrap_or(""));+ println!("Next: {}", report["nextAction"].as_str().unwrap_or("review remaining scope"));+ if let Some(w) = &warning { println!("WARNING: {w}"); } }+ std::process::exit(if report["mayStop"] == true { 0 } else { 2 });+ }+ let dir = run_dir(&cli);+ // the KiCad DRC gate and kicad-cli steps run a desktop's native KiCad through Adom Bridge when this+ // container has none: the run's own desktop, unless the human named another (a fresh thread never+ // knew to set KICAD_REMOTE_TARGET, and the helper fell back to one office machine)+ if std::env::var("KICAD_REMOTE_TARGET").is_err() {+ if let Some(t) = std::fs::read_to_string(dir.join("run.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v["target"].as_str().or(v["data"]["target"].as_str()).filter(|t| !t.is_empty()).map(String::from)) {+ std::env::set_var("KICAD_REMOTE_TARGET", t);+ }+ }+ // All engines share the same default. Server/write children must never open tabs recursively.+ if !matches!(cli.cmd, Cmd::Timeline { .. } | Cmd::Demo { .. }) {+ let ai_thread = cli.ai_thread.clone().or_else(|| Run::open(&dir).and_then(|r|r.data["aiThread"].as_str().map(str::to_owned))).unwrap_or_else(|| "adom-aiflow".into());+ let _ = DASHBOARD.set((dir.clone(), ai_thread.clone()));+ ensure_dashboard(&dir, &ai_thread, matches!(cli.cmd, Cmd::Prompt { .. }));+ }+ // every command is a turn in the run's ledger; the gap before it is the AI's thinking time+ if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. } | Cmd::Demo { .. }) && !matches!(&cli.cmd, Cmd::Timeline { action, .. } if action == "serve") {+ if let Some(mut r) = Run::open(&dir) {+ let idx = r.turn_begin(&command_line());+ let _ = r.save();+ let _ = TURN.set((dir.clone(), idx));+ // the over-budget nag: a run that sits in one step far past its expected minutes is most+ // likely doing later steps' work undeclared (issue #44). Warn only; `step` itself is the fix.+ if !matches!(cli.cmd, Cmd::Step { .. }) { if let Some(w) = overdue_warning(&r) { eprintln!("WARNING: {w}"); } }+ // the clip guard: a recording that has outrun its step's budget is a mistake, not evidence.+ // Stop it here, on whatever command comes next, before it eats the disk (one once ran+ // nine hours on a static editor because deliver did not stop it).+ if let (Some(target), Some(t)) = (r.data["target"].as_str().map(str::to_string), cli.ai_thread.clone()) {+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+ let started = caps[pos]["start"].as_str().and_then(aiflow_run::parse).unwrap_or(u64::MAX);+ let age = aiflow_run::parse(&now()).unwrap_or(0).saturating_sub(started);+ let step = caps[pos]["step"].as_str().unwrap_or("").to_string();+ let budget_min = match step.as_str() { "placement" => 40, "routing" => 30, "pours" => 15, "intake" => 10, "models" => 20, "sourcing" => 20, "design" | "simulation" | "schematic" => 30, _ => 10 } as u64;+ let guard_due = age > budget_min * 60 * 2 || age > 3600;+ // issue #59: a take that died (window gone, bridge restarted) is restarted on the step's window now+ if !guard_due && !matches!(cli.cmd, Cmd::Step { .. } | Cmd::Capture { .. } | Cmd::Finish | Cmd::Deliver { .. }) {+ if let Some(mut r2) = Run::open(&dir) {+ let br = Bridge { ai_thread: t.clone(), target: target.clone() };+ if let Some(line) = clipwatch::watch(&mut r2, &br, &dir) { eprintln!("WARNING: {line}"); }+ let _ = r2.save();+ }+ }+ if guard_due {+ let br = Bridge { ai_thread: t, target };+ if let Some(mut r2) = Run::open(&dir) {+ r2.mark(&format!("clip guard: the {step} clip ran {} min, past its {budget_min} min budget twice over; stopped by the guard", age / 60));+ stop_running_clip(&mut r2, &br, &dir, "guard");+ let _ = r2.save();+ eprintln!("clip guard: stopped the {step} clip after {} min (budget {budget_min} min)", age / 60);+ }+ }+ }+ }+ }+ }+ match &cli.cmd {+ Cmd::BrowserCheck { input, lane } => {+ let data: Value = serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e| err(&e.to_string(), &[]))).unwrap_or_else(|e| err(&e.to_string(), &[]));+ let result = browser_access::classify(&data, lane).unwrap_or_else(|e| err(&e, &[]));+ println!("{}", serde_json::to_string_pretty(&result).unwrap());+ }+ Cmd::Exec { command } => {+ thread(&cli); let mut r=load_run(&cli);+ // an id and this process's pid: other commands see a running exec (the overdue warning waits for it)+ let exec_id=format!("{}-{}",std::process::id(),now());+ r.log("external-start",json!({"program":command[0],"args":&command[1..],"execId":exec_id,"pid":std::process::id()}));+ r.save().unwrap();+ let status=std::process::Command::new(&command[0]).args(&command[1..]).status();+ let mut r=load_run(&cli);+ match status {+ Ok(status)=>{+ r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success(),"execId":exec_id}));+ if status.success() && provenance::is_fields_solve(command) { if let Some(f)=provenance::fields_out(command) { record_fields_solve(&mut r, Path::new(&f), "exec", command); } }+ r.save().unwrap();+ if !status.success(){err(&format!("external command failed: {} ({status})",command[0]), &["Inspect the command output above, fix the reported failure and retry with adom-aiflow exec -- <program> <args>. The failed command's time is retained.".into()]);}+ ok("external command completed; its running time is in this session", &["Use exec for build, browser automation and other work outside AI Flow. Do not start an idle heartbeat; only actual work counts.".into()]);+ }+ Err(e)=>{ r.log("external-end",json!({"program":command[0],"success":false,"error":e.to_string(),"execId":exec_id})); r.save().unwrap(); err(&format!("could not start external command {}: {e}",command[0]), &["Check the executable path, then retry adom-aiflow exec -- <program> <args>.".into()]) }+ }+ }+ Cmd::SilkscreenSections { input, out } => {+ thread(&cli); let _r=load_run(&cli);+ let data:serde_json::Value=serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e|err(&e.to_string(), &[]))).unwrap_or_else(|e|err(&e.to_string(), &[]));+ let result=silkscreen_sections::solve(&data).unwrap_or_else(|e|err(&e,&[]));+ std::fs::write(out,serde_json::to_vec_pretty(&result).unwrap()).unwrap_or_else(|e|err(&e.to_string(), &[]));+ if !result["unresolved"].as_array().unwrap().is_empty(){err("Documentation sections remain unresolved", &["Keep rows together; try a shorter line layout, another interior region or a readable font. Do not scatter sentences.".into()]);}+ let fallbacks=result["sections"].as_array().unwrap().iter().filter(|s|s["edgeFallback"]==true).count();+ ok(&format!("Native Rust placed {} documentation labels; {fallbacks} edge fallbacks",result["placements"].as_array().unwrap().len()), &["Final documentation pass: prefer open board interior, preserve left-aligned sections and include a source-backed board description. Physical pin/contact labels remain local. Review edge fallbacks, font sizes, native DRC and BOTH EDA views.".into()]);+ }+ Cmd::SilkscreenPreflight { input, out } => {+ thread(&cli); let _r=load_run(&cli);+ let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-preflight.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("preflight could not run: {e}"), &[]));+ if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Repack every reported collision before transferring to the EDA. Include full custom-pad mask geometry, fitted bodies, holes and footprint artwork.".into()]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Preflight passed; confirm with native DRC and inspect both faces. Association quality remains a separate visual gate.".into()]);+ }+ Cmd::BoardGlb { action, glb, board, out, finish, paste, report } => {+ let mut run = Run::open(&dir);+ if run.is_some() { thread(&cli); }+ if action == "copper" {+ if let Some(open) = run.as_ref().and_then(|r| step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null))) {+ err(&format!("board twin not delivered: {open}"), &["The STEP behind this GLB carries exporter geometry warnings nobody has reviewed. Review the STEP at those spots, then `adom-aiflow step-export --ack --why \"...\"`; never drop a layer to silence the exporter.".into()]);+ }+ }+ let board_path = board.clone().map(|b| b.display().to_string()).or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("board-glb needs --board <file.kicad_pcb> outside a run", &["Pass the .kicad_pcb the STEP was exported from.".into()]));+ let spec_finish = run.as_ref().map(spec_soft).and_then(|v| v["fab"]["finish"].as_str().map(String::from));+ let finish = finish.clone().or(spec_finish).unwrap_or_else(|| "copper".into());+ let mut command = std::process::Command::new(aiflow_python());+ command.arg("-c").arg(include_str!("../../../tools/board-glb-copper.py")).arg(action).arg("--glb").arg(glb).arg("--board").arg(&board_path).arg("--finish").arg(&finish).arg("--paste").arg(paste);+ if let Some(v) = out { command.arg("--out").arg(v); }+ if let Some(v) = report { command.arg("--report").arg(v); }+ let result = command.output().unwrap_or_else(|e| err(&format!("board-glb needs python3 with shapely and numpy: {e}"), &["Reinstall adom-aiflow so its isolated Python environment exists.".into()]));+ let text = format!("{}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));+ if let Some(r) = run.as_mut() {+ r.outcome("boardGlb", json!({"ok": result.status.success(), "action": action, "glb": out.as_ref().unwrap_or(glb), "board": board_path, "finish": finish, "paste": paste}));+ r.save().unwrap_or_else(|e| err(&e, &[]));+ }+ if !result.status.success() {+ // the export hint only when the tool found the STEP lacking pads the board has (issue #81)+ let export_hint = if text.contains("Export the STEP with --include-pads") { "Use the .kicad_pcb the STEP was exported from, and export the STEP with --include-pads --include-soldermask (skill aiflow-molecule)." } else { "Use the .kicad_pcb the STEP was exported from (skill aiflow-molecule)." };+ err(&text, &["Pads and test points are always real copper (or the board's stated finish), and the mask opens exactly where the footprint mask layer says. Fix it by construction with `adom-aiflow board-glb copper`; never hand-recolour a material index or lift the pads.".into(), export_hint.into()]);+ }+ ok(&text, &["Look at it before shipping: render a close-up of the test points and an under-part pad (skill aiflow-molecule). Grey or pale pads are not done.".into()]);+ }+ Cmd::SilkscreenMask { board, out } => {+ thread(&cli); let _r=load_run(&cli);+ let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-kicad-mask.py")).arg("--board").arg(board).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("mask export could not run: {e}"), &[]));+ if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &["Unsupported mask geometry is a blocking preflight error, never an anchor-box fallback.".into()]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Use these complete native mask polygons with fitted body, hole and artwork obstacles.".into()]);+ }+ Cmd::SilkscreenContactPolicy { input, geometry, out, no_contact_borders } => {+ thread(&cli); let _r=load_run(&cli);+ let mut command=std::process::Command::new(aiflow_python());command.arg("-c").arg(include_str!("../../../tools/silkscreen-contact-policy.py")).arg("--input").arg(input).arg("--geometry").arg(geometry).arg("--out").arg(out);+ if *no_contact_borders{command.arg("--no-contact-borders");}+ let result=command.output().unwrap_or_else(|e|err(&format!("contact policy could not run: {e}"), &[]));+ if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &[]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);+ }+ Cmd::SilkscreenAudit { input, out, record, board } => {+ thread(&cli); let mut r = load_run(&cli);+ let result = std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));+ if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }+ if *record {+ // the step proof: this audit passed, on an inventory that covers the board it claims to audit+ let board_path = board.clone().unwrap_or_else(|| r.current_board());+ let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));+ let audit = read_json(&out.display().to_string());+ let data = read_json(&input.display().to_string());+ let mut proof = silkproof::proof(&audit, &data, &b).unwrap_or_else(|why| {+ r.log("silkscreen-proof-refused", json!({"board": board_path, "audit": out, "why": why}));+ err(&format!("silkscreen proof not recorded: {}", why.join("; ")), &["Build the inventory from the whole board (every footprint by its reference, every via and hole on both faces) and fix the labels, then `silkscreen-audit --record` again.".into()])+ });+ proof["board"] = json!(board_path);+ proof["boardSha256"] = json!(sha256_file(&board_path));+ proof["audit"] = json!(out.display().to_string());+ proof["auditSha256"] = json!(sha256_file(&out.display().to_string()));+ proof["input"] = json!(input.display().to_string());+ proof["inputSha256"] = json!(sha256_file(&input.display().to_string()));+ r.outcome("silkscreen", proof.clone());+ r.save().unwrap();+ ok(&format!("{}silkscreen step proof recorded: {} footprints and {} vias covered, {} labels", String::from_utf8_lossy(&result.stdout), proof["footprints"], proof["vias"], proof["labels"]), &["Geometry audit only; native DRC, label association and both native 2D/3D views remain required. Then `step 3d`.".into()]);+ return;+ }+ ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required. `--record` records the silkscreen step proof from a passing audit that covers the board.".into()]);+ }+ Cmd::StepExport { board, out, log, ack, why } => {+ thread(&cli); let mut r = load_run(&cli);+ if *ack {+ let why = why.as_deref().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("step-export --ack needs --why \"what you reviewed in the STEP and why it is complete\"", &[]));+ let mut rec = r.outcomes().get("step-export").cloned().unwrap_or_else(|| err("no STEP export recorded in this run", &["Run `adom-aiflow step-export` first.".into()]));+ if rec["geometryWarnings"].as_u64().unwrap_or(0) == 0 { ok("nothing to acknowledge: the last STEP export has no geometry warnings", &[]); return; }+ rec["ack"] = json!({"why": why, "at": now(), "stepSha256": rec["stepSha256"].clone()});+ r.outcome("step-export", rec.clone());+ r.log("step-export-ack", json!({"why": why, "step": rec["step"], "geometryWarnings": rec["geometryWarnings"]}));+ r.save().unwrap();+ ok(&format!("acknowledged {} STEP geometry warning(s) for {} (bound to this STEP file; a new export needs a new review)", rec["geometryWarnings"], rec["step"].as_str().unwrap_or("")), &[]);+ return;+ }+ let board_path = board.clone().unwrap_or_else(|| r.current_board());+ let stem = Path::new(&board_path).file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();+ let step = out.clone().unwrap_or_else(|| dir.join("twin").join(format!("{stem}.step")));+ let rec = match log {+ Some(l) => { let text = std::fs::read_to_string(l).unwrap_or_else(|e| err(&format!("{}: {e}", l.display()), &[])); let mut v = step_export::record(&board_path, Some(&step), Some(0), &text, &format!("output of an export run elsewhere: {}", l.display())); v["exitCode"] = Value::Null; v["log"] = json!(l.display().to_string()); if !v["stepWritten"].as_bool().unwrap_or(false) { err(&format!("--log needs --out <the STEP that export wrote>; {} is not here", step.display()), &[]); } v["exitCode"] = json!(0); v["exitCodeSource"] = json!("assumed 0: the export ran elsewhere and wrote the STEP"); v }+ None => {+ let _ = std::fs::create_dir_all(step.parent().unwrap_or(Path::new(".")));+ let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+ step_export::run(&board_path, &step, &kicad)+ }+ };+ write_json(&step.with_extension("export.json"), &rec);+ r.outcome("step-export", rec.clone());+ r.save().unwrap();+ let (g, m, o) = (rec["geometryWarnings"].as_u64().unwrap_or(0), rec["modelWarnings"].as_u64().unwrap_or(0), rec["otherWarnings"].as_u64().unwrap_or(0));+ if let Some(open) = step_export::open_issue(&rec) {+ let spots: Vec<String> = rec["diagnostics"].as_array().into_iter().flatten().map(|d| format!("{}: {}{}", d["kind"].as_str().unwrap_or(""), d["message"].as_str().unwrap_or(""), d["location"].as_object().map(|l| format!(" at z {} box ({}, {})-({}, {}) near {}", l["z"], l["x0"], l["y0"], l["x1"], l["y1"], d["nearbyFootprints"].as_array().map(|n| n.iter().filter_map(|x| x["ref"].as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default())).unwrap_or_default())).take(12).collect();+ err(&format!("STEP export recorded with open issues: {open}\n {}", spots.join("\n ")), &[format!("Exit status {} and the file are recorded separately from these diagnostics ({}).", rec["exitCode"], step.with_extension("export.json").display()), "Do not re-export without silkscreen or mask to hide them. Review the STEP at each spot; if it is complete, `step-export --ack --why \"...\"`; if not, fix the board geometry and export again.".into()]);+ }+ ok(&format!("STEP export {}: exit {}, {g} geometry, {m} model, {o} other warning(s){}", step.display(), rec["exitCode"], if g > 0 { " (acknowledged)" } else { "" }), &[if m > 0 { "Model warnings: a part may be missing from the twin; check the models step.".into() } else { String::new() }, "Next: step2glb convert, then `board-glb copper` (skill aiflow-molecule).".into()]);+ }+ Cmd::Export { board, out } => {+ thread(&cli); let mut r = load_run(&cli);+ let lane = sequence::lane(&dir, &r.data);+ if lane != "kicad" { err(&format!("this run builds in {}: its export is the tool's own", aiflow_bridge::eda::active().name()), &[format!("`adom-aiflow {lane} export` writes and checks the set from the {lane} design (Gerbers, drill, CPL, BOM).")]); }+ let board_path = board.clone().unwrap_or_else(|| r.current_board());+ let out_dir = out.clone().unwrap_or_else(|| dir.join("manufacturing"));+ let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+ match export::export(&board_path, &out_dir, &kicad) {+ Ok(report) => {+ write_json(&out_dir.join("export-check.json"), &report);+ r.outcome("export", report.clone());+ r.save().unwrap();+ let mut hints: Vec<String> = report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}")).collect();+ hints.extend(report["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str()).map(str::to_string));+ hints.push("Tell the human where every file is. Bottom-side CPL rotations follow kicad-cli's convention and were counted, not compared; check them against the fab's preview.".into());+ let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));+ let checked = verified::run_items(&r, facts.as_ref());+ ok(&format!("manufacturing files in {}, checked against the board: Gerbers ({} copper layers of {}, pours present as regions on {} poured layer(s)), drill ({} hits = vias and drilled pads), pick and place ({} parts), BOM ({} lines)\n{}", out_dir.display(), report["gerbers"]["copperLayers"], report["gerbers"]["boardCopperLayers"], report["fillsGerbers"]["layersWithPours"].as_object().map(|o| o.len()).unwrap_or(0), report["drill"]["hits"], report["cpl"]["refs"], report["bom"]["lines"], verified::message(&checked)), &hints);+ }+ Err((report, problems)) => {+ let _ = std::fs::create_dir_all(&out_dir);+ write_json(&out_dir.join("export-check.json"), &report);+ r.log("export-refused", json!({"board": board_path, "dir": out_dir, "problems": problems}));+ r.save().unwrap();+ let fill_hint = if problems.iter().any(|p| p.contains("filled polygon") || p.contains("G36")) { fills::HINT.to_string() } else { String::new() };+ err(&format!("manufacturing files do not check out against the board: {}", problems.join("; ")), &[fill_hint, "Fix the board or its footprint attributes (DNP, exclude from BOM/position files) and run `export` again; never hand the human files that do not match the board.".into(), "kicad-cli: ADOM_AIFLOW_KICAD_CLI picks it; in a container with no native KiCad, ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>.".into()]);+ }+ }+ }+ Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {+ let t = thread(&cli); let _r = load_run(&cli);+ let mut command = std::process::Command::new("adom-aiflow-silkscreen");+ command.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t).arg("--port").arg(port).arg("--surface").arg(surface);+ if let Some(v)=url { command.arg("--url").arg(v); }+ if let Some(v)=target { command.arg("--target").arg(v); }+ if let Some(v)=json { command.arg("--json").arg(v); }+ if let Some(v)=reason { command.arg("--reason").arg(v); }+ let result=command.output().unwrap_or_else(|e|err(&format!("silkscreen dashboard helper missing: {e}"), &["Install the matching AI Flow package helper.".into()]));+ if !result.status.success() {err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &[]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Live feed and replay visualize recorded solver events. Proposed labels do not modify the native board.".into()]);+ }+ Cmd::SilkscreenLayout { input, out, events } => {+ thread(&cli);+ let _r = load_run(&cli);+ let source = include_str!("../../../tools/silkscreen-layout.py");+ let mut command = std::process::Command::new(aiflow_python());+ command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out);+ if let Some(v)=events {command.arg("--events").arg(v);}+ let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));+ if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }+ ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);+ }+ Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force, sch, page } => {+ let t = thread(&cli);+ if !Path::new(board).is_file() {+ err(&format!("board not found: {board}"), &[]);+ }+ if !Path::new(spec).is_file() {+ err(&format!("spec not found: {spec}"), &["docs/spec-example.json on the adom-aiflow page is the shape.".into()]);+ }+ if let Some(s) = sch { if !Path::new(s).is_file() { err(&format!("schematic not found: {s}"), &["--sch names the .kicad_sch the board was made from.".into()]); } }+ // a run made by `intake` has no board yet: start attaches it and keeps the clock and the ledger+ let intake_run = Run::open(&dir).filter(|r| r.board_pending() && !*force);+ if let (Some(r), false, true) = (Run::open(&dir), *force, intake_run.is_none()) {+ err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory, or --force to start over.".into()]);+ }+ std::fs::create_dir_all(&dir).unwrap();+ let copy = dir.join("board-0.kicad_pcb");+ let src_text = std::fs::read_to_string(board).unwrap_or_default();+ // the plan board: rendered by the backend --remote-board names (a Fusion export, an Altium+ // read), or the KiCad board copied as it is (eda.rs import_board)+ if let Some(text) = aiflow_bridge::eda::import_board(remote_board.as_deref(), &src_text).unwrap_or_else(|e| err(&e, &[])) {+ std::fs::write(©, text).unwrap_or_else(|e| err(&format!("write: {e}"), &[]));+ // the fab's KiCad rules beside the .brd (`fusion schematic` puts them there) go with the+ // plan board, so the gate checks the fab, not KiCad's defaults+ let dru = std::path::Path::new(board).with_extension("kicad_dru");+ if dru.is_file() { let _ = std::fs::copy(&dru, copy.with_extension("kicad_dru")); }+ aiflow_bridge::eda::set_run_board(remote_board.as_deref().unwrap_or(""));+ } else {+ std::fs::copy(board, ©).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));+ aiflow_copper::copy_project_rules(board, ©.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+ }+ let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone());+ if let Some(mut r) = intake_run {+ let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("").to_string();+ let differs = prompt_time.as_ref().filter(|p| **p != pt).map(|p| format!("--prompt-time {p} ignored: the intake run's clock started at {pt} and stays there. "));+ r.attach_board(©.display().to_string(), &specp, engine, target.as_deref(), remote_board.as_deref());+ r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));+ if let Some(p) = page { r.data["page"] = json!(p); }+ if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }+ let idx = r.turn_begin(&command_line());+ record_usage(&mut r, "start");+ r.save().unwrap();+ let _ = TURN.set((dir.clone(), idx));+ let steps: Vec<String> = r.data["steps"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+ let mut attach_hints = vec![differs.unwrap_or_default() + &format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Re-run `sourcing check --bom <csv>` now: the check before the board could not diff the BOM against the board's references.".into()];+ // `rules use` in the intake run recorded fabRules before there was a spec: the gate+ // falls back to it when the spec names no fab.rules, and says when they disagree+ if let Some((p, layers, warning)) = checks::rules_target(&read_json(spec), &r.data["fabRules"]) {+ if let Some(w) = warning { attach_hints.push(format!("Fab rules: {w}.")); }+ if let Err((e, hint)) = checks::rules_gate(&p, ©.display().to_string(), layers) { attach_hints.push(format!("Fab rules: {e}. {hint}")); }+ }+ let rc_flags = rules_card::at_start(&dir, &read_json(spec), ©, &r.data["fabRules"]);+ for f in &rc_flags { eprintln!("WARNING: {f}"); }+ attach_hints.push(rules_card_hint(&rc_flags));+ attach_hints.extend(converter_warning(&r.data, &read_json(spec), None, Board::load(©.display().to_string()).ok().as_ref()));+ ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &attach_hints);+ print_next(&cli);+ return;+ }+ let pt = prompt_time.clone().unwrap_or_else(now);+ let mut r = Run::create(&dir, ©.display().to_string(), &specp, engine, &pt, &t, target.as_deref(), remote_board.as_deref()).unwrap_or_else(|e| err(&e, &[]));+ r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));+ if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }+ if let Some(p) = page { r.data["page"] = json!(p); }+ let idx = r.turn_begin(&command_line());+ record_usage(&mut r, "start");+ r.save().unwrap();+ let _ = TURN.set((dir.clone(), idx));+ let mut start_hints = vec![format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display())];+ if sch.is_some() { start_hints.push("The run has a schematic: `adom-aiflow netlist check` proves the board equals it pin by pin; finish requires it.".into()); }+ else { start_hints.push("No --sch: finish will not ask for schematic-to-board netlist equivalence. When the board came from a schematic, start with --sch <file.kicad_sch> (or run `netlist check --sch <file>` to record it).".into()); }+ let spec_v = read_json(spec);+ if let Some((p, layers, _)) = checks::rules_target(&spec_v, &Value::Null) { if let Err((e, hint)) = checks::rules_gate(&p, board, layers) { start_hints.push(format!("Fab rules: {e}. {hint}")); } }+ let rc_flags = rules_card::at_start(&dir, &spec_v, ©, &r.data["fabRules"]);+ for f in &rc_flags { eprintln!("WARNING: {f}"); }+ start_hints.push(rules_card_hint(&rc_flags));+ start_hints.extend(converter_warning(&r.data, &spec_v, None, Board::load(©.display().to_string()).ok().as_ref()));+ if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }+ start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);+ ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);+ print_next(&cli);+ }+ Cmd::Intake { prompt_time, brief, requirements, engine, target, page, eda } => {+ let t = thread(&cli);+ if aiflow_run::parse(prompt_time).is_none() { err(&format!("--prompt-time {prompt_time} is not a time (RFC 3339 UTC, e.g. 2026-09-29T00:39:00Z)"), &["Use the human message's own timestamp, not the moment you got to it.".into()]); }+ if let Some(r) = Run::open(&dir) {+ err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory: intake opens a new run. A follow-up prompt on this run is `prompt --text ...`.".into()]);+ }+ for f in brief.iter().chain(requirements.iter()) { if !Path::new(f).is_file() { err(&format!("not found: {f}"), &[]); } }+ let canon = |p: &String| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or(p.clone());+ // a brief that is already a requirements file (aiflow-intake's shape) is recorded as both+ let req = requirements.as_ref().map(canon).or_else(|| brief.as_ref().filter(|b| std::fs::read_to_string(b).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v.get("fab").is_some() || v.get("sourcing").is_some() || v.get("outputs").is_some()).unwrap_or(false)).map(canon));+ let mut r = Run::create_intake(&dir, prompt_time, &t, engine.as_deref(), brief.as_ref().map(canon).as_deref(), req.as_deref(), target.as_deref()).unwrap_or_else(|e| err(&e, &[]));+ if let Some(p) = page { r.data["page"] = json!(p); }+ // the EDA tool, named: the step sequence is fixed at the first step after intake, before any+ // backend project exists, so a brief that never says "Altium" must not fall into kicad-new+ if let Some(e) = eda { r.data["eda"] = json!(e); }+ let idx = r.turn_begin(&command_line());+ record_usage(&mut r, "start");+ r.save().unwrap();+ let _ = TURN.set((dir.clone(), idx));+ let profile = preboard::named_profile(&requirements_of(&r));+ ok(&format!("intake run started at {}; the clock started at the prompt, {prompt_time}; step intake is open and there is no board yet", dir.display()), &[+ "Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step you declared, and with --target each step before the board films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else your own terminal window (nothing meaningful on screen is a recorded skip): work in a visible window.".into(),+ match profile { Some(p) => format!("Sourcing profile {p} (from the requirements): `sourcing check --bom design/bom.csv` gates the BOM; finish requires it."), None => "No sourcing profile named yet: write requirements.json with fab.target `fab` (default) or `jlcpcb` (aiflow-intake), then `sourcing check --bom <csv> --requirements requirements.json`.".into() },+ "Record the design record as it lands: `evidence add --kind calcs --file design/calcs.json`, `evidence add --kind simulation --file sim/<results>.json` (a failing simulation is refused), `evidence add --kind vendor-model --file <result>` for a vendor PSpice/LTspice run (adom/adom-spice-skillpack).".into(),+ "When the board exists: `start --board B.kicad_pcb --spec spec.json --engine <you> [--target <box> --remote-board <path>]` on this same --run attaches it; the clock stays at the prompt.".into(),+ if r.data["page"].is_null() { "Name the run's page once (`clips --page <owner/slug>` or `report --page ...`) so every clip lands on docs/clips.md as it stops.".into() } else { String::new() },+ "Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it.".into(),+ ]);+ print_next(&cli);+ }+ Cmd::Sourcing { what, bom, profile, qty, board, requirements } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if what != "check" { err(&format!("sourcing {what}: use `sourcing check --bom <csv>`"), &[]); }+ if let Some(q) = requirements {+ if !Path::new(q).is_file() { err(&format!("not found: {q}"), &[]); }+ r.data["requirements"] = json!(std::fs::canonicalize(q).map(|p| p.display().to_string()).unwrap_or(q.clone()));+ }+ let (spec, req) = (spec_soft(&r), requirements_of(&r));+ let named = preboard::named_profile(&spec).map(|p| (p, "the spec")).or_else(|| preboard::named_profile(&req).map(|p| (p, "the requirements")));+ let prof = profile.clone().or_else(|| named.as_ref().map(|n| n.0.clone())).unwrap_or_else(|| "fab".into());+ let override_note = match (profile, &named) { (Some(p), Some((n, by))) if p != n => format!("--profile {p} overrides {by}, which name {n}; finish checks against the recorded outcome, so re-run without --profile if that was not meant."), _ => String::new() };+ let (qty, qty_src) = match qty { Some(q) => (*q, "--qty"), None => match req["build"]["quantity"].as_u64().or(req["buildQuantity"].as_u64()).map(|q| (q, "the requirements")).or_else(|| spec["buildQuantity"].as_u64().map(|q| (q, "the spec"))) { Some(x) => x, None => (10, "the default; say --qty or requirements build.quantity") } };+ let text = std::fs::read_to_string(bom).unwrap_or_else(|e| err(&format!("{bom}: {e}"), &[]));+ let board_path = board.clone().or_else(|| (!r.board_pending()).then(|| r.current_board()));+ let refs = board_path.as_ref().map(|p| Board::load(p).map(|b| preboard::board_refs(&b)).unwrap_or_else(|e| err(&format!("board {p}: {e}"), &[])));+ let mut out = preboard::sourcing_check(&text, refs.as_ref(), &prof, qty);+ design_evidence::exact_mpn_evidence(&text, design_evidence::require_exact(&spec, &req), &mut out);+ let bomp = std::fs::canonicalize(bom).map(|p| p.display().to_string()).unwrap_or(bom.clone());+ out["bom"] = json!(bomp);+ out["bomSha256"] = json!(library_review::hash(Path::new(bom)).unwrap_or_default());+ out["board"] = json!(board_path);+ out["checkedAt"] = json!(now());+ out["profileNamedBy"] = json!(if profile.is_some() { "--profile" } else { named.as_ref().map(|n| n.1).unwrap_or("default") });+ out["qtySource"] = json!(qty_src);+ r.outcome("sourcing", out.clone());+ r.save().unwrap();+ let warnings: Vec<String> = out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("warning: {s}"))).collect();+ let rule = if prof == "jlcpcb" { "jlcpcb: every row carries its LCSC number (JLCPCB/LCSC basic parts first)" } else { "fab: every row from Mouser or Adom stock, no LCSC/JLCPCB parts (the 3rd party fab)" };+ if out["pass"] != true {+ let errs: Vec<String> = out["errors"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!(" {s}"))).collect();+ let mut hints = vec![format!("Profile {rule}. Fix the BOM (aiflow-sourcing: search by spec and in stock, record stock and the date), then check again."), "Every electrical reference needs a row with an MPN; contacts and test pads the footprint does not exclude from the BOM take a `no part` row.".into(), override_note];+ hints.extend(warnings);+ err(&format!("sourcing check failed on the {prof} profile ({} problem(s)):\n{}", errs.len(), errs.join("\n")), &hints);+ }+ let diff = if out["boardChecked"] == true { format!("every one of the board's {} BOM references is in the BOM ({} excluded by their footprints)", out["boardRefs"], out["excludedByBoard"].as_array().map(|a| a.len()).unwrap_or(0)) } else { "no board yet, so no reference diff: check again after `start --board` (finish requires the diff)".into() };+ let mut hints = vec![format!("Profile {rule}; build quantity {qty} ({qty_src})."), override_note];+ hints.extend(warnings);+ hints.push("Next: `evidence add --kind calcs --file design/calcs.json` once the values are derived (aiflow-circuit-design).".into());+ ok(&format!("sourcing check passed on the {prof} profile: {} rows, {} references ({} Adom stock, {} distributor, {} no part), stock checked {}; {diff}", out["rows"], out["refs"], out["adomStockRows"], out["distributorRows"], out["noPartRows"], out["stockCheckDates"].as_array().map(|a| a.iter().filter_map(|d| d.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()), &hints);+ }+ Cmd::Evidence { what, kind, file, note } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if what != "add" { err(&format!("evidence {what}: use `evidence add --kind calcs|simulation|vendor-model --file <json|md>`"), &[]); }+ let src = PathBuf::from(file);+ if !src.is_file() { err(&format!("no such file: {file}"), &[]); }+ let text = std::fs::read_to_string(&src).unwrap_or_else(|e| err(&format!("{file}: {e}"), &["Evidence is a JSON or Markdown file.".into()]));+ let is_json = src.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("json")).unwrap_or(false);+ if is_json && serde_json::from_str::<Value>(&text).is_err() { err(&format!("{file}: not JSON"), &[]); }+ let sha = library_review::hash(&src).unwrap_or_else(|e| err(&e, &[]));+ let (top, checks, failing) = preboard::evidence_checks(&text, is_json);+ let pass = if top == Some(false) || !failing.is_empty() { Some(false) } else if top == Some(true) || !checks.is_empty() { Some(true) } else { None };+ let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());+ let step = r.data["currentStep"].clone();+ if kind == "simulation" && pass == Some(false) {+ r.log("evidence-refused", json!({"kind": kind, "file": canon.display().to_string(), "sha256": sha, "failing": failing, "pass": top}));+ r.save().unwrap();+ err(&format!("simulation evidence refused: {} says it fails{}", file, if failing.is_empty() { " (top-level pass: false)".to_string() } else { format!(": {}", failing.iter().map(|c| format!("{} {} against {}", c["name"].as_str().unwrap_or("?"), c["value"], c["limit"])).collect::<Vec<_>>().join("; ")) }), &["A failing simulation is a design result, not evidence of a working design: change the values (aiflow-circuit-design), simulate again, and add the passing result. Obey a datasheet limit over a fitted model (aiflow-simulate).".into()]);+ }+ // a copy in the run keeps the record when the design folder moves on+ let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());+ let copy = if canon.starts_with(&rdir) { canon.clone() } else {+ let d = dir.join("evidence");+ let _ = std::fs::create_dir_all(&d);+ let dest = d.join(format!("{kind}-{}", src.file_name().and_then(|n| n.to_str()).unwrap_or("file")));+ std::fs::copy(&src, &dest).unwrap_or_else(|e| err(&format!("copy into the run: {e}"), &[]));+ std::fs::canonicalize(&dest).unwrap_or(dest)+ };+ let review: Vec<String> = match (kind.as_str(), serde_json::from_str::<Value>(&text)) { ("calcs", Ok(v)) => design_evidence::calcs_warnings(&v), ("simulation", Ok(v)) => design_evidence::simulation_warnings(&requirements_of(&r), &v), _ => Vec::new() };+ let entry = json!({"kind": kind, "file": canon.display().to_string(), "copy": copy.display().to_string(), "sha256": sha, "bytes": text.len(), "note": note, "step": step, "at": now(), "pass": pass, "checks": checks, "warnings": review});+ if !r.data["evidence"].is_array() { r.data["evidence"] = json!([]); }+ r.data["evidence"].as_array_mut().unwrap().push(entry.clone());+ r.log("evidence", entry);+ r.save().unwrap();+ let n = r.data["evidence"].as_array().map(|a| a.iter().filter(|e| e["kind"] == json!(kind)).count()).unwrap_or(0);+ let state = match pass { Some(true) => format!("pass ({} check(s))", checks.len()), Some(false) => format!("FAIL ({} failing check(s))", failing.len()), None => "no pass/fail fields".into() };+ let hint = match kind.as_str() {+ "calcs" => "Next: simulate the values the equations cannot settle (loop, startup, load step; aiflow-simulate), then `evidence add --kind simulation --file sim/<results>.json` with a top-level \"pass\" or \"checks\": [{\"name\",\"value\",\"limit\",\"pass\"}] so the gate can read it.".to_string(),+ "simulation" => if pass.is_none() { "This result states no pass or fail: add a top-level \"pass\" or a \"checks\" list against the requirements so the gate can read it (the file is recorded either way).".to_string() } else { "Next: the vendor model if the brief asks for one (`evidence add --kind vendor-model`), then `step schematic`.".to_string() },+ _ => format!("Vendor PSpice/LTspice runs belong to the adom/adom-spice-skillpack (`adom-wiki pkg install adom/adom-spice-skillpack`: PSpice for TI and LTspice on a Windows desktop through Adom Bridge); this records its result{}. Publish results, never solver speed numbers.", if pass == Some(false) { ", which says it fails: write the design conclusion in sim/<vendor>-validation.md" } else { "" }),+ };+ let mut hints = vec![hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()];+ hints.extend(review.iter().map(|w| format!("warning: {w}")));+ // issue #75: the calcs say what the converter is; a spec that exists already must carry Fields' block+ if kind == "calcs" {+ let spec = spec_soft(&r);+ if !spec.is_null() { hints.extend(converter_warning(&r.data, &spec, None, Board::load(&r.current_board()).ok().as_ref())); }+ }+ ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &hints);+ }+ Cmd::Clips { page, push } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if let Some(p) = page { r.data["clipsPage"] = json!(p); r.save().unwrap(); }+ let pg = clips_page_of(&r).unwrap_or_else(|| err("no page for the clips: `clips --page <owner/slug>` once (the project's wiki page)", &[]));+ match publish_clips(&r, &dir, &pg, *push) {+ Ok((said, files)) => ok(&said, &[if *push { "Look at the rendered page once (a pup tab or a Hydrogen webview on the human's machine): a successful push is not a working page. Link docs/clips.md from the README once (wiki-sub-readme).".to_string() } else { format!("Would push {} file(s): {}", files.len(), files.iter().take(8).cloned().collect::<Vec<_>>().join(", ")) + if files.len() > 8 { ", ..." } else { "" } }, "From now on every clip stop rebuilds and pushes this page by itself; write each clip's note in <run>/clip-notes.json (step, shows, state) after you look at its contact sheet (aiflow-live-clips).".into()]),+ Err(e) => err(&e, &["Fix the page text (clip-notes.json is where notes come from), then `clips --push` again.".into()]),+ }+ }+ Cmd::Widget { action, file, label, step, progress } => {+ let t = thread(&cli);+ let _r = load_run(&cli);+ let mut cmd = std::process::Command::new("adom-aiflow-widget");+ cmd.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t);+ if let Some(v) = file { cmd.arg("--file").arg(v); }+ if let Some(v) = label { cmd.arg("--label").arg(v); }+ if let Some(v) = step { cmd.arg("--step").arg(v); }+ if let Some(v) = progress { cmd.arg("--progress").arg(v.to_string()); }+ let status = cmd.status().unwrap_or_else(|e| err(&format!("widget helper unavailable: {e}"), &["Install the package's adom-aiflow-widget helper; the board run is unchanged.".into()]));+ if !status.success() { err("widget helper failed", &[]); }+ ok("widget preference or artifact updated", &["Optional: open AI Flow progress from Hydrogen Widgets. Reads the run and existing artifacts without AI calls. Disable stops feed updates and new thumbnails.".into()]);+ }+ Cmd::BindRemoteBoard { remote_board } => {+ thread(&cli);let mut r=load_run(&cli);let br=bridge_of(&r,&cli);+ let proof=checked_remote_binding(&r,&br,remote_board,&dir).unwrap_or_else(|e|err(&e,&["Transfer the current adopted board and unchanged project/rule sidecars to the intended desktop path, then bind again. This never edits the desktop file or accepts a gate.".into()]));+ let old=r.data["remoteBoard"].clone();r.data["remoteBoard"]=proof["remoteBoard"].clone();r.data["remoteBinding"]=proof.clone();r.log("bind-remote-board",json!({"previousRemoteBoard":old,"proof":proof}));r.save().unwrap();+ ok("native board and project rules match the adopted snapshot; desktop binding updated",&["Run models and verify the native 2D/3D views. Binding is not engineering acceptance.".into()]);+ }+ Cmd::AdoptBoard { board, evidence } => {+ thread(&cli); let mut r=load_run(&cli);+ if evidence.trim().is_empty(){err("adopt-board needs --evidence describing native save/refill/read-back", &["Save and refill the board in its EDA, then pass --board <file> --evidence <review notes or report path>.".into()]);}+ Board::load(board).unwrap_or_else(|e|err(&e,&[]));+ let base=aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e|err(&e,&[]));+ let candidate=aiflow_copper::run_drc(board).unwrap_or_else(|e|err(&e,&[]));+ let summary=aiflow_copper::summarize(&candidate,Some(&base));+ if summary["newErrors"].as_u64()!=Some(0){err(&format!("adopt-board adds native DRC violations: {}",summary["newErrorList"]), &["Correct the new violations and rerun adopt-board. Existing connectivity may remain for later routing.".into()]);}+ let n=r.data["boards"].as_array().map(|a|a.len()).unwrap_or(1);+ let dest=dir.join(format!("board-{n}.kicad_pcb"));+ std::fs::copy(board,&dest).unwrap_or_else(|e|err(&e.to_string(),&[]));+ aiflow_copper::copy_project_rules(board,&dest.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+ let (carried, carry_note) = copper_carry::adopt(&mut r, &dest.display().to_string());+ r.data["remoteBinding"]=Value::Null;+ r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary,"copperCarried":carried}));+ r.save().unwrap();+ if carried { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["Silkscreen changed: record the silkscreen proof again (`silkscreen-audit --record`) and export again; finish reruns the full native DRC. Bind the desktop board again (bind-remote-board) before live commands.".into()]); }+ else { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["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, project_lib } => {+ 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();+ // the MPN, then the wiki's slug form of it (AP2112K-3.3TRG1 lives at ap2112k-3-3trg1, issue #76)+ let mut page = None;+ let mut queries = vec![mpn.clone()];+ if let Some(w) = preboard::part_slug_candidates(&mpn).into_iter().skip(1).next() { queries.push(w); }+ for q in &queries {+ let o = std::process::Command::new("adom-wiki").args(["search", q, "--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();+ page = preboard::part_page(&hits, &mpn);+ if page.is_some() { break; }+ }+ if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); }+ row["wiki"] = json!(page);+ }+ // parts with no public page may come from the board's own project library (issue #66)+ let plib = project_lib.clone().or_else(|| spec_soft(&r)["projectLib"].as_str().or(requirements_of(&r)["projectLib"].as_str()).map(str::to_string));+ let mut why_missing: Vec<(String, String)> = Vec::new();+ if let Some(d) = &plib {+ let lib = project_lib::ProjectLib::load(Path::new(d)).unwrap_or_else(|e| err(&e, &["A project library is a folder with *.kicad_sym, *.pretty/*.kicad_mod, STEP models and provenance.json (see the aiflow-sourcing skill).".into()]));+ why_missing = project_lib::resolve(&mut rows, &lib);+ missing.retain(|m| why_missing.iter().any(|(w, _)| w == m));+ }+ let from_lib: Vec<String> = rows.iter().filter(|x| x["projectLib"].is_object()).filter_map(|x| x["mpn"].as_str().map(str::to_string)).collect();+ 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, "projectLib": plib, "projectLibParts": from_lib, "projectLibGaps": why_missing.iter().map(|(m, w)| json!({"mpn": m, "why": w})).collect::<Vec<_>>()}));+ 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().map(str::to_string).or_else(|| x["projectLib"].is_object().then(|| "project library".to_string())).unwrap_or_else(|| "MISSING".into()))).collect();+ if !pass {+ let gaps: Vec<String> = why_missing.iter().map(|(m, w)| format!(" {m}: project library: {w}")).collect();+ err(&format!("{} of {} parts have no Adom wiki component page{}:\n{}{}", missing.len(), rows.len(), if plib.is_some() { " and no complete project-library entry" } else { "" }, list.join("\n"), if gaps.is_empty() { String::new() } else { format!("\n{}", gaps.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(), "A part whose page cannot be public yet: `parts --project-lib <dir>` with its symbol, footprint, STEP model and a provenance.json entry (aiflow-sourcing skill).".into()]);+ }+ ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, 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, existing, 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.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());+ let lane = sequence::lane(&dir, &r.data);+ let cfg = json!({"run": dir.display().to_string(), "eda": lane, "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, "existing": existing, "version": "1.0.0",+ "libRef": r.data["cloudLibrary"].as_str().or(r.data["outcomes"]["project-page"]["cloudLibrary"].as_str())});+ 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": aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 600)}));+ if v["ok"] != true {+ if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); r.save().unwrap(); }+ err(&format!("project page: {}", v["error"].as_str().unwrap_or("failed")), &["Nothing was published. Rate-limited adds are retried with backoff; rerun `project-page` after the wait and it reuses the same Cloud Library (never a second one).".into()]);+ }+ r.outcome("project-page", v.clone());+ r.data["projectPage"] = json!(page);+ if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); }+ 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();+ }+ if lane == "altium" {+ let from_lib: Vec<&str> = v["projectLibParts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+ ok(&format!("project page {} ({}): README revision 1 with {} parts and the component table (Altium Designer: the library step builds the SchLib and PcbLib from these same wiki pages{})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"], if from_lib.is_empty() { String::new() } else { format!(" and, for {}, from the board's project library", from_lib.join(", ")) }),+ &[if from_lib.is_empty() { String::new() } else { format!("from the board's project library (no public page yet): {}", from_lib.join(", ")) },+ "Next: `step library`, then `adom-aiflow altium library`: one SchLib and one PcbLib from the parts' wiki pages (project-library parts from their own KiCad files, through the same converters). Every step you leave is pushed to this page as steps/<nn>-<step>/README.md.".into()]);+ return;+ }+ let c = &v["counts"];+ let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect();+ if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+ ok(&format!("project page {} ({}): README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),+ &[hints, vec![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(""))]].concat());+ }+ Cmd::Pcm { what: _ } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if sequence::lane(&dir, &r.data) != "kicad" { err("pcm check is KiCad's library check (the project page's PCM package installed in KiCad); this run builds in another tool", &["Altium: `adom-aiflow altium library` builds and records the library.".into()]); }+ 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 sequence::lane(&dir, &r.data) != "kicad" { err("schematic check reads a KiCad schematic (kicad-cli ERC); this run builds in another tool", &["Altium: `adom-aiflow altium schematic` draws, compiles and checks the schematic pin for pin.".into()]); }+ 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 (_, erc_errors) = schematic_check::run_erc(&kc, sch, &erc).unwrap_or_else(|e| err(&format!("schematic check: {e}"), &["ERC must run and its report come back before the schematic can pass. In a container: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> (it runs `sch erc` on the desktop's KiCad).".into()]));+ let net = sdir.join("netlist.net");+ export_sch_netlist(sch, &net).unwrap_or_else(|e| err(&e, &[]));+ let on_sheet = schematic_check::sheet_symbols(&std::fs::read_to_string(&net).unwrap_or_default()).unwrap_or_else(|e| err(&format!("netlist: {e}"), &[]));+ 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();+ // issue #39: symbol polarity drawings, active-device pin types and readability (schematic_symbols.rs)+ let symbols = schematic_symbols::check(Path::new(sch), &spec_soft(&r)).unwrap_or_else(|e| json!({"ok": false, "errors": [format!("could not read the hierarchy: {e}")], "warnings": []}));+ schematic_symbols::record(&mut r, sch, &symbols);+ let pass = erc_errors.is_empty() && missing.is_empty() && foreign.is_empty() && symbols["ok"] == true;+ 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(", "))); }+ why.extend(symbols["errors"].as_array().into_iter().flatten().filter_map(|e| e.as_str().map(str::to_string)));+ let mut hints = vec!["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.".to_string()];+ hints.extend(schematic_symbols::render(&symbols, true).1);+ hints.push(schematic_symbols::warning_hint(&symbols));+ err(&format!("schematic check failed:\n {}", why.join("\n ")), &hints);+ }+ 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(", ")) }),+ &[schematic_symbols::warning_hint(&symbols), "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);+ let b = board_of(&r);+ let path = dir.join("components.json");+ let old: Value = if path.exists() {+ let text = std::fs::read_to_string(&path).unwrap_or_else(|e| err(&format!("read components register: {e}"), &[]));+ serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("invalid components register (left untouched): {e}"), &[]))+ } else { json!({}) };+ let preference = etch.as_deref().or_else(|| old["mpnMarking"].as_str()).unwrap_or("on");+ let mut entries = Vec::new();+ for fp in b.root.find_all("footprint") {+ let props: BTreeMap<String, String> = fp.find_all("property").filter_map(|p| Some((p.atom(1)?.into(), p.atom(2)?.into()))).collect();+ let Some(reference) = props.get("Reference") else { continue; };+ let mut entry = old["components"].as_array().and_then(|a| a.iter().find(|e| e["reference"].as_str() == Some(reference.as_str()))).cloned().unwrap_or_else(|| json!({"reference": reference, "review": {"status": "pending", "classification": "unreviewed", "wikiPage": null, "geometryEvidence": [], "visualEvidence": [], "redistributionEvidence": [], "assetProvenance": [], "variants": []}}));+ let models = json!(fp.find_all("model").filter_map(|n| n.value()).collect::<Vec<_>>());+ if entry.get("properties").is_some() && (entry["properties"] != json!(props) || entry["footprint"] != json!(fp.value()) || entry["modelReferences"] != models) {+ entry["review"]["status"] = json!("stale");+ entry["review"]["staleReason"] = json!("Board properties, footprint or model references changed; re-review retained evidence.");+ }+ entry["properties"] = json!(props);+ entry["footprint"] = json!(fp.value());+ entry["modelReferences"] = models;+ entries.push(entry);+ }+ let report = json!({"schemaVersion": 1, "board": b.path, "mpnMarking": preference, "components": entries,+ "qualityGate": "AI review required; this inventory does not certify identities, geometry, redistribution rights or native rendering"});+ std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()).unwrap_or_else(|e| err(&format!("write component register: {e}"), &[]));+ r.data["components"] = json!({"register": path, "mpnMarking": preference, "reviewStatus": "pending"});+ r.save().unwrap_or_else(|e| err(&e, &[]));+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let step = flow["steps"].as_array().unwrap().iter().find(|s| s["name"] == "components").unwrap();+ let mut hints: Vec<String> = step["workflow"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect();+ hints.push(format!("MPN marking preference: {preference} (default on: every IC's model carries its laser-etched MPN, and finish checks it). Mark a model with `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>`: the mark planner places it (along the longest usable dimension, between the terminations, as large as fits, upright with pin 1 lower left), the shared service writes it with the model's colours, and the legibility gate must say PASS; look at <etched>.check.png before you report (Marking checklist in the adom-aiflow skill). 'off' keeps plain models; 'ask' offers the option. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));+ ok(&format!("component register: {} ({} references); AI review is pending", path.display(), report["components"].as_array().unwrap().len()), &hints);+ }+ Cmd::Plan => {+ let mut r = load_run(&cli);+ // the flow is a file: the steps in order, who does each, what the binary offers, what comes later+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ r.data["flow"] = flow.clone();+ let spec = spec_of(&r);+ let b = board_of(&r);+ let parked = b.footprints.values().filter(|f| f.x > b.bbox.2).count();+ let fixed = spec.get("fixedRefs").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0);+ let stages = json!({+ "place": {"engines": ["ai"], "default": "ai", "why": format!("{parked} parts parked outside the outline, {fixed} fixed by the interface; the AI places (kicad_placement_state, place pack/check/land help), the binary checks")},+ "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says take route=ai"},+ "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},+ "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},+ "land": {"engines": ["binary"], "default": "binary", "why": "native bridge undo transactions with candidate DRC; pours use the complete explicitly owned zone plan without a delete/create fallback"},+ "measure": {"engines": ["binary"], "default": "binary", "why": "request refill once, then read two delayed matching native revision/copper snapshots; acknowledgement is not measurement and post-commit errors forbid blind replay"},+ "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},+ "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},+ "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},+ });+ r.data["plan"] = json!({"at": now(), "stages": stages, "board": {"footprints": b.footprints.len(), "nets": b.nets.len(), "parked": parked, "layers": b.copper_layers}});+ r.save().unwrap_or_else(|e| err(&e, &[]));+ let mut lines = vec![format!("plan for {}: {} footprints, {} nets, {} copper layers, {parked} parked", r.data["engine"].as_str().unwrap_or(""), b.footprints.len(), b.nets.len(), b.copper_layers.len())];+ lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));+ for st in flow["steps"].as_array().cloned().unwrap_or_default() {+ lines.push(format!(" step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));+ if let Some(workflow) = st["workflow"].as_array() {+ for (i, action) in workflow.iter().filter_map(Value::as_str).enumerate() {+ lines.push(format!(" {}. {}", i + 1, action));+ }+ }+ }+ lines.push(format!(" later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));+ lines.push("stages the binary offers:".into());+ for n in STAGES {+ let s = &r.data["plan"]["stages"][n];+ lines.push(format!("{n:<16} {:<12} {}", s["engines"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default(), s["why"].as_str().unwrap_or("")));+ }+ ok(&lines.join("\n"), &[format!("Take a stage yourself with: adom-aiflow take route=ai --run {} (default: binary where it is offered).", dir.display()), "Every stage stamps the clock; for a stage you take yourself call `stage start <name>` and `stage end <name>` around your own work.".into()]);+ }+ Cmd::Take { decisions } => {+ thread(&cli);+ let mut r = load_run(&cli);+ for kv in decisions {+ let Some((stage, engine)) = kv.split_once('=') else { err(&format!("bad decision {kv}; use stage=engine"), &[]) };+ if stage.is_empty() || !stage.chars().all(|c| c.is_ascii_alphanumeric() || c=='-' || c=='_') || !["ai","binary"].contains(&engine) {+ err("invalid stage decision", &["Use adom-aiflow take <stage>=ai or <stage>=binary; custom stage names may contain letters, digits, hyphens and underscores.".into()]);+ }+ r.data["decisions"][stage] = json!(engine);+ }+ r.save().unwrap();+ ok(&format!("decisions recorded: {}", r.data["decisions"]), &["They are part of the run manifest and the comparison chart.".into()]);+ }+ Cmd::Stage { action, name, note, result } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if action == "start" {+ let e = r.data["decisions"][name].as_str().unwrap_or("ai").to_string();+ r.stage_start(name, Some(&e), note.as_deref());+ r.save().unwrap();+ ok(&format!("stage {name} started at {} by {e}", now()), &[]);+ } else {+ let res = result.as_deref().map(|s| serde_json::from_str(s).unwrap_or(json!(s)));+ r.stage_end(name, res, note.as_deref());+ r.save().unwrap();+ ok(&format!("stage {name} ended at {}", now()), &[format!("Minutes so far per stage: {}; {:.1} min since the prompt.", Value::Object(r.stage_minutes()), r.elapsed_minutes())]);+ print_next(&cli);+ }+ }+ Cmd::Place { what, wish, moves, out, pause_ms } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let b = board_of(&r);+ match what.as_str() {+ "verify" => {+ let board_path=r.current_board();+ let spec_path=r.data["spec"].as_str().unwrap_or("").to_string();+ let board_hash=placement_verify::digest(Path::new(&board_path)).unwrap_or_else(|e|err(&e,&[]));+ let spec_hash=placement_verify::digest(Path::new(&spec_path)).unwrap_or_else(|e|err(&e,&[]));+ if let Some(f)=molecule_findings(&spec_of(&r),&b,Some(&r)) {+ if !f.errors.is_empty() { err(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); }+ }+ let spec_i=spec_of(&r);+ let intent_lines=placement_intent::check(&mut r,&dir,&spec_i,&b,"place verify").unwrap_or_else(|e|{r.save().ok();err(&e,&[placement_intent::HINT.into()])});+ for l in &intent_lines { eprintln!("{l}"); }+ let rb=r.data["remoteBoard"].as_str().filter(|s|!s.is_empty()).unwrap_or_else(||err("place verify requires the named native board",&[])).to_string();+ let br=bridge_of(&r,&cli);+ let before=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));+ let validation=br.call("kicad_placement_validate",&json!({"filePath":rb}));+ let after=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));+ let report=dir.join("placement-verify.json");+ write_json(&report,&json!({"before":before,"validation":validation,"after":after}));+ let checked=placement_verify::check_with_spec(&b,&before,&validation,&after,&rb,&spec_of(&r));+ let stable=placement_verify::digest(Path::new(&board_path)).ok().as_deref()==Some(board_hash.as_str()) && placement_verify::digest(Path::new(&spec_path)).ok().as_deref()==Some(spec_hash.as_str());+ let error=if !stable { Some("saved board or spec changed during placement verification".to_string()) } else { checked.as_ref().err().cloned() };+ let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()).map(|v|v.to_json());+ r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.as_ref().map(|v|v.accepted).unwrap_or(0)}else{0},"excused":excused,"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error}));+ r.save().unwrap_or_else(|e|err(&e,&[]));+ if let Some(e)=error { err(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); }+ let excused_line=excused.as_ref().map(|e|format!("; excused for spec.fixedRefs: {} bridge box overlap(s), {} padless footprint(s) without a courtyard (KiCad's exact placement DRC clean)",e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()))).unwrap_or_default();+ ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved{excused_line}",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]);+ }+ "pack" => {+ let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));+ let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));+ let spec = spec_of(&r);+ let courtyard_edge=spec["placement"]["courtyardEdgeClearance"].as_f64().unwrap_or(0.0);+ let chains = aiflow_place::chains_from_spec(&spec).unwrap_or_else(|e| err(&format!("spec.chains: {e}"), &[]));+ let intent = placement_intent::of_spec(&spec);+ let (wishes, anchor_lines) = aiflow_place::intent::apply_anchors(&b, &wishes, &intent);+ let (wishes, chain_plans, chain_lines) = aiflow_place::apply_chains(&b, &wishes, &chains, 0.05);+ let (mv, mut lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);+ lines.extend(anchor_lines);+ lines.extend(placement_intent::pack_lines(&b, &mv, &intent));+ write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());+ let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();+ let verdicts = aiflow_place::chain_report(&b, &mv, &chain_plans);+ let mut msg = format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n"));+ if !chains.is_empty() {+ let honoured = verdicts.iter().filter(|v| v.1).count();+ msg.push_str(&format!("\nchains: {honoured} of {} honoured", chains.len()));+ for l in &chain_lines { msg.push_str(&format!("\n {l}")); }+ for (name, _, v) in &verdicts { msg.push_str(&format!("\n chain {name}: {v}")); }+ }+ let chain_hint = if verdicts.iter().any(|v| !v.1) || chain_lines.iter().any(|l| l.contains("not applied")) { "A chain not honoured: give its first member a wish with room along the chain's direction (or set \"direction\" in spec.chains), put the chain early in the wishes, and pack again.".to_string() } else { String::new() };+ ok(&msg, &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }, chain_hint]);+ }+ "check" => {+ let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));+ let mv = aiflow_place::moves_from_json(&read_json(m)).unwrap_or_else(|e| err(&e, &[]));+ let (text, lines) = aiflow_place::move_in_text(&b, &mv).unwrap_or_else(|e| err(&e, &[]));+ let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);+ let outp = dir.join(format!("board-{n}.kicad_pcb"));+ std::fs::write(&outp, &text).unwrap();+ aiflow_copper::copy_project_rules(&r.current_board(), &outp.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+ let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+ let moved: Vec<String> = mv.keys().cloned().collect();+ let problems = aiflow_place::check(&nb, &moved, 0.05);+ if let Some(f) = molecule_findings(&spec_of(&r), &nb, Some(&r)) {+ if !f.errors.is_empty() {+ let mut h = f.hints.clone();+ h.push(format!("The moves break the molecule; the run's current board is unchanged. Fix the wishes (never move the interface parts) and check again; see {}.", outp.display()));+ err(&format!("the moved board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &h);+ }+ }+ let spec_i = spec_of(&r);+ if let Err(e) = placement_intent::check(&mut r, &dir, &spec_i, &nb, "place check") { r.save().ok(); err(&e, &[placement_intent::HINT.into(), format!("The run's current board is unchanged; the moved board is {}.", outp.display())]); }+ let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));+ let baseline = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));+ let s = aiflow_copper::summarize(&drc, Some(&baseline));+ let errors = s["errors"].as_u64().unwrap_or(0);+ // Bounding boxes are conservative packing hints, not exact courtyard geometry.+ // Native KiCad DRC decides whether the actual courtyards overlap.+ if !problems.is_empty() { eprintln!("Hint: conservative bounding-box advisory: {}. Native courtyard DRC is authoritative.", problems.join("; ")); }+ if s["newErrors"].as_u64() != Some(0) {+ err(&format!("the moved board adds {} native DRC violations: {}", s["newErrors"], s["newErrorTypes"]), &[format!("Change the wishes and pack again; inspect {}. Findings are matched by identity, not an inherited-error count.", outp.display())]);+ }+ // issue #39: courtyards present and clear, part bodies clear, whatever the project severities say+ let mech_line = if mechanical::applies(&outp.display().to_string()) {+ let mech = mechanical::check_board(&nb, &outp.display().to_string(), &project_dir_of(&r), &spec_soft(&r));+ for n in &mech.severity_notes { eprintln!("Warning: {n}"); }+ if !mech.errors().is_empty() { err(&format!("the moved board fails the courtyard and body checks:\n {}", mech.errors().join("\n ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); }+ format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked)+ } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() };+ r.add_board(&outp.display().to_string());+ r.save().unwrap();+ ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line} and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);+ }+ "land" => {+ let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ r.stage_start("place-land", Some("binary"), None);+ let mv = read_json(m);+ let t0 = std::time::Instant::now();+ // one part at a time: the camera sees each part land, not a board that changes at once+ let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {+ eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });+ });+ let total = mv.as_object().map(|o| o.len()).unwrap_or(0);+ write_json(&dir.join("place-land-log.json"), &json!({"moves":m,"accepted":accepted,"replies":replies,"stoppedAt":stopped}));+ r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);+ r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));+ r.save().unwrap();+ if let Some(i) = stopped {+ let rep = replies.last().cloned().unwrap_or(Value::Null);+ if rep["errorCode"] == "board_mismatch" {+ err(&format!("live placement refused before part {}: requested {rb}; IPC openBoards {}",i+1,rep["openBoards"]), &["Save and gracefully close only your own conflicting KiCad editors, then `capture open` to reopen this board first. Never close another user's editor or restart a shared bridge. Inspect place-land-log.json; no failed part was moved.".into()]);+ }+ err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+ }+ ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);+ }+ other => err(&format!("place {other}: use pack, check, land or verify"), &[]),+ }+ }+ Cmd::Route { passes, rip_per_net, rip_global } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if r.engine_for("route") == "ai" {+ err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);+ }+ // a Fusion run routes around what is in Fusion now (copper already landed, a net taken+ // up for a refine), not the placement snapshot: plan on a fresh copy of the live board+ if let Some((_, k)) = live_board(&r, &cli, &dir) {+ let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);+ let dest = dir.join(format!("board-{n}.kicad_pcb"));+ std::fs::copy(&k, &dest).unwrap_or_else(|e| err(&e.to_string(), &[]));+ aiflow_copper::copy_project_rules(&r.current_board(), &dest.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+ r.add_board(&dest.display().to_string());+ r.save().unwrap();+ eprintln!("routing on the live Fusion board ({})", dest.display());+ }+ let spec = spec_of(&r);+ let b = board_of(&r);+ let missing=aiflow_pours::missing_planes(&b,&spec);+ if !missing.is_empty(){err(&format!("declared reference planes do not exist: {}",missing.join(", ")), &["Create the listed net/layer zones through the EDA bridge, fill and save them, then `adom-aiflow adopt-board --board <saved.kicad_pcb> --evidence <native review notes>` before route. A spec.planes declaration alone is not copper.".into()]);}+ let rules = aiflow_router::Rules::from_spec(&spec);+ let regions = aiflow_router::plane_under::regions(&b, &rules.plane_under).unwrap_or_else(|e| err(&format!("spec.planeUnder: {e}"), &["Each entry needs a layer (F.Cu or B.Cu), the net that owns the region, and \"around\" (parts or pads on this board, grown by \"margin\", default 1.0 mm) or a \"polygon\".".into()]));+ if spec.get("planeUnder").is_some() && regions.len() != spec["planeUnder"].as_array().map(|a| a.len()).unwrap_or(1) {+ err("spec.planeUnder has an entry that does not parse", &["Use {\"layer\": \"B.Cu\", \"net\": \"GND\", \"around\": [\"U1\", \"C2\"], \"margin\": 1.0} or {\"layer\": ..., \"net\": ..., \"polygon\": [[x, y], ...]}.".into()]);+ }+ let preexisting = aiflow_router::plane_under::issues(&b, ®ions);+ decoupling_check::refuse_bad_spec(&spec, &b);+ if spec["stub"].as_f64().map(|s|s<rules.stub).unwrap_or(false){eprintln!("Hint: escape width raised to {} mm to satisfy minTrackWidth (default: track width). Declare a smaller manufacturing minimum only when the board process supports it.",rules.stub);}+ r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));+ let mut router = aiflow_router::Router::new(b, rules, true);+ let (mut plan, mut report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);+ // issue #39: staircases to clean 45 degree runs before the gate (the router's own clearance check decides)+ let simp = aiflow_router::simplify::simplify_plan(&mut router, &mut plan);+ report["simplify"] = simp.clone();+ let simp_line = format!("simplified: {} staircase(s) replaced, {} kept (no clear 45 degree path), {} collinear point(s) merged; vertices {} -> {} (per net in plan-route-report.json \"simplify\")", simp["staircasesReplaced"], simp["staircasesKept"], simp["collinearMerged"], simp["verticesBefore"], simp["verticesAfter"]);+ std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();+ let planp = dir.join("plan-route.json");+ let bc = report["boardCopper"].clone();+ let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) > 0;+ let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});+ if has_copper {+ let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0);+ plan_doc["boardCopper"] = bc.clone();+ plan_doc["note"] = json!(if plan.is_empty() && failed == 0 {+ format!("nothing to route: the board's own copper ({} track pieces, {} vias, filled zones) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"])+ } else {+ format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias); {complete} nets are complete on it and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["openConnections"])+ });+ }+ if !regions.is_empty() {+ plan_doc["planeUnder"] = json!(regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>());+ }+ write_json(&planp, &plan_doc);+ write_json(&dir.join("plan-route-report.json"), &report);+ let dec = decoupling_check::review_route(&spec, &router.board, &plan_doc, &report, r.outcomes().get("pour"));+ decoupling_check::record(&mut r, &dir, &dec);+ let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();+ r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);+ r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}));+ r.save().unwrap();+ let policy_hint = if regions.is_empty() { String::new() } else if preexisting.is_empty() {+ format!("planeUnder: {} kept free of every other net's copper.", regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>().join("; "))+ } else {+ format!("planeUnder: the board's own copper already sits inside a region ({}); the gate reports it until that copper is moved (rework it in KiCad, then adopt-board).", preexisting.iter().take(4).cloned().collect::<Vec<_>>().join("; "))+ };+ if failed > 0 {+ let mut blockers: Vec<String> = Vec::new();+ for u in &unrouted {+ if let Some(why) = u.get(1).and_then(|x| x.get(1)).and_then(|w| w.as_str()) {+ if let Some(rest) = why.strip_prefix("blocked by ") {+ for b in rest.trim_matches(|c| c == '[' || c == ']').split(", ") {+ let b = b.trim_matches('"').trim_matches('\'');+ if !b.is_empty() && !blockers.contains(&b.to_string()) {+ blockers.push(b.to_string());+ }+ }+ }+ }+ }+ let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect();+ let region_hint = if regions.is_empty() { String::new() } else { "A planeUnder region is a keepout for every other net on its layer: a pin inside it can only leave on the other layer. Shrink the margin, list fewer parts, or give the region a polygon that leaves that pin a way out.".to_string() };+ err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);+ }+ decoupling_check::refuse_errors("route", &dec);+ if has_copper && plan.is_empty() {+ ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias); empty plan {}", bc["trackPieces"], bc["vias"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]);+ } else {+ let copper_line = if has_copper { format!("; the board's own copper was kept and {} connection(s) it left open were planned", bc["openConnections"]) } else { String::new() };+ ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);+ }+ }+ Cmd::Pour { plan } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let spec = spec_of(&r);+ // a Fusion run pours around what is in Fusion now (the landed copper and heatsink vias)+ let b = live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r));+ let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string));+ let plan_v = planp.as_deref().map(read_json);+ // the heatsink vias planned (landed or not) are holes the stitching vias must keep clear of+ let tv_holes = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).filter(|p| std::path::Path::new(p).is_file()).map(|p| aiflow_pours::plan_holes(&read_json(p))).unwrap_or_default();+ r.stage_start("pour", Some("binary"), None);+ let (zones, vias, keepouts) = aiflow_pours::build_with(&b, &spec, plan_v.as_ref(), &tv_holes).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });+ let zp = dir.join("plan-pours.json");+ let vp = dir.join("plan-vias.json");+ write_json(&zp, &json!({"zones": zones}));+ pourverify::record_plan(&mut r.data, &zp, "pour").unwrap_or_else(|e| err(&e, &[]));+ write_json(&vp, &json!({"engine": "adom-aiflow pour plan (thermal and stitching vias)", "nets": vias}));+ r.stage_end("pour", Some(json!({"zones": zones.len(), "keepouts": keepouts, "vias": vias.len()})), None);+ r.outcome("pour", json!({"pours": zp.display().to_string(), "vias": vp.display().to_string(), "zones": zones.len(), "keepouts": keepouts, "viaCount": vias.len()}));+ r.save().unwrap();+ let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect();+ ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]);+ }+ Cmd::Fusion { args, target } => {+ // the Fusion steps run in Python beside the bridge calls; the run gives them their folder,+ // the desktop and the thread+ let run = Run::open(&dir);+ let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();+ let status = std::process::Command::new("python3").arg(lane_script("fusion", &format!("{}\n{}", include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/fusion-project.py")))).args(args)+ .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())+ .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));+ std::process::exit(status.code().unwrap_or(1));+ }+ Cmd::Altium { args, target } => {+ // the Altium steps: the shared project half (tools/project-common.py), the shared silkscreen planner,+ // the lane's pure helpers (tools/altium-lane.py: the symbol check, project-library parts), then the Altium half+ let run = Run::open(&dir);+ let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();+ let script = format!("{}\n{}\n{}\n{}", include_str!("../../../tools/project-common.py"), include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/altium-lane.py"), include_str!("../../../tools/altium-project.py"));+ let status = std::process::Command::new("python3").arg(lane_script("altium", &script)).args(args)+ .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())+ .status().unwrap_or_else(|e| err(&format!("altium steps need python3: {e}"), &[]));+ std::process::exit(status.code().unwrap_or(1));+ }+ Cmd::Unroute { net } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ let live = br.live_copper(&rb).unwrap_or_else(|| err("could not read the live board", &["Open the board in its EDA (or fusion_show_2d_board) and retry.".into()]));+ let ids: Vec<String> = live.segments.iter().filter(|s| net.contains(&s.net)).map(|s| s.uuid.clone())+ .chain(live.vias.iter().filter(|v| net.contains(&v.net)).map(|v| v.uuid.clone())).filter(|u| !u.is_empty()).collect();+ if ids.is_empty() { ok(&format!("nothing to unroute: no tracks or vias on {}", net.join(", ")), &["Next: route, gate, land route.".into()]); return; }+ let rep = br.remove_items(&rb, &ids);+ r.log("unroute", json!({"nets": net, "items": ids.len(), "reply": rep}));+ r.save().unwrap();+ if rep["success"].as_bool() != Some(true) { err(&format!("unroute refused: {} {}", rep["errorCode"], rep["error"].as_str().or(rep["message"].as_str()).unwrap_or("")), &["Nothing else was changed. Read the live board state before retrying.".into()]); }+ ok(&format!("unrouted {}: {} track(s) and via(s) taken up, pours kept", net.join(", "), ids.len()), &["Change what needs changing (spec wideNets for a wider track), then route, gate and land route again.".into()]);+ }+ Cmd::Gate { plan } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let b = board_of(&r);+ let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| {+ // an adopted board that already carries its copper: gate the board as it is+ if b.tracks().is_empty() && b.vias().is_empty() {+ err("no routing plan to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]);+ }+ let p = dir.join("plan-route.json");+ write_json(&p, &json!({"engine": "none (the board's own copper)", "nets": [], "note": "gate with no routing plan on a board that carries copper: the board is gated as it is"}));+ eprintln!("Hint: no routing plan; the board carries copper, so it is gated as it is with an empty plan ({})", p.display());+ p.display().to_string()+ });+ // the same normalisation `land route` applies, so the gate passes only what lands (issue #33)+ let (plan_v, plan_notes) = read_plan_normalised(&planp, &b);+ print_plan_notes(&plan_notes);+ let spec_g = spec_of(&r);+ if let Some((p, layers, warning)) = checks::rules_target(&spec_g, &r.data["fabRules"]) {+ if let Some(w) = warning { eprintln!("Warning: {w}"); }+ if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { err(&format!("gate refused: {e}"), &[hint, "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }+ }+ if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {+ if !f.errors.is_empty() { err(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); }+ }+ r.stage_start("gate", Some("binary"), None);+ // an empty plan adds no copper: the board's own zone fills stand, a raster refill would only invent errors+ let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true);+ let (text, stats) = aiflow_copper::apply(&b, &plan_v, adds_copper && !aiflow_copper::native_drc_available()).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));+ let routed = dir.join("board-routed.kicad_pcb");+ std::fs::write(&routed, &text).unwrap();+ aiflow_copper::copy_project_rules(&r.current_board(), &routed.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+ let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));+ let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+ let mut s = aiflow_copper::summarize(&data, Some(&base));+ s["copper"] = stats;+ s["file"] = json!(routed.display().to_string());+ // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error+ let spec = spec_of(&r);+ let pu = aiflow_router::rules::PlaneUnder::from_spec(&spec);+ let pu_issues: Vec<String> = if pu.is_empty() { Vec::new() } else {+ let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[]));+ match aiflow_router::plane_under::regions(&rb, &pu) { Ok(regs) => aiflow_router::plane_under::issues(&rb, ®s), Err(e) => vec![format!("spec.planeUnder: {e}")] }+ };+ s["planeUnderIssues"] = json!(pu_issues);+ // the switch node: short, and never under another part (switchnode.rs)+ let sw_issues = { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); switchnode::findings(&spec_g, &rb) };+ s["switchNodeIssues"] = json!(sw_issues);+ let dec = decoupling_check::review_text("gate", &spec_g, &routed.display().to_string(), &text, !adds_copper, if adds_copper && !aiflow_copper::native_drc_available() { "an approximate raster fill by the gate, not a native refill" } else { "the zone fills saved in the board file" }, &plan_v, None, r.outcomes().get("pour"));+ if let Some(d) = &dec { s["decoupling"] = d.value.clone(); }+ // issue #39: courtyards and bodies (aiflow's own geometry), staircases left in the copper (a warning)+ let mech = mechanical::applies(&r.current_board()).then(|| { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); mechanical::check_board(&rb, &routed.display().to_string(), &project_dir_of(&r), &spec_g) });+ let stairs = Board::from_text(&routed.display().to_string(), text.clone()).map(|rb| aiflow_router::simplify::board_staircases(&rb)).unwrap_or_default();+ s["mechanical"] = mech.as_ref().map(|m| m.to_json()).unwrap_or(json!("not checked: Fusion board"));+ s["staircases"] = json!(stairs);+ decoupling_check::record(&mut r, &dir, &dec);+ if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); }+ if !plan_notes.is_empty() { s["planNormalised"] = json!(plan_notes); }+ write_json(&dir.join("gate.json"), &s);+ let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));+ let npu = pu_issues.len() as u64;+ r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);+ r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));+ r.save().unwrap();+ if !sw_issues.is_empty() {+ r.outcome("switch-node", json!({"issues": sw_issues}));+ r.save().unwrap();+ err(&format!("gate failed: the switch node:\n {}", sw_issues.join("\n ")), &[switchnode::HINT.into()]);+ }+ if npu > 0 && newe == 0 && unc == 0 {+ err(&format!("gate failed: {npu} planeUnder issue(s), other-net copper inside a plane region:\n{}", pu_issues.join("\n")), &["spec.planeUnder keeps these regions for one net's plane. Route again (the router keeps other nets out of them), or move the copper by hand and adopt the board; if the region is wrong, change spec.planeUnder.".into()]);+ }+ if newe > 0 || unc > 0 {+ // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever+ let mut hair: Vec<String> = Vec::new();+ for v in s["newErrorList"].as_array().cloned().unwrap_or_default() {+ if v["type"].as_str() == Some("clearance") {+ let d = v["description"].as_str().unwrap_or("");+ let actual = d.split("actual ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());+ let rule = d.split("clearance ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());+ if let (Some(a), Some(r)) = (actual, rule) {+ if r - a < 0.02 {+ for it in v["items"].as_array().cloned().unwrap_or_default() {+ if let Some(t) = it.as_str() {+ if t.starts_with("Track [") { if let Some(n) = t.split('[').nth(1).and_then(|x| x.split(']').next()) { hair.push(format!("\"{n}\": {:.2}", r + 0.02)); } }+ }+ }+ }+ }+ }+ }+ hair.sort(); hair.dedup();+ let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };+ let pu_hint = if npu > 0 { format!("Also {npu} planeUnder issue(s): {}", pu_issues.iter().take(4).cloned().collect::<Vec<_>>().join("; ")) } else { String::new() };+ err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[pu_hint, format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);+ }+ decoupling_check::refuse_errors("gate", &dec);+ if let Some(m) = &mech {+ for n in &m.severity_notes { eprintln!("Warning: {n}"); }+ if !m.errors().is_empty() { err(&format!("gate failed: courtyards or part bodies:\n {}", m.errors().join("\n ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); }+ } else { eprintln!("Warning: courtyards and part bodies not checked: a Fusion board carries no courtyard geometry aiflow reads; check them in Fusion's DRC"); }+ if !stairs.is_empty() { eprintln!("Warning: {} staircase run(s) left in the copper (tiny alternating 0/90 degree steps; redraw as one 45 degree segment):\n {}", stairs.len(), stairs.iter().take(8).cloned().collect::<Vec<_>>().join("\n ")); }+ let checked = match &mech { Some(m) => format!("; courtyards present and clear on {} footprint(s), {} part bodies clear", m.with_courtyard, m.bodies_checked), None => "; courtyards and bodies not checked (Fusion board)".into() };+ let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false);+ ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}{checked}; copper {}\nThis is the routing gate (DRC, connectivity, courtyards, bodies), not a fabrication check: pours in the saved board, Gerbers and human review come later.", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]);+ }+ Cmd::Land { what, plan, moves, pause_ms, from } => {+ let mut r = load_run(&cli);+ if what == "route" {+ // a gated empty plan (the board's own copper already joins every net): nothing to land, no bridge call+ let gate = r.outcomes().get("gate").cloned().unwrap_or(Value::Null);+ let gate_ok = gate["newErrors"].as_u64() == Some(0) && gate["unconnected"].as_u64() == Some(0) && gate["planeUnderIssues"].as_u64().unwrap_or(0) == 0 && mechanical::gate_clear(&gate);+ let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));+ let empty_after_normalising = |p: &String| { let (v, _) = read_plan_normalised(p, &board_of(&r)); v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) };+ if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && empty_after_normalising(p)) {+ let note = read_json(&planp).get("note").cloned().unwrap_or(json!("empty plan"));+ r.stage_start("land-route", Some("binary"), None);+ r.stage_end("land-route", Some(json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0})), Some("empty plan: nothing to land"));+ r.outcome("land-route", json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0, "stoppedAt": Value::Null, "emptyPlan": true, "note": note}));+ r.save().unwrap();+ ok(&format!("nothing to land: the gated plan {planp} is empty (the board already carries its copper); recorded as landed"), &["Next: adom-aiflow pour, land vias, land pours.".into()]);+ return;+ }+ }+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ match what.as_str() {+ "moves" => {+ let m = moves.as_deref().unwrap_or_else(|| err("land moves needs --moves moves.json", &[]));+ r.stage_start("place-land", Some("binary"), None);+ let mv = read_json(m);+ let t0 = std::time::Instant::now();+ // one part at a time: the camera sees each part land, not a board that changes at once+ let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {+ eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });+ });+ let total = mv.as_object().map(|o| o.len()).unwrap_or(0);+ r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);+ r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));+ r.save().unwrap();+ if let Some(i) = stopped {+ let rep = replies.last().cloned().unwrap_or(Value::Null);+ err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+ }+ ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);+ }+ "route" | "vias" => {+ let planp = if what == "route" {+ let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("planeUnderIssues")).and_then(|v| v.as_u64()).unwrap_or(0) == 0 && r.outcomes().get("gate").map(mechanical::gate_clear).unwrap_or(false);+ if !gate_ok && plan.is_none() {+ err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);+ }+ plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[]))+ } else {+ plan.clone().or_else(|| r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))+ };+ // the gate's normalisation (issue #33): no zero-length trace reaches the bridge, and+ // trace numbers (`--from`) count the same traces the gate passed+ let b = board_of(&r);+ let (plan_main, plan_notes) = read_plan_normalised(&planp, &b);+ print_plan_notes(&plan_notes);+ let entries: Vec<Value> = aiflow_copper::plan_entries(&plan_main).unwrap_or_else(|e| err(&format!("{planp}: {e}"), &[]));+ // a via must not touch another net's copper, planned or already on the board: KiCad+ // hands a touching via the other net and the bridge's DRC then sees nothing wrong+ let pad_copper: Vec<Copper> = b.pads.iter().flat_map(|p| p.polys.iter().map(move |poly| Copper::Poly { poly: poly.clone(), net: p.net.clone(), what: format!("pad {} ({})", p.key, if p.net.is_empty() { "no net" } else { p.net.as_str() }) })).collect();+ let mut plans: Vec<Value> = vec![plan_main.clone()];+ if what == "vias" {+ if let Some(gp) = r.outcomes().get("gate").and_then(|g| g.get("plan")).or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan"))).and_then(|p| p.as_str()) {+ if Path::new(gp).is_file() { plans.push(read_plan_normalised(gp, &b).0); }+ }+ }+ let mut copper_cache: Option<Vec<Copper>> = None;+ let precheck = |_i: usize, e: &Value, live: &landing::LiveCopper| -> Option<Value> {+ let t = landing::resolve(e, &live.pads)?;+ if t.vias.is_empty() { return None; }+ let copper = copper_cache.get_or_insert_with(|| {+ let mut c = pad_copper.clone();+ c.extend(landing::live_copper(live));+ for p in &plans { c.extend(landing::plan_copper(p, &live.pads)); }+ c+ });+ t.vias.iter().find_map(|v| landing::via_touch(&t.net, *v, t.via_size / 2.0, copper).map(|what| json!({"success": false, "errorCode": "via_touches_other_net", "net": t.net, "at": [v.0, v.1], "touches": what, "message": format!("the planned {} via at ({:.2}, {:.2}) would touch {what}; KiCad would give it that net", t.net, v.0, v.1)})))+ };+ let stage = format!("land-{what}");+ r.stage_start(&stage, Some("binary"), None);+ let t0 = std::time::Instant::now();+ // one trace at a time: a net with several paths lands as several undo steps+ let res = br.land_plan(&rb, &entries, from.unwrap_or(1), *pause_ms, precheck, |i, n, entry, rep| {+ if rep["alreadyPresent"] == true { return; }+ eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().or(entry["net"].as_str()).unwrap_or("?"), entry["width"], if Bridge::ok(rep) { "ok" } else { rep["errorCode"].as_str().unwrap_or("failed") }, rep["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?"), rep["drc"]["unconnected"]);+ });+ let (accepted, stopped, traces) = (res.accepted, res.stopped, res.traces);+ if res.skipped > 0 {+ eprintln!("{} {} already on the live board (same net, same points): not landed again", res.skipped, if what == "vias" { "vias" } else { "traces" });+ }+ if res.recovered + res.retried > 0 {+ eprintln!("transport: {} lost repl(ies) whose trace had landed, {} trace(s) landed on the one retry", res.recovered, res.retried);+ }+ // read back every landed via: the planned net must be the net KiCad gave it+ let mut mismatches: Vec<Value> = Vec::new();+ let with_vias: Vec<&(usize, Value, Value)> = res.landed.iter().filter(|(_, e, _)| e["paths"].to_string().contains("\"layer\"") || e["points"].to_string().contains("\"layer\"")).collect();+ if !with_vias.is_empty() {+ let Some(live) = br.live_copper(&rb) else {+ r.stage_end(&stage, Some(json!({"accepted": accepted, "viaReadback": "unavailable"})), None);+ r.save().unwrap();+ err(&format!("{accepted} trace(s) landed, but the live board could not be read back to check the vias' nets"), &[format!("Read kicad_routing_state for {rb} and compare every via's net with {planp} before going on.")]);+ };+ let landed: Vec<(usize, landing::Trace)> = with_vias.iter().filter_map(|(n, e, _)| landing::resolve(e, &live.pads).map(|t| (*n, t))).collect();+ for m in landing::via_net_mismatches(&landed, &live) {+ let reply = with_vias.iter().find(|(n, _, _)| *n == m.trace).map(|(_, _, rep)| rep.clone()).unwrap_or(Value::Null);+ let mut ids: Vec<String> = reply["itemIds"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();+ if ids.is_empty() && !m.uuid.is_empty() { ids.push(m.uuid.clone()); }+ let undo = if ids.is_empty() { json!({"success": false, "errorCode": "no_item_ids"}) } else { br.remove_items(&rb, &ids) };+ eprintln!("trace {}: the {} via at ({:.2}, {:.2}) landed as {}; {}", m.trace, m.planned, m.at.0, m.at.1, m.actual, if Bridge::ok(&undo) { "taken back" } else { "NOT taken back" });+ mismatches.push(json!({"trace": m.trace, "planned": m.planned, "actual": m.actual, "at": [m.at.0, m.at.1], "viaId": m.uuid, "removedIds": ids, "undone": Bridge::ok(&undo), "undo": undo}));+ }+ }+ let undone = mismatches.iter().filter(|m| m["undone"] == true).count();+ let accepted = accepted - undone.min(accepted);+ let wall = (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0;+ write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "from": from, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "accepted": accepted, "viaNetMismatches": mismatches, "replies": res.replies, "wallSeconds": t0.elapsed().as_secs_f64()}));+ r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "wallSeconds": wall})), None);+ r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "viaNetMismatches": mismatches.len(), "wallSeconds": wall, "stoppedAt": stopped}));+ r.save().unwrap();+ if !mismatches.is_empty() {+ let lines: Vec<String> = mismatches.iter().map(|m| format!("trace {}: planned {} at {}, landed as {}{}", m["trace"], m["planned"], m["at"], m["actual"], if m["undone"] == true { " (taken back)" } else { " (NOT taken back: remove it by hand)" })).collect();+ err(&format!("{} via(s) landed on another net than planned:\n{}", mismatches.len(), lines.join("\n")), &["A via whose copper touches another net's track or pad takes that net in KiCad, and the DRC sees nothing wrong. Move the via clear of that copper (spec thermalVias/stitchVias, then `pour`), or move the track, and land again: the vias already right are skipped.".into()]);+ }+ if let Some(i) = stopped {+ let rep = res.replies.last().cloned().unwrap_or(Value::Null);+ if rep["errorCode"] == "via_touches_other_net" {+ err(&format!("live {what} refused before landing anything: trace {} of {traces}: {}", i + 1, rep["message"].as_str().unwrap_or("")), &["Move the via clear of that copper (spec thermalVias/stitchVias or the plan), run `pour` or `gate` again, then land. Nothing was landed by this command.".into()]);+ }+ err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Rerun `land {what}` once fixed: traces already on the live board are skipped. To resume at this trace: `adom-aiflow land {what}{} --from {}`. The log has every reply.", plan.as_ref().map(|p| format!(" --plan {p}")).unwrap_or_default(), i + 1)]);+ }+ let skipped_note = if res.skipped > 0 { format!(", {} already on the board skipped", res.skipped) } else { String::new() };+ ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries{skipped_note}, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);+ }+ "pours" => {+ let zp = r.outcomes().get("pour").and_then(|p| p.get("pours")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no pours plan; run `adom-aiflow pour` first", &[]));+ let b = board_of(&r);+ let zones = aiflow_pours::resolve_outline(&b, read_json(&zp)["zones"].as_array().unwrap_or(&Vec::new()));+ pourverify::landable(&zones).unwrap_or_else(|e| err(&e, &["Write that zone natively in KiCad, save and fill it, then accept it with `verify-pours --plan <plan>` (add --adopt --why to own it). Nothing was landed.".into()]));+ r.stage_start("land-pours", Some("binary"), None);+ let before = br.zone_state(&rb, true);+ if !Bridge::ok(&before) { err("cannot read settled zones before landing", &[before.to_string()]); }+ let mut owned: Vec<String> = r.data["ownedZoneIds"].as_array().into_iter().flatten().filter_map(|v|v.as_str().map(str::to_string)).collect();+ // Legacy successful operation replies are evidence of creation, not an inference.+ if let Some(groups)=r.outcomes().get("land-pours").and_then(|o|o["groups"].as_array()).filter(|_| r.data.get("ownedZoneIds").is_none()) {+ for g in groups.iter().filter(|g|g["ok"]==true) {+ for id in g["itemIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {+ if !owned.iter().any(|x|x==id) { owned.push(id.to_string()); }+ }+ }+ }+ let results = br.land_zones(&rb, &zones, *pause_ms, &owned);+ for (_,reply) in &results {+ if Bridge::ok(reply) || reply["deletionVerified"] == true {+ for id in reply["removedIds"].as_array().into_iter().flatten().filter_map(Value::as_str) { owned.retain(|x|x!=id); }+ }+ if Bridge::ok(reply) || reply["mutated"]==true {+ for id in reply["itemIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {+ if !owned.iter().any(|x|x==id) { owned.push(id.to_string()); }+ }+ }+ }+ r.data["ownedZoneIds"]=json!(owned);+ if let Some((_, rep)) = results.iter().find(|(n, _)| n == "removed-stale") {+ eprintln!("{}", rep["message"].as_str().unwrap_or(""));+ }+ let after = br.zone_state(&rb, results.iter().all(|(_, reply)| Bridge::ok(reply)));+ let mut results = results;+ if !Bridge::ok(&after) { results.push(("post-landing-readback".into(), after.clone())); }+ let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();+ r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);+ r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"]})).collect::<Vec<_>>()}));+ write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "results": results, "after": after}));+ r.save().unwrap();+ if let Some((net, rep)) = failed.first() {+ let mut types: BTreeMap<String, usize> = BTreeMap::new();+ for v in rep["violations"].as_array().cloned().unwrap_or_default() {+ *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;+ }+ err(&format!("pour for {net} refused by the preflight: {} {:?}", rep["errorCode"], types), &["starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);+ }+ ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into()]);+ }+ other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),+ }+ }+ Cmd::VerifyPours { plan, adopt, why, record_plan } => {+ if *adopt || *record_plan { thread(&cli); }+ let mut r = load_run(&cli);+ match pourverify::command(&mut r, &dir, plan, *adopt, why.as_deref(), *record_plan) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ }+ Cmd::Measure => {+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ r.stage_start("measure", Some("binary"), None);+ let zs = br.zone_state(&rb, true);+ r.stage_end("measure", Some(json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"]})), None);+ write_json(&dir.join("zone-state.json"), &zs);+ r.log("artifact", json!({"step": r.data["currentStep"], "kind": "zone-state", "file": dir.join("zone-state.json").display().to_string()}));+ if !Bridge::ok(&zs) {+ r.outcome("measure", json!({"failed":true,"errorCode":zs["errorCode"],"reply":zs}));+ r.save().unwrap();+ err("native copper measurement did not settle", &["The refill request was not replayed. Inspect zone-state.json and the native board before retrying a read.".into()]);+ }+ r.outcome("measure", json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"], "coveragePercent": zs["filledCoverageByLayerPercent"], "zones": zs["zoneCount"]}));+ r.save().unwrap();+ ok(&format!("copper kept per layer ({}'s fills): {}; coverage {}", aiflow_bridge::eda::active().name(), zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);+ }+ Cmd::Analyze { what, plan, fields, ack, why } => {+ let t = thread(&cli);+ if !ack.is_empty() && what != "current" { err("--ack acknowledges a current-density peak: `analyze current --ack [email protected] --why ...`", &[]); }+ let mut r = load_run(&cli);+ let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));+ // a Fusion run analyzes the board as it is in Fusion now, not the placement snapshot+ let live = live_board(&r, &cli, &dir);+ let board_now = match &live { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };+ let solved_board = live.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());+ let plan_v = plan_with_board_copper(&read_json(&planp), &board_now);+ let spec = spec_of(&r);+ let zsp = dir.join("zone-state.json");+ let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None };+ let name = format!("analyze-{what}");+ r.stage_start(&name, Some("binary"), None);+ let (mut heuristic_passed, mut res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal_on_board(&plan_v, &spec, zs.as_ref(), &board_now), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+ let mut fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));+ // provenance: a fields.json is evidence only when it is the output of a solve aiflow ran or watched;+ // any other file (copied, edited, or written to look like one) is solved again here, same inputs+ let mut unobserved: Option<String> = None;+ if aiflow_analyze::needs_field_screen(&spec,what) && fields_file.is_file() && !fields_observed(&r,&fields_file) {+ let out_dir = dir.join("fields");+ let args: Vec<String> = vec!["analyze".into(),"--board".into(),solved_board.clone(),"--spec".into(),r.data["spec"].as_str().unwrap_or("").to_string(),"--out".into(),out_dir.display().to_string()];+ eprintln!("{} was not produced by an Adom Fields solve this run watched; solving it now: adom-fields {}", fields_file.display(), args.join(" "));+ match std::process::Command::new("adom-fields").args(&args).status() {+ Ok(st) if st.success() => { fields_file = out_dir.join("fields.json"); let mut full=vec!["adom-fields".to_string()]; full.extend(args.clone()); record_fields_solve(&mut r, &fields_file, "analyze", &full); r.save().unwrap(); }+ Ok(st) => { unobserved = Some(format!("the field solve aiflow started failed ({st}); fix what adom-fields printed, then `adom-aiflow exec -- adom-fields {}` and analyze {what} again", args.join(" "))); }+ Err(e) => { unobserved = Some(format!("{} was not produced by an Adom Fields solve this run watched, and adom-fields could not start ({e}): install adom-fields, then `adom-aiflow exec -- adom-fields {}`", fields_file.display(), args.join(" "))); }+ }+ }+ if !ack.is_empty() && (unobserved.is_some() || !fields_file.is_file()) { err("--ack needs a same-input Fields solve this run watched to read the peak from; analyze current without --ack first", &[]); }+ let mut expired_acks: Vec<Value> = Vec::new();+ let mut sink_notes: Vec<String> = Vec::new();+ let screen=if let Some(u)=unobserved.clone() { Err(u) } else if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(Vec::new()) } else if fields_file.is_file() {+ let f=read_json(&fields_file.display().to_string());+ if let Err(e)=std::fs::metadata(&solved_board) { err(&e.to_string(),&[]); }+ let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[]));+ // issue #70: record reviewed part-limited peaks, then screen with the live ones+ let live_acks = if what == "current" {+ let copper = ack_copper(live.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board));+ if !ack.is_empty() {+ let spec_sha = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(&spec_bytes)) };+ if f["inputIdentity"]["spec"].as_str() != Some(spec_sha.as_str()) || copper_carry::carried_from(&r.data, f["inputIdentity"]["board"].as_str().unwrap_or(""), Path::new(&solved_board)).is_err() { err("--ack: the Fields report is not of this saved board (or its carried-over copper) and spec; solve again, then acknowledge", &[]); }+ let copper = copper.clone().unwrap_or_else(|e| err(&format!("--ack: this board has no copper fingerprint to bind the acknowledgement to ({e})"), &[]));+ let w = why.clone().unwrap_or_else(|| err("--ack needs --why \"<what limits the peak and why the board cannot change it>\"", &[]));+ let limit = spec["maxCurrentDensityAperMm2"].as_f64().unwrap_or(60.0);+ for a in ack.iter() {+ let rec = aiflow_analyze::current_acks::record(a, &w, &t, &f, &board_now, &copper, limit, &now()).unwrap_or_else(|e| err(&e, &["The place is a pad of the part that limits the peak (REF.PAD on that net) or the Fields peak location x,y within 0.5 mm.".into()]));+ let mut list: Vec<Value> = r.data["currentAcks"].as_array().cloned().unwrap_or_default();+ list.retain(|x| x["target"] != rec["target"]);+ list.push(rec);+ r.data["currentAcks"] = json!(list);+ }+ r.save().unwrap();+ }+ let (live_a, expired) = aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &copper.unwrap_or_else(|_| "unavailable".into()), f["inputIdentity"]["spec"].as_str().unwrap_or(""));+ expired_acks = expired;+ live_a+ } else { Vec::new() };+ let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e });+ // issue #67: the same-input solve with a declared board-sink block stands in for the estimate+ if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }+ s+ } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };+ let passed=heuristic_passed && screen.is_ok();+ // issue #75: the fields step refuses a converter Fields did not model; say so here already+ let converter_hints = converter_warning(&r.data, &spec, if fields_file.is_file() { std::fs::read(&fields_file).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) } else { None }.as_ref(), Some(&board_now));+ let covered_acks = screen.as_ref().map(|c| c.clone()).unwrap_or_default();+ let sink_assumptions = aiflow_analyze::board_sink::assumptions(&res);+ let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into());+ r.stage_end(&name, Some(json!({"pass": passed})), None);+ r.outcome(&name, json!({"pass": passed, "heuristicPass":heuristic_passed, "fieldReport":fields_file, "fieldNote":field_note, "results": res, "acknowledgedPeaks": covered_acks, "expiredAcks": expired_acks, "boardSinkAssumptions": sink_assumptions, "boardSinkNotes": sink_notes}));+ r.save().unwrap();+ let mut lines: Vec<String> = res.iter().map(|x| format!("{} {}: {}", x.get("net").or(x.get("ref")).and_then(|v| v.as_str()).unwrap_or("?"), x["status"].as_str().unwrap_or(""), x.get("how").or(x.get("hint")).and_then(|v| v.as_str()).unwrap_or(""))).collect();+ for c in &covered_acks { lines.push(format!("acknowledged peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} by --ack {}: \"{}\" ({}); Fields reports one maximum per net, so check its density map for a second spot", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))); }+ for a in &expired_acks { lines.push(format!("expired acknowledgement (copper or spec changed since the review; acknowledge again if it still holds): {}", aiflow_analyze::current_acks::describe(a, "expired"))); }+ for a in &sink_assumptions { lines.push(format!("board-sink assumption: {a}")); }+ for n in &sink_notes { lines.push(format!("board sink: {n}")); }+ // the copper this analysis judged: the nets with pours, for the drawing and the walkthrough+ let mut pour_nets: Vec<String> = Vec::new();+ for x in &res {+ let net = x.get("net").or(x.get("tabNet")).and_then(|v| v.as_str()).unwrap_or("");+ if !net.is_empty() && x["pourLayers"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !pour_nets.contains(&net.to_string()) { pour_nets.push(net.to_string()); }+ }+ let draw = match what.as_str() {+ "current" => "Now do the analysis yourself, not only this table: draw the current density on the copper (amps per square millimetre on every loaded net's pours and tracks; a Laplace solve on the filled copper between the source pads and the sink pads is enough, the board file has the filled polygons) as a 1920x1080 PNG with a colour scale, then `artifact --kind analysis-image --file <png> --caption \"...\"`. It becomes a fullscreen shot in the final video and a figure on the run page.".to_string(),+ _ => "Now do the analysis yourself, not only this table, as two drawings: first the heat flow in the copper, layer by layer like the current picture (a multi-layer solve: each hot tab's watts spreading on its layer and going down through its vias to the other layers, watts per millimetre of width, with the vias drawn and each chip's split between its layer, its vias and the bare dielectric); then the temperature rise over the whole board (the same solve read as temperature, the FR4 spreading). Each a 1920x1080 PNG with a colour scale, registered in that order with `artifact --kind analysis-image --file <png> --caption \"...\"` (`--replaces <old png>` when you redo one). They become fullscreen shots in the final video and figures on the run page.".to_string(),+ };+ let fields_hint = "Adom Fields 0.4.0 also supports specified conducting heatsinks, per-layer copper thickness, and an optional Signals view for overlap capacitance and differential-pair screening. Read its heatsinks-and-signals guide; use real assembly/material data, never invent a heatsink to pass this gate. Unsupported signal geometry is not a passing impedance result. adom-fields does this solve for you and shows it on the board in 3D: `adom-fields analyze --board <current board> --spec <spec> --out fields` (about two minutes), `adom-fields serve --fields fields &`, then under the fields step `tour fields` films the walkthrough; its screenshots are drawings you can register with `artifact`.".to_string();+ let walk = if pour_nets.is_empty() { String::new() } else { format!("Then walk the pours this analysis judged on camera, under this same step: `tour nets --nets {}` (each net lit as a whole, pours and traces together, framed).", pour_nets.join(",")) };+ if !passed {+ let why = if fields_file.is_file() { let bt = std::fs::read_to_string(live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default(); refine_hints(&read_json(&fields_file.display().to_string()), &bt, &spec) } else { Vec::new() };+ if !why.is_empty() { eprintln!("why:\n {}", why.join("\n ")); }+ err(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);+ }+ let mut hints = vec![draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()];+ hints.extend(converter_hints);+ ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &hints);+ }+ Cmd::SpecAmend { why } => {+ let t = thread(&cli);+ let r = load_run(&cli);+ if why.trim().chars().count() < spec_freeze::MIN_WHY { err("spec-amend needs a real reason in --why: the datasheet page and what was misread", &[]); }+ if r.data["spec"].is_null() { err("this run has no spec yet", &[]); }+ let spec = read_json(r.data["spec"].as_str().unwrap_or(""));+ let Some(mut rec) = spec_freeze::read(&r.dir) else { err("the budgets are not frozen yet (they freeze once placement begins): edit the spec freely until then", &[]) };+ let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(&spec));+ if changed.is_empty() { err("the spec's budgets match the frozen ones: nothing to amend", &[]); }+ let a = json!({"at": now(), "step": r.data["currentStep"], "why": why.trim(), "changes": changed, "aiThread": t});+ rec["budgets"] = spec_freeze::budgets(&spec);+ rec["amendments"].as_array_mut().unwrap().push(a.clone());+ spec_freeze::write(&r.dir, &rec);+ r.log("spec-amend", a);+ ok(&format!("spec budgets amended on the record ({} change(s)): {}", changed.len(), changed.join("; ")), &["The amendment is listed in the finish summary; the gates now measure against the new values.".into()]);+ }+ Cmd::Finish => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t.clone(), target }, &dir);+ if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+ }+ let o = r.outcomes().clone();+ let mut missing = Vec::new();+ let placement=o.get("place-land").cloned().unwrap_or(Value::Null);+ if placement["mode"]=="verified-existing" && copper_carry::placement_bound(&r.data,&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err() {+ missing.push("place verify (saved board or spec changed; verify again)".into());+ }+ let gate = o.get("gate").cloned().unwrap_or(Value::Null);+ if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {+ missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());+ }+ if let Some(m) = decoupling_check::blocks_finish(o.get("decoupling")) { missing.push(m); }+ if let Some(m) = placement_intent::blocks_finish(o.get("placement-intent")) { missing.push(m); }+ if !mechanical::gate_clear(&gate) {+ missing.push("gate: courtyards and part bodies (missing or overlapping; see gate.json \"mechanical\")".to_string());+ }+ if gate["planeUnderIssues"].as_u64().unwrap_or(0) > 0 {+ missing.push(format!("gate: {} planeUnder issue(s) (other-net copper inside a plane region; see gate.json)", gate["planeUnderIssues"]));+ }+ if !o.contains_key("land-route") {+ missing.push("land route".into());+ }+ if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false) || copper_carry::pours_bound(&r.data, p, Path::new(&r.current_board()), Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err()).unwrap_or(true) {+ missing.push("land pours or verify-pours (every planned zone accepted; current inputs required)".into());+ }+ if !measurement_usable(o.get("measure")) {+ missing.push("measure (successful native copper readback)".into());+ }+ // a Fusion run is judged on the board as it is in Fusion now+ let live_board = live_board(&r, &cli, &dir);+ let solved_board = live_board.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());+ let board_now = || match &live_board { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };+ let mut acked_peaks: Vec<Value> = Vec::new();+ for k in ["analyze-current", "analyze-thermal"] {+ if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {+ missing.push(format!("{k} (pass)"));+ } else {+ if !aiflow_analyze::needs_field_screen(&spec_of(&r),k.trim_start_matches("analyze-")){continue;}+ let report=o[k]["fieldReport"].as_str().and_then(|p|std::fs::read(p).ok()).and_then(|b|serde_json::from_slice::<Value>(&b).ok());+ let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();+ if let Some(report)=report {+ let report_path=o[k]["fieldReport"].as_str().unwrap_or("").to_string();+ if !fields_observed(&r, Path::new(&report_path)) { missing.push(format!("{k}: the field report was not produced by an Adom Fields solve this run watched (analyze {} again; aiflow solves it)", k.trim_start_matches("analyze-"))); }+ // issue #70: only acknowledgements of this copper fingerprint and this Fields spec identity cover a peak+ let live_acks = if k == "analyze-current" { aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &ack_copper(live_board.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board)).unwrap_or_else(|_| "unavailable".into()), report["inputIdentity"]["spec"].as_str().unwrap_or("")).0 } else { Vec::new() };+ match copper_carry::field_screen_with_acks(&r.data,&report,&spec_of(&r),Path::new(&solved_board),&spec_bytes,k.trim_start_matches("analyze-"),&live_acks) { Err(reason) => missing.push(format!("{k}: {reason}")), Ok((n, c)) => { if let Some(n) = n { eprintln!("{n}"); } acked_peaks.extend(c); } }+ } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }+ }+ }+ // issue #75: a switching converter needs Fields' voltages/sources/switching and a solve that modelled it+ {+ let report = o.get("analyze-current").and_then(|a| a["fieldReport"].as_str()).map(PathBuf::from).filter(|p| p.is_file()).or_else(|| Some(dir.join("fields/fields.json")).filter(|p| p.is_file()))+ .and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice::<Value>(&b).ok());+ if let Some((msg, hints)) = converter::check(&spec_of(&r), converter::calcs_of_run(&r.data).as_ref(), report.as_ref(), Some(&board_now())) {+ missing.push(format!("fields: {msg}. {}", hints.join(" ")));+ }+ }+ if let Err(m) = netlist_gate(&r) { missing.push(m); }+ if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); }+ let silk_warnings = silk_polarity::warnings(&board_now());+ for w in &silk_warnings { eprintln!("Warning: {w}"); }+ let spec_f = spec_of(&r);+ if let Some((p, layers, warning)) = checks::rules_target(&spec_f, &r.data["fabRules"]) {+ if let Some(w) = warning { eprintln!("Warning: {w}"); }+ if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { missing.push(format!("fab rules ({e}; {hint})")); }+ }+ if let Some(f) = molecule_findings(&spec_f, &board_now(), Some(&r)) {+ if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }+ }+ // issue #39: courtyards, part bodies and the pours in the saved board (the board that ships)+ // Altium: the live board is a fresh render of the board of record (its courtyards and fab+ // outlines from the parts' source footprints, Altium's own fills), so the facts hold for it+ let facts = ((live_board.is_none() || aiflow_bridge::eda::active().id() == "altium") && mechanical::applies(&solved_board)).then(|| verified::board_facts(&board_now(), &solved_board, &project_dir_of(&r), &spec_soft(&r)));+ match &facts {+ Some(f) => {+ for n in &f.mech.severity_notes { eprintln!("Warning: {n}"); }+ for e in f.mech.errors() { missing.push(format!("mechanical: {e} ({})", f.mech.hint())); }+ for p in &f.fill_problems { missing.push(format!("pours in the saved board: {p} ({})", fills::HINT)); }+ }+ None => eprintln!("Warning: courtyards, bodies and saved pours not checked here (Fusion board); the summary says so"),+ }+ {+ let (labels, fps) = provenance::silk_labels(&board_now());+ // On the Fusion lane the board checked here is the KiCad copy converted from the Fusion .brd,+ // which now carries its silkscreen text (issue #74); `fusion silkscreen`'s proof is the second source.+ if aiflow_bridge::eda::active().id() == "fusion" {+ let proof = std::fs::read_to_string(dir.join("proofs").join("silkscreen.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());+ let (m, w) = provenance::fusion_silk(labels, fps, proof.as_ref());+ missing.extend(m);+ if let Some(w) = w { eprintln!("Warning: {w}"); }+ }+ if fps > 0 && (labels as f64) < provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 {+ missing.push(format!("silkscreen: {labels} visible label(s) for {fps} footprints (finished boards carry at least {:.1} per footprint): references, values and contact labels belong on the silkscreen (skill aiflow-silkscreen)", provenance::MIN_SILK_PER_FOOTPRINT));+ }+ let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));+ if !proxies.is_empty() {+ missing.push(format!("3D models: {} footprint(s) use the laser-etch proxy box as their model ({}): fetch or build the real part's model (wiki component page, vendor STEP, adom-step)", proxies.len(), proxies.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>().join(", ")));+ }+ }+ if r.outcomes().contains_key("models-orientation") {+ let (w, _) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));+ let open = unacked_orientation(&r, &w);+ if !open.is_empty() { missing.push(format!("3D models right way up (fix or `models --ack {} --why ...`)", open.join(","))); }+ }+ let mut live = Value::Null;+ if let Some(target) = r.data["target"].as_str() {+ let br = Bridge { ai_thread: t.clone(), target: target.to_string() };+ live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));+ let eda = aiflow_bridge::eda::active();+ if eda.caps().file_backed_board {+ let st = br.status();+ let kv = st["kicadVersion"].as_str().or(st["data"]["kicadVersion"].as_str()).unwrap_or("").to_string();+ let text = std::fs::read_to_string(r.current_board()).unwrap_or_default();+ if !text.contains("(generator \"adom-aiflow-fusion\")") {+ if let Err(e) = provenance::saved_by_live_kicad(&text, &kv) { missing.push(format!("board file: {e}")); }+ }+ }+ let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);+ if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {+ missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));+ }+ // KiCad's full DRC on the live board, warnings included: the silkscreen findings the fab makes real+ // (ink over a mask opening is clipped off, ink on ink, ink past the edge, text under the fab minimum)+ // block whatever severity the project gives them; the routing validate above counts errors only+ if !eda.caps().full_drc {+ eprintln!("finish: {} has no full DRC through its bridge; the silkscreen findings below are not checked (recorded as a gap)", eda.name());+ r.log("capability-gap", json!({"step": "finish", "missing": "full_drc", "eda": eda.id()}));+ }+ let drc = if eda.caps().full_drc { br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"})) } else { Value::Null };+ let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).cloned().unwrap_or_default();+ let silk: Vec<String> = found.iter().filter(|v| v["type"].as_str().map(|t| aiflow_copper::SILK_BLOCKING.contains(&t)).unwrap_or(false))+ .map(|v| format!("{} ({})", v["type"].as_str().unwrap_or(""), v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).collect::<Vec<_>>().join(" / ")).unwrap_or_default())).collect();+ if !silk.is_empty() {+ missing.push(format!("silkscreen the fab would clip or blur ({}): {}. Move the text (or the pad), never downgrade the rule (skill aiflow-silkscreen)", silk.len(), silk.iter().take(6).cloned().collect::<Vec<_>>().join("; ")));+ }+ }+ // the work before the board: the sourcing check when a profile is named, the evidence the spec asks for+ let (spec_v, req_v) = (spec_soft(&r), requirements_of(&r));+ if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)) {+ let sc = o.get("sourcing").cloned().unwrap_or(Value::Null);+ if sc["pass"] != true { missing.push(format!("sourcing check on the {p} profile (sourcing check --bom <csv>)")); }+ else if sc["boardChecked"] != true { missing.push("sourcing check against the board's references (run it again now the board exists)".into()); }+ else if sc["profile"].as_str() != Some(p.as_str()) { missing.push(format!("sourcing check on the {p} profile the spec or requirements name (the last check used {})", sc["profile"].as_str().unwrap_or("?"))); }+ }+ let mut required: Vec<String> = Vec::new();+ for v in [&spec_v, &req_v] { for k in v["requireEvidence"].as_array().cloned().unwrap_or_default() { if let Some(k) = k.as_str() { if !required.iter().any(|x| x == k) { required.push(k.to_string()); } } } }+ for k in &required {+ let have = r.data["evidence"].as_array().map(|a| a.iter().any(|e| e["kind"].as_str() == Some(k.as_str()) && e["pass"] != false)).unwrap_or(false);+ if !have { missing.push(format!("{k} evidence (evidence add --kind {k} --file <json|md>)")); }+ }+ // MPN marking: unless it was turned off, every IC (U*, IC*) carries its laser-etched MPN on the+ // marking face of its model (an 'Adom.LaserEtch' or legacy 'laser_etch' part in the STEP), so the board and the component+ // pages show which chip is which+ let marking = r.data["components"]["mpnMarking"].as_str().unwrap_or("on").to_string();+ if marking != "off" {+ let unmarked = unmarked_ics(&r);+ if !unmarked.is_empty() { missing.push(format!("MPN laser etch on the ICs ({}): `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>` (gate must PASS), or `components --etch off` to decline", unmarked.join(", "))); }+ }+ {+ let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+ for (a, h, t) in thermalvias::short(&spec_f, &board_now(), &acked) { missing.push(format!("heatsink vias: {a} has {h} of {t} (thermal-vias plan, land)")); }+ }+ {+ // the outline as the board is now: the live snapshot when the tool has one, else the plan board+ let edges = outline::kicad_edges(&std::fs::read_to_string(live_board.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default());+ let sharp = outline::sharp_corners(&edges);+ if !sharp.is_empty() && r.data["sequence"].is_array() { missing.push(format!("outline: {} sharp corner(s) (outline round)", sharp.len())); }+ }+ for f in switchnode::findings(&spec_f, &board_now()) { missing.push(format!("switch node: {f} (back to placement)")); }+ // a recorded silkscreen proof is re-checked against the board's own pads and vias (the audit's geometry is supplied)+ if let Some(inp) = o.get("silkscreen").and_then(|p| p["input"].as_str()) {+ if let Ok(t) = std::fs::read_to_string(inp) {+ if let Ok(v) = serde_json::from_str::<Value>(&t) {+ for g in silkproof::geometry_gaps(&v, &board_now()) { missing.push(format!("silkscreen audit: {g} (silkscreen-audit --record again on real geometry)")); }+ }+ }+ }+ if copper_carry::silk_dropped(&r.data) { missing.push("silkscreen proof (adopt-board changed the board file; `silkscreen-audit --record` again)".into()); }+ // the test plan again, on the finished board: every measurement still has a landing+ if let Some(tp) = r.data["testPlan"].as_str().map(str::to_string) {+ let list = testplan::tests_of(&read_json(&tp));+ let (_, gaps, _) = testplan::check_board(&list, &board_now());+ if !gaps.is_empty() { missing.push(format!("test plan: no probe landing on {} (`testplan check` says why)", gaps.join(", "))); }+ }+ // every required step before the finish line, done in order and proven+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).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_default();+ if !seq.is_empty() {+ let st = sequence::states(&dir, &r.data, &flow_full, &seq);+ for b in sequence::blockers(&st, "finish") { missing.push(format!("step {b}")); }+ sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE);+ }+ // parts from the board's own project library stay visible as evidence (issue #66)+ let parts_v = o.get("parts").map(|p| p["parts"].clone()).unwrap_or(Value::Null);+ let lib_parts = project_lib::report(&parts_v);+ for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); }+ if !missing.is_empty() {+ err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);+ }+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let brs = Bridge { ai_thread: t.clone(), target };+ stop_running_clip(&mut r, &brs, &dir, "finish");+ }+ r.data["clock"]["finishTime"] = json!(now());+ record_usage(&mut r, "finish");+ let sink_assumptions = o.get("analyze-thermal").map(|x| x["boardSinkAssumptions"].clone()).filter(|v| v.is_array()).unwrap_or(json!([]));+ let mut reviewed: Vec<String> = acked_peaks.iter().map(|c| format!("acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} ({}): \"{}\" by {}", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))).collect();+ reviewed.extend(sink_assumptions.as_array().into_iter().flatten().filter_map(|a| a.as_str()).map(|a| format!("board-sink assumption: {a}")));+ let checked = verified::run_items(&r, facts.as_ref());+ let summary = json!({"verified": verified::to_json(&checked), "mechanical": facts.as_ref().map(|f| f.mech.to_json()), "poursInSavedBoard": facts.as_ref().map(|f| f.fills.clone()), "engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true, "acknowledgedPeaks": acked_peaks, "boardSinkAssumptions": sink_assumptions});+ r.data["summary"] = summary;+ if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); }+ r.data["summary"]["projectLibraryParts"] = json!(lib_parts);+ r.data["summary"]["silkscreenWarnings"] = json!(silk_warnings);+ r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));+ r.save().unwrap();+ ok(&(format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\npours: {}\n{}{}", r.data["engine"], r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n {}", reviewed.join("\n ")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);+ }+ Cmd::Capture { action, label, reason } => {+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ let engine = r.data["engine"].as_str().unwrap_or("").to_string();+ match action.as_str() {+ "open" => {+ let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));+ r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));+ r.save().unwrap();+ let (revision, reply) = br.revision_with_reply(&rb);+ write_json(&dir.join("capture-open-ipc.json"), &reply);+ if revision.is_none() {+ err(&format!("board window opened (hwnd {hwnd}) but IPC is not ready for {rb}: {} {}; openBoards {}",reply["errorCode"],reply["error"],reply["openBoards"]), &["Read capture-open-ipc.json. For board_mismatch, save and gracefully close only your task-owned conflicting editors, then `capture open` so this board owns the IPC socket. Preserve other users' windows and shared bridges.".into()]);+ }+ let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum();+ let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) };+ let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };+ ok(&format!("board open, maximised, zoomed to fit (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]);+ }+ "start" => {+ let why = reason.clone().unwrap_or_else(|| format!("aiflow run {engine}"));+ // the PCB editor window itself, background-capturable; the monitor only when no editor is open+ // the editor open NOW first: a stored handle dies when the editor is closed and reopened (an offline edit)+ let hwnd = br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))));+ let (rid, kind) = match hwnd {+ Some(h) => (br.record_window_start(h, &why).unwrap_or_else(|rep| err(&format!("window recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "window", "hwnd": h})),+ None => (br.record_start(&why).unwrap_or_else(|rep| err(&format!("recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "monitor"})),+ };+ let mut entry = json!({"recordingId": rid, "start": now()});+ for (k, v) in kind.as_object().unwrap() { entry[k] = v.clone(); }+ r.data["captures"].as_array_mut().unwrap().push(entry);+ r.mark("capture-start");+ r.save().unwrap();+ ok(&format!("recording {rid} started at {}", now()), &["Every stage command writes markers; `capture mark <label>` adds your own.".into()]);+ }+ "mark" => {+ let l = label.clone().unwrap_or_else(|| err("capture mark needs a label", &[]));+ r.mark(&l);+ r.save().unwrap();+ ok(&format!("marker {l} at {}", now()), &[]);+ }+ "stop" => {+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) else { ok("no recording running: nothing to stop (a walkthrough stops its own clip)", &["Next: `done --message ...` when the answer is complete.".into()]); return; };+ let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ let stop = br.record_stop(&rid, &save_to);+ let local = settle_clip_stop(&mut r, pos, &stop, &dir);+ let cap = &r.data["captures"][pos];+ let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+ let p = stop.remote.clone();+ r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": "capture stop"}), &stop));+ r.mark("capture-stop");+ if let Some(f) = local.filter(|f| Path::new(f).is_file()) {+ clip_artifacts(&mut r, &st, &f, 10);+ }+ r.save().unwrap();+ if p.is_some() && !stop.pulled {+ err(&format!("recording stopped, but {} did not reach the run after {} pulls", p.clone().unwrap_or_default(), stop.attempts), &["The capture is flagged pullFailed with its desktop path; `adom-aiflow recut` pulls it again. Do it soon: the desktop cleans its recordings folder.".into()]);+ }+ let clips_note = auto_clips(&r, &dir);+ if r.data["captures"][pos]["truncated"] == true { err(&format!("recording stopped, but the clip {} is truncated (no moov atom: the take died before it was finalised)", p.unwrap_or_default()), &["Re-record the step (`step <name> --back --why \"clip truncated\"`) while the work is fresh.".into(), clips_note]); }+ ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into(), clips_note]);+ }+ "reject" => {+ // the AI looked at a contact sheet and saw a clip the blank check accepted but a+ // human would not (stale pixels after a window resize, a covered canvas)+ let tag = label.clone().unwrap_or_else(|| err("capture reject <step>-<visit> --reason \"...\"", &[]));+ let why = reason.clone().unwrap_or_else(|| err("say why in --reason", &[]));+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let pos = caps.iter().rposition(|c| format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag || c["file"].as_str().map(|f| f.ends_with(&tag)).unwrap_or(false)).unwrap_or_else(|| err(&format!("no clip {tag} in this run"), &["Tags are <step>-<visit>, e.g. nets-2; or the clip's file name.".into()]));+ r.data["captures"][pos]["suspect"] = json!(format!("rejected by the AI: {why}"));+ let st = json!(format!("{}-{}", caps[pos]["step"].as_str().unwrap_or("?"), caps[pos]["visit"].as_u64().unwrap_or(1)));+ r.log("clip-suspect", json!({"step": st, "file": caps[pos]["file"], "why": why, "by": "ai"}));+ r.save().unwrap();+ ok(&format!("clip {tag} rejected: it stays on the page as evidence and out of the video"), &["Re-take the step if the walkthrough matters: `step <name> --back --why ...`.".into()]);+ }+ other => err(&format!("capture {other}: use open, start, mark, stop or reject"), &[]),+ }+ }+ Cmd::Status => {+ let r = load_run(&cli);+ let o = r.outcomes();+ ok(&format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",+ dir.display(), r.data["engine"], r.elapsed_minutes(), r.data["clock"]["promptTime"], Value::Object(r.stage_minutes()), r.data["decisions"],+ o.get("route").map(|x| x["unrouted"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),+ if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { pourverify::mode_label(&o["land-pours"]) } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),+ o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);+ }+ Cmd::Report { page, push, refresh, user_machine, surface } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ if let Some(p) = page { r.data["page"] = json!(p); }+ if let Some(m) = user_machine { r.data["userMachine"] = json!(m); }+ if let Some(sf) = surface { if !matches!(sf.as_str(), "browser" | "webview") { err("--surface is browser or webview", &[]); } r.data["reportSurface"] = json!(sf); }+ r.save().unwrap();+ let pg = r.data["page"].as_str().map(str::to_string).unwrap_or_else(|| err("no page for this run: `report --page <owner/slug>` once (the wiki page the run belongs to)", &[]));+ if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }+ let (url, notes) = publish_report(&r, &dir, &pg, *push, *refresh, &t);+ if notes.iter().any(|n| n.contains("FAILED") || n.starts_with("push failed")) { err(&format!("report built but NOT published: {}", notes.join("; ")), &["Run `report --push` again: only the files that failed go up (the pushed ones are cached).".into()]); }+ ok(&format!("report built for {pg}: {url}{}", if notes.is_empty() { String::new() } else { format!("; {}", notes.join("; ")) }), &["From now on every `step` and `deliver` rebuilds, pushes and refreshes it by itself.".into()]);+ }+ Cmd::Giveback { url, kind, note } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if !r.data["giveback"].is_array() { r.data["giveback"] = json!([]); }+ r.data["giveback"].as_array_mut().unwrap().push(json!({"url": url, "kind": kind.clone().unwrap_or_else(|| if url.contains("/pulls/") || url.contains("/pr/") { "pr".into() } else { "issue".into() }), "note": note, "at": now()}));+ r.log("giveback", json!({"url": url, "kind": kind, "note": note}));+ r.save().unwrap();+ ok(&format!("given back: {url} ({} so far this run)", r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0)), &["Adom processes PRs within minutes during business hours; the run's summary counts what its AI gave back.".into()]);+ }++ Cmd::Recut => {+ let t = thread(&cli);+ if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then recut again. No clip was judged.".into()]); }+ let mut r = load_run(&cli);+ // a clip whose pull failed at its stop comes first: the desktop cleans its recordings folder+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t, target }, &dir);+ if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+ }+ let recovered = recover_capture_files(&mut r, &dir);+ if recovered > 0 { eprintln!("recovered {recovered} clip file(s) the manifest had lost"); }+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let mut n = 0;+ for c in &caps {+ let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+ if !Path::new(f).is_file() { continue; }+ let tag = json!(format!("{step}-{}", c["visit"].as_u64().unwrap_or(1)));+ clip_artifacts(&mut r, &tag, f, 10);+ n += 1;+ }+ r.mark(&format!("recut: {n} clips re-cut (10x and action) with this binary's rules"));+ r.save().unwrap();+ ok(&format!("{n} clips re-cut; the run page uses the action cuts"), &["Next: adom-aiflow compose --out <mp4>, then report --push --refresh.".into()]);+ }+ Cmd::Compose { out, max_seconds, still_seconds, with, script } => {+ thread(&cli);+ if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then `adom-aiflow recut` (clips flagged blank only because the tools were missing are re-checked) and compose again.".into()]); }+ let mut r = load_run(&cli);+ if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }+ let cwd = std::env::current_dir().unwrap_or_else(|e| err(&format!("cannot read the working directory: {e}"), &[]));+ let outp = compose_out_path(out.as_deref(), &dir, &cwd);+ if let Some(parent) = outp.parent() {+ if let Err(e) = std::fs::create_dir_all(parent) { err(&format!("compose cannot create the output folder {} for {}: {e}", parent.display(), outp.display()), &["Pass --out <file.mp4> in a folder you can write; a relative --out is resolved against the current working directory, and without --out the video is final.mp4 in the run directory.".into()]); }+ }+ if let Some(other) = with {+ let b = Run::open(other).unwrap_or_else(|| err(&format!("no run at {}", other.display()), &["The other run needs its run.json, run.jsonl and its clips' 10x cuts in that directory.".into()]));+ let script: Option<serde_json::Map<String, Value>> = script.as_ref().map(|p| serde_json::from_str::<Value>(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("script {}: {e}", p.display()), &[]))).ok().and_then(|v| v.as_object().cloned()).unwrap_or_else(|| err("the script must be a JSON object of step name to words", &["{\"placement\": \"...\", \"routing\": \"...\", \"drawings:current\": \"...\"}".into()])));+ let (secs, steps, pairs, has_tts) = compose_pair(&mut r, &b, &dir, &outp, *max_seconds, *still_seconds, script.as_ref());+ r.log("artifact", json!({"step": "finish", "kind": "video-comparison", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "steps": steps, "drawingPairs": pairs, "with": other.display().to_string(), "withEngine": b.data["engine"], "maxSeconds": max_seconds, "narrated": has_tts}));+ r.data["comparison"] = json!({"video": outp.display().to_string(), "with": other.display().to_string(), "withEngine": b.data["engine"], "seconds": (secs * 10.0).round() / 10.0});+ r.save().unwrap();+ ok(&format!("composed {secs:.0} s side by side: {} left, {} right, {steps} steps and {pairs} drawing pair(s){}: {}", r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or(""), if has_tts { ", narrated" } else { ", no narration" }, outp.display()), &["Look at it before you post it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png.".into(), "report --push --refresh puts it on this run's page under the single-run video.".into()]);+ return;+ }+ let (secs, parts, stills, rate, has_tts) = compose_run(&mut r, &dir, &outp, *max_seconds, *still_seconds);+ r.log("artifact", json!({"step": "finish", "kind": "video", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "segments": parts, "stills": stills, "rate": rate, "maxSeconds": max_seconds, "narrated": has_tts}));+ if r.data["delivery"].is_object() {+ r.data["delivery"]["video"] = json!(outp.display().to_string());+ r.mark("compose: the delivered video replaced by the composed cut (motion only, the ledger's overlays, the AI's drawings, narration cut to the picture); the clock of record unchanged");+ }+ r.save().unwrap();+ let minutes = r.active_minutes_total();+ ok(&format!("composed {secs:.0} s from {parts} step visits and {stills} drawing(s){} for {}, {minutes:.1} min of AI time, motion at {rate:.1} frames per second: {}", if has_tts { ", narrated" } else { ", no narration (adom-tts not reachable)" }, r.data["engine"].as_str().unwrap_or(""), outp.display()), &["Look at it before you deliver it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png gives one page of it.".into(), "Next: report --push --refresh so the run page carries it; deliver --video <this file> if the run is not delivered yet.".into()]);+ }+ Cmd::Artifact { file, kind, caption, step, replaces, review } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let src = PathBuf::from(file);+ if !src.is_file() { err(&format!("no such file: {file}"), &["Make the drawing first (a PNG at 1920x1080 reads best fullscreen), then register it.".into()]); }+ let step = step.clone().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err("no current step", &["`step <name>` first: an artifact belongs to a step visit.".into()]));+ let visit = r.data["steps"][&step]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+ // Snapshot even in-run sources: later edits must not rewrite earlier evidence.+ let (dest, artifact_hash) = artifact_store::snapshot(&src, &dir)+ .unwrap_or_else(|e| err(&e, &["No artifact was registered. Resolve the storage error and retry; existing evidence is preserved.".into()]));+ let is_image = matches!(dest.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("png") | Some("jpg") | Some("jpeg") | Some("webp"));+ let review_path=if kind=="library-overview" {+ let rp=review.as_ref().unwrap_or_else(||err("library-overview needs --review <review.json>", &["Review exact selected STEP/GLB variants in the native EDA and the overview. See docs/library-overview-review.md.".into()]));+ library_review::verify(rp,Path::new(&r.current_board()),&dest).unwrap_or_else(|e|err(&e,&["Update the actual selection/native evidence, then re-register; do not mark pending evidence passed.".into()]));+ let seconds=media_seconds(dest.to_str().unwrap());+ if !(1.0..=6.0).contains(&seconds){err("library overview must be a moving clip between one and six seconds", &["Keep the full component tour separate; cut a roughly five-second overview.".into()]);}+ Some(std::fs::canonicalize(rp).unwrap_or(rp.clone()).display().to_string())+ }else{None};+ let replaced = replaces.as_ref().map(|p| std::fs::canonicalize(p).unwrap_or(PathBuf::from(p)).display().to_string());+ r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced, "review":review_path, "sha256": artifact_hash, "sourceFile": src.display().to_string()}));+ r.mark(&format!("artifact {kind} for {step}-{visit}: {caption}"));+ r.save().unwrap();+ ok(&format!("{kind} registered for {step} (visit {visit}): {}", dest.display()), &[if is_image { "It goes on the run page under this step with your caption above it, and into the final video as a fullscreen shot at this point of the step (the one place the video leaves the EDA window).".to_string() } else if kind=="library-overview" { "The reviewed moving overview goes into compose; the full tour remains separate.".into() } else { "It goes on the run page under this step with your caption; this artifact kind does not automatically enter the final video.".to_string() }, "Next: if this was an analysis, walk the copper it judged on camera under this same step: `tour nets --nets <the pour nets>`.".into()]);+ }+ Cmd::Compare { with, page, push, refresh } => {+ let t = thread(&cli);+ let r = load_run(&cli);+ let b = Run::open(with).unwrap_or_else(|| err(&format!("no run at {}", with.display()), &[]));+ let page = page.clone().or_else(|| r.data["page"].as_str().map(str::to_string)).unwrap_or_else(|| err("no page: --page <owner/slug>", &[]));+ let (rel, url, files) = build_comparison(&r, &b, &dir, &page);+ let mut notes = Vec::new();+ if *push {+ let stage = dir.join("report");+ let mut cmd = std::process::Command::new("adom-wiki");+ cmd.current_dir(&stage).args(["repo", "push", &page, "-m", &format!("aiflow comparison {}: {} versus {}", rel, r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or("")), "--files"]);+ for f in &files { cmd.arg(f); }+ match cmd.output() { Ok(o) if o.status.success() => notes.push(format!("pushed {} files", files.len())), Ok(o) => err(&format!("push failed: {}", aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 200)), &["Run it again: the files are still staged.".into()]), Err(e) => err(&format!("push failed: {e}"), &[]) }+ }+ if *refresh {+ if let Some(m) = r.data["userMachine"].as_str() {+ let br = Bridge { ai_thread: t.clone(), target: m.to_string() };+ let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+ let op = br.call("pup_open_window", &json!({"sessionId": format!("aiflow-compare-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>()), "url": bust, "reason": "show the human the comparison page"}));+ notes.push(if Bridge::ok(&op) { format!("opened in pup on {m}") } else { format!("pup on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });+ }+ }+ ok(&format!("comparison page built: {url}{}", if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }), &["Link it from the page's README so people find it; the two run pages are linked from it.".into()]);+ }+ Cmd::Prompt { text, at } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let at = at.clone().unwrap_or_else(now);+ if aiflow_run::parse(&at).is_none() { err(&format!("--at {at} is not a time (RFC 3339, e.g. 2026-09-14T15:04:05Z)"), &[]); }+ let n = r.sessions().len() + 1;+ r.log("prompt", json!({"n": n, "text": text, "at": at}));+ r.data["clock"]["lastPromptTime"] = json!(at);+ r.save().unwrap();+ ok(&format!("prompt {n} at {at}: the AI's time on it counts from here"), &["Continue the original requested scope after supporting tasks and status updates. Use `done --message \"...\"` only at completion, an explicit pause, or a genuine blocking handoff; it stops session accounting, not the engineering run.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into(), "Measure external work with adom-aiflow exec -- <program> <args>. Unwrapped long gaps are unobserved and may undercount active work, not proof of idle time.".into()]);+ print_next(&cli);+ }+ Cmd::FinishGuard { .. } => unreachable!("handled before side effects"),+ Cmd::Done { message, stop_evidence } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let verdict = continuation_report(&r, stop_evidence.as_deref());+ if verdict["mayStop"] != true {+ err("finish line not reached; session remains open", &[verdict["reason"].as_str().unwrap_or("").into(), verdict["nextAction"].as_str().unwrap_or("").into(), "A status update, a passing diagnostic, a fixable tool bug or a failed engineering gate is not a stopping point. Run finish-guard before any final answer.".into()]);+ }+ let ss = r.sessions();+ let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };+ if cur.done_at.is_some() && !cur.open { err(&format!("session {} is already done", cur.n), &["A new human prompt is `prompt --text \"...\"`; then `done` closes that one.".into()]); }+ let t = now();+ r.log("done", json!({"n": cur.n, "message": message, "stopDecision": verdict}));+ r.save().unwrap();+ let ss = r.sessions();+ let cur = ss.last().unwrap();+ ok(&format!("session {} done at {t}: {:.1} min of AI time on it ({:.0} min unobserved gaps excluded); {:.1} min of AI time on the run so far", cur.n, cur.active_seconds() as f64 / 60.0, cur.idle_seconds as f64 / 60.0, r.active_minutes_total()), &["report --push --refresh puts the session on the run page.".into()]);+ }+ Cmd::Sessions { json: as_json } => {+ thread(&cli);+ let r = load_run(&cli);+ let ss = r.sessions();+ if *as_json {+ println!("{}", serde_json::to_string_pretty(&ss.iter().map(|x| json!({"n": x.n, "promptAt": aiflow_run::iso(x.prompt_at), "doneAt": x.done_at.map(aiflow_run::iso), "text": x.text, "activeMinutes": (x.active_seconds() as f64 / 6.0).round() / 10.0, "idleMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "unobservedMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "measurement": "ledger activity with heuristic gap exclusion; not harness-complete", "spans": x.spans.iter().map(|(a, b)| json!([aiflow_run::iso(*a), aiflow_run::iso(*b)])).collect::<Vec<_>>(), "open": x.open})).collect::<Vec<_>>()).unwrap());+ return;+ }+ println!("{}", sessions_table(&r));+ ok(&format!("{} session(s), {:.1} min of AI time in all; idle threshold {} min", ss.len(), r.active_minutes_total(), r.idle_minutes()), &["Run finish-guard before a final answer. Intermediate progress stays in commentary; done requires completed delivery or explicit stop evidence. Unobserved time is not automatically charged.".into()]);+ }+ Cmd::Usage => {+ thread(&cli);+ let mut r = load_run(&cli);+ let snap = record_usage(&mut r, "manual");+ r.save().unwrap();+ if snap.is_empty() { err("no plan usage readable: no Claude Code (~/.claude/.credentials.json) or Codex (~/.codex/auth.json) sign-in in this container, or the provider did not answer", &["The run still counts time; usage is the second cost line and it needs a sign-in the binary can read.".into()]); }+ ok(&format!("{}", snap.iter().map(|l| format!("{} {}: {:.0}% used", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""), l["pct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", ")), &[usage_line(&r)]);+ }++ Cmd::Netlist { action: _, sch, board, net } => {+ let mut run = Run::open(&dir);+ if run.is_some() { thread(&cli); }+ let sch = sch.clone().or_else(|| run.as_ref().and_then(|r| r.data["schematic"].as_str().map(str::to_string)))+ .unwrap_or_else(|| err("netlist check needs --sch <file.kicad_sch> (or a run started with --sch)", &["The schematic the board was made from; its netlist is exported with kicad-cli and compared pin by pin.".into()]));+ if !Path::new(&sch).is_file() { err(&format!("schematic not found: {sch}"), &[]); }+ let schematic_files = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy: {e}"), &[]));+ let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("netlist check needs --board <file.kicad_pcb> outside a run", &[]));+ let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));+ let out_dir = if run.is_some() { dir.clone() } else { std::env::temp_dir() };+ let (netfile, via) = match net {+ Some(n) => (PathBuf::from(n), "given netlist".to_string()),+ None => {+ let out = out_dir.join(if run.is_some() { "netlist-sch.net".to_string() } else { format!("aiflow-netlist-{}.net", std::process::id()) });+ let _ = std::fs::remove_file(&out);+ let cli_used = export_sch_netlist(&sch, &out).unwrap_or_else(|e| err(&e, &["Export needs KiCad's own kicad-cli for the schematic's version (a KiCad 7 kicad-cli cannot read a KiCad 10 schematic). In a container with no native KiCad: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> KICAD_REMOTE_THREAD=<your thread>. Or export it yourself (`kicad-cli sch export netlist --format kicadsexpr`) and pass --net <file>.".into()]));+ (out, format!("exported by {cli_used}"))+ }+ };+ let text = std::fs::read_to_string(&netfile).unwrap_or_else(|e| err(&format!("{}: {e}", netfile.display()), &[]));+ let netlist_hash = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(text.as_bytes())) };+ let sn = checks::parse_sch_netlist(&text).unwrap_or_else(|e| err(&e, &[]));+ let after_export = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy after export: {e}"), &[]));+ if schematic_files != after_export { err("schematic hierarchy changed during netlist export; rerun with stable inputs", &[]); }+ let rep = checks::compare_netlists(&sn, &b);+ let passed = rep["ok"] == true;+ let mut lines = vec![format!("schematic {} ({} refs) vs board {} ({} refs): {}/{} connected pins on the same net ({via})", sch, rep["schematicRefs"], board_path, rep["boardRefs"], rep["matchedPins"], rep["connectedPins"])];+ for (k, label) in [("missingRefs", "missing on the board"), ("extraRefs", "on the board, not in the schematic")] {+ if let Some(a) = rep[k].as_array().filter(|a| !a.is_empty()) { lines.push(format!(" {label}: {}", a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+ }+ for m in rep["mismatches"].as_array().cloned().unwrap_or_default() {+ lines.push(format!(" {:<10} schematic {:<12} board {:<12} {}", m["pin"].as_str().unwrap_or(""), m["schematic"].as_str().unwrap_or("-"), m["board"].as_str().unwrap_or("-"), m["why"].as_str().unwrap_or("")));+ }+ let sch_check = schematic_symbols::check(Path::new(&sch), &run.as_ref().map(spec_soft).unwrap_or(Value::Null));+ lines.push(schematic_symbols::summary_line(&sch_check));+ if let (Some(r), Ok(sc)) = (run.as_mut(), &sch_check) { schematic_symbols::record(r, &sch, sc); }+ let fpd: Vec<String> = rep["footprintDifferences"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ if let Some(r) = run.as_mut() {+ let report = dir.join("netlist.json");+ write_json(&report, &rep);+ if r.data["schematic"].is_null() { r.data["schematic"] = json!(std::fs::canonicalize(&sch).map(|p| p.display().to_string()).unwrap_or(sch.clone())); }+ r.outcome("netlist", json!({"ok": passed, "schematic": sch, "sha256": sha256_file(&sch), "schematicFiles": schematic_files, "netlistSha256": netlist_hash, "netlist": std::fs::canonicalize(&netfile).map(|p| p.display().to_string()).unwrap_or_default(), "board": board_path, "report": report.display().to_string(), "matchedPins": rep["matchedPins"], "connectedPins": rep["connectedPins"], "mismatches": rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), "missingRefs": rep["missingRefs"], "extraRefs": rep["extraRefs"]}));+ r.save().unwrap_or_else(|e| err(&e, &[]));+ }+ let fp_hint = if fpd.is_empty() { String::new() } else { format!("Footprints differ (not blocking, but check they are the parts you meant): {}", fpd.join("; ")) };+ if !passed {+ err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint]);+ }+ ok(&lines.join("\n"), &["This is electrical parity between schematic and board only: not ERC, DRC, courtyards, pours or fabrication readiness; never tell the human the board can go to the fab on this result.".into(), if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]);+ }+ Cmd::Molecule { action: _, board, spec } => {+ let mut run = Run::open(&dir);+ if run.is_some() && board.is_none() { thread(&cli); }+ let spec_v = match spec { Some(p) => read_json(p), None => run.as_ref().map(spec_of).unwrap_or_else(|| err("molecule check needs --spec <spec.json> outside a run", &[])) };+ if spec_v.get("molecule").is_none() { eprintln!("Hint: the spec has no \"molecule\" object; checking with the Adom defaults (2 mm grid, 2 mm edge margin, medium pins). Add \"molecule\": {{...}} so place check, gate and finish run it too."); }+ let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("molecule check needs --board <file.kicad_pcb> outside a run", &[]));+ let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));+ let before = if board.is_none() { run.as_ref().and_then(first_board) } else { None };+ let f = checks::molecule_check(&b, &spec_v, before.as_ref());+ if let (Some(r), None) = (run.as_mut(), board) {+ r.outcome("molecule", json!({"ok": f.errors.is_empty(), "board": board_path, "errors": f.errors, "warnings": f.warnings}));+ r.save().unwrap_or_else(|e| err(&e, &[]));+ }+ let text = format!("molecule check on {board_path}: {} error(s), {} warning(s)\n{}", f.errors.len(), f.warnings.len(), f.lines.join("\n"));+ if !f.errors.is_empty() { err(&text, &f.hints); }+ let mut hints = f.hints.clone();+ hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish, then adom-aiflow board-glb copper so pads and test points are real copper (skill aiflow-molecule).".into());+ ok(&text, &hints);+ }+ Cmd::ThermalVias { action, ack, why, pause_ms } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let spec = spec_of(&r);+ if let Some(refs) = ack {+ let w = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<why this part needs no more vias>\"", &[]));+ for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["thermalViaAcks"][x] = json!(w.clone()); }+ }+ let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+ // a Fusion run plans on the live board (the copper landed since the last snapshot), as the finish step reads it+ let b = if action == "plan" { live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))) } else { None }.unwrap_or_else(|| board_of(&r));+ let sw = switchnode::nets(&spec, &b);+ if action == "plan" {+ let (parts, plan) = thermalvias::plan_with_acknowledgements(&spec, &b, &sw, &acked);+ let pp = dir.join("plan-thermal-vias.json");+ write_json(&pp, &plan);+ // issue #40: spec.pours against the board's zones; a stale outline makes the counts wrong+ let pours_landed = r.outcomes().get("land-pours").map(|p| p["pass"] == true || p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false);+ let freshness = thermalvias::pour_freshness(&spec, &b, pours_landed);+ for f in freshness.iter().filter(|f| f.starts_with("WARNING")) { eprintln!("{f}"); }+ let mut lines: Vec<String> = parts.iter().map(|p| format!(" {:<5} {:<5} target {:>2} existing {:>2} planned {:>2}{}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note.as_ref().map(|n| format!(" ({n})")).unwrap_or_default())).collect();+ lines.extend(freshness.iter().map(|f| format!(" {f}")));+ r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note})).collect::<Vec<_>>(), "pourFreshness": freshness}));+ r.save().unwrap();+ if spec["hot"].as_object().map(|o| o.is_empty()).unwrap_or(true) { err("spec.hot names no hot parts", &["Add every part that dissipates real power: \"hot\": {\"U1\": {\"watts\": 0.25, \"tabNet\": \"GND\"}} (watts from the datasheet's efficiency or Rds(on), the inductor's DCR times I squared).".into()]); }+ ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into()]);+ } else {+ let pp = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).map(str::to_string).unwrap_or_else(|| err("no heatsink via plan; run `thermal-vias plan` first", &[]));+ let n = read_json(&pp)["nets"].as_array().map(|a| a.len()).unwrap_or(0);+ if n > 0 {+ let exe = std::env::current_exe().unwrap();+ let st = std::process::Command::new(exe).args(["--run", &dir.display().to_string(), "--ai-thread", &thread(&cli), "land", "vias", "--plan", &pp, "--pause-ms", &pause_ms.to_string()]).status().unwrap_or_else(|e| err(&e.to_string(), &[]));+ if !st.success() { err("landing the heatsink vias stopped (see above)", &["Fix what it says, then `thermal-vias plan` again (the plan counts what landed) and land.".into()]); }+ }+ let r2 = load_run(&cli);+ let live_board = live_board(&r2, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r2));+ let short = thermalvias::short(&spec, &live_board, &acked);+ let mut r = r2;+ r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked}));+ r.save().unwrap();+ if !short.is_empty() { err(&format!("{n} heatsink vias landed, but parts are still short: {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); }+ ok(&format!("{n} heatsink vias landed; every hot part at its target"), &["Next: `step pours` (the pours connect the vias), then the analyses read the heat path through them.".into()]);+ }+ }+ Cmd::Outline { action, radius } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let rad = radius.or_else(|| spec_soft(&r)["outline"]["cornerRadius"].as_f64()).unwrap_or(1.0);+ let eda = aiflow_bridge::eda::active();+ let read = |r: &Run| -> Vec<outline::Edge> {+ let board = live_board(r, &cli, &dir).map(|l| l.1).unwrap_or_else(|| r.current_board());+ outline::kicad_edges(&std::fs::read_to_string(board).unwrap_or_default())+ };+ let before = outline::sharp_corners(&read(&r));+ if action == "check" || before.is_empty() {+ if action == "round" { r.outcome("outline", json!({"radius": rad, "rounded": 0, "sharp": 0})); r.save().unwrap(); }+ if before.is_empty() { ok("the board outline has no sharp corners", &[]); return; }+ err(&format!("{} sharp outline corner(s): {}", before.len(), before.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["`outline round` turns each into an arc (spec outline.cornerRadius, default 1.0 mm).".into()]);+ }+ let br = bridge_of(&r, &cli);+ let rep = eda.round_corners(&|v: &str, a: &Value| br.raw_call(v, a), &before, rad);+ if rep["errorCode"].as_str().map(|c| c.starts_with("unsupported_in_")).unwrap_or(false) {+ err(&format!("outline round does not land live in {} yet", eda.name()), &["Round the board-outline corners in the tool (the corner radius), save, then `outline check` and `outline round` again to record it.".into()]);+ }+ let done = rep["rounded"].as_u64().unwrap_or(0);+ let after = outline::sharp_corners(&read(&r));+ r.outcome("outline", json!({"radius": rad, "rounded": done, "sharp": after.len()}));+ r.save().unwrap();+ if !after.is_empty() { err(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); }+ ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]);+ }+ Cmd::Timeline { action, port, json: presentation } => {+ 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::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::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");+ match action.as_str() {+ "write" => ok(&format!("timeline written: step {} of {}, {} done", tl["position"], tl["total"], tl["done"]), &[]),+ "serve" => {+ timeline_server::serve(&tdir,*port).unwrap_or_else(|e|err(&e,&["Use the existing run server; do not create another dashboard.".into()]));+ }+ "present" => {+ let caller=thread(&cli);+ let patch:Value=serde_json::from_str(presentation.as_deref().unwrap_or("{}")).unwrap_or_else(|e|err(&e.to_string(),&[]));+ dashboard::ensure(&dir,&caller,None,false).unwrap_or_else(|e|err(&e,&[]));+ let config=read_json(&dir.join("dashboard.json").to_string_lossy());+ let reply=timeline_server::update(config["port"].as_u64().unwrap() as u16,&caller,&patch).unwrap_or_else(|e|err(&e,&["The existing server must support the current live channel. Restart only this run server after an upgrade.".into()]));+ ok(&format!("presentation updated in the existing dashboard: {}",reply["presentation"]), &["SSE updates the open page in place; engineering gates are unchanged.".into()]);+ }+ _ => {+ let ai_thread=cli.ai_thread.clone().or_else(||r.data["aiThread"].as_str().map(str::to_owned)).unwrap_or_else(||"adom-aiflow".into());+ let url=dashboard::ensure(&dir,&ai_thread,Some(*port),true).unwrap_or_else(|e|err(&e,&["Dashboard failure does not change any board gate. Retry when Hydrogen is connected.".into()]));+ ok(&format!("timeline open in Hydrogen: {url} (step {} of {})", tl["position"], tl["total"]), &["Keep it in the right pane beside the chat (the VS Code tab stays on the left; never widen panes). Every `step` updates it within a second.".into()]);+ }+ }+ }+ Cmd::Demo { action, at, port, title } => {+ let dir = run_dir(&cli);+ if action == "serve" {+ let d = dir.join("demo").join("timeline");+ if !d.join("timeline.json").exists() { err("nothing to serve yet", &["Run `demo at <step>` first; it writes <run>/demo/timeline/.".into()]); }+ timeline_server::serve(&d, *port).unwrap_or_else(|e| err(&e, &["Pick another --port; the run's live dashboard keeps its own.".into()]));+ return;+ }+ let r = load_run(&cli);+ let at = at.clone().unwrap_or_else(|| err("demo at needs a moment", &["`demo at placement`, `demo at routing@2+60`, or an ISO time like 2026-10-18T19:50:00Z.".into()]));+ let t = demo::resolve(&r.data, &at).unwrap_or_else(|e| err(&e, &["Step names are the run's (`status` lists them); @n picks the nth visit.".into()]));+ let state = demo::rewind(&dir, &t).unwrap_or_else(|e| err(&e, &[]));+ let rs = Run::open(&state).unwrap_or_else(|| err("the rewound state did not open", &[]));+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).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::sequence_key(&dir, &r.data)));+ let mut tl = sequence::timeline(&state, &rs.data, &flow_full, &seq, &rs.step_report());+ let d = demo::write(&dir, &t, &mut tl, TIMELINE_PAGE, title.as_deref()).unwrap_or_else(|e| err(&e, &[]));+ ok(&format!("demo at {t}: step {} of {}, {} done, {} loop back(s), current {}; written to {}", tl["position"], tl["total"], tl["done"], tl["loops"].as_array().map(|a| a.len()).unwrap_or(0), tl["current"], d.display()),+ &["`demo serve --port 8894` serves it (the run's live dashboard is untouched); an open page updates in place on the next `demo at`.".into()]);+ }+ Cmd::Testplan { action: _, tests, netlist, add } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let tp = tests.clone().or_else(|| r.data["testPlan"].as_str().map(str::to_string)).unwrap_or_else(|| err("testplan check needs --tests tests.json the first time", &["Write tests.json: [{id, what, net, why, kind: dc|ripple|waveform|camera|ground, expect}], one per measurement you will take on the probing workcell (skill aiflow-test-plan).".into()]));+ let tv = read_json(&tp);+ let list = testplan::tests_of(&tv);+ if list.is_empty() { err(&format!("{tp} has no tests"), &["A board with nothing to measure has not been thought through: list the rails, the switch node, feedback, ripple, enable and every LED.".into()]); }+ let tp_abs = std::fs::canonicalize(&tp).map(|p| p.display().to_string()).unwrap_or(tp.clone());+ r.data["testPlan"] = json!(tp_abs);+ let has_board = r.data["boards"].as_array().map(|a| !a.is_empty()).unwrap_or(false);+ let (rows, gaps, notes, added, mode) = if has_board {+ let (rows, gaps, warn) = testplan::check_board(&list, &board_of(&r));+ (rows, gaps, warn, Vec::new(), "board")+ } else {+ let np = netlist.clone().unwrap_or_else(|| err("before the board, testplan check needs --netlist <netlist.json>", &[]));+ let mut nl = read_json(&np);+ let (rows, gaps, unknown) = testplan::check_netlist(&list, &nl);+ if !unknown.is_empty() { err(&format!("tests name nets the netlist does not have: {}", unknown.join(", ")), &["Use the netlist's own net names (or rename the net in the design so the measurement has a name).".into()]); }+ let added = if *add && !gaps.is_empty() { let a = testplan::add_test_points(&mut nl, &gaps); write_json(Path::new(&np), &nl); a } else { vec![] };+ (rows, if *add { vec![] } else { gaps }, vec![], added, "netlist")+ };+ let table: Vec<String> = rows.iter().map(|x| format!(" {:<10} {:<12} {:<9} {}", x["id"].as_str().unwrap_or(""), x["net"].as_str().unwrap_or(""), x["kind"].as_str().unwrap_or(""), match x["status"].as_str().unwrap_or("") { "gap" => format!("GAP: {}", x["why"].as_str().unwrap_or("no test point")), s => format!("{s} {}", x["spot"].as_object().map(|o| format!("{} at ({}, {})", o["pad"].as_str().unwrap_or(""), o["x"], o["y"])).or_else(|| x["spot"].as_str().map(str::to_string)).unwrap_or_default()) })).collect();+ r.outcome("testplan", json!({"mode": mode, "tests": rows, "gaps": gaps, "added": added, "notes": notes, "file": tp_abs}));+ r.save().unwrap();+ let head = format!("test plan ({mode}): {} measurements, {} probe-able, {} gap(s){}", rows.len(), rows.iter().filter(|x| x["status"] != "gap").count(), gaps.len(), if added.is_empty() { String::new() } else { format!("; added {}", added.join(", ")) });+ let body = format!("{head}\n{}", table.join("\n"));+ if !gaps.is_empty() {+ err(&body, &[if mode == "board" { "Each gap needs a spot: move a part off the pad, add a test point on the net (adom/testpoint-pad-0-5mm, schematic first: `step schematic --back --why \"test point for <net>\"`), or widen the copper so a 0.5 mm landing fits. Then `testplan check` again.".into() } else { "Add a test point per gap: `testplan check --netlist <netlist.json> --add` writes TP<n> (0.5 mm round, adom/testpoint-pad-0-5mm) into the netlist.".into() }]);+ }+ let mut hints = vec!["Read the table to the human: what you will measure on the workcell and where the probe lands. Every added test point goes into the library and the schematic like any part.".to_string()];+ hints.extend(notes.iter().map(|n| format!("Note: {n}")));+ if mode == "netlist" { hints.push("Run it again once the board is placed (`testplan check`): a test point under a part or out of tether reach is a gap again. finish runs it too.".into()); }+ ok(&body, &hints);+ }+ Cmd::Rules { action, profile, board, force, layers } if action == "use" || action == "list" => {+ let page = "adom/pcb-design-rules";+ let list = || -> Vec<String> {+ let o = std::process::Command::new("adom-wiki").args(["api", "/api/v1/pages/adom/pcb-design-rules/files?path=kicad"]).output().ok();+ let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+ let mut out: Vec<String> = v["files"].as_array().cloned().unwrap_or_default().iter().filter_map(|f| f["name"].as_str()).filter(|n| n.ends_with(".kicad_dru")).map(|n| n.trim_end_matches(".kicad_dru").to_string()).collect();+ out.sort(); out+ };+ if action == "list" {+ let l = list();+ if l.is_empty() { err(&format!("could not read the rule packs on {page}"), &["Check `adom-wiki whoami` and the wiki's health, then retry.".into()]); }+ ok(&format!("{} rule packs on {page} (<fab>[-<process>]-<n>L):\n {}", l.len(), l.join("\n ")), &["`rules use --profile 3rdPartyFab` (the default) or `--profile <fab>[-<process>]`; --layers picks the copper count.".into()]);+ return;+ }+ let mut r = load_run(&cli);+ let asked = profile.clone().unwrap_or_else(|| "3rdPartyFab".into());+ // `--profile jlcpcb-6L` names the layers too; --layers must agree with it+ let (asked_base, asked_layers) = checks::split_profile(&asked);+ if let (Some(a), Some(b)) = (asked_layers, *layers) { if a != b { err(&format!("--profile {asked} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }+ let cb = board.clone().unwrap_or_else(|| r.current_board());+ let layers: u32 = layers.or(asked_layers).or_else(|| checks::board_copper_layers(&cb)).unwrap_or(2);+ let slug = match asked_base.as_str() { "fab" | "adom-3rdpartyfab" => "adom-3rdpartyfab".to_string(), o => o.to_string() };+ let shown = if slug == "adom-3rdpartyfab" { "3rdPartyFab".to_string() } else { slug.clone() };+ let file = format!("{slug}-{layers}L.kicad_dru");+ let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, &format!("kicad/{file}")]).output();+ let text = o.ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.contains("(rule")).unwrap_or_else(|| {+ let l = list();+ err(&format!("no {layers}-layer rule pack for {shown} on {page} ({file})"), &[format!("Packs there: {}. Pick one with --profile and --layers.", if l.is_empty() { "(could not list)".into() } else { l.join(", ") })])+ });+ let marked = format!("# adom-aiflow rules: {slug} {layers}L (from {page})\n{text}");+ let rd = dir.join("rules"); let _ = std::fs::create_dir_all(&rd);+ std::fs::write(rd.join(&file), &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));+ let mut placed = vec![rd.join(&file).display().to_string()];+ let mut replaced_note = String::new();+ if cb.ends_with(".kicad_pcb") {+ let dru = Path::new(&cb).with_extension("kicad_dru");+ let cur = std::fs::read_to_string(&dru).ok();+ let cur_pack = cur.as_deref().and_then(checks::pack_of);+ let same_pack = cur_pack.as_ref().is_some_and(|(s, l)| *s == slug && l.is_none_or(|l| l == layers));+ if cur.is_some() && !*force && !same_pack {+ let what = cur_pack.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "other rules".into());+ err(&format!("{} already carries {what}", dru.display()), &[format!("Pass --force to replace them with {slug}-{layers}L (the old file is kept as .kicad_dru.bak).")]);+ }+ if cur.is_some() {+ let _ = std::fs::copy(&dru, dru.with_extension("kicad_dru.bak"));+ if !same_pack { replaced_note = format!(" (replaced {}; the old file is {})", cur_pack.map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into()), dru.with_extension("kicad_dru.bak").display()); }+ }+ std::fs::write(&dru, &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));+ placed.push(dru.display().to_string());+ // a plan board rendered from another tool has no KiCad project: give it one carrying the same+ // fab floors, so the gate's KiCad DRC judges by the fab's rules, not KiCad's defaults+ let pro = Path::new(&cb).with_extension("kicad_pro");+ if !aiflow_bridge::eda::active().caps().file_backed_board && !pro.is_file() {+ let name = pro.file_name().and_then(|n| n.to_str()).unwrap_or("board.kicad_pro").to_string();+ std::fs::write(&pro, aiflow_bridge::eda::plan_project_json(&marked, &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));+ placed.push(pro.display().to_string());+ }+ }+ // Fusion: the bridge loads the same pack's Fusion rules into the open design+ let mut fusion_note = String::new();+ let mut fusion_loaded = Value::Null;+ let eda = aiflow_bridge::eda::active();+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let br = Bridge { ai_thread: thread(&cli), target };+ if let Some(rep) = eda.load_design_rules(&|v: &str, a: &Value| br.raw_call(v, a), &slug, layers, &text) {+ let okk = Bridge::ok(&rep);+ fusion_loaded = json!(okk);+ fusion_note = if okk { format!("; {}: {shown} {layers}-layer rules loaded into the open design", eda.name()) } else { format!("; {} did not load them ({}): open the design and run `rules use` again", eda.name(), rep["errorCode"].as_str().or(rep["error"].as_str()).unwrap_or("?")) };+ }+ }+ // the choice: in the spec when there is one, and in the run (an intake run has no spec yet)+ r.data["fabRules"] = json!({"profile": shown, "slug": slug, "layers": layers, "file": file});+ if let Some(sp) = r.data["spec"].as_str().map(str::to_string) {+ if let Ok(mut v) = std::fs::read_to_string(&sp).map_err(|_| ()).and_then(|t| serde_json::from_str::<Value>(&t).map_err(|_| ())) {+ if !v["fab"].is_object() { v["fab"] = json!({}); }+ v["fab"]["rules"] = json!(slug);+ write_json(Path::new(&sp), &v);+ }+ }+ let mins: Vec<String> = text.lines().filter(|l| l.starts_with("# Min") || l.starts_with("# Copper to") || l.starts_with("# Annular")).map(|l| l.trim_start_matches("# ").to_string()).take(6).collect();+ r.outcome("rules", json!({"profile": shown, "slug": slug, "layers": layers, "kicad": placed, "native": {"eda": eda.id(), "loaded": fusion_loaded}, "fusion": if eda.id() == "fusion" { fusion_loaded.clone() } else { Value::Null }}));+ r.save().unwrap();+ ok(&format!("design rules: {shown}, {layers} layers{fusion_note}. KiCad rules at {}{replaced_note}", placed.join(", ")), &[+ format!("Tell the human in plain words which rules are now enforced: {}.", if mins.is_empty() { "see the file".into() } else { mins.join("; ") }),+ "Every later step designs to these: placement checks, routing widths and clearances, the gate's DRC and finish all read them. Design with margin above the minimums.".into()]);+ }+ Cmd::Rules { action, profile, board, force, layers } => {+ let run = Run::open(&dir);+ if run.is_some() { thread(&cli); }+ let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("rules needs --board <file.kicad_pcb> outside a run", &[]));+ // the profile: --profile, else the spec's fab.rules, else the run's fabRules (`rules use`)+ let target = run.as_ref().and_then(|r| checks::rules_target(&spec_of(r), &r.data["fabRules"]));+ if profile.is_none() { if let Some(w) = target.as_ref().and_then(|t| t.2.clone()) { eprintln!("Warning: {w}"); } }+ let recorded_layers = if profile.is_none() { target.as_ref().and_then(|t| t.1) } else { None };+ let profile = profile.clone().or_else(|| target.as_ref().map(|t| t.0.clone())).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|jlcpcb-<n>L|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));+ match action.as_str() {+ "install" => {+ let (base, suffix) = checks::split_profile(&profile);+ if base != "jlcpcb" {+ err(&format!("no shipped rules for profile {profile:?}"), &["The 3rd party fab's rules are private and are not shipped with adom-aiflow: keep the project's own .kicad_dru beside the board (spec \"fab\": {\"rules\": \"fab\"}). `rules install` ships only --profile jlcpcb (2 layers); `rules use --profile <fab> --layers <n>` loads any pack on adom/pcb-design-rules.".into()]);+ }+ if let (Some(a), Some(b)) = (suffix, *layers) { if a != b { err(&format!("--profile {profile} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }+ let board_layers = checks::board_copper_layers(&board_path);+ let n = layers.or(suffix).or(board_layers).or(recorded_layers).unwrap_or(2);+ if let Some(bl) = board_layers.filter(|bl| *bl != n) {+ err(&format!("--layers {n} but {board_path} has {bl} copper layers"), &[format!("Install the pack for the board's stackup: `adom-aiflow rules use --profile jlcpcb --layers {bl}`{}.", if bl == 2 { " or `rules install --profile jlcpcb --layers 2`" } else { "" })]);+ }+ let shipped = checks::shipped_pack(&base, n).unwrap_or_else(|| err(&format!("no shipped jlcpcb {n}-layer rules: adom-aiflow ships only jlcpcb-2L"), &[format!("Load the {n}-layer pack from adom/pcb-design-rules instead: `adom-aiflow rules use --profile jlcpcb --layers {n}` (`rules list` shows the packs).")]));+ let dru = Path::new(&board_path).with_extension("kicad_dru");+ let cur = std::fs::read_to_string(&dru).ok();+ let cur_pack = cur.as_deref().and_then(checks::pack_of);+ let describe = |p: &Option<(String, Option<u32>)>| p.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into());+ let mut replaced = String::new();+ if let Some(c) = &cur {+ if cur_pack == Some(("jlcpcb".into(), Some(n))) { let _ = c; ok(&format!("{} already carries the jlcpcb-{n}L profile", dru.display()), &[]); return; }+ if !*force {+ err(&format!("{} already exists and carries {}", dru.display(), describe(&cur_pack)), &[format!("Keep it (it may be the 3rd party fab's rules or a pack `rules use` loaded) or pass --force to replace it with the jlcpcb-{n}L profile (the old file is kept as .kicad_dru.bak).")]);+ }+ let bak = dru.with_extension("kicad_dru.bak");+ std::fs::copy(&dru, &bak).unwrap_or_else(|e| err(&format!("{}: {e}", bak.display()), &[]));+ replaced = format!("; replaced {} (kept as {})", describe(&cur_pack), bak.display());+ }+ std::fs::write(&dru, shipped).unwrap_or_else(|e| err(&format!("{}: {e}", dru.display()), &[]));+ let mut hints = vec!["DRC loads it with the board: `gate` and `place check` need a native kicad-cli for project rules (ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote in a container).".into(), "Set \"fab\": {\"rules\": \"jlcpcb\"} in the spec so the gate refuses a board without it, and source parts on the jlcpcb profile (skill aiflow-sourcing).".into()];+ if run.is_some() && board.is_none() { hints.push("Installed beside the run's current board; later snapshots carry it. Put the same file beside the source board too so the project keeps it.".into()); }+ if let Some(fr) = run.as_ref().map(|r| r.data["fabRules"].clone()).filter(|f| f.is_object()) {+ let (fs, fl) = (fr["slug"].as_str().unwrap_or(""), fr["layers"].as_u64());+ if fs != "jlcpcb" || fl.is_some_and(|l| l as u32 != n) { hints.push(format!("Note: run.json fabRules still records {fs} {}L from `rules use`; the board now carries jlcpcb-{n}L.", fl.unwrap_or(0))); }+ }+ ok(&format!("installed the jlcpcb-{n}L rules (JLCPCB published {n}-layer capabilities, cited in the file) at {}{replaced}", dru.display()), &hints);+ }+ _ => {+ let fallback_layers = layers.or(recorded_layers);+ let result = checks::rules_check_layers(&profile, &board_path, fallback_layers);+ // A read-only check of this run's selected project rules is proof too.+ // Do not rewrite custom rules, or qualify a different board/profile.+ if let Some(mut r) = run {+ let same_board = std::fs::canonicalize(&board_path).ok()+ .zip(std::fs::canonicalize(r.current_board()).ok())+ .is_some_and(|(a, b)| a == b);+ let same_profile = checks::rules_target(&spec_of(&r), &r.data["fabRules"])+ .is_some_and(|t| t.0 == profile);+ let dru = Path::new(&board_path).with_extension("kicad_dru");+ let selected_dru = Path::new(&r.current_board()).with_extension("kicad_dru");+ let same_rules = dru == selected_dru || std::fs::canonicalize(&dru).ok()+ .zip(std::fs::canonicalize(&selected_dru).ok())+ .is_some_and(|(a, b)| a == b);+ if same_board && same_profile && same_rules {+ r.outcome("rules", json!({+ "pass": result.is_ok(), "profile": profile,+ "method": "existing-project-check", "board": board_path,+ "boardSha256": sha256_file(&board_path),+ "file": dru.display().to_string(),+ "rulesSha256": sha256_file(&dru.display().to_string()),+ "note": result.as_ref().map(|m| m.as_str())+ .unwrap_or_else(|e| e.as_str())+ }));+ r.save().unwrap_or_else(|e| err(&e.to_string(), &[]));+ }+ }+ match result {+ Ok(m) => ok(&m, &[]),+ Err(e) => err(&e, &[checks::rules_hint_layers(&profile, checks::effective_layers(&profile, &board_path, fallback_layers))]),+ }+ }+ }+ }+ Cmd::Models { offline, ack, why } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ if let Some(refs) = ack {+ let why = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<what you checked in the native 3D viewer>\"", &[]));+ for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["modelAcks"][x] = json!(why.clone()); }+ r.log("models-ack", json!({"refs": refs, "why": why}));+ }+ // the orientation pass: offline, every model bound on the board, right way up+ let (orient, orient_notes) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));+ let unacked = unacked_orientation(&r, &orient);+ r.outcome("models-orientation", json!({"warnings": orient, "notChecked": orient_notes, "unacknowledged": unacked}));+ let orient_lines: Vec<String> = orient.iter().map(|w| format!(" {} {}: {}{}", w["ref"].as_str().unwrap_or(""), w["model"].as_str().unwrap_or(""), w["finding"].as_str().unwrap_or(""), if r.data["modelAcks"].get(w["ref"].as_str().unwrap_or("")).is_some() { " (acknowledged)" } else { "" })).collect();+ let orient_hint = if unacked.is_empty() { String::new() } else { format!("3D orientation warnings to fix or acknowledge ({}): correct the model's rotate/offset in the footprint and look at it in the native 3D viewer, then `models` again; when the model is right as it is, `models --ack {} --why \"<what you saw>\"`. finish refuses while any stays open.", unacked.join(","), unacked.join(",")) };+ if *offline {+ r.save().unwrap();+ let text = format!("3D orientation (offline): {} warning(s), {} not checked here{}{}", orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }, if orient_notes.is_empty() { String::new() } else { format!("\n {}", orient_notes.join("\n ")) });+ if !unacked.is_empty() { err(&text, &[orient_hint]); }+ ok(&text, &["The live resolution check is `models` without --offline (kicad_model_check on the test box).".into()]);+ return;+ }+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ let _ = t;+ if aiflow_bridge::eda::active().caps().file_backed_board {+ let proof=checked_remote_binding(&r,&br,&rb,&dir).unwrap_or_else(|e|err(&e,&["Use bind-remote-board --remote-board <exact native path> after adopting a different board. Old desktop model results do not qualify the current snapshot.".into()]));+ r.data["remoteBinding"]=proof;+ }+ r.stage_start("models", Some("binary"), None);+ let v = br.call("kicad_model_check", &json!({"filePath": rb}));+ let d = v.get("data").cloned().unwrap_or(v.clone());+ let counts = d["counts"].clone();+ let unresolved = counts["unresolvedModel"].as_u64().unwrap_or(0) + counts["missingModel"].as_u64().unwrap_or(0);+ // complete modelBindings + viewer classes (KiCad Bridge 1.0.22+); file resolution is not render proof+ let mc = aiflow_bridge::model_check_summary(&d);+ let missing = mc.missing.clone();+ let vis = if mc.hidden.is_empty() { String::new() } else { format!("; NOT visible proof, {}: read/set with kicad_3d_viewer_settings (KiCad closed) and confirm in the 3D frame, never by changing BOM/POS/DNP flags", mc.hidden.join("; ")) };+ r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);+ r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing, "viewerHidden": mc.hidden, "bindingsComplete": mc.bindings_complete, "evidenceSource": mc.evidence}));+ r.log("artifact", json!({"step": "models", "kind": "model-check", "counts": counts}));+ r.save().unwrap();+ if !Bridge::ok(&v) && d["counts"].is_null() {+ err(&format!("model check did not answer: {}", v["errorCode"].as_str().unwrap_or("?")), &["The run needs a live board on the test box (capture open) for kicad_model_check.".into()]);+ }+ if unresolved > 0 {+ err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!(" {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);+ }+ let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));+ if !proxies.is_empty() {+ err(&format!("{} footprint model(s) are the laser-etch proxy box, not the part:\n{}", proxies.len(), proxies.iter().map(|(r, m)| format!(" {r}: {m}")).collect::<Vec<_>>().join("\n")), &["A renamed or resized proxy box is a placeholder, not a 3D model. Use the part's real model: its wiki component page, the vendor STEP (adom-parts-search, chip-fetcher), or build one from the datasheet (adom-step); then `models` again.".into()]);+ }+ if !unacked.is_empty() {+ err(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);+ }+ ok(&format!("every footprint has its 3D model ({} with a model, {} expect none{vis}); 3D orientation: {} warning(s), all acknowledged; {} not checked here{}", counts["withModel"], counts["noModelExpected"], orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }), &["Next: step placement.".into()]);+ }+ Cmd::Tour { what, nets, port, on } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ // a localhost Fields URL ({{port}}.localhost, 127.0.0.1) is reachable only on the machine+ // hosting this container: film it there (the user's primary machine), not on the test box+ let local_proxy = what == "fields" && std::env::var("VSCODE_PROXY_URI").map(|u| preboard::proxy_is_local(&u)).unwrap_or(false);+ let mut on_note = String::new();+ let br = match on {+ Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() },+ _ if local_proxy => match primary_machine() {+ Some(m) => { on_note = format!("filmed on {m}, the user's primary machine: the Fields URL is a localhost proxy only the container's host can reach (--on <box> overrides)"); Bridge { ai_thread: t.clone(), target: m } }+ None => { on_note = "the Fields URL is a localhost proxy only the container's host can reach, and `adom-bridge targets` named no primary machine: filmed on the test box; say --on <the machine hosting this container> if the window does not appear".into(); bridge_of(&r, &cli) }+ },+ _ => bridge_of(&r, &cli),+ };+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ let editor = if what == "fields" { 0 } else { br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())))).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) };+ match what.as_str() {+ "3d" => {+ r.stage_start("tour-3d", Some("binary"), None);+ // the viewer, its own window, is this step's camera: swap the running clip onto it+ let op = br.call("kicad_open_3d_viewer", &json!({"editor": "pcb", "pcbHwnd": editor}));+ std::thread::sleep(std::time::Duration::from_secs(6));+ let find_viewer = || -> Option<i64> {+ let w = br.call("kicad_window_info", &json!({}));+ let d = w.get("data").cloned().unwrap_or(w.clone());+ d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer"))).and_then(|e| e["hwnd"].as_i64())+ };+ let viewer = op.get("hwnd").and_then(|h| h.as_i64()).or_else(find_viewer).unwrap_or_else(|| err("the 3D Viewer did not open", &["kicad_open_3d_viewer {editor:\"pcb\"} by hand and look at kicad_screenshot_all.".into()]));+ // a viewer opened in the background never paints its GL canvas (a white window with a+ // black bar, "last render time 1 ms"): show it once, maximised, and wait until the+ // bridge's screenshot says the canvas rendered, or refuse rather than film a blank+ let _ = br.call("desktop_ui_window", &json!({"hwnd": viewer, "action": "maximize", "reason": "aiflow 3D walkthrough: the viewer fills the monitor on the test box"}));+ let _ = br.call("desktop_bring_to_front", &json!({"hwnd": viewer, "reason": "aiflow 3D walkthrough: the viewer must paint before it is filmed"}));+ let mut painted = false;+ for _ in 0..12 {+ std::thread::sleep(std::time::Duration::from_secs(3));+ let sh = br.call("kicad_screenshot_all", &json!({}));+ let d = sh.get("data").cloned().unwrap_or(sh.clone());+ if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8)).unwrap_or(false) {+ painted = true;+ break;+ }+ }+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ // the editor clip of this step visit is not the walkthrough: discard it, the viewer is the camera+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("3d").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("3d")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+ if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+ let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+ let stop = br.record_stop(&rid, &save_to);+ settle_clip_stop(&mut r, pos, &stop, &dir);+ r.data["captures"][pos]["discarded"] = json!("editor clip before the viewer opened; the viewer clip is this visit's camera");+ }+ if !painted {+ let _ = br.call("kicad_close_3d_viewer", &json!({}));+ r.log("clip-refused", json!({"step": step, "visit": visit, "why": "the 3D viewer's canvas never painted"}));+ r.save().unwrap();+ err("the 3D Viewer's canvas never painted (a white window), so nothing was filmed", &["Look at kicad_screenshot_all on the test box: a GPU-less host needs kicad_enable_software_opengl; a hidden window needs to be shown once.".into()]);+ }+ let rid = br.record_window_start(viewer, &format!("aiflow 3D walkthrough")).unwrap_or_else(|rep| err(&format!("viewer recording did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));+ r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"}));+ r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));+ r.save().unwrap();+ let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-start"));+ if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-start", "file": pth})); }+ let tour = br.call("kicad_3d_view", &json!({"action": "tour", "pauseMs": 700}));+ let cmds = tour["commands"].as_u64().unwrap_or(0);+ std::thread::sleep(std::time::Duration::from_secs(2));+ let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-end"));+ if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-end", "file": pth})); }+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {+ let stop = br.record_stop(&rid, &save_to);+ let local = settle_clip_stop(&mut r, pos, &stop, &dir);+ r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));+ if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }+ }+ let _ = br.call("kicad_close_3d_viewer", &json!({}));+ let clips_note = auto_clips(&r, &dir);+ if !clips_note.is_empty() { eprintln!("{clips_note}"); }+ r.stage_end("tour-3d", Some(json!({"commands": cmds, "viewer": viewer})), None);+ r.outcome("tour-3d", json!({"commands": cmds}));+ r.save().unwrap();+ if !Bridge::ok(&tour) { err(&format!("the 3D tour did not run: {}", tour["errorCode"].as_str().unwrap_or("?")), &["kicad_3d_view {action:\"actions\"} lists what the viewer answers to.".into()]); }+ ok(&format!("3D walkthrough recorded: {cmds} view commands on the viewer, no mouse, clip and screenshots in the run"), &["Next: step nets, or step capture / finish.".into()]);+ }+ "nets" => {+ let spec = spec_of(&r);+ let mut list: Vec<String> = nets.as_deref().map(|n| n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();+ if list.is_empty() {+ // the pour walkthrough: every poured net first, then the loaded, wide and Kelvin ones+ for z in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() { if let Some(n) = z["net"].as_str() { if !list.iter().any(|x| x == n) { list.push(n.to_string()); } } }+ for k in spec.get("loads").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }+ for k in spec.get("wideNets").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }+ for (src, dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {+ let b = board_of(&r);+ for key in [src.clone(), dst.as_str().unwrap_or("").to_string()] { if let Some(p) = b.pad_by_key(&key) { if !list.contains(&p.net) { list.push(p.net.clone()); } } }+ }+ }+ if list.is_empty() { err("no nets to tour: the spec has no loads, wideNets or kelvin, and --nets was not given", &[]); }+ r.stage_start("tour-nets", Some("binary"), None);+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("nets").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("nets")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+ let mut toured: Vec<Value> = Vec::new();+ let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));+ std::thread::sleep(std::time::Duration::from_millis(1500));+ for net in &list {+ let sel = br.call("kicad_select_net", &json!({"filePath": rb, "net": net}));+ if !Bridge::ok(&sel) || sel["selected"].as_u64().unwrap_or(0) == 0 {+ let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));+ r.stage_end("tour-nets", Some(json!({"failed": net, "error": sel["errorCode"]})), None);+ r.save().unwrap();+ err(&format!("net {net}: {} selected ({}); the walkthrough would film a static board, so it stops here", sel["selected"].as_u64().unwrap_or(0), sel["errorCode"].as_str().unwrap_or("no error, nothing matched")), &["A loaded net with nothing to select means the editor the bridge talks to is not this run's board: with two boards open (another run's), kicad_routing_state {filePath} answers board_mismatch. Close the other board or open this one alone, then `tour nets` again.".into()]);+ }+ r.mark(&format!("net {net}: {} items highlighted", sel["selected"]));+ let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Selected Objects"}));+ std::thread::sleep(std::time::Duration::from_millis(2600));+ let safe: String = net.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();+ if let Some(pth) = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }+ let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));+ std::thread::sleep(std::time::Duration::from_millis(1200));+ toured.push(json!({"net": net, "selected": sel["selected"], "byKind": sel["byKind"]}));+ }+ let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));+ r.stage_end("tour-nets", Some(json!({"nets": toured.len()})), None);+ r.outcome("tour-nets", json!({"nets": toured}));+ r.save().unwrap();+ ok(&format!("net walkthrough recorded: {} nets, each selected as a whole (pours, tracks, vias, pads) and framed: {}", toured.len(), toured.iter().map(|t| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["selected"])).collect::<Vec<_>>().join(", ")), &["The clip of this step is the walkthrough; markers name each net at its moment.".into()]);+ }+ "fields" => {+ // the fields step: adom-fields solved the board and serves it; a pup window on the test+ // box shows it, the recorder films that window while `adom-fields tour` drives it+ let health = std::process::Command::new("adom-fields").args(["health", "--port", &port.to_string()]).output().map(|o| o.status.success()).unwrap_or(false);+ if !health { err(&format!("adom-fields is not serving on port {port}"), &["Solve and serve first: `adom-fields analyze --board <the current board> --spec <spec> --out fields` then `adom-fields serve --fields fields --port ".to_string() + &port.to_string() + " &` (adom-wiki pkg install adom/adom-fields if it is missing).".into()]); }+ // a server left up by an earlier run answers health just as well, and the walkthrough then+ // films that run's board (old models, missing parts): the solve served must be this run's+ let served: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+ if let Some(sb) = served["source"]["board"].as_str() {+ let run_dir = std::fs::canonicalize(&dir).unwrap_or(dir.to_path_buf());+ let board = std::fs::canonicalize(sb).unwrap_or_else(|_| PathBuf::from(sb));+ if !board.starts_with(&run_dir) {+ err(&format!("adom-fields on port {port} serves another run's solve ({sb}), not this run's board"), &[+ format!("Stop that server (`pgrep -af \"adom-fields serve\"` names it; kill its pid), then solve this board and serve it: `adom-fields analyze --board <this run's board> --spec <spec> --out {}/fields` and `adom-fields serve --fields {}/fields --port {port} &`.", run_dir.display(), run_dir.display())]);+ }+ }+ // issue #75: a switching converter's walkthrough (the step's proof) needs Fields to have modelled+ // the converter: the spec's voltages/sources/switching and a solve whose review.converter is set+ {+ let spec = spec_soft(&r);+ let calcs = converter::calcs_of_run(&r.data);+ if let Some((msg, hints)) = converter::check(&spec, calcs.as_ref(), Some(&served), Board::load(&r.current_board()).ok().as_ref()) {+ r.log("fields-refused", json!({"why": msg}));+ r.save().unwrap();+ err(&format!("the fields step cannot pass: {msg}"), &hints);+ }+ }+ let proxy = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| err("no VSCODE_PROXY_URI in this container: the test box cannot reach the app", &["Run inside an Adom container (the proxy URL is how a browser outside reaches a port inside), or set VSCODE_PROXY_URI to https://<slug>.adom.cloud/proxy/{{port}}/.".into()]));+ r.stage_start("tour-fields", Some("binary"), None);+ // pup's browser on a cold box takes longer than one relay call: warm it, wait until it is ready+ let _ = br.call("pup_prewarm", &json!({"reason": "aiflow fields walkthrough: warming pup on the test box"}));+ for _ in 0..30 {+ let rd = br.call("pup_readiness", &json!({}));+ if rd["ready"].as_bool() == Some(true) { break; }+ std::thread::sleep(std::time::Duration::from_secs(3));+ }+ // a fresh pup session every time: a session whose browser died answers browser_gone forever+ let sid = format!("aiflow-fields-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>());+ let op = br.call("pup_open_window", &json!({"sessionId": sid, "url": proxy, "width": 1600, "height": 1000, "reason": "aiflow fields walkthrough: the AI's current and heat analysis on camera"}));+ let open_err = if Bridge::ok(&op) { None } else { Some(op["errorCode"].as_str().unwrap_or("?").to_string()) };+ // pup can take a minute to bring a browser up on a cold box: the window is polled for, not assumed+ let mut hwnd: Option<i64> = None;+ for _ in 0..40 {+ std::thread::sleep(std::time::Duration::from_secs(2));+ let lw = br.call("pup_list_windows", &json!({}));+ let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);+ let sessions = lw["sessions"].as_array().or_else(|| inner["sessions"].as_array());+ // Only the session created by this invocation owns the recording target.+ // A title match can select another user's or another run's Fields window.+ hwnd = sessions.and_then(|rows| rows.iter().find(|w| w["sessionId"].as_str() == Some(sid.as_str())))+ .and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())))+ .filter(|h| *h > 0);+ if hwnd.is_some() { break; }+ }+ let Some(win) = hwnd else { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); err(&format!("the app's window did not appear on {} (no window for this walkthrough session{})", br.target, open_err.map(|e| format!("; pup said {e}")).unwrap_or_default()), &["`adom-bridge --target <box> pup_readiness` says whether pup is ready there; a browser_gone means pup's browser died: `pup_close_window` the old sessions or restart pup on the box.".into(), if preboard::proxy_is_local(&proxy) { format!("The URL {proxy} is a localhost proxy: only the machine hosting this container can open it. `tour fields --on <that machine>`.") } else { String::new() }, on_note.clone()]); };+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("fields").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("fields")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+ // the editor clip of this visit is not the walkthrough: the app window is the camera+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+ let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+ let stop = br.record_stop(&rid, &save_to);+ settle_clip_stop(&mut r, pos, &stop, &dir);+ r.data["captures"][pos]["discarded"] = json!("editor clip before the fields app opened; the app window is this visit's camera");+ }+ std::thread::sleep(std::time::Duration::from_secs(4));+ let rid = br.record_window_start(win, "aiflow fields walkthrough").unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));+ r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"}));+ r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));+ r.save().unwrap();+ if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-start")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-start", "file": pth})); }+ // no pace of our own: the app holds each beat and drifts the board gently, because+ // 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();+ if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {+ let stop = br.record_stop(&rid, &save_to);+ let local = settle_clip_stop(&mut r, pos, &stop, &dir);+ r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));+ if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }+ }+ let _ = br.call("pup_close_window", &json!({"sessionId": sid}));+ let clips_note = auto_clips(&r, &dir);+ if !clips_note.is_empty() { eprintln!("{clips_note}"); }+ // the solved numbers ride the run: the issues are what the AI answers for+ let fields: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+ let issues = fields["issues"].as_array().cloned().unwrap_or_default();+ r.stage_end("tour-fields", Some(json!({"issues": issues.len(), "tour": tour_ok})), None);+ if tour_ok {+ r.outcome("tour-fields", json!({"issues": issues, "nets": fields["nets"], "chips": fields["chips"], "heat": fields["heat"]}));+ }+ r.log("artifact", json!({"step": step, "visit": visit, "kind": "fields-summary", "issues": issues.len(), "peakJ": fields["scales"]["jMax"], "peakRiseC": fields["heat"]["peakRiseC"]}));+ r.save().unwrap();+ if !tour_ok { err("the app's walkthrough did not run (adom-fields tour failed)", &["`adom-fields tour` by hand shows why; the clip of this visit still exists.".into()]); }+ ok(&format!("fields walkthrough recorded: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into(), on_note]);+ }+ other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),+ }+ }+ Cmd::Ledger { json: as_json } => {+ let r = load_run(&cli);+ let led = r.ledger();+ if led.is_empty() {+ err("no run.jsonl in this run (started before the ledger existed)", &["run.json still carries turns; start a fresh run to get the append-only ledger.".into()]);+ }+ if *as_json {+ for e in &led {+ println!("{e}");+ }+ } else {+ for e in &led {+ let t = e["t"].as_str().unwrap_or("");+ match e["event"].as_str().unwrap_or("") {+ "start" => println!("{t} start engine {} prompt {}", e["engine"], e["promptTime"]),+ "turn" => println!("{t} turn {:>3} {:<10} think {:>6.1} min {}", e["n"], e["step"].as_str().unwrap_or(""), e["thinkingSeconds"].as_f64().unwrap_or(0.0) / 60.0, e["command"].as_str().unwrap_or("")),+ "turn-end" => println!("{t} end {:>3} {:<10} tool {:>6.1} min {} {}", e["n"], e["step"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, if e["ok"].as_bool() == Some(true) { "OK" } else { "ERROR" }, e["said"].as_str().unwrap_or("")),+ "step" => println!("{t} step {}{} visit {}{}", e["step"].as_str().unwrap_or(""), if e["back"].as_bool() == Some(true) { " (back)" } else { "" }, e["visit"], e["why"].as_str().map(|w| format!(": {w}")).unwrap_or_default()),+ "stage-end" => println!("{t} stage {} {:.1} min {}", e["stage"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, e["result"].to_string().chars().take(100).collect::<String>()),+ "marker" => println!("{t} marker {}", e["label"].as_str().unwrap_or("")),+ "finish" | "deliver" => println!("{t} {} {}", e["event"].as_str().unwrap_or(""), e["minutes"]),+ "prompt" => println!("{t} prompt {:>2} {}", e["n"], e["text"].as_str().unwrap_or("")),+ "board-attached" => println!("{t} board {} attached to the intake run (clock from {})", e["board"].as_str().unwrap_or(""), e["promptTime"].as_str().unwrap_or("")),+ "evidence" => println!("{t} evidence {} {} pass {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or(""), e["pass"]),+ "evidence-refused" => println!("{t} refused {} {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or("")),+ "done" => println!("{t} done {:>2} {}", e["n"], e["message"].as_str().unwrap_or("")),+ _ => {}+ }+ }+ }+ println!("{}", step_table(&r));+ }+ Cmd::Step { name, back, why } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());+ for s in preboard::PREBOARD_STEPS { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } }+ 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() {+ let last = |s: &str| r.data["steps"][s]["visits"].as_array().and_then(|a| a.iter().filter_map(|v| v["at"].as_str()).last()).map(str::to_string);+ if let Some(sch) = last("schematic") {+ if last("simulation").map(|sim| sim < sch).unwrap_or(true) {+ err("simulate the schematic before the board: no simulation since the schematic was drawn", &[+ "`step simulation`, then run the circuit in the Adom SPICE dashboard and show it to the human: `adom-spice start`, `adom-spice study <study.json>`, `adom-spice run ngspice`, `adom-spice show` (a Hydrogen webview in the right pane, beside the chat).".into(),+ "Check start-up, ripple and the load step against the requirements; change values in the schematic when a measurement misses, and say what changed. Record it: `evidence add --kind simulation --file <result.json>`. Then `step placement`.".into()]);+ }+ }+ }+ // the required steps, in order: a run that starts now carries its lane's sequence, and no step+ // in it can be skipped (sequence.rs)+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ // 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::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();+ // --back is a return: it may only go to an earlier step of the flow, never jump ahead past the checks+ if !seq.is_empty() && *back {+ if let (Some(pos), Some(cp)) = (sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))) {+ if pos > cp { err(&format!("--back goes to an earlier step, but {name} comes after {} in the flow", seq[cp]), &[format!("`step {name}` without --back: every step before it must be done first.")]); }+ }+ }+ if !seq.is_empty() && !*back {+ if let Some(pos) = sequence::position(&seq, name) {+ let cur = r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c));+ if let Some(cp) = cur { if pos < cp {+ err(&format!("{name} comes before {} in the flow: going back is a return", seq[cp]), &[format!("`step {name} --back --why \"<what has to change>\"`: the return is filmed and shown on the timeline as a loop, and every later step is redone after it.")]);+ } }+ let st = sequence::states(&dir, &r.data, &flow_full, &seq);+ let b = sequence::blockers(&st, name);+ if !b.is_empty() {+ let first = b[0].split(' ').next().unwrap_or("").to_string();+ err(&format!("not yet: {name} needs every required step before it done, and these are not:\n {}", b.join("\n ")), &[+ format!("`step {first}` and do it: a step is done when its own command has run during its latest visit (flows/board.json proofs). No step of the flow can be skipped."),+ "If a step truly does not apply to this board, say so to the human and ask; never jump past it.".into()]);+ }+ }+ }+ record_usage(&mut r, &format!("step:{name}"));+ r.step_set(name, *back, why.as_deref());+ if !seq.is_empty() { sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE); }+ // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)+ let mut clip_note = String::new();+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let br = Bridge { ai_thread: thread(&cli), target };+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+ let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ // the step's closing screenshot: what the board looked like when the step ended+ if let Some(h) = caps[pos]["hwnd"].as_i64() {+ let tag = format!("shot-{}-{}-end", caps[pos]["step"].as_str().unwrap_or("step"), caps[pos]["visit"].as_u64().unwrap_or(1));+ if let Some(pth) = br.screenshot_window(h, &save_to, &tag) {+ r.log("artifact", json!({"step": caps[pos]["step"], "visit": caps[pos]["visit"], "kind": "screenshot", "when": "end", "file": pth}));+ }+ }+ let stop = br.record_stop(&rid, &save_to);+ let local = settle_clip_stop(&mut r, pos, &stop, &dir);+ let cap = &r.data["captures"][pos];+ let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+ let (cs, cv, cb) = (cap["step"].clone(), cap["visit"].clone(), cap["back"].clone());+ r.log("clip-stop", clip_stop_event(json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid}), &stop));+ if let Some(f) = local.filter(|f| Path::new(f).is_file()) {+ clip_artifacts(&mut r, &st, &f, 10);+ }+ clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));+ if r.data["captures"][pos]["truncated"] == true { clip_note.push_str(&format!("WARNING: clip {} is truncated (no moov atom): re-record it with `step {} --back --why \"clip truncated\"` if it matters; ", st.as_str().unwrap_or(""), cs.as_str().unwrap_or(""))); }+ clip_stopped = true;+ }+ // Recording can be delegated to another host without changing the EDA target.+ // Always settle an existing take above, but never start/focus/capture here when off.+ if std::env::var("AIFLOW_AUTO_CAPTURE").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false) {+ clip_note.push_str("automatic step capture disabled; record reviewed clips on the chosen capture host");+ r.log("clip-auto-disabled", json!({"step":name,"reason":"AIFLOW_AUTO_CAPTURE"}));+ } else {+ // the fields step films the app's window through `tour fields`: no editor clip for it+ // a Fusion run films Fusion's own window, the board fitted to it first (a remembered+ // hwnd can be a KiCad window from before the board, and Fusion keeps whatever zoom it had)+ // the window the step works in, from the run's EDA backend: KiCad's PCB editor for this board,+ // a one-window tool (Fusion, Altium) with the board fitted to it, and Hydrogen in front for the+ // human-facing steps (the chat, the parts pages, the SPICE dashboard, the fields app). A project+ // before its board exists (library, schematic) films its tool, never a KiCad window.+ let eda = aiflow_bridge::eda::active();+ let pre_board = aiflow_bridge::eda::project_backend(&dir).filter(|_| eda.id() == "kicad")+ .map(|b| aiflow_bridge::eda::for_board(&aiflow_bridge::eda::BoardRef::parse(&format!("{b}:"))));+ let cam = pre_board.unwrap_or(eda);+ let tx = |v: &str, a: &Value| br.raw_call(v, a);+ 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())));+ // issue #59: a step before the board films Hydrogen's dashboard, the AI's window or the tool's own+ // window, never an idle editor; nothing meaningful on screen is a recorded skip+ let pre_cam = (name != "fields" && clipwatch::preboard_step(name)).then(|| clipwatch::choose_preboard(&br, &*cam, name, r.data["engine"].as_str().unwrap_or("")));+ if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if what.starts_with("Hydrogen") { ensure_dashboard(&dir, &thread(&cli), true); } }+ let hwnd = match &pre_cam { Some(clipwatch::Camera::Window { hwnd, .. }) => Some(*hwnd), Some(clipwatch::Camera::Skip(_)) => None, None => if name == "fields" { None } else { cam.camera_window(&tx, name, cached) } };+ let disk = disk_check(&dir, &br, name);+ if let Err(e) = &disk {+ r.log("clip-refused", json!({"step": name, "why": e}));+ r.save().unwrap();+ err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]);+ }+ match hwnd {+ Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) {+ Ok(rid) => {+ // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back+ let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+ r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h, "step": name, "visit": visit, "back": *back, "why": why}));+ if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(what); } clip_note.push_str(&format!("filming {what}; ")); }+ r.mark(&format!("clip-start:{name}-{visit}"));+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ if let Some(pth) = br.screenshot_window(h, &save_to, &format!("shot-{name}-{visit}-start")) {+ r.log("artifact", json!({"step": name, "visit": visit, "kind": "screenshot", "when": "start", "file": pth}));+ }+ r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid}));+ clip_note.push_str(&format!("clip {name}-{visit}{} recording (window {h}; {})", if *back { " (rework)" } else { "" }, disk.as_deref().unwrap_or("")));+ }+ Err(rep) if rep["errorCode"].as_str() == Some("window_not_found") => {+ // the remembered editor window is gone (KiCad closed, the box restarted): look once more, live+ match br.pcb_editor_hwnd() {+ Some(h2) if h2 != h => match br.record_window_start(h2, &format!("aiflow step {name}")) {+ Ok(rid) => { let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h2, "step": name, "visit": visit, "back": *back, "why": why})); r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid})); clip_note.push_str(&format!("clip {name}-{visit} recording (the editor moved to window {h2})")); }+ Err(rep2) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep2["errorCode"].as_str().unwrap_or("?"))),+ },+ _ => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box any more (capture open to bring the board back)")),+ }+ }+ Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),+ },+ None if name == "fields" => clip_note.push_str("the fields step films the app: `tour fields` brings its own camera"),+ // before the board there is no editor to film: the desktop is the camera+ None if matches!(pre_cam, Some(clipwatch::Camera::Skip(_))) => {+ let why_skip = match &pre_cam { Some(clipwatch::Camera::Skip(w)) => w.clone(), _ => String::new() };+ let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+ if !r.data["clipSkips"].is_array() { r.data["clipSkips"] = json!([]); }+ r.data["clipSkips"].as_array_mut().unwrap().push(json!({"step": name, "visit": visit, "at": now(), "why": why_skip}));+ r.log("clip-skipped", json!({"step": name, "visit": visit, "why": why_skip}));+ clip_note.push_str(&format!("no clip for {name}-{visit}: {why_skip}"));+ }+ None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),+ }+ }+ }+ r.save().unwrap();+ let clips_note = if clip_stopped { auto_clips(&r, &dir) } else { String::new() };+ let mut report_note = String::new();+ if let Some(pg) = r.data["page"].as_str().map(str::to_string) {+ let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));+ report_note = format!("Progress page: {url} ({})", notes.join("; "));+ }+ let skill_lines = skill_hints::step_hints(&flow_full, name, &skill_home, skill_pkg.as_deref());+ let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();+ let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };+ let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };+ ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], skill_lines, vec![back_hint, report_note, clips_note]].concat());+ print_next(&cli);+ }+ Cmd::ProjectPageCheck { project, live_json } => {+ let mut command = std::process::Command::new(aiflow_python());+ command.arg("-c").arg(include_str!("../../../tools/project-page-check.py")).arg("--project").arg(project);+ if let Some(path) = live_json { command.arg("--live-json").arg(path); }+ let output = command.output().unwrap_or_else(|e| err(&format!("project page check could not run: {e}"), &[]));+ print!("{}", String::from_utf8_lossy(&output.stdout));+ if !output.status.success() { err("project page preflight failed", &["Fix explicit board GLB and project PDF bindings, readable datasheet and 5–8 README screenshots. Read skills/aiflow-project-page/SKILL.md. PDF extraction requires pdftotext.".into()]); }+ }+ Cmd::Deliver { video, message, accept_suspect } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: thread(&cli), target }, &dir);+ if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+ }+ let suspects: Vec<String> = r.data["captures"].as_array().cloned().unwrap_or_default().iter().filter(|c| c.get("suspect").is_some() && c.get("discarded").is_none()).map(|c| format!("{}-{}: {}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1), c["suspect"].as_str().unwrap_or(""))).collect();+ if !suspects.is_empty() && !*accept_suspect {+ err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!(" {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);+ }+ if r.data["clock"]["finishTime"].is_null() {+ err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the checked board design first, then the video and your message. Physical qualification is a separate claim.".into()]);+ }+ let vp = Path::new(video);+ if !vp.is_file() {+ err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);+ }+ if let Some(open) = step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null)) {+ err(&format!("not delivered: the board twin's STEP export: {open}"), &["Never drop a layer (silkscreen, mask, zones) to make an exporter warning go away. Review the STEP at the reported spots in a STEP viewer, then acknowledge with a reason, or fix the geometry and export again.".into()]);+ }+ let board_path = r.current_board();+ let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));+ let checked = verified::run_items(&r, facts.as_ref());+ let bytes = std::fs::metadata(vp).map(|m| m.len()).unwrap_or(0);+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let brs = Bridge { ai_thread: thread(&cli), target };+ stop_running_clip(&mut r, &brs, &dir, "deliver");+ }+ let t = now();+ 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"]["verifiedAtDelivery"] = verified::to_json(&checked);+ r.data["summary"]["steps"] = Value::Object(r.step_report());+ r.data["summary"]["turns"] = json!(r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0));+ r.data["summary"]["clips"] = json!(r.data["captures"].as_array().map(|a| a.iter().filter(|c| c.get("file").is_some()).map(|c| json!({"step": c["step"], "visit": c["visit"], "back": c["back"], "why": c["why"], "start": c["start"], "stop": c["stop"], "file": c["file"], "camera": c["camera"], "died": c["died"], "resumedAfterGap": c["resumedAfterGap"], "truncated": c["truncated"]})).collect::<Vec<_>>()).unwrap_or_default());+ r.data["summary"]["clipSkips"] = r.data["clipSkips"].clone();+ r.log("deliver", json!({"minutes": r.delivered_minutes(), "activeMinutes": r.delivered_active_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));+ r.save().unwrap();+ let gb = r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0);+ let mut report_note = format!("Given back this run: {gb} issue(s) or PR(s). {}.", usage_line(&r));+ if let Some(pg) = r.data["page"].as_str().map(str::to_string) {+ let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));+ report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; ")));+ }+ let verified_text = verified::message(&checked);+ ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);+ }+ }+}++fn measurement_usable(value: Option<&Value>) -> bool {+ value.map(|v| v["failed"] != true && v["filledAreaByLayerMm2"].as_object().map(|a| !a.is_empty() && a.values().all(|n| n.as_f64().map(|x| x.is_finite() && x >= 0.0).unwrap_or(false))).unwrap_or(false)).unwrap_or(false)+}++#[cfg(test)]+mod mpn_mark_tests {+ use super::*;+ #[test]+ fn recognises_service_and_legacy_marks() {+ assert!(has_mpn_mark(b"#405 = PRODUCT('Adom.LaserEtch','Adom.LaserEtch','',(#406));"));+ assert!(has_mpn_mark(b"#3258 = PRODUCT('laser_etch','laser_etch','',(#1));"));+ assert!(!has_mpn_mark(b"#7 = PRODUCT('SOT-23-6','SOT-23-6','',(#8));"));+ }+}++#[cfg(test)]+mod netlist_freshness_tests {+ use super::*;+ #[test]+ fn gate_rejects_child_only_edits_old_evidence_and_export_edits() {+ let dir=std::env::temp_dir().join(format!("aiflow-net-freshness-{}",std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let sch=dir.join("root.kicad_sch"); let child=dir.join("child.kicad_sch");+ let net=dir.join("export.net"); let board=dir.join("board.kicad_pcb");+ std::fs::write(&sch,"(kicad_sch (sheet (property \"Sheetfile\" \"child.kicad_sch\")))").unwrap();+ std::fs::write(&child,"(kicad_sch)").unwrap();+ std::fs::write(&net,"(export (components) (nets))").unwrap();+ std::fs::write(&board,"(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")))").unwrap();+ let proof=json!({"ok":true,"sha256":sha256_file(sch.to_str().unwrap()),"schematicFiles":schematic_identity::capture(&sch).unwrap(),"netlist":net,"netlistSha256":sha256_file(net.to_str().unwrap())});+ let mut run=Run{dir:dir.clone(),data:json!({"schematic":sch,"boards":[board],"outcomes":{"netlist":proof}})};+ assert!(netlist_gate(&run).is_ok());+ std::fs::write(&child,"(kicad_sch (changed yes))").unwrap();+ assert!(netlist_gate(&run).unwrap_err().contains("hierarchy changed"));+ std::fs::write(&child,"(kicad_sch)").unwrap();+ std::fs::write(&net,"(export (components) (nets))\n").unwrap();+ assert!(netlist_gate(&run).unwrap_err().contains("exported netlist changed"));+ run.data["outcomes"]["netlist"].as_object_mut().unwrap().remove("schematicFiles");+ assert!(netlist_gate(&run).unwrap_err().contains("older check"));+ std::fs::remove_dir_all(dir).unwrap();+ }+}++#[cfg(test)]+mod measurement_gate_tests {+ use super::*;+ #[test]+ fn refuses_failed_or_missing_copper_measurement() {+ for v in [Value::Null, json!({"failed":true,"filledAreaByLayerMm2":{"F.Cu":12}}), json!({"filledAreaByLayerMm2":null}), json!({"filledAreaByLayerMm2":{}}), json!({"filledAreaByLayerMm2":{"F.Cu":-1}})] {+ assert!(!measurement_usable(Some(&v)));+ }+ assert!(!measurement_usable(None));+ assert!(measurement_usable(Some(&json!({"filledAreaByLayerMm2":{"F.Cu":12,"B.Cu":0}}))));+ }+}++#[cfg(test)]+mod clip_stop_tests {+ use super::*;++ fn run_with_capture(dir: &Path, cap: Value) -> Run {+ let _ = std::fs::remove_dir_all(dir);+ std::fs::create_dir_all(dir).unwrap();+ Run { dir: dir.to_path_buf(), data: json!({"captures": [cap]}) }+ }+ fn stop(remote: Option<&str>, pulled: bool) -> ClipStop {+ ClipStop { remote: remote.map(str::to_string), local: None, pulled, attempts: if remote.is_some() { 3 } else { 0 }, reply: Value::Null }+ }++ #[test]+ fn a_failed_pull_is_flagged_with_its_remote_path_not_a_local_file() {+ let dir = std::env::temp_dir().join(format!("aiflow-clipstop-a-{}", std::process::id()));+ let mut r = run_with_capture(&dir, json!({"recordingId": "rec-1", "step": "components", "visit": 1}));+ let remote = "C:/Users/j/AppData/Local/Temp/adom-bridge-recordings/window-1.mp4";+ assert_eq!(settle_clip_stop(&mut r, 0, &stop(Some(remote), false), &dir), None);+ let c = &r.data["captures"][0];+ assert!(c["file"].is_null() && c.get("file").is_some(), "stopped, with no local file");+ assert_eq!((c["pullFailed"].clone(), c["remoteFile"].clone()), (json!(true), json!(remote)));+ assert_eq!(clip_stop_event(json!({}), &stop(Some(remote), false))["pullFailed"], true);+ // the pull that lands clears the flag and names the local file+ std::fs::write(dir.join("window-1.mp4"), b"mp4").unwrap();+ let f = settle_clip_stop(&mut r, 0, &stop(Some(remote), true), &dir).unwrap();+ assert!(f.ends_with("window-1.mp4"));+ assert!(r.data["captures"][0].get("pullFailed").is_none());+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn a_second_stop_never_blanks_the_first_stops_file() {+ let dir = std::env::temp_dir().join(format!("aiflow-clipstop-b-{}", std::process::id()));+ let local = dir.join("window-2.mp4").display().to_string();+ let mut r = run_with_capture(&dir, json!({"recordingId": "rec-2", "step": "placement", "visit": 1, "stop": "2026-09-29T02:43:48Z", "file": local, "remoteFile": "C:/rec/window-2.mp4"}));+ std::fs::write(&local, b"mp4").unwrap();+ // the recorder answers "nothing running": no path+ assert_eq!(settle_clip_stop(&mut r, 0, &stop(None, false), &dir), Some(local.clone()));+ let c = &r.data["captures"][0];+ assert_eq!((c["file"].as_str(), c["remoteFile"].as_str(), c["stop"].as_str()), (Some(local.as_str()), Some("C:/rec/window-2.mp4"), Some("2026-09-29T02:43:48Z")));+ // a failed re-pull of the same file keeps the good local copy+ settle_clip_stop(&mut r, 0, &stop(Some("C:/rec/window-2.mp4"), false), &dir);+ assert_eq!(r.data["captures"][0]["file"].as_str(), Some(local.as_str()));+ assert!(r.data["captures"][0].get("pullFailed").is_none());+ // a first stop with no path still marks the capture stopped+ let mut r = run_with_capture(&dir, json!({"recordingId": "rec-3", "step": "nets", "visit": 1}));+ settle_clip_stop(&mut r, 0, &stop(None, false), &dir);+ assert!(r.data["captures"][0].get("file").is_some());+ let _ = std::fs::remove_dir_all(&dir);+ }+}++#[cfg(test)]+mod media_tools_tests {+ use super::*;++ #[test]+ fn a_missing_ffprobe_is_an_error_naming_it_not_a_blank_clip() {+ let e = media_tools_missing(&["aiflow-no-such-ffprobe"]).unwrap_err();+ assert!(e.starts_with("aiflow-no-such-ffprobe is not installed"), "{e}");+ let e = clip_verdict("/nonexistent/clip.mp4", &["aiflow-no-such-ffprobe", "ffmpeg"]).unwrap_err();+ assert!(e.contains("aiflow-no-such-ffprobe") && !e.contains("no frames"), "{e}");+ }++ #[test]+ fn a_clip_is_left_unjudged_when_the_tools_are_missing() {+ let dir = std::env::temp_dir().join(format!("aiflow-media-tools-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(&dir).unwrap();+ let file = dir.join("placement-1.mp4").display().to_string();+ std::fs::write(&file, b"not really a video").unwrap();+ let mut r = Run { dir: dir.clone(), data: json!({"captures": [{"file": file, "step": "placement", "visit": 1}]}) };+ clip_artifacts_with(&mut r, &json!("placement-1"), &file, 10, &["aiflow-no-such-ffprobe"]);+ assert!(r.data["captures"][0].get("suspect").is_none(), "a missing tool must not flag the footage");+ let events: Vec<String> = r.ledger().iter().filter_map(|e| e["event"].as_str().map(str::to_string)).collect();+ assert!(events.contains(&"clip-check-unavailable".to_string()) && !events.contains(&"clip-suspect".to_string()), "{events:?}");+ assert!(blank_check_verdict("no frames") && !blank_check_verdict("rejected by the AI: covered"));+ let _ = std::fs::remove_dir_all(dir);+ }+}++#[cfg(test)]+mod compose_out_tests {+ use super::*;++ #[test]+ fn out_resolves_against_the_working_directory_and_defaults_into_the_run() {+ let (run, cwd) = (Path::new("/p/aiflow/run-1mm"), Path::new("/p/aiflow"));+ assert_eq!(compose_out_path(None, run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));+ // issue #80: run-1mm/final.mp4 from /p/aiflow is the run's own final.mp4, not run-1mm/run-1mm/final.mp4+ assert_eq!(compose_out_path(Some("run-1mm/final.mp4"), run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));+ assert_eq!(compose_out_path(Some("cut.mp4"), run, cwd), PathBuf::from("/p/aiflow/cut.mp4"));+ assert_eq!(compose_out_path(Some("/v/cut.mp4"), run, cwd), PathBuf::from("/v/cut.mp4"));+ }++ #[test]+ fn the_failure_names_the_output_and_ffmpeg_last_lines() {+ let stderr = (1..=20).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n") + "\n/p/run-1mm/run-1mm/final.mp4: No such file or directory\n";+ let e = compose_error("ffmpeg could not assemble the video and its narration", Path::new("/p/run-1mm/run-1mm/final.mp4"), &stderr);+ assert!(e.contains("(output /p/run-1mm/run-1mm/final.mp4)"), "{e}");+ assert!(e.contains("No such file or directory") && e.contains("line 20") && !e.contains("line 12\n"), "{e}");+ let e = compose_error("x", Path::new("/o.mp4"), " \n");+ assert!(e.contains("(ffmpeg printed nothing)"), "{e}");+ // a long line keeps its head and tail+ let long = format!("{}MIDDLE{}", "a".repeat(1000), "z".repeat(1000));+ let e = compose_error("x", Path::new("/o.mp4"), &long);+ assert!(e.contains(" ... ") && e.contains("aaaa") && e.ends_with("zzzz"), "{e}");+ }++ #[test]+ fn a_failed_ffmpeg_keeps_its_stderr() {+ assert!(!ffmpeg_ok(std::process::Command::new("sh").args(["-c", "echo first >&2; echo 'Error opening output file' >&2; exit 1"])));+ let kept = FFMPEG_LAST_ERROR.with(|l| l.borrow().clone());+ assert!(kept.contains("Error opening output file"), "{kept}");+ assert!(ffmpeg_ok(std::process::Command::new("sh").args(["-c", "exit 0"])));+ }+}+@@ -1,764 +1,796 @@⋯ 544 unchanged lines ⋯ } /// `finish`: a run with a schematic needs a passing schematic check on the hierarchy as it is now.+/// A run with no KiCad schematic is judged on its tool's proof instead (`tool_gate`). pub fn gate(r: &Run) -> Result<(), String> {- let Some(sch) = r.data["schematic"].as_str() else { return Ok(()) };+ let Some(sch) = r.data["schematic"].as_str() else { return tool_gate(&r.dir) }; let Some(o) = r.outcomes().get(OUTCOME) else { return Err("schematic check (the run has a schematic; run `adom-aiflow schematic check --sch <root>.kicad_sch`, or `netlist check`, which runs it too)".into()) }; let now = crate::schematic_identity::capture(Path::new(sch)).map_err(|e| format!("schematic check (schematic hierarchy: {e})"))?; if o["schematicFiles"] != json!(now) { return Err("schematic check (a sheet changed since the last check; run it again)".into()); }⋯ 1 unchanged line ⋯ Ok(()) } +/// The Altium lane's symbol check (`altium schematic` / `altium symbols` record it in+/// `<run>/proofs/schematic.json` as `symbols`): `finish` refuses while it has errors. A proof without+/// it (no Altium schematic, or one from before 0.2.10) leaves the verified list's "not known".+pub fn tool_gate(dir: &Path) -> Result<(), String> {+ let proof = std::fs::read_to_string(dir.join("proofs").join("schematic.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());+ let s = proof.as_ref().map(|p| &p["data"]["symbols"]);+ match s.and_then(|s| s["ok"].as_bool()) {+ Some(false) => {+ let errs: Vec<&str> = s.and_then(|s| s["errors"].as_array()).into_iter().flatten().filter_map(|e| e.as_str()).collect();+ Err(format!("schematic check ({} symbol error(s) on the Altium sheet: {}{}; fix the symbols, then `adom-aiflow altium symbols` or `altium schematic` again)", errs.len(), errs.iter().take(3).cloned().collect::<Vec<_>>().join("; "), if errs.len() > 3 { "; ..." } else { "" }))+ }+ _ => Ok(()),+ }+}+ /// The reply: (message, hints). pub fn render(rep: &Value, in_run: bool) -> (String, Vec<String>) { let list = |k: &str| rep[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| format!(" {s}"))).collect::<Vec<_>>()).unwrap_or_default();⋯ 201 unchanged lines ⋯ record(&mut run, root.to_str().unwrap(), &rep); assert!(gate(&run).is_ok()); }++ /// An Altium run (no KiCad schematic) is judged on the symbol check its `altium schematic` proof carries.+ #[test]+ fn an_altium_symbol_check_with_errors_stops_finish() {+ let f = Fixture::new();+ assert!(tool_gate(&f.0).is_ok(), "no proof: nothing to judge (the verified list says not known)");+ std::fs::create_dir_all(f.0.join("proofs")).unwrap();+ f.write("proofs/schematic.json", r#"{"at":"t","data":{"parity":true,"erc":{"pass":true,"errors":[]}}}"#);+ assert!(tool_gate(&f.0).is_ok(), "a proof from before 0.2.10 has no symbol check");+ f.write("proofs/schematic.json", r#"{"at":"t","data":{"parity":true,"symbols":{"ok":false,"errors":["pins on the body: U1 (TPS54202DDCR) mode 0 (std): 6 of 6 pin(s) not on the body edge"]}}}"#);+ let e = tool_gate(&f.0).unwrap_err();+ assert!(e.contains("1 symbol error(s)") && e.contains("TPS54202DDCR") && e.contains("altium symbols"), "{e}");+ f.write("proofs/schematic.json", r#"{"at":"t","data":{"parity":true,"symbols":{"ok":true,"errors":[],"warnings":["x"]}}}"#);+ assert!(tool_gate(&f.0).is_ok());+ } }+@@ -1,164 +1,194 @@⋯ 96 unchanged lines ⋯ /// A backend that records its checks as proof files (`<run>/proofs/<key>.json`, the Altium lane's /// Python) instead of outcomes: its export (fills in the Gerbers), its schematic compile (pin-for-pin-/// parity with the netlist, the compiler's ERC) count here too. A recorded outcome always wins.+/// parity with the netlist, the compiler's ERC) and its symbol check (`symbols`, read as the+/// `schematic-check` outcome) count here too. A recorded outcome always wins. pub fn with_tool_proofs(outcomes: &Map<String, Value>, dir: &std::path::Path) -> Map<String, Value> { let mut o = outcomes.clone(); let proof = |k: &str| std::fs::read_to_string(dir.join("proofs").join(format!("{k}.json"))).ok()⋯ 4 unchanged lines ⋯ if let Some(s) = proof("schematic") { if !o.contains_key("netlist") { if let Some(p) = s["parity"].as_bool() { o.insert("netlist".into(), json!({"ok": p, "source": "altium schematic (compiled to the netlist pin for pin)"})); } } if !o.contains_key("erc") { if let Some(p) = s["erc"]["pass"].as_bool() { o.insert("erc".into(), json!({"pass": p, "errors": s["erc"]["errors"], "source": "altium schematic (compiler)"})); } }+ // the symbol check on the drawn sheet (tools/altium-lane.py check_symbols): pins on their bodies,+ // pin types on active parts, polarity drawings; the same item KiCad's `schematic check` records+ if !o.contains_key(crate::schematic_symbols::OUTCOME) {+ if let Some(ok) = s["symbols"]["ok"].as_bool() {+ let n = |k: &str| s["symbols"][k].as_array().map(|a| a.len()).unwrap_or(0);+ o.insert(crate::schematic_symbols::OUTCOME.into(), json!({"ok": ok, "errors": n("errors"), "warnings": n("warnings"), "report": s["symbols"]["report"], "source": "altium schematic (symbols and pin types read back from the drawn sheet)"}));+ }+ } } o }⋯ 22 unchanged lines ⋯ rec.insert("erc".into(), serde_json::json!({"pass": false})); let o = super::with_tool_proofs(&rec, &dir); assert_eq!(o["erc"]["pass"], false, "a recorded outcome wins");+ // no symbol check in the proof (an Altium run from before 0.2.10): the item stays not known+ assert!(matches!(st("schematic symbols and pin types"), super::State::Unknown(_)));+ // the Altium symbol check maps into the outcome KiCad's `schematic check` records+ std::fs::write(dir.join("proofs/schematic.json"), r#"{"at":"t","data":{"parity":true,"erc":{"pass":true,"errors":[]},"symbols":{"ok":true,"errors":[],"warnings":["pin type: U1 pin 2 (TPS54202DDCR): power pin `GND` is passive; declare it Power"],"report":"/r/schematic-check.json"}}}"#).unwrap();+ let mut gate = serde_json::Map::new();+ gate.insert("gate".into(), serde_json::json!({"newErrors": 0, "unconnected": 0}));+ let o = super::with_tool_proofs(&gate, &dir);+ assert_eq!(o[crate::schematic_symbols::OUTCOME]["ok"], true);+ assert_eq!(o[crate::schematic_symbols::OUTCOME]["warnings"], 1);+ let items = super::from_run(&o, &serde_json::json!({}), Some((true, true)), Some(true));+ let st = |n: &str| items.iter().find(|i| i.name == n).unwrap().state.clone();+ assert_eq!(st("schematic symbols and pin types"), super::State::Yes);+ assert!(super::all_passed(&items), "an Altium board can be fully cleared: {}", super::line(&items));+ std::fs::write(dir.join("proofs/schematic.json"), r#"{"at":"t","data":{"parity":true,"erc":{"pass":true,"errors":[]},"symbols":{"ok":false,"errors":["pin type: U2 pin 5 (AP2112K-3.3TRG1): output `VOUT` is passive"],"warnings":[]}}}"#).unwrap();+ let o = super::with_tool_proofs(&serde_json::Map::new(), &dir);+ let items = super::from_run(&o, &serde_json::json!({}), Some((true, true)), Some(true));+ assert!(matches!(items.iter().find(|i| i.name == "schematic symbols and pin types").unwrap().state, super::State::No(_)));+ let mut rec = serde_json::Map::new();+ rec.insert(crate::schematic_symbols::OUTCOME.into(), serde_json::json!({"ok": true}));+ assert_eq!(super::with_tool_proofs(&rec, &dir)[crate::schematic_symbols::OUTCOME]["ok"], true, "a recorded outcome wins"); let _ = std::fs::remove_dir_all(&dir); } ⋯ 20 unchanged lines ⋯ assert!(!v.to_lowercase().contains("ready to fabricate")); } }+@@ -1,99 +1,150 @@⋯ 15 unchanged lines ⋯ After `sourcing`, the Altium list has the same two steps as a KiCad board from scratch: - `parts --bom design/bom.csv`: every BOM MPN matched to its Adom wiki component page (one Hydrogen tab- walks through them); a part with no page fails the step.+ walks through them); a part with no page fails the step, unless the board's project library gives it+ (`parts --project-lib <dir>`, or `"projectLib"` in the spec or requirements: its KiCad symbol, footprint,+ STEP model and provenance.json entry, checked complete there). `altium library` then builds that part+ from those local files (below). - `project-page --page <owner>/<slug> --create`: the board's own project page, revision 1, before any EDA- work: the README with the component table, each part linked to its page. No KiCad PCM package: the- `library` step builds the Altium libraries from the same pages. Every step the run leaves after this is+ work: the README with the component table, each part linked to its page (a project-library part is+ listed as "project library (no public page yet)" and named in the "Built in Altium Designer" section).+ No KiCad PCM package: the `library` step builds the Altium libraries from the same pages. Every step the run leaves after this is pushed to the page as `steps/<nn>-<step>/README.md`. `pcm check` and `schematic check` are KiCad's and refuse an Altium run; `altium library` and-`altium schematic` record those proofs here.+`altium schematic` record those proofs here (`altium schematic` runs the symbol and pin-type check+itself, below). ## The whole project, by command ⋯ 1 unchanged line ⋯ | step | what it does | |---|---|-| `altium library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE project library (a `.SchLib` and a `.PcbLib`): each part from its wiki component page (footprint and symbol converted from the page's KiCad files, the STEP bound), MPN, Manufacturer and the Mouser, DigiKey and LCSC numbers checked against the exact MPN. Board-only pads with no MPN (test points) take their footprint from `--footprints`. Run again with new parts and they join the same library; `--only <MPN>` imports one part again. |+| `altium library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE project library (a `.SchLib` and a `.PcbLib`): each part from its wiki component page (footprint and symbol converted from the page's KiCad files, the STEP bound), MPN, Manufacturer and the Mouser, DigiKey and LCSC numbers checked against the exact MPN. The page is the netlist's `page`, else the one `parts` matched, else the MPN. A part with no public page that `parts --project-lib` took from the board's project library is built from its local files through the same converters (below). Board-only pads with no MPN (test points) take their footprint from `--footprints`. Run again with new parts and they join the same library; `--only <MPN>` imports one part again. Each symbol's spec is kept in `<run>/altium/symbols/` for the schematic's symbol check. | | `altium reference --kicad <board.kicad_pcb> --spec <spec.json>` | A reference design's placement and spec coordinates in the Altium board's frame: `moves.json`, `fixed.json`, `spec.json` in `<run>/altium/`. |-| `altium schematic --netlist <netlist.json> --name <Design>` | The schematic drawn from the netlist on Altium's own sheet template (one column per `block`, every connected pin with a wire and a net label, the title block filled), inside a new project (`<Design>.PrjPcb` with the board, the libraries and the manufacturing OutJob), compiled and checked pin by pin against the netlist. Then the board in the same project: the outline, the fixed parts where the spec puts them, every other part parked below the board, every pad on its net, the run's fab rules loaded. Writes `<run>/altium/<Design>.altium.json` for `start`. |+| `altium schematic --netlist <netlist.json> --name <Design>` | The schematic drawn from the netlist on Altium's own sheet template (one column per `block`, every connected pin with a wire and a net label, the title block filled), inside a new project (`<Design>.PrjPcb` with the board, the libraries and the manufacturing OutJob), compiled and checked pin by pin against the netlist, then its symbols and pin types checked as drawn (below; an error fails the step after everything is built and recorded). Then the board in the same project: the outline, the fixed parts where the spec puts them, every other part parked below the board, every pad on its net, the run's fab rules loaded. Writes `<run>/altium/<Design>.altium.json` for `start`. |+| `altium symbols` | The symbol and pin-type check again on the sheet `altium schematic` drew (after a library fix, or on a run from before 0.2.10); it replaces the schematic proof's `symbols` and keeps the rest. | | `altium board --netlist <netlist.json> --name <Design>` | The board alone, as above (no schematic). | | `altium silkscreen [--title "<board>"]` | The silkscreen planned by the planner the lanes share (`tools/silkscreen-plan.py`) and landed: part names beside their parts, machine contacts framed with their nets on the edge side, values, leaders where a label has to go further out, the board identity on the bottom. Checked on Altium's own measure of every printed label: clear of copper, vias, the board edge and each other. What it cannot fit is listed, never dropped silently. | | `altium view3d` | The board in Altium's 3D view: a still saved in the run (every part must sit flat on its pads) and a slow orbit for the recording. | | `altium export` | The manufacturing set from the project's OutJob: Gerbers (zipped), NC drill, pick and place (CSV, mm) and the BOM with every distributor number, plus the JLCPCB BOM and CPL; pulled into `<run>/manufacturing/` and checked against the board (every layer a fab needs, drill hits = vias + drilled pads, every placed part in the pick and place and the BOM, test points out, machine parts DNP). | | `altium reset` | A refine pass: every track, via and pour cleared from the live board; the parts stay. | +## Parts from the board's project library++A part whose wiki page cannot be public yet (a private page, a reviewed project-only footprint, a page+waiting for its owner) comes from the board's project library, as on KiCad (skill aiflow-sourcing):+`parts --project-lib <dir>` checks its symbol, footprint, STEP model and provenance source and records+them in the run. `altium library` then takes that part from those files instead of a page: its one symbol+is cut out of the library's `.kicad_sym` (with the symbol it extends), and the symbol, the footprint and+the STEP are staged as `<run>/altium/pages/<MPN>/` (where a wiki page is cloned, so the board render takes+its courtyard from that footprint too) and go through the same `altium-kicad2altium` and+`altium-symbol-modes` conversions and the same `altium_import_part`. Manufacturer, package, description and+any `mouser`/`digikey`/`lcsc` numbers come from its provenance.json entry (then the BOM row); the+distributor lookups run as for any part. A page named in the netlist, or one `parts` matched, still wins.+`finish` re-checks that the project-library files are still there.++## Symbols and pin types on the Altium sheet++`altium schematic` checks what a clean compile does not prove (KiCad's `schematic check`, issue #39), on+the sheet as Altium reads it back: `altium_schematic_data` (every placed part's pins with their electrical+type and place), `altium_symbol_prims` per used symbol (its SchLib record) and the symbol spec the library+step imported (kept in `<run>/altium/symbols/`). The logic is `check_symbols` in `tools/altium-lane.py`:++- **Pins on the body.** A pin's x,y is its body end and must sit on the body rectangle's edge, pointing+ away from it: in every display mode of the spec, and in the mode drawn on the sheet (read back, so a+ symbol imported before its spec was kept is still checked there). Pins one pin length off the box with+ their names in the gap (the symbol_modes bug fixed in the bridge on 2026-10-19) are errors. A small part+ drawn as its own symbol (a potentiometer's wiper) only warns.+- **Pin types on active devices** (U/IC references, op-amps and comparators), with KiCad's rules: an+ amplifier input or an output (`OUT`, `VOUT`, `OUTA`) declared Passive is an error; a Passive power pin is+ an error on op-amps and comparators and a warning on other ICs. `spec.schematic.allowPassive` (with a+ datasheet `why`) turns an error into a warning, as on KiCad.+- **Polarity drawings** of small parts drawn as their own symbol: the diode's triangle and cathode bar at+ the pin named K, a BJT's emitter arrow on the pin named E (`spec.schematic.polarityReviewed`).+- Not checked on Altium: sheet readability (`altium_build_schematic` draws one wire stub and one net label+ per pin), op-amp `+`/`-` drawings (the box symbol shows the pin names).++The result goes into the schematic proof (`<run>/proofs/schematic.json`, `symbols: {ok, errors, warnings}`)+and `<run>/schematic-check.json`; the verified list reads it as "schematic symbols and pin types" (a+recorded `schematic-check` outcome still wins), and `finish` refuses while it has errors. A proof from+before 0.2.10 has no `symbols`: the item stays "not known" until `altium symbols` runs.++Today's bridge creates every SchLib pin as Passive (`altium_build_symbol_modes` does not take a pin type+from the spec), so an IC's power pins warn and an output named `OUT`/`VOUT` is an error until the bridge+carries the KiCad pin types into Altium (follow-up in `docs/release-0.2.10.md`).+ ## Starting the board After `altium schematic`, attach the board to the run (same `--run` as the intake):⋯ 31 unchanged lines ⋯ footprint's pads are proven to sit where the Altium part's pads do (same names, within 0.02 mm). A part with no source footprint, or whose pads differ, gets a courtyard drawn 0.25 mm around its pads. `place check`, `gate`, `land route` and `finish` then judge courtyard and body overlaps on Altium boards as on-KiCad ones. The verified list reads `altium schematic`'s pin-for-pin parity and compiler ERC and `altium-export`'s Gerber regions (every poured layer's Gerber has G36 regions) from their proofs.+KiCad ones. The verified list reads `altium schematic`'s pin-for-pin parity, compiler ERC and symbol+check and `altium export`'s Gerber regions (every poured layer's Gerber has G36 regions) from their proofs. A failing field screen names the neck and its fix. The solve runs on the snapshot `analyze` just wrote: `analyze current` (it writes `board-live.kicad_pcb`), then `adom-aiflow exec -- adom-fields analyze⋯ 12 unchanged lines ⋯ to 0.95 x the height and the stroke (a seventh of the height) pads every side. On an Altium run every step's clip films Altium's main window, the board fitted to the view first.+@@ -0,0 +1,81 @@+# adom-aiflow 0.2.10++The two Altium gaps 0.2.9 left open: the schematic symbol and pin-type check, and parts from the+board's own project library.++- **Symbols and pin types on the Altium sheet.** `altium schematic` now checks, after the sheet compiles+ pin for pin, what a clean compile does not prove (the Altium half of `schematic check`, issue #39). It+ reads the drawn sheet back (`altium_schematic_data`: every placed part's pins with their electrical type+ and place), each used symbol's SchLib record (`altium_symbol_prims`) and the symbol spec the library step+ imported, now kept in `<run>/altium/symbols/`. The logic is `check_symbols` in the new+ `tools/altium-lane.py` (pure, embedded in front of `altium-project.py`):+ - every pin's x,y (its body end) on the body rectangle's edge and pointing away from it, in every+ display mode of the spec and in the mode drawn on the sheet: pins one pin length off the box with+ their names in the gap (the bridge's symbol_modes bug fixed 2026-10-19) are errors;+ - pin electrical types on U/IC parts, op-amps and comparators with KiCad's rules (an amplifier input or+ an `OUT`/`VOUT` output declared Passive is an error; a Passive power pin an error on op-amps and+ comparators, a warning on other ICs), `spec.schematic.allowPassive` honoured;+ - polarity drawings of small parts drawn as their own symbol: diode triangle and cathode bar at the pin+ named K, a BJT's emitter arrow on the pin named E, `spec.schematic.polarityReviewed` honoured.+ The result is recorded in the schematic proof (`symbols: {ok, errors, warnings, checked, report}`) and+ `<run>/schematic-check.json`; an error fails `altium schematic` after the sheet, the board and the proof+ are written. `verified.rs` maps it into the `schematic-check` outcome KiCad's check records (a recorded+ outcome still wins), so the verified list's "schematic symbols and pin types" is yes or no on Altium+ and an Altium board can be fully cleared; `finish` refuses while the Altium check has errors+ (`schematic_symbols::tool_gate`; KiCad runs are judged exactly as before).+- **`altium symbols`.** The same check again on the drawn sheet, without redrawing it (after a library+ fix, or on a run from before 0.2.10, whose proof has no `symbols` and whose list says "not known").+- **Project-library parts on Altium.** `altium library` takes a part with no public wiki page from the+ board's project library (`parts --project-lib <dir>`, or `"projectLib"` in the spec or requirements,+ checked complete there and recorded in the run's `parts` outcome). Its one symbol is cut out of the+ library's `.kicad_sym` (with the symbol it extends; the converter reads every pin in its file), and the+ symbol, footprint and STEP are staged as `<run>/altium/pages/<MPN>/` (so the board render takes its+ courtyard from that footprint, as for a wiki part). The per-part conversion is now one function for+ every source (`part_source` + `convert_part`: `altium-kicad2altium`, `altium-symbol-modes`, the STEP,+ `altium_import_part`); manufacturer, package, description and any `mouser`/`digikey`/`lcsc` come from+ the part's provenance.json entry. The page a wiki part is cloned from is now the netlist's `page`, else+ the page `parts` matched (it knows slugs such as `ap2112k-3-3trg1`), else the MPN as before. The OK+ line and the library proof (`projectLibParts`) name the project-library parts.+- **`project-page` on Altium** names the project-library parts in the "Built in Altium Designer" section+ (the component table already said "project library (no public page yet)"), and its OK line lists them.+- Tests: `tests/altium-lane.py` (15: the check on plain data, a project-library part staged, and+ `altium library` / `altium symbols` end to end against fake bridge and converter CLIs),+ `test_altium_page_lists_project_library_parts` in `tests/project-page-library.py`,+ `tool_proofs_fill_in_parity_erc_and_gerber_fills` extended, `an_altium_symbol_check_with_errors_stops_finish`.++- **One desktop project folder per run.** Every run is named `aiflow-run`, so the Altium (and Fusion) lane put every project in `Documents/AdomAiFlow/aiflow-run` on the desktop, and a second project wrote over the first one's files. The folder is now `<project folder>-<run>`, claimed with a `.aiflow-run` marker naming the run; a folder another run claimed gets a numbered sibling. Runs that already recorded their folder keep it.+- **Older Altium bridges.** Before Altium Bridge 0.2.52 every library pin was Passive and `altium_symbol_prims` answered the body of any display mode, so on such a bridge the pin-type findings, and the sheet-body findings of a symbol with no kept spec, are warnings that say to update the bridge instead of errors.++## Bridge side (adom/altium-bridge 0.2.52)++All three follow-ups below shipped in Altium Bridge 0.2.52 and were verified live on Altium Designer 26.11.1: `altium-symbol-modes` carries the KiCad pin type and `build_symbol_modes` sets it (the TPS54202's GND, SW and VIN read Power (7), FB and EN Input (0), BOOT Passive (4), the numbering this check assumes); `read_symbol` returns each pin's `electrical`; `symbol_prims`' `bodyRectMil` is the union of the drawn mode's rectangles. On the 2026-10-16 TPS54202 buck run, `altium symbols` passes with only the "no kept spec" note for that run's pre-0.2.10 library.++## The original write-up of the follow-ups+++1. **Pin electrical types into Altium (needed for real ICs to pass the pin-type check).** Today every SchLib+ pin is created Passive: `CmdBuildSymbolModes` (src/agent/AdomAgent.pas.tmpl) sets+ `Pin.Electrical := eElectricPassive` for every `PIN` line, and `tools/symbol_modes.py` drops the KiCad+ pin type (`parse_pins` keeps only number and name). So on Altium an IC's power pins warn and an output+ named `OUT`/`VOUT` (an LDO) is an error until:+ - `symbol_modes.py` keeps the type (`(pin <etype> <shape> ...)`, the first atom) and writes it as a+ 10th field of every PIN line, after the hide field (empty for IC pins):+ `PIN|mode|number|name|x|y|orient|len|<hide or empty>|<kicad etype>`, in `build()` and `native_lines()`;+ - `CmdBuildSymbolModes` (the one place `altium_import_part` and `altium_build_symbol_modes` build pins)+ maps `FieldP(L, 10)`: `input` -> eElectricInput, `output` -> eElectricOutput, `bidirectional` ->+ eElectricIO, `tri_state` -> eElectricHiZ, `open_collector` -> eElectricOpenCollector, `open_emitter`+ -> eElectricOpenEmitter, `power_in`/`power_out` -> eElectricPower, anything else or absent ->+ eElectricPassive (today's behaviour);+ - `altium_read_symbol` adds `electrical` (the Pin.Electrical ordinal) to each `pins[]` entry, so a+ library can be checked without a sheet.+ Then `altium library --again` and `altium schematic` (or `altium symbols`) make the Altium pin types+ the KiCad symbol's; nothing in AI Flow changes.+2. **Doc only:** `altium_schematic_data`'s verb_doc lists pins as `{number, name, electrical}`; the agent+ already returns `xMil`, `yMil`, `orientation` and `lengthMil` per pin, which this check uses (the body+ end on the sheet). Say so, and that `electrical` is TPinElectrical (0 Input, 1 IO, 2 Output,+ 3 OpenCollector, 4 Passive, 5 HiZ, 6 OpenEmitter, 7 Power).+3. **Optional:** `altium_symbol_prims` reports `bodyRectMil` for the first rectangle of any display mode and+ counts pins over all modes. Per-mode rectangles and pins with positions (`modes: [{mode, rectsMil,+ pins: [{number, name, xMil, yMil, orientation, lengthMil, electrical}]}]`) would let the check read+ every display mode from Altium itself instead of the kept spec.+@@ -1,94 +1,95 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.9",+ "version": "0.2.10", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",⋯ 84 unchanged lines ⋯ ], "license": "MIT" }+@@ -1,100 +1,100 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.9",+ "version": "0.2.10", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",⋯ 93 unchanged lines ⋯@@ -1,545 +1,552 @@⋯ 180 unchanged lines ⋯ **the parts** from their wiki component pages (aiflow-sourcing). The netlist is `netlist.json`: `components` (ref, mpn, value, block, page or fp) and `nets` (`{"VIN": ["U1.3", "C1.1"]}`). 4. **The parts on the wiki and the project page**, as for a KiCad board from scratch: `parts --bom- design/bom.csv` (every MPN on its wiki component page), then `project-page --page <owner>/<slug>+ design/bom.csv` (every MPN on its wiki component page; a part with no public page yet from the board's+ project library: `parts --project-lib <dir>`, aiflow-sourcing), then `project-page --page <owner>/<slug> --create` (README revision 1 with the component table; no KiCad PCM package on the Altium lane). Every step after it is pushed to the page as it is left. 5. **One project library in Altium.** `altium library --parts <netlist.json> --footprints <folder of- .kicad_mod for board-only pads> --name <Project>`.+ .kicad_mod for board-only pads> --name <Project>`. Project-library parts are built from their own+ KiCad symbol, footprint and STEP through the same converters as wiki parts; the OK line names them. 6. **A reference board**, when the design has one: `altium reference --kicad <board.kicad_pcb> --spec <spec.json>` (placement and spec in Altium's frame; use its `spec.json` and `moves.json` from here on). 7. **The schematic, then the board.** `altium schematic --netlist <netlist.json> --name <Design>`: the- sheet drawn and compiled pin for pin, the board built in the same project with the fab's rules.- Show the sheet (`altium_show_schematic`).+ sheet drawn and compiled pin for pin, the board built in the same project with the fab's rules, then+ the symbols checked as drawn (every pin on its body edge in every display mode, pin types on active+ parts, diode and transistor polarity drawings). An error there fails the step after everything is+ built: fix the part (`altium library --only <MPN>`) or justify it (`spec.schematic.allowPassive` /+ `polarityReviewed` with a datasheet `why`), then `altium symbols` checks the sheet again. The verified+ list's "schematic symbols and pin types" and `finish` read it. Show the sheet (`altium_show_schematic`). 8. **SPICE**, as on the Fusion lane (`step simulation`, the Adom SPICE dashboard, `evidence add --kind simulation`). 9. **Placement, then routing.** `start --board <run>/altium/<Design>.altium.json --spec <spec.json>⋯ 346 unchanged lines ⋯ `spec.boardSink` declares a pedestal or plate on a board face for named tabless parts: a conservative via, mask, interface and sink series estimate in `analyze thermal`, or the same-input Fields solve with the named conducting block. Both record their assumption in the run summary and `finish`; other parts keep the still-air check. Read `docs/board-sink.md`. `outline check` reads only Edge.Cuts: board items and footprint Edge.Cuts items placed in board coordinates.+@@ -1,178 +1,179 @@⋯ 144 unchanged lines ⋯ {"parts": [{"mpn": "ABC-123", "source": "datasheet rev C, fig. 12 (AI-created footprint)", "symbol": "ABC-123", "footprint": "QFN-16_3x3", "model": "3dmodels/ABC-123.step"}]} ``` -`symbol` defaults to the MPN, `footprint` to the symbol's Footprint property, `model` to the footprint's model file. `parts` checks every listed MPN has all four (symbol, footprint, STEP, source) and still prefers a wiki page when one exists. `project-page` packages these parts into the board's PCM library beside the wiki parts, and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then.+`symbol` defaults to the MPN, `footprint` to the symbol's Footprint property, `model` to the footprint's model file. `parts` checks every listed MPN has all four (symbol, footprint, STEP, source) and still prefers a wiki page when one exists. `project-page` packages these parts into the board's PCM library beside the wiki parts (on Altium, `altium library` builds them into the SchLib and PcbLib from these same files), and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then. ## Required component pages include off-board hardware ⋯ 24 unchanged lines ⋯ A part number decoded from a family's part-number grammar is not proof the part is made: the family table may not list that combination. Cite the exact MPN's page in `mpn_url` for every purchased row. A missing page, a PDF (usually the family datasheet) or a URL that does not name the MPN is a warning; with `"sourcing": {"requireExactMpnEvidence": true}` in the spec or requirements a missing page fails the check. When a part is unavailable, re-derive the circuit around what is stocked instead of freezing a value: an RC pair of 15.8 kOhm and 10 nF meets the same corner as 1.58 kOhm and 100 nF. Keep the spec, not the first values.+@@ -0,0 +1,373 @@+"""The Altium lane's symbol check and project-library parts, with no Altium and no bridge:++ - check_symbols (tools/altium-lane.py) on plain data shaped as altium_schematic_data, altium_symbol_prims+ and the altium-symbol-modes spec return them: pins off the body edge (the symbol_modes bug fixed+ 2026-10-19) are errors, pin types on active parts follow the KiCad check's rules, a diode drawn+ backwards is an error, and a symbol with no kept spec is checked on its drawn mode only;+ - project-library parts: the part's one symbol cut out of a many-symbol library, staged as the lane's+ pages/<key>/, and `altium library` run end to end against fake adom-bridge / converter CLIs on PATH:+ the project-library part goes through the same converters and altium_import_part as a wiki part.++ python3 tests/altium-lane.py+"""+import importlib.util, json, os, subprocess, sys, tempfile, unittest+from pathlib import Path++ROOT = Path(__file__).resolve().parents[1]+sys.dont_write_bytecode = True # no tools/__pycache__ from loading the lane helpers+_spec = importlib.util.spec_from_file_location('altium_lane', ROOT / 'tools' / 'altium-lane.py')+lane = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(lane)++# a 6-pin IC in symbol_modes' 2-column look: body 0..800 x -400..0 mil, pins on its left and right edges+IC_GOOD = """META|TPS54202DDCR|buck|U+MODE|0|std+RECT|0|0|-400|800|0+PIN|0|1|GND|0|-100|left|300+PIN|0|2|SW|0|-200|left|300+PIN|0|3|VIN|0|-300|left|300+PIN|0|4|FB|800|-300|right|300+PIN|0|5|EN|800|-200|right|300+PIN|0|6|BOOT|800|-100|right|300+MODE|1|2col+RECT|1|0|-400|800|0+PIN|1|1|GND|0|-100|left|300+PIN|1|2|SW|0|-200|left|300+PIN|1|3|VIN|0|-300|left|300+PIN|1|4|FB|800|-100|right|300+PIN|1|5|EN|800|-200|right|300+PIN|1|6|BOOT|800|-300|right|300+"""+# the same symbol as symbol_modes wrote it before 2026-10-19: every pin's OUTER end, one pin length off the box+IC_BAD = IC_GOOD.replace('|0|-100|left', '|-300|-100|left').replace('|0|-200|left', '|-300|-200|left').replace('|0|-300|left', '|-300|-300|left').replace('|800|-', '|1100|-')++# KiCad's Device:D as native_lines converts it: K left, bar and triangle apex at the K end (mil, y up)+DIODE_GOOD = """META|D-KEY|diode|D+MODE|0|native+LINE|0|-50|50|-50|-50+LINE|0|50|0|-50|0+LINE|0|50|50|50|-50+LINE|0|50|-50|-50|0+LINE|0|-50|0|50|50+PIN|0|1|K|-50|0|left|100|hide+PIN|0|2|A|50|0|right|100|hide+"""+# the drawing mirrored, pins kept: the triangle and the bar point at the anode+DIODE_BAD = """META|D-KEY|diode|D+MODE|0|native+LINE|0|50|50|50|-50+LINE|0|-50|0|50|0+LINE|0|-50|50|-50|-50+LINE|0|-50|-50|50|0+LINE|0|50|0|-50|50+PIN|0|1|K|-50|0|left|100|hide+PIN|0|2|A|50|0|right|100|hide+"""+++def placed(ref, libref, spec_text, at=(1000, 2000), types=None, mode=0):+ """A placed part as altium_schematic_data reports it: mode-0 pins at their sheet position."""+ sym = lane.parse_symbol_spec(spec_text)+ pins = []+ orient = {'right': 0, 'up': 1, 'left': 2, 'down': 3}+ for p in sym['modes'][mode]['pins']:+ pins.append({'number': p['number'], 'name': p['name'], 'electrical': (types or {}).get(p['name'], 4),+ 'xMil': at[0] + p['x'], 'yMil': at[1] + p['y'], 'orientation': orient[p['orient']], 'lengthMil': p['len']})+ return {'designator': ref, 'libReference': libref, 'comment': '', 'xMil': at[0], 'yMil': at[1], 'orientation': 0, 'pins': pins}+++def prims(spec_text):+ """altium_symbol_prims for a SchLib symbol built from this spec."""+ sym = lane.parse_symbol_spec(spec_text)+ rects = [r for m in sym['modes'].values() for r in m['rects']]+ return {'ok': True, 'pins': sum(len(m['pins']) for m in sym['modes'].values()), 'rectangles': len(rects), 'displayModeCount': len(sym['modes']),+ 'bodyRectMil': [int(v) for v in lane._box(rects[0])] if rects else [0, 0, 0, 0]}+++POWER = {'GND': 7, 'VIN': 7}+++class SymbolCheck(unittest.TestCase):+ def test_a_good_symbol_passes(self):+ rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': prims(IC_GOOD)})+ self.assertTrue(rep['ok'], rep)+ self.assertEqual(rep['warnings'], [])+ self.assertEqual(rep['checked']['bodyEdgeModes'], 2)+ self.assertEqual(rep['checked']['drawnSymbols'], 1)+ self.assertEqual(rep['checked']['pinTypes'], 1)++ def test_pins_one_pin_length_off_the_body_are_errors(self):+ rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_BAD, types=POWER)], {'TPS54202DDCR': IC_BAD}, {'TPS54202DDCR': prims(IC_BAD)})+ self.assertFalse(rep['ok'])+ edge = [e for e in rep['errors'] if e.startswith('pins on the body')]+ # both spec modes and the drawn sheet+ self.assertEqual(len(edge), 3, rep['errors'])+ self.assertIn('6 of 6 pin(s) not on the body edge', edge[0])+ self.assertIn('pin 1 (GND) 300 mil off the body edge', edge[0])+ self.assertTrue(any('as drawn on the sheet' in e for e in edge))++ def test_a_pin_pointing_into_the_body_is_an_error(self):+ bad = IC_GOOD.replace('PIN|0|4|FB|800|-300|right', 'PIN|0|4|FB|800|-300|left')+ rep = lane.check_symbols([], {'X': bad}, {}, None)+ self.assertTrue(rep['ok'], 'nothing placed: nothing checked')+ rep = lane.check_symbols([placed('U1', 'X', bad, types=POWER)], {'X': bad}, {'X': prims(bad)})+ self.assertIn('pin 4 (FB) points into the body', ' '.join(rep['errors']))++ def test_pin_types_follow_the_kicad_rules(self):+ ldo = IC_GOOD.replace('|SW|', '|VOUT|').replace('|BOOT|', '|NC|')+ rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})+ self.assertFalse(rep['ok'])+ self.assertEqual(rep['errors'], ['pin type: U2 pin 2 (AP2112K): output `VOUT` is passive; declare it Output (Power for a regulator output)'])+ self.assertTrue(any('power pin `GND` is passive' in w for w in rep['warnings']), rep['warnings'])+ # typed right in Altium: clean+ rep = lane.check_symbols([placed('U2', 'AP2112K', ldo, types={'VOUT': 7, 'GND': 7, 'VIN': 7})], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})+ self.assertTrue(rep['ok'], rep)+ self.assertEqual(rep['warnings'], [])+ # the datasheet reason in spec.schematic.allowPassive turns the error into a warning; no reason does not count+ why = {'schematic': {'allowPassive': [{'ref': 'U2', 'pin': 'VOUT', 'why': 'datasheet p.3: VOUT is a passive switch'}]}}+ rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)}, why)+ self.assertTrue(rep['ok'], rep)+ self.assertTrue(any('allowed by spec.schematic.allowPassive' in w for w in rep['warnings']))+ rep = lane.check_symbols([placed('U2', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)}, {'schematic': {'allowPassive': [{'ref': 'U2', 'pin': '2'}]}})+ self.assertFalse(rep['ok'])+ # a passive part (R, C) is not an active device: its pin types are not judged+ rep = lane.check_symbols([placed('R1', 'AP2112K', ldo)], {'AP2112K': ldo}, {'AP2112K': prims(ldo)})+ self.assertTrue(rep['ok'], rep)+ self.assertEqual(rep['checked']['pinTypes'], 0)++ def test_an_op_amp_passive_input_is_an_error(self):+ amp = IC_GOOD.replace('|SW|', '|IN+|').replace('|VIN|', '|IN-|').replace('|FB|', '|OUT|').replace('|EN|', '|V+|')+ rep = lane.check_symbols([placed('A1', 'OPA', amp, types={'OUT': 2, 'V+': 7, 'GND': 7})], {'OPA': amp}, {'OPA': prims(amp)})+ self.assertEqual(len([e for e in rep['errors'] if 'amplifier input' in e]), 2, rep['errors'])++ def test_a_diode_drawn_backwards_is_an_error(self):+ rep = lane.check_symbols([placed('D1', 'D-KEY', DIODE_GOOD)], {'D-KEY': DIODE_GOOD}, {'D-KEY': prims(DIODE_GOOD)})+ self.assertTrue(rep['ok'], rep)+ self.assertFalse([w for w in rep['warnings'] if 'polarity' in w], rep['warnings'])+ self.assertEqual(rep['checked']['polarity'], 1)+ rep = lane.check_symbols([placed('D1', 'D-KEY', DIODE_BAD)], {'D-KEY': DIODE_BAD}, {'D-KEY': prims(DIODE_BAD)})+ self.assertFalse(rep['ok'])+ self.assertIn('pointing at the anode', rep['errors'][0])+ reviewed = {'schematic': {'polarityReviewed': [{'symbol': 'D-KEY', 'why': 'checked against the datasheet drawing'}]}}+ self.assertTrue(lane.check_symbols([placed('D1', 'D-KEY', DIODE_BAD)], {'D-KEY': DIODE_BAD}, {'D-KEY': prims(DIODE_BAD)}, reviewed)['ok'])++ def test_a_bjt_arrow_on_the_collector_is_an_error(self):+ q = """META|Q|npn|Q+MODE|0|native+LINE|0|0|60|0|-60+LINE|0|0|20|50|100+LINE|0|0|-20|50|-100+LINE|0|40|-80|30|-95+LINE|0|30|-95|48|-97+LINE|0|48|-97|40|-80+PIN|0|1|B|0|0|left|100|hide+PIN|0|2|C|50|100|up|100|hide+PIN|0|3|E|50|-100|down|100|hide+"""+ rep = lane.check_symbols([placed('Q1', 'Q', q)], {'Q': q}, {'Q': prims(q)})+ self.assertTrue(rep['ok'], rep)+ # the arrow head drawn at the collector end instead+ flipped = q.replace('LINE|0|40|-80|30|-95\nLINE|0|30|-95|48|-97\nLINE|0|48|-97|40|-80', 'LINE|0|40|80|30|95\nLINE|0|30|95|48|97\nLINE|0|48|97|40|80')+ self.assertNotEqual(flipped, q)+ rep = lane.check_symbols([placed('Q1', 'Q', flipped)], {'Q': flipped}, {'Q': prims(flipped)})+ self.assertIn('emitter arrow is drawn on the collector lead', ' '.join(rep['errors']))++ def test_no_kept_spec_checks_the_drawn_mode_only(self):+ rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_BAD, types=POWER)], {}, {'TPS54202DDCR': dict(prims(IC_BAD), bodyRectMil=[0, -400, 800, 0])})+ self.assertFalse(rep['ok'])+ self.assertEqual(rep['checked']['noSpec'], ['TPS54202DDCR'])+ self.assertEqual(len(rep['errors']), 1)+ self.assertIn('as drawn on the sheet', rep['errors'][0])+ self.assertTrue(any('no kept symbol spec for TPS54202DDCR' in w for w in rep['warnings']))+ # a SchLib that differs from the kept spec: the spec is set aside, said as a warning+ rep = lane.check_symbols([placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': dict(prims(IC_GOOD), displayModeCount=10)})+ self.assertTrue(rep['ok'], rep)+ self.assertTrue(any('not the one its kept spec describes' in w for w in rep['warnings']), rep['warnings'])++ def test_a_rotated_part_is_read_back_in_its_symbol_frame(self):+ c = placed('U1', 'TPS54202DDCR', IC_GOOD, types=POWER)+ for p in c['pins']: # turn the part 90 degrees counterclockwise about its origin+ dx, dy = p['xMil'] - 1000, p['yMil'] - 2000+ p['xMil'], p['yMil'] = 1000 - dy, 2000 + dx+ p['orientation'] = (p['orientation'] + 1) % 4+ c['orientation'] = 1+ rep = lane.check_symbols([c], {'TPS54202DDCR': IC_GOOD}, {'TPS54202DDCR': prims(IC_GOOD)})+ self.assertTrue(rep['ok'], rep)+ self.assertEqual(rep['warnings'], [])+++SYM_LIB = '''(kicad_symbol_lib (version 20241209) (generator "kicad_symbol_editor")+ (symbol "ABC-123" (property "Reference" "U") (property "Footprint" "lib:QFN-16") (property "Description" "a (tricky) \\"name\\"")+ (symbol "ABC-123_1_1" (pin input line (at -5.08 0 0) (length 2.54) (name "IN" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))))+ (symbol "XYZ-9" (property "Reference" "U") (property "Footprint" "")+ (symbol "XYZ-9_1_1" (pin output line (at 5.08 0 180) (length 2.54) (name "OUT" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))))+ (symbol "ABC-123-T" (extends "ABC-123") (property "Reference" "U"))+)+'''+++def project_lib(d, with_model=True):+ (d / 'lib.pretty').mkdir(parents=True); (d / '3dmodels').mkdir()+ (d / 'lib.kicad_sym').write_text(SYM_LIB)+ (d / 'lib.pretty' / 'QFN-16.kicad_mod').write_text('(footprint "QFN-16" (layer "F.Cu") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")) (model "${KIPRJMOD}/3dmodels/ABC-123.step"))\n')+ if with_model: (d / '3dmodels' / 'ABC-123.step').write_text('ISO-10303-21;\nHEADER;\nENDSEC;\n')+ return {'dir': str(d), 'symbol': 'ABC-123', 'symbolFile': str(d / 'lib.kicad_sym'), 'footprint': 'QFN-16', 'footprintFile': str(d / 'lib.pretty' / 'QFN-16.kicad_mod'),+ 'model': str(d / '3dmodels' / 'ABC-123.step'), 'source': 'datasheet rev C, p. 12',+ 'provenance': {'mpn': 'ABC-123', 'source': 'datasheet rev C, p. 12', 'manufacturer': 'Acme', 'lcsc': 'C12345'}}+++class ProjectLibParts(unittest.TestCase):+ def setUp(self):+ self.tmp = tempfile.TemporaryDirectory(); self.d = Path(self.tmp.name)++ def tearDown(self):+ self.tmp.cleanup()++ def test_origin_prefers_a_page_then_the_project_library(self):+ pl = {'dir': '/lib'}+ self.assertEqual(lane.part_origin({'mpn': 'ABC-123', 'page': 'me/abc'}, {'projectLib': pl}), ('wiki', 'me/abc'))+ self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {'wiki': 'adom/abc-123', 'projectLib': pl}), ('wiki', 'adom/abc-123'))+ self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {'wiki': None, 'projectLib': pl}), ('project-lib', pl))+ self.assertEqual(lane.part_origin({'mpn': 'ABC-123'}, {}), ('wiki', 'abc-123'))+ rows = lane.parts_rows({'outcomes': {'parts': {'parts': [{'mpn': 'abc-123 ', 'projectLib': pl}]}}})+ self.assertIn('ABC-123', rows)++ def test_one_symbol_is_cut_out_of_the_library(self):+ one = lane.single_symbol_lib(SYM_LIB, 'lib:ABC-123')+ self.assertIn('(symbol "ABC-123"', one)+ self.assertNotIn('XYZ-9', one)+ self.assertNotIn('(pin output', one, 'the converter reads every pin in its file')+ self.assertIn('\\"name\\"', one, 'quoted parentheses and escapes survive')+ child = lane.single_symbol_lib(SYM_LIB, 'ABC-123-T')+ self.assertTrue(child.index('(symbol "ABC-123"') < child.index('(symbol "ABC-123-T"'), 'the parent it extends comes along, first')+ self.assertIsNone(lane.single_symbol_lib(SYM_LIB, 'NOPE'))++ def test_a_part_is_staged_like_a_wiki_page(self):+ pl = project_lib(self.d / 'lib')+ files, problems = lane.stage_project_part('ABC-123', pl, self.d / 'pages' / 'ABC-123')+ self.assertEqual(problems, [])+ self.assertEqual(sorted(p.name for p in (self.d / 'pages' / 'ABC-123').iterdir()), ['ABC-123.kicad_sym', 'ABC-123.step', 'QFN-16.kicad_mod'])+ self.assertNotIn('XYZ-9', files['sym'].read_text())+ facts = lane.project_part_facts(pl, {'value': 'ABC'})+ self.assertEqual((facts['mfr'], facts['package'], facts['links'], facts['desc']), ('Acme', 'QFN-16', {'lcsc': 'C12345'}, 'ABC'))+ pl2 = project_lib(self.d / 'lib2', with_model=False)+ files, problems = lane.stage_project_part('ABC-123', pl2, self.d / 'pages' / 'X')+ self.assertIsNone(files)+ self.assertIn('STEP model', problems[0])+++FAKE_BRIDGE = r'''#!/usr/bin/env python3+import json, os, sys+log = os.environ["FAKE_LOG"]+a = sys.argv[1:]+verb = a[a.index("--ai-thread") + 2]+args = json.loads(a[a.index("--ai-thread") + 3])+with open(log, "a") as f: f.write(json.dumps({"verb": verb, "args": args}) + "\n")+replies = json.load(open(os.environ["FAKE_REPLIES"])) if os.environ.get("FAKE_REPLIES") else {}+print(json.dumps(replies.get(verb, {"success": True})))+'''++FAKE_K2A = r'''#!/usr/bin/env python3+import json, os, sys+with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"tool": "kicad2altium", "argv": sys.argv[1:]}) + "\n")+open(sys.argv[2], "w").write("FP|%s\n" % sys.argv[3])+'''++FAKE_MODES = r'''#!/usr/bin/env python3+import json, os, sys+src = open(sys.argv[1]).read()+with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"tool": "symbol-modes", "argv": sys.argv[1:], "pins": src.count("(pin ")}) + "\n")+open(sys.argv[2], "w").write("META|%s|%s|U\nMODE|0|std\nRECT|0|0|-200|600|0\nPIN|0|1|IN|0|-100|left|300\n" % (sys.argv[3], sys.argv[4]))+'''++FAKE_WIKI = r'''#!/usr/bin/env python3+import sys; sys.exit(1)+'''+++class LibraryCommand(unittest.TestCase):+ """`altium library` end to end with fake CLIs: a part with no wiki page, given by the project library."""++ def test_project_library_part_is_imported(self):+ with tempfile.TemporaryDirectory() as t:+ d = Path(t); b = d / 'bin'; b.mkdir()+ for name, src in (('adom-bridge', FAKE_BRIDGE), ('altium-kicad2altium', FAKE_K2A), ('altium-symbol-modes', FAKE_MODES), ('adom-wiki', FAKE_WIKI)):+ (b / name).write_text(src); (b / name).chmod(0o755)+ run = d / 'run'; run.mkdir()+ pl = project_lib(d / 'lib')+ (run / 'run.json').write_text(json.dumps({'outcomes': {'parts': {'pass': True, 'projectLib': str(d / 'lib'), 'parts': [{'mpn': 'ABC-123', 'refs': ['U1'], 'wiki': None, 'projectLib': pl, 'value': 'ABC'}]}}}))+ net = d / 'netlist.json'; net.write_text(json.dumps({'components': [{'ref': 'U1', 'mpn': 'ABC-123'}], 'nets': {}}))+ script = d / 'altium.py'+ script.write_text('\n'.join((ROOT / 'tools' / f).read_text() for f in ('project-common.py', 'silkscreen-plan.py', 'altium-lane.py', 'altium-project.py')))+ log = d / 'log.jsonl'+ env = dict(os.environ, PATH='%s:%s:/usr/bin:/bin' % (b, os.path.dirname(sys.executable)), AIFLOW_RUN=str(run), AIFLOW_TARGET='box', AIFLOW_BOX_DIR='C:/Users/t/Documents/AdomAiFlow/run', FAKE_LOG=str(log))+ r = subprocess.run([sys.executable, str(script), 'library', '--parts', str(net), '--name', 'Board'], capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL)+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)+ self.assertIn('1 from the board\'s project library: ABC-123', r.stdout)+ calls = [json.loads(l) for l in log.read_text().splitlines()]+ modes = next(c for c in calls if c.get('tool') == 'symbol-modes')+ self.assertEqual(modes['pins'], 1, 'the converter got the one symbol, not the whole library')+ self.assertTrue(modes['argv'][0].endswith('pages/ABC-123/ABC-123.kicad_sym'))+ k2a = next(c for c in calls if c.get('tool') == 'kicad2altium')+ self.assertTrue(k2a['argv'][0].endswith('pages/ABC-123/QFN-16.kicad_mod'))+ imp = next(c['args'] for c in calls if c.get('verb') == 'altium_import_part')+ self.assertEqual(imp['footprintSpec'], 'C:\\Users\\t\\Documents\\AdomAiFlow\\run\\library\\stage\\ABC-123.fp.spec')+ self.assertTrue(imp['symbolSpec'].endswith('ABC-123.sym.spec') and imp['step'].endswith('ABC-123.step'), imp)+ self.assertEqual((imp['params']['MPN'], imp['params']['Manufacturer'], imp['params']['LCSC Part #']), ('ABC-123', 'Acme', 'C12345'))+ sent = [c['args']['filePaths'] for c in calls if c.get('verb') == 'send_files'][0]+ self.assertEqual(sorted(Path(p).name for p in sent), ['ABC-123.fp.spec', 'ABC-123.step', 'ABC-123.sym.spec'])+ st = json.loads((run / 'altium' / 'project.json').read_text())+ self.assertEqual(st['parts']['ABC-123']['source'], 'project-lib')+ self.assertEqual(st['parts']['ABC-123']['projectLib']['dir'], str(d / 'lib'))+ self.assertTrue((run / 'altium' / 'symbols' / 'ABC-123.sym.spec').is_file(), 'the symbol spec is kept for the schematic check')+ self.assertTrue((run / 'altium' / 'pages' / 'ABC-123' / 'QFN-16.kicad_mod').is_file(), 'the board render finds its courtyard there')+ proof = json.loads((run / 'proofs' / 'library.json').read_text())+ self.assertEqual(proof['data']['projectLibParts'], ['ABC-123'])+++class SymbolsCommand(unittest.TestCase):+ """`altium symbols` end to end with a fake bridge: the sheet and the SchLib read back, the check run,+ the schematic proof's symbols replaced (its parity and ERC kept), the report written."""++ def run_symbols(self, d, spec_text, sheet_spec_text):+ b = d / 'bin'; b.mkdir(exist_ok=True)+ (b / 'adom-bridge').write_text(FAKE_BRIDGE); (b / 'adom-bridge').chmod(0o755)+ run = d / 'run'; (run / 'altium' / 'symbols').mkdir(parents=True, exist_ok=True); (run / 'proofs').mkdir(exist_ok=True)+ (run / 'altium' / 'project.json').write_text(json.dumps({'parts': {}, 'schematic': 'C:\\p\\Buck.SchDoc', 'schLib': 'C:/p/library/Board.SchLib'}))+ (run / 'altium' / 'symbols' / 'TPS54202DDCR.sym.spec').write_text(spec_text)+ (run / 'proofs' / 'schematic.json').write_text(json.dumps({'at': 't', 'tool': 'altium', 'data': {'sch': 'C:\\p\\Buck.SchDoc', 'parity': True, 'erc': {'pass': True, 'errors': []}}}))+ replies = {'altium_schematic_data': {'success': True, 'data': {'ok': True, 'document': 'Buck.SchDoc', 'count': 1, 'components': [placed('U1', 'TPS54202DDCR', sheet_spec_text, types=POWER)]}},+ 'altium_symbol_prims': {'success': True, 'data': dict(prims(spec_text), libref='TPS54202DDCR')}}+ (d / 'replies.json').write_text(json.dumps(replies))+ script = d / 'altium.py'+ script.write_text('\n'.join((ROOT / 'tools' / f).read_text() for f in ('project-common.py', 'silkscreen-plan.py', 'altium-lane.py', 'altium-project.py')))+ env = dict(os.environ, PATH='%s:%s:/usr/bin:/bin' % (b, os.path.dirname(sys.executable)), AIFLOW_RUN=str(run), AIFLOW_TARGET='box', AIFLOW_BOX_DIR='C:/p',+ FAKE_LOG=str(d / 'log.jsonl'), FAKE_REPLIES=str(d / 'replies.json'))+ r = subprocess.run([sys.executable, str(script), 'symbols'], capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL)+ return r, json.loads((run / 'proofs' / 'schematic.json').read_text())['data'], run++ def test_symbols_are_checked_and_recorded(self):+ with tempfile.TemporaryDirectory() as t:+ d = Path(t)+ r, proof, run = self.run_symbols(d, IC_GOOD, IC_GOOD)+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)+ self.assertIn('pins on their bodies, pin types and polarity drawings check out', r.stdout)+ self.assertEqual(proof['symbols']['ok'], True)+ self.assertEqual((proof['parity'], proof['erc']['pass']), (True, True), 'the rest of the proof is kept')+ self.assertTrue((run / 'schematic-check.json').is_file())+ calls = [json.loads(l) for l in (d / 'log.jsonl').read_text().splitlines()]+ self.assertEqual(next(c['args'] for c in calls if c['verb'] == 'altium_schematic_data')['filePath'], 'C:\\p\\Buck.SchDoc')+ self.assertEqual(next(c['args'] for c in calls if c['verb'] == 'altium_symbol_prims')['symbolName'], 'TPS54202DDCR')++ def test_pins_off_the_body_fail_and_are_recorded(self):+ with tempfile.TemporaryDirectory() as t:+ r, proof, _ = self.run_symbols(Path(t), IC_BAD, IC_BAD)+ self.assertEqual(r.returncode, 1, r.stdout + r.stderr)+ self.assertIn('ERROR: schematic', r.stdout)+ self.assertIn('300 mil off the body edge', r.stdout)+ self.assertEqual(proof['symbols']['ok'], False)+ self.assertEqual(len(proof['symbols']['errors']), 3)+++if __name__ == '__main__':+ unittest.main(verbosity=1)+@@ -1,242 +1,262 @@ """project-page's Cloud Library and PCM package against a fake adom-wiki (and a fake kicad-cli) on PATH: HTTP 429 retries, one library per board (recorded lib_ id, no duplicates), an incomplete export refused,-the symbol header normalised and parse-checked, project-library parts packaged (issues #64, #66), and+the symbol header normalised and parse-checked, project-library parts packaged (issues #64, #66) and listed on+the Altium lane's page, and every part resolving through shared footprints and models while a missing footprint or model is refused (#76). python3 tests/project-page-library.py⋯ 225 unchanged lines ⋯ self.assertIsNone(v["cloudLibrary"]) self.assertEqual(self.read_state().get("calls", []), []) + def test_altium_page_lists_project_library_parts(self):+ # the Altium lane: README revision 1 only (no Cloud Library, no PCM package); the project-library+ # part is in the component table, in projectLibParts and named in the Altium section+ v = self.run_page(parts=self.parts() + [self.project_lib()], eda="altium")+ self.assertTrue(v["ok"], v)+ self.assertEqual(v["eda"], "altium")+ self.assertEqual(v["projectLibParts"], ["ABC-123"])+ self.assertEqual(self.read_state().get("calls", []), [])+ readme = (self.run_dir / "project-page" / "README.md").read_text()+ self.assertIn("| U9 | 1 | | ABC-123 | | project library (no public page yet) |", readme)+ self.assertIn("every one has its Adom wiki page or comes from the board's own project library", readme)+ self.assertIn("for ABC-123 (no public page yet), from the board's own project library", readme)+ self.assertNotIn("Install every part in KiCad", readme)+ v = self.run_page(eda="altium")+ readme = (self.run_dir / "project-page" / "README.md").read_text()+ self.assertIn("AI Flow builds this board's Altium libraries from the parts' wiki pages above:", readme)+ self.assertNotIn("project library", readme)+ def test_brief_short_description_skips_the_heading(self): v = self.run_page(brief="# Test board\n\nA 12 V to 5 V buck.\n") meta = json.loads(zipfile.ZipFile(v["zip"]).read("metadata.json"))⋯ 2 unchanged lines ⋯ if __name__ == "__main__": unittest.main()+@@ -0,0 +1,500 @@+"""altium-lane: the Altium lane's pure helpers (no bridge, no run state), run in front of altium-project.py+and tested on their own (tests/altium-lane.py).++ Symbols and pin types (the Altium half of `schematic check`, issue #39 items 1 and 2):+ check_symbols(placed, specs, prims, spec) judges the drawn sheet as Altium reads it back+ (altium_schematic_data: every placed part's pins with their electrical type and place), each used+ symbol's SchLib record (altium_symbol_prims: pins, rectangles, display modes, body rectangle) and the+ symbol spec the library step imported (altium-symbol-modes output, kept in <run>/altium/symbols/):+ - pins on the body: a pin's x,y is its BODY end and must sit on the body rectangle's edge, pointing+ away from it, in every display mode (the spec) and on the sheet (the drawn mode, read back);+ - pin electrical types on active devices (U/IC references, op-amps and comparators), with the KiCad+ check's rules: an amplifier input or an output declared passive is an error, a passive power pin+ an error on op-amps and comparators and a warning on other ICs;+ - polarity drawings of the small parts drawn as their own symbol (the spec's native mode): the diode+ triangle and cathode bar at the pin named K, a BJT's emitter arrow on the pin named E.+ Project-library parts (issue #66 on the Altium lane):+ part_origin(), single_symbol_lib(), stage_project_part(): a part with no wiki component page that the+ board's project library gives (`parts --project-lib`) is staged as the lane's pages/<key>/ and goes+ through the same converters as a wiki part.+"""+import math, re, shutil+from pathlib import Path++MIL = 0.0254 # mm per mil++# Altium's TPinElectrical ordinals (altium_schematic_data reports the ordinal) and the KiCad names the+# rules use; Altium has one Power type (no power_in / power_out)+ALTIUM_ETYPE = {0: 'input', 1: 'bidirectional', 2: 'output', 3: 'open_collector', 4: 'passive', 5: 'tri_state', 6: 'open_emitter', 7: 'power'}+# Altium's pin Orientation ordinal (eRotate0..eRotate270): the direction the pin runs away from the body+ALTIUM_ORIENT = {0: 'right', 1: 'up', 2: 'left', 3: 'down'}+DIRS = {'right': (1, 0), 'up': (0, 1), 'left': (-1, 0), 'down': (0, -1)}++LIMITS = ("Altium: pins checked on the body rectangle (every display mode from the imported spec, the drawn mode read back "+ "from the sheet); pin types read from the placed parts (Altium's Power has no in/out); polarity only on small parts "+ "drawn as their own symbol (diodes with pins named K/A, BJTs with pins named E/C), arcs ignored; op-amp +/- are the "+ "box symbol's shown pin names (no drawn signs to contradict them); sheet readability not checked (altium_build_schematic "+ "draws one wire stub and one net label per pin)")+++# ---------------------------------------------------------------- symbol specs++def parse_symbol_spec(text):+ """An altium-symbol-modes spec (META, MODE, RECT, PIN, LINE, ARC lines; mils, y up) as+ {libref, desc, designator, modes: {i: {name, rects, lines, arcs, pins: [{number, name, x, y, orient, len, hidden}]}}}."""+ sym = {'libref': '', 'desc': '', 'designator': 'U', 'modes': {}}++ def mode(i):+ return sym['modes'].setdefault(i, {'name': '', 'rects': [], 'lines': [], 'arcs': [], 'pins': []})+ for raw in (text or '').splitlines():+ f = raw.rstrip('\r').split('|')+ try:+ if f[0] == 'META': sym['libref'], sym['desc'], sym['designator'] = (f + ['', '', ''])[1:4]+ elif f[0] == 'MODE': mode(int(f[1]))['name'] = f[2] if len(f) > 2 else ''+ elif f[0] == 'RECT': mode(int(f[1]))['rects'].append(tuple(float(v) for v in f[2:6]))+ elif f[0] == 'LINE': mode(int(f[1]))['lines'].append(tuple(float(v) for v in f[2:6]))+ elif f[0] == 'ARC': mode(int(f[1]))['arcs'].append(tuple(float(v) for v in f[2:7]))+ elif f[0] == 'PIN':+ mode(int(f[1]))['pins'].append({'number': f[2], 'name': f[3], 'x': float(f[4]), 'y': float(f[5]), 'orient': f[6],+ 'len': float(f[7]), 'hidden': len(f) > 8 and f[8] == 'hide'})+ except (ValueError, IndexError):+ continue+ return sym+++def _box(r):+ return min(r[0], r[2]), min(r[1], r[3]), max(r[0], r[2]), max(r[1], r[3])+++def pin_edge_problems(pins, rect, tol=1.0):+ """The pins whose body end (x, y) is not on the rectangle's edge, or that point into the body:+ [(pin, 'off', distance in mil) | (pin, 'inward', 0)]."""+ x0, y0, x1, y1 = _box(rect)+ out = []+ for p in pins:+ x, y = p['x'], p['y']+ on_l, on_r, on_b, on_t = abs(x - x0) <= tol, abs(x - x1) <= tol, abs(y - y0) <= tol, abs(y - y1) <= tol+ in_y, in_x = y0 - tol <= y <= y1 + tol, x0 - tol <= x <= x1 + tol+ if not (((on_l or on_r) and in_y) or ((on_b or on_t) and in_x)):+ dx = max(x0 - x, 0, x - x1); dy = max(y0 - y, 0, y - y1)+ d = math.hypot(dx, dy) if (dx or dy) else min(x - x0, x1 - x, y - y0, y1 - y)+ out.append((p, 'off', round(d)))+ continue+ ux, uy = DIRS.get(p.get('orient'), (0, 0))+ away = (on_l and in_y and ux < 0) or (on_r and in_y and ux > 0) or (on_b and in_x and uy < 0) or (on_t and in_x and uy > 0)+ if not away: out.append((p, 'inward', 0))+ return out+++def _edge_line(who, what, probs, total, native):+ eg = []+ for p, kind, d in probs[:3]:+ nm = ' (%s)' % p['name'] if p.get('name') else ''+ eg.append('pin %s%s %s' % (p['number'], nm, ('%d mil off the body edge' % d) if kind == 'off' else 'points into the body'))+ more = '' if len(probs) <= 3 else ', ...'+ why = ("a small part's pin should end on its drawing" if native else+ "a pin's x,y is its body end, on the body rectangle's edge (pins one pin length off the box, names in the gap, was the symbol_modes bug fixed 2026-10-19)")+ return '%s %s: %d of %d pin(s) not on the body edge: %s%s; %s' % (who, what, len(probs), total, '; '.join(eg), more, why)+++# ---------------------------------------------------------------- names (the KiCad check's rules)++def norm(name):+ n = (name or '').strip().upper()+ for ch in '{}~ _': n = n.replace(ch, '')+ return n.replace('−', '-').replace('–', '-')+++def amp_input(name):+ """'+' or '-' for a pin named as an amplifier input, else None."""+ n = norm(name)+ if n in ('+', '-'): return n+ if 'IN' in n and not n.startswith('V'):+ if '+' in n: return '+'+ if '-' in n: return '-'+ if n.endswith('INP') or n == 'NONINV': return '+'+ if n.endswith('INN'): return '-'+ return None+++def is_output(name):+ s = norm(name).lstrip('0123456789')+ return s in ('OUT', 'VOUT') or (s.startswith('OUT') and len(s) == 4)+++def is_power(name):+ n = norm(name)+ return (n in ('V+', 'V-', '+V', '-V', 'VS', 'VS+', 'VS-', '+VS', '-VS', 'AVDD', 'DVDD', 'AVSS', 'DVSS', 'AVCC')+ or any(n.startswith(p) for p in ('VCC', 'VDD', 'VEE', 'VSS')) or (n.endswith('GND') and len(n) <= 5))+++def ref_prefix(r):+ return re.match(r'[A-Za-z]*', r or '').group(0).upper()+++def amp_like(info, pin_names):+ i = (info or '').lower()+ return any(k in i for k in ('opamp', 'op amp', 'op-amp', 'operational', 'comparator')) or (+ any(amp_input(n) == '+' for n in pin_names) and any(amp_input(n) == '-' for n in pin_names))+++def pin_type_findings(pins, amp):+ """[(severity 'error'|'warn', pin, message)] for one symbol's pins ({number, name, etype})."""+ out, seen = [], set()+ ok_out = ('output', 'tri_state', 'open_collector', 'open_emitter', 'power', 'power_out', 'bidirectional')+ for p in pins:+ k = (p['number'], p['name'])+ if k in seen: continue+ seen.add(k)+ et, s = p['etype'], amp_input(p['name'])+ if s:+ if et == 'passive': out.append(('error', p, 'amplifier input `%s` (%s) is passive; declare it Input' % (p['name'], s)))+ elif et != 'input': out.append(('warn', p, 'amplifier input `%s` is %s; amplifier inputs are Input' % (p['name'], et)))+ elif is_output(p['name']):+ if et == 'passive': out.append(('error', p, 'output `%s` is passive; declare it Output (Power for a regulator output)' % p['name']))+ elif et not in ok_out: out.append(('warn', p, 'output `%s` is %s' % (p['name'], et)))+ elif is_power(p['name']):+ if et == 'passive': out.append(('error' if amp else 'warn', p, 'power pin `%s` is passive; declare it Power' % p['name']))+ elif et not in ('power', 'power_in', 'power_out'): out.append(('warn', p, 'power pin `%s` is %s; power pins are Power' % (p['name'], et)))+ return out+++def passive_allowed(spec, ref, pin):+ """spec.schematic.allowPassive [{ref, pin?, why}]: True (allowed with a datasheet why), False (named+ without a why), None (not named)."""+ for e in ((spec or {}).get('schematic') or {}).get('allowPassive') or []:+ if e.get('ref') == ref and (e.get('pin') is None or e.get('pin') in (pin['number'], pin['name'])):+ return len((e.get('why') or '').strip()) >= 8+ return None+++def polarity_reviewed(spec, libref, refs):+ for e in ((spec or {}).get('schematic') or {}).get('polarityReviewed') or []:+ if (e.get('symbol') == libref or e.get('symbol') in refs) and len((e.get('why') or '').strip()) >= 8: return True+ return False+++# ---------------------------------------------------------------- polarity (the KiCad check, on LINE segments)++def _d(a, b): return math.hypot(a[0] - b[0], a[1] - b[1])+++def _same(a, b): return _d(a, b) < 0.01+++def _triangles(segs):+ """Closed triangles drawn as three LINE segments (a KiCad closed polyline becomes segments in the spec)."""+ tris, used = [], set()+ n = len(segs)+ for i in range(n):+ for j in range(i + 1, n):+ for k in range(j + 1, n):+ ends = [segs[i][0], segs[i][1], segs[j][0], segs[j][1], segs[k][0], segs[k][1]]+ pts = []+ for e in ends:+ if not any(_same(e, q) for q in pts): pts.append(e)+ if len(pts) == 3 and all(sum(1 for e in ends if _same(e, q)) == 2 for q in pts) and all(not _same(s[0], s[1]) for s in (segs[i], segs[j], segs[k])):+ tris.append(pts); used |= {i, j, k}+ return tris, used+++def _vees(segs, used, max_len=1.5):+ """Two short segments sharing an end (an arrow head whose fill the converter dropped): their 3 points."""+ out = []+ for i in range(len(segs)):+ for j in range(i + 1, len(segs)):+ if i in used or j in used: continue+ a, b = segs[i], segs[j]+ if _d(*a) > max_len or _d(*b) > max_len: continue+ for p in a:+ for q in b:+ if _same(p, q): out.append([a[0] if a[1] is p else a[1], p, b[0] if b[1] is q else b[1]])+ return out+++def _named(pins, names):+ return [p for p in pins if norm(p['name']) in names]+++def diode_finding(pins, tris, bars_open, bars_closed):+ """The KiCad check's diode rule: (severity, message) or None. pins carry 'at' (outer end) in mm."""+ k, a = _named(pins, ('K', 'CATHODE', 'KATHODE')), _named(pins, ('A', 'ANODE'))+ if len(k) != 1 or len(a) != 1: return None+ k, a = k[0], a[0]+ l = _d(a['at'], k['at'])+ if l < 0.1: return None+ u = ((k['at'][0] - a['at'][0]) / l, (k['at'][1] - a['at'][1]) / l)+ t = lambda p: ((p[0] - a['at'][0]) * u[0] + (p[1] - a['at'][1]) * u[1]) / l+ s = lambda p: -(p[0] - a['at'][0]) * u[1] + (p[1] - a['at'][1]) * u[0]+ who = 'pin %s (%s) is the cathode, pin %s (%s) the anode' % (k['number'], k['name'], a['number'], a['name'])+ tri = None+ for pts in tris:+ for i in range(3):+ ap, pj, pk = pts[i], pts[(i + 1) % 3], pts[(i + 2) % 3]+ width = abs(s(pj) - s(pk))+ if width < 0.3 or abs(t(pj) - t(pk)) * l > 0.25 * width or abs(s(ap)) > 0.25 * width or abs((s(pj) + s(pk)) / 2) > 0.25 * width: continue+ if tri is None or width > tri[2]: tri = ((t(pj) + t(pk)) / 2, t(ap), width)+ bars = []+ for pts, closed in [(b, False) for b in bars_open] + [(b, True) for b in bars_closed]:+ ts, ss = [t(p) for p in pts], [s(p) for p in pts]+ along = (max(ts) - min(ts)) * l+ smin, smax = min(ss), max(ss)+ across = smax - smin+ if across < 0.5 or across < (3.0 if closed else 1.5) * along or smin > -0.2 or smax < 0.2: continue+ n = len(pts)+ i = max(range(n if closed else n - 1), key=lambda i: _d(pts[i], pts[(i + 1) % n]))+ bars.append((t(((pts[i][0] + pts[(i + 1) % n][0]) / 2, (pts[i][1] + pts[(i + 1) % n][1]) / 2)), across))+ if tri: bar = min(bars, key=lambda b: abs(b[0] - tri[1]))[0] if bars else None+ else: bar = max(bars, key=lambda b: b[1])[0] if bars else None+ if tri:+ base, apex = tri[0], tri[1]+ if apex < base:+ if bar is None or abs(bar - apex) < abs(bar - base):+ return ('error', 'the diode is drawn pointing at the anode: the triangle and cathode bar are at the pin %s end, but %s' % (a['number'], who))+ return ('warn', 'the diode triangle points at pin %s (named %s) while the bar is at the other end; %s' % (a['number'], a['name'], who))+ if bar is not None and bar < base - 0.02: return ('warn', 'the cathode bar is on the anode side of the triangle; %s' % who)+ return None+ if bar is None: return ('warn', 'no cathode bar or triangle recognised in the drawing; check by eye that the bar is at the cathode (%s)' % who)+ if bar < 0.4: return ('error', 'the cathode bar is drawn at the pin %s end, but %s' % (a['number'], who))+ if bar < 0.6: return ('warn', 'the cathode bar is near the middle, so the drawing does not show which end is the cathode; %s' % who)+ return None+++def bjt_finding(pins, arrows):+ """The KiCad check's BJT rule on arrow heads (point lists, mm): (severity, message) or None."""+ e, c = _named(pins, ('E', 'EMITTER')), _named(pins, ('C', 'COLLECTOR'))+ if len(e) != 1 or len(c) != 1: return None+ e, c = e[0], c[0]+ small = [pts for pts in arrows if math.hypot(max(p[0] for p in pts) - min(p[0] for p in pts), max(p[1] for p in pts) - min(p[1] for p in pts)) < 2.5]+ if not small: return ('warn', 'a transistor with E and C pins but no emitter arrow recognised in the drawing')+ cen = [(sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) for pts in small]+ best = min(cen, key=lambda x: min(_d(x, e['inner']), _d(x, c['inner'])))+ de, dc = _d(best, e['inner']), _d(best, c['inner'])+ who = 'pin %s is named %s and pin %s %s' % (e['number'], e['name'], c['number'], c['name'])+ if dc * 1.5 < de: return ('error', 'the emitter arrow is drawn on the collector lead; %s' % who)+ if dc < de: return ('warn', 'the emitter arrow is closer to the collector than to the emitter; %s' % who)+ return None+++def polarity_findings(mode):+ """Polarity of one spec mode (a small part drawn as its symbol): [(severity, message)]."""+ mm = lambda v: v * MIL+ pins = []+ for p in mode['pins']:+ ux, uy = DIRS.get(p['orient'], (0, 0))+ inner = (mm(p['x']), mm(p['y']))+ pins.append(dict(p, inner=inner, at=(inner[0] + ux * mm(p['len']), inner[1] + uy * mm(p['len']))))+ segs = [((mm(l[0]), mm(l[1])), (mm(l[2]), mm(l[3]))) for l in mode['lines']]+ tris, used = _triangles(segs)+ bars_open = [[s[0], s[1]] for i, s in enumerate(segs) if i not in used]+ bars_closed = []+ for r in mode['rects']:+ x0, y0, x1, y1 = (mm(v) for v in _box(r))+ bars_closed.append([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])+ out = [f for f in (diode_finding(pins, tris, bars_open, bars_closed), bjt_finding(pins, tris + _vees(segs, used))) if f]+ return out+++# ---------------------------------------------------------------- the check++def _rel(pin, comp):+ """A placed pin's place in its symbol's frame (mils): the sheet position less the part's, rotated back."""+ dx, dy = pin.get('xMil', 0) - comp.get('xMil', 0), pin.get('yMil', 0) - comp.get('yMil', 0)+ q = int(comp.get('orientation') or 0) % 4+ for _ in range(q): dx, dy = dy, -dx # undo one 90 degree counterclockwise turn+ orient = DIRS_ROT(ALTIUM_ORIENT.get(int(pin.get('orientation') or 0) % 4, 'right'), -q)+ return {'number': str(pin.get('number', '')), 'name': pin.get('name') or '', 'x': dx, 'y': dy, 'orient': orient}+++def DIRS_ROT(orient, quarter_turns):+ order = ['right', 'up', 'left', 'down']+ return order[(order.index(orient) + quarter_turns) % 4] if orient in order else orient+++def check_symbols(placed, specs, prims, spec=None):+ """The Altium schematic's symbols and pin types (see the module doc). placed: altium_schematic_data+ components; specs: {libref: spec text or parse_symbol_spec()}; prims: {libref: altium_symbol_prims data};+ spec: the run's spec (schematic.allowPassive, schematic.polarityReviewed). Returns the report the+ `altium schematic` proof carries: {ok, errors, warnings, symbols, librarySymbols, checked, limits}."""+ errors, warnings = [], []+ by_lib = {}+ for c in placed or []:+ d = by_lib.setdefault(c.get('libReference') or '', {'refs': [], 'comps': []})+ d['refs'].append(c.get('designator') or '?'); d['comps'].append(c)+ spec_up = {k.upper(): v for k, v in (specs or {}).items()}+ prim_up = {k.upper(): v for k, v in (prims or {}).items()}+ checked = {'pinTypes': 0, 'bodyEdgeModes': 0, 'drawnSymbols': 0, 'polarity': 0, 'noSpec': []}+ for lr, d in sorted(by_lib.items()):+ refs, comp = d['refs'], d['comps'][0]+ who = '%s (%s)' % (','.join(refs), lr)+ sp = spec_up.get(lr.upper())+ sp = parse_symbol_spec(sp) if isinstance(sp, str) else sp+ pr = prim_up.get(lr.upper())+ live = [_rel(p, comp) for p in comp.get('pins') or []]+ # 1. the SchLib symbol is the one its imported spec describes (else the spec says nothing about it)+ if sp and pr:+ n_pins = sum(len(m['pins']) for m in sp['modes'].values()); n_rect = sum(len(m['rects']) for m in sp['modes'].values())+ diffs = []+ if pr.get('displayModeCount') is not None and int(pr['displayModeCount']) != len(sp['modes']): diffs.append('%s display modes, the spec %d' % (pr['displayModeCount'], len(sp['modes'])))+ if pr.get('pins') is not None and int(pr['pins']) != n_pins: diffs.append('%s pins, the spec %d' % (pr['pins'], n_pins))+ if pr.get('rectangles') is not None and int(pr['rectangles']) != n_rect: diffs.append('%s rectangles, the spec %d' % (pr['rectangles'], n_rect))+ if diffs:+ warnings.append('library: %s: the SchLib symbol is not the one its kept spec describes (%s); only the drawn mode was checked. `altium library --only <MPN>` imports it again' % (who, '; '.join(diffs)))+ sp = None+ if not sp:+ checked['noSpec'].append(lr)+ # 2. pins on the body edge, every display mode of the spec+ if sp:+ for i, m in sorted(sp['modes'].items()):+ if len(m['rects']) != 1 or not m['pins']: continue+ checked['bodyEdgeModes'] += 1+ native = m['name'] == 'native'+ probs = pin_edge_problems(m['pins'], m['rects'][0])+ if probs: (warnings if native else errors).append('pins on the body: ' + _edge_line(who, 'mode %d (%s)' % (i, m['name'] or '?'), probs, len(m['pins']), native))+ # ... and the drawn mode, read back from the sheet+ body = None+ if sp and sp['modes'].get(0) and len(sp['modes'][0]['rects']) == 1: body = sp['modes'][0]['rects'][0]+ elif not sp and pr and int(pr.get('rectangles') or 0) >= 1 and pr.get('bodyRectMil'): body = tuple(pr['bodyRectMil'])+ if body and live:+ checked['drawnSymbols'] += 1+ native = (sp['modes'][0]['name'] == 'native') if sp else len(live) <= 3+ probs = pin_edge_problems(live, body)+ if probs: (warnings if native else errors).append('pins on the body: ' + _edge_line(who, 'as drawn on the sheet', probs, len(live), native))+ if sp and sp['modes'].get(0) and live:+ want = {p['number']: p for p in sp['modes'][0]['pins']}+ moved = [p for p in live if p['number'] in want and (abs(p['x'] - want[p['number']]['x']) > 1 or abs(p['y'] - want[p['number']]['y']) > 1)]+ if moved: warnings.append('library: %s: %d drawn pin(s) are not where the spec puts mode 0 (e.g. pin %s at %d,%d mil, the spec %d,%d)' % (+ who, len(moved), moved[0]['number'], moved[0]['x'], moved[0]['y'], want[moved[0]['number']]['x'], want[moved[0]['number']]['y']))+ # 3. polarity of a small part drawn as its own symbol+ if sp and sp['modes'].get(0) and sp['modes'][0]['name'] == 'native':+ checked['polarity'] += 1+ reviewed = polarity_reviewed(spec, lr, refs)+ for sev, msg in polarity_findings(sp['modes'][0]):+ line = '%s: %s' % (who, msg)+ if sev == 'error' and not reviewed: errors.append('polarity drawing: ' + line)+ else: warnings.append('polarity drawing: ' + line + (' [reviewed in spec.schematic.polarityReviewed]' if sev == 'error' else ''))+ # 4. pin electrical types on active devices, as Altium holds them on the placed parts+ pins = [{'number': str(p.get('number', '')), 'name': p.get('name') or '', 'etype': ALTIUM_ETYPE.get(p.get('electrical'), str(p.get('electrical')))} for p in comp.get('pins') or []]+ info = ' '.join([lr, comp.get('comment') or '', (sp or {}).get('desc') or ''])+ amp = amp_like(info, [p['name'] for p in pins])+ active = [r for r in refs if ref_prefix(r) in ('U', 'IC')] if not amp else list(refs)+ if active:+ checked['pinTypes'] += 1+ for sev, pin, msg in pin_type_findings(pins, amp):+ if sev == 'warn':+ warnings.append('pin type: %s pin %s (%s): %s' % (','.join(active), pin['number'], lr, msg)); continue+ blocked = []+ for r in active:+ a = passive_allowed(spec, r, pin)+ if a is True: warnings.append('pin type: %s pin %s (%s): %s [allowed by spec.schematic.allowPassive]' % (r, pin['number'], lr, msg))+ elif a is False: warnings.append('pin type: spec.schematic.allowPassive names %s pin %s without a datasheet `why`; it does not count' % (r, pin['number'])); blocked.append(r)+ else: blocked.append(r)+ if blocked: errors.append('pin type: %s pin %s (%s): %s' % (','.join(blocked), pin['number'], lr, msg))+ if checked['noSpec']:+ warnings.append('no kept symbol spec for %s (imported before AI Flow 0.2.10, or the SchLib differs): only the drawn mode was checked, not the other display modes or the polarity drawing; `altium library --only <MPN>` imports it again and keeps its spec' % ', '.join(checked['noSpec']))+ return {'ok': not errors, 'errors': errors, 'warnings': warnings, 'symbols': len(placed or []), 'librarySymbols': len(by_lib),+ 'checked': checked, 'limits': LIMITS}+++# ---------------------------------------------------------------- project-library parts++def mpn_key(m):+ return (m or '').strip().upper()+++def parts_rows(run_data):+ """The `parts` step's rows (each MPN's wiki page or project-library files) by MPN."""+ rows = (((run_data or {}).get('outcomes') or {}).get('parts') or {}).get('parts') or []+ return {mpn_key(r.get('mpn')): r for r in rows if isinstance(r, dict) and r.get('mpn')}+++def part_origin(comp, row):+ """Where one netlist part's KiCad files come from: ('wiki', slug) or ('project-lib', projectLib).+ A page named in the netlist wins, then the page `parts` matched, then the board's project library+ (`parts --project-lib`, already checked complete there), then the MPN as the page slug."""+ if comp.get('page'): return 'wiki', comp['page']+ if (row or {}).get('wiki'): return 'wiki', row['wiki']+ if isinstance((row or {}).get('projectLib'), dict): return 'project-lib', row['projectLib']+ return 'wiki', (comp.get('mpn') or '').lower()+++def _top_blocks(text):+ """The top-level child blocks of an s-expression file (strings with parentheses respected)."""+ out, depth, start, in_str, i, n = [], 0, None, False, 0, len(text)+ while i < n:+ ch = text[i]+ if in_str:+ if ch == '\\': i += 2; continue+ if ch == '"': in_str = False+ elif ch == '"': in_str = True+ elif ch == '(':+ depth += 1+ if depth == 2: start = i+ elif ch == ')':+ if depth == 2 and start is not None: out.append(text[start:i + 1]); start = None+ depth -= 1+ i += 1+ return out+++def _sym_name(block):+ m = re.match(r'\(symbol\s+"((?:[^"\\]|\\.)*)"', block)+ return m.group(1) if m else None+++def single_symbol_lib(text, name):+ """A .kicad_sym with just the symbol `name` (and the symbol it extends): the converter reads every pin+ in its file, and a project library holds many symbols. None when the library has no such symbol."""+ syms = {}+ for b in _top_blocks(text):+ n = _sym_name(b)+ if n is not None: syms[n] = b+ key = name.split(':')[-1]+ hit = syms.get(key) or next((b for n, b in syms.items() if n.upper() == key.upper()), None)+ if hit is None: return None+ want = [hit]+ ext = re.search(r'\(extends\s+"((?:[^"\\]|\\.)*)"', hit)+ if ext and ext.group(1) in syms: want.insert(0, syms[ext.group(1)])+ v = re.search(r'\(version\s+(\d+)\)', text)+ return '(kicad_symbol_lib (version %s) (generator "adom-aiflow")\n %s\n)\n' % (v.group(1) if v else '20241209', '\n '.join(want))+++def is_step(path):+ try:+ return Path(path).read_bytes()[:64].decode('latin-1').lstrip().startswith('ISO-10303-21')+ except OSError:+ return False+++def stage_project_part(key, pl, dest):+ """One project-library part's KiCad files staged where a wiki part's page is cloned (the lane's+ pages/<key>/: the board render takes its courtyard from the footprint there): its one symbol, its+ footprint and its STEP model. Returns ({sym, outline, fp, step}, []) or (None, [what is wrong])."""+ sf, ff, mf = Path(pl.get('symbolFile') or ''), Path(pl.get('footprintFile') or ''), Path(pl.get('model') or '')+ problems = ['%s %s is missing' % (what, f) for what, f in (('symbol file', sf), ('footprint', ff), ('STEP model', mf)) if not f.is_file()]+ if problems: return None, problems+ name = pl.get('symbol') or key+ text = single_symbol_lib(sf.read_text(errors='ignore'), name)+ if text is None: problems.append('no symbol %s in %s' % (name, sf))+ if not is_step(mf): problems.append('%s is not a STEP file (no ISO-10303-21 header)' % mf)+ if problems: return None, problems+ dest = Path(dest)+ shutil.rmtree(dest, ignore_errors=True); dest.mkdir(parents=True)+ sym = dest / (re.sub(r'[^A-Za-z0-9_.-]', '_', name.split(':')[-1]) + '.kicad_sym')+ sym.write_text(text)+ fp = dest / ff.name; shutil.copy(ff, fp)+ step = dest / mf.name; shutil.copy(mf, step)+ return {'sym': sym, 'outline': None, 'fp': fp, 'step': step}, []+++def project_part_facts(pl, row):+ """What a project-library part says about itself for the Altium parameters: description, manufacturer,+ package and any distributor numbers named in its provenance.json entry."""+ prov = pl.get('provenance') or {}+ links = dict(prov.get('distributor_links') or {})+ for k in ('mouser', 'digikey', 'lcsc'):+ if prov.get(k) and not links.get(k): links[k] = prov[k]+ desc = prov.get('description') or (row or {}).get('value') or pl.get('footprint') or ''+ return {'desc': str(desc)[:120].replace('\n', ' '), 'mfr': prov.get('manufacturer') or (row or {}).get('manufacturer') or '',+ 'package': prov.get('package') or pl.get('footprint') or '', 'links': links, 'source': pl.get('source') or prov.get('source') or ''}+@@ -1,584 +1,712 @@ """adom-aiflow altium: the Altium side of a project, done the same way every time (run after project-common). library one project library in Altium (a .SchLib and a .PcbLib): every part from its wiki component- page, its footprint and symbol converted from the page's KiCad files, its STEP model bound,+ page (or, with no public page yet, from the board's project library: `parts --project-lib`), its+ footprint and symbol converted from the KiCad files, its STEP model bound, the Mouser, DigiKey and LCSC numbers looked up and checked; later parts join the SAME library reference placement and spec coordinates from a reference KiCad board, in the Altium board's frame (the board's lower-left corner at 0,0, y up) schematic the design's schematic drawn in Altium from the netlist (one column per block, every pin labelled with its net) inside a project, compiled and checked pin by pin against the- netlist; then the board, as `board` builds it, in the same project+ netlist, its symbols and pin types checked as drawn; then the board, as `board` builds it, in the same project+ symbols the schematic's symbols checked again on the drawn sheet (pins on their bodies, pin types on+ active parts, polarity drawings), as `schematic` does after it compiles; recorded in its proof board the design's board built in Altium from the netlist: the outline, the fixed parts where the spec puts them, every other part parked below the board for placement, every pad on its net; saved, read back pin by pin, and the read written for `adom-aiflow start`⋯ 28 unchanged lines ⋯ # ---------------------------------------------------------------- library +def run_data():+ """The run's state (run.json), or {}."""+ try:+ d = json.loads((RUN / 'run.json').read_text())+ return d.get('data') or d+ except Exception:+ return {}+++def run_spec():+ """The run's spec (its schematic overrides: allowPassive, polarityReviewed), or {}."""+ try:+ return json.loads(Path(run_data().get('spec') or '').read_text())+ except Exception:+ return {}+++def part_source(key, w):+ """One part's KiCad files and facts, from its wiki component page or from the board's project library+ (`parts --project-lib`): {sym, outline, fp, step, desc, mfr, package, links, origin, source}."""+ pdir = STATE / 'pages' / key+ if w['origin'] == 'project-lib':+ pl = w['projectLib']+ files, problems = stage_project_part(key, pl, pdir)+ if problems: err('%s: the project library part (%s) is not complete: %s' % (w['mpn'], pl.get('dir'), '; '.join(problems)),+ ['Fix the project library (symbol, footprint, STEP model and its provenance.json entry), run `adom-aiflow parts --project-lib %s` again, then `altium library --only %s`.' % (pl.get('dir') or '<dir>', w['mpn'])])+ (pdir / 'provenance.json').write_text(json.dumps(pl, indent=1))+ return dict(files, origin='project-lib', **project_part_facts(pl, w.get('row')))+ page = wiki_page(w['page'], pdir)+ if not page: err('%s: no wiki component page at %s' % (w['mpn'], w['page']), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in the netlist: "page": "<owner>/<slug>".',+ 'A part whose page cannot be public yet comes from the board\'s project library: `adom-aiflow parts --project-lib <dir>` (symbol, footprint, STEP model, provenance.json), then `altium library` again.'])+ comp = page.get('component', {})+ sym, outline, fp, step = pick_files(pdir, comp)+ if not fp: err('%s: the page has no .kicad_mod footprint' % w['mpn'])+ return {'sym': sym, 'outline': outline, 'fp': fp, 'step': step, 'desc': (page.get('brief') or comp.get('package') or '')[:120].replace('\n', ' '),+ 'mfr': comp.get('manufacturer') or '', 'package': comp.get('package') or '', 'links': comp.get('distributor_links'), 'origin': 'wiki', 'source': w['page']}+++def convert_part(key, label, src, stage):+ """The import specs of one part from its KiCad files, by the same converters whatever their source:+ altium-kicad2altium (footprint), altium-symbol-modes (symbol; a chip's outline symbol when the page has+ one and the footprint more than two pads), the STEP bound as it is. Returns (files, symbol spec or None)."""+ spec_fp = stage / (key + '.fp.spec'); spec_sym = stage / (key + '.sym.spec'); files = []+ r = run(['altium-kicad2altium', str(src['fp']), str(spec_fp), key, src['desc']])+ if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (label, clip(r.stdout + r.stderr, 300)))+ files.append(spec_fp)+ outline, sym = src.get('outline'), src.get('sym')+ use = outline if (outline and len(set(re.findall(r'\(pad "([^"]+)"', Path(src['fp']).read_text(errors='ignore')))) > 2) else sym+ if use:+ r = run(['altium-symbol-modes', str(use), str(spec_sym), key, src['desc']])+ if r.returncode != 0 or not spec_sym.is_file(): err('%s: symbol conversion failed: %s' % (label, clip(r.stdout + r.stderr, 300)))+ files.append(spec_sym)+ if src.get('step'): files.append(src['step'])+ return files, (spec_sym if spec_sym in files else None)++ def cmd_library(a): for tool, pkg in (('altium-kicad2altium', 'adom/altium-bridge'), ('altium-symbol-modes', 'adom/altium-bridge'), ('adom-wiki', 'adom/adom-wiki')): need(tool, pkg)⋯ 7 unchanged lines ⋯ cache = json.loads(Path(a.numbers).read_text()) if a.numbers else {} refs = st.setdefault('refs', {}) stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); stage.mkdir(parents=True)+ kept_specs = STATE / 'symbols'; kept_specs.mkdir(parents=True, exist_ok=True) bdir, sch_lib, pcb_lib = lib_paths(st)- added, kept, unverified = [], [], {}+ added, kept, unverified, from_lib = [], [], {}, []+ # the `parts` step's choice per MPN: the wiki page it matched, or the board's project library+ rows = parts_rows(run_data()) work = {} for c in comps: mpn = c.get('mpn') if mpn: key = fp_name(mpn)- work.setdefault(key, {'mpn': mpn, 'page': c.get('page') or mpn.lower(), 'refs': []})['refs'].append(c.get('ref'))+ row = rows.get(mpn_key(mpn)) or {}+ origin, where = part_origin(c, row)+ w = work.setdefault(key, {'mpn': mpn, 'origin': origin, 'page': where if origin == 'wiki' else None,+ 'projectLib': where if origin == 'project-lib' else None, 'row': row, 'refs': []})+ w['refs'].append(c.get('ref')) else: fpn = (c.get('fp') or '').split(':')[-1] hit = next(iter(sorted(Path(a.footprints).rglob(fpn + '.kicad_mod'))), None) if a.footprints and fpn else None if not hit: err('%s has no MPN and its footprint %s was not found' % (c.get('ref'), fpn or '?'), ['Give --footprints <folder with %s.kicad_mod>, or an MPN in the netlist.' % fpn]) key = fp_name(fpn)- work.setdefault(key, {'footprint': str(hit), 'refs': []})['refs'].append(c.get('ref'))+ work.setdefault(key, {'footprint': str(hit), 'origin': 'board-only', 'refs': []})['refs'].append(c.get('ref')) refs[c.get('ref')] = key only = {fp_name(m) for m in (a.only or [])} if only - set(work): err('--only names part(s) not in --parts: %s' % ', '.join(sorted(only - set(work)))) for key, w in work.items():+ if w['origin'] == 'project-lib': from_lib.append(w['mpn']) if only and key not in only: kept.append(key); continue if st['parts'].get(key, {}).get('imported') and not a.again and not only: kept.append(key); continue- spec_fp = stage / (key + '.fp.spec'); spec_sym = stage / (key + '.sym.spec'); files = [] params = {}- step = None- if w.get('footprint'):- r = run(['altium-kicad2altium', w['footprint'], str(spec_fp), key, 'board-only pad'])- if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (key, clip(r.stdout + r.stderr, 300)))- files.append(spec_fp)+ if w['origin'] == 'board-only':+ files, spec_sym = convert_part(key, key, {'fp': w['footprint'], 'desc': 'board-only pad'}, stage)+ step = None else:- pdir = STATE / 'pages' / key- page = wiki_page(w['page'], pdir)- if not page: err('%s: no wiki component page at %s' % (w['mpn'], w['page']), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in the netlist: "page": "<owner>/<slug>".'])- comp = page.get('component', {})- sym, outline, fp, step = pick_files(pdir, comp)- if not fp: err('%s: the page has no .kicad_mod footprint' % w['mpn'])- desc = (page.get('brief') or comp.get('package') or '')[:120].replace('\n', ' ')- r = run(['altium-kicad2altium', str(fp), str(spec_fp), key, desc])- if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (w['mpn'], clip(r.stdout + r.stderr, 300)))- files.append(spec_fp)- use = outline if (outline and len(set(re.findall(r'\(pad "([^"]+)"', fp.read_text(errors='ignore')))) > 2) else sym- if use:- r = run(['altium-symbol-modes', str(use), str(spec_sym), key, desc])- if r.returncode != 0 or not spec_sym.is_file(): err('%s: symbol conversion failed: %s' % (w['mpn'], clip(r.stdout + r.stderr, 300)))- files.append(spec_sym)- if step: files.append(step)- numbers, notes = lookup_numbers(w['mpn'], comp.get('distributor_links'), cache)- mfr = comp.get('manufacturer') or ''- params = {'MPN': w['mpn'], 'Manufacturer': mfr, 'Package': comp.get('package') or ''}+ src = part_source(key, w)+ files, spec_sym = convert_part(key, w['mpn'], src, stage)+ step = src.get('step')+ numbers, notes = lookup_numbers(w['mpn'], src.get('links'), cache)+ mfr = src.get('mfr') or ''+ params = {'MPN': w['mpn'], 'Manufacturer': mfr, 'Package': src.get('package') or ''} if numbers.get('mouser'): params['Mouser Part #'] = numbers['mouser'] if numbers.get('digikey'): params['DigiKey Part #'] = numbers['digikey'] if numbers.get('lcsc'): params['LCSC Part #'] = numbers['lcsc'] missing = [k for k in ('mouser', 'digikey', 'lcsc') if not numbers.get(k)] if missing and mfr.strip().lower() != 'adom': unverified[w['mpn']] = missing + (['(' + '; '.join(sorted(set(notes))) + ')'] if notes else [])+ spec_fp = files[0] send(files, bdir + '/stage') imp = {'pcbLib': win(pcb_lib), 'footprintSpec': win(bdir + '/stage/' + spec_fp.name)}- if spec_sym in files:+ if spec_sym: imp['schLib'] = win(sch_lib); imp['symbolSpec'] = win(bdir + '/stage/' + spec_sym.name)- if step: imp['step'] = win(bdir + '/stage/' + step.name)+ if step: imp['step'] = win(bdir + '/stage/' + Path(step).name) if params: imp['params'] = params r = ab('altium_import_part', imp, timeout=900) if not r.get('success'): err('%s: altium_import_part failed: %s' % (key, str(r.get('error') or r.get('errorCode'))[:300]), [r.get('_hint')])- st['parts'][key] = {'imported': True, 'mpn': w.get('mpn'), 'refs': w['refs'], 'symbol': spec_sym in files, 'step': bool(step), 'params': params}+ # the symbol spec as imported, kept for the schematic's symbol check (every display mode's pins on its body)+ if spec_sym: shutil.copy(spec_sym, kept_specs / spec_sym.name)+ st['parts'][key] = {'imported': True, 'mpn': w.get('mpn'), 'refs': w['refs'], 'symbol': bool(spec_sym), 'step': bool(step), 'params': params, 'source': w['origin']}+ if w['origin'] == 'project-lib': st['parts'][key]['projectLib'] = {'dir': w['projectLib'].get('dir'), 'source': w['projectLib'].get('source')} added.append(key)- say('part %s: footprint%s%s, refs %s' % (key, ', symbol' if spec_sym in files else '', ', 3D' if step else '', ' '.join(w['refs'])))+ say('part %s: footprint%s%s, refs %s%s' % (key, ', symbol' if spec_sym else '', ', 3D' if step else '', ' '.join(w['refs']), ' (from the project library)' if w['origin'] == 'project-lib' else '')) save_state(st) st['schLib'], st['pcbLib'] = sch_lib, pcb_lib save_state(st)- prove('library', {'name': name, 'parts': len(st['parts']), 'tool': 'altium'})- ok('library %s: %d part(s) (%d added, %d kept): %s and %s' % (name, len(st['parts']), len(added), len(kept), pcb_lib, sch_lib),- ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `altium library` again when the services are back, or add the numbers to the part\'s wiki page.' if unverified else '',+ prove('library', {'name': name, 'parts': len(st['parts']), 'tool': 'altium', 'projectLibParts': from_lib})+ ok('library %s: %d part(s) (%d added, %d kept%s): %s and %s' % (name, len(st['parts']), len(added), len(kept),+ '; %d from the board\'s project library: %s' % (len(from_lib), ', '.join(from_lib)) if from_lib else '', pcb_lib, sch_lib),+ ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `altium library` again when the services are back, or add the numbers to the part\'s wiki page (a project-library part: "mouser"/"digikey"/"lcsc" in its provenance.json entry).' if unverified else '', 'Next: adom-aiflow altium reference --kicad <reference .kicad_pcb> --spec <spec.json> (if there is a reference board), then adom-aiflow altium schematic --netlist <netlist.json> --name <Design> (the schematic, then the board in the same project).']) ⋯ 144 unchanged lines ⋯ st['schematic'] = sch; st['project'] = r.get('project'); save_state(st) b = build_board(a) # the project opened before its board file existed lists the board twice (one greyed): open it again- # from its file, now that every document is there, and show the sheet+ # from its file, now that every document is there if r.get('project'): for d in (b['pcb'], sch): ab('altium_save', {'filePath': d})⋯ 1 unchanged line ⋯ for d in (b['pcb'], sch, win(st['schLib']), win(st['pcbLib']), r['project']): ab('altium_close_document', {'path': d}) ab('altium_open_project', {'projectPath': r['project'], 'filePath': r['project']})- ab('altium_show_schematic', {'filePath': sch})- ab('altium_run_command', {'command': 'Sch:Zoom', 'params': {'Action': 'All'}})+ # symbols and pin types (what a clean compile does not prove): the sheet read back from Altium+ say('checking the drawn symbols: pins on their bodies, pin types, polarity drawings')+ sym = symbol_check(st, sch)+ ab('altium_show_schematic', {'filePath': sch})+ ab('altium_run_command', {'command': 'Sch:Zoom', 'params': {'Action': 'All'}}) # parity: altium_build_schematic succeeds only when the sheet compiles to the netlist pin for pin; ERC:- # the compiler's messages (Altium's connection matrix checks pin types)+ # the compiler's messages (Altium's connection matrix checks pin types); symbols: tools/altium-lane.py prove('schematic', {'sch': sch, 'project': r.get('project'), 'parts': r.get('placed'), 'nets': r.get('compiledNets'), 'boardOnly': board_only, 'pcb': b['pcb'],- 'parity': True, 'erc': {'pass': not r.get('compileErrors'), 'errors': r.get('compileErrors') or []}})- ok('schematic %s: %d part(s) drawn, %d labelled pin(s), compiled to the netlist pin for pin (%d nets); the board followed with %d part(s) (%d parked for placement) in project %s' % (- sch, r.get('placed') or 0, r.get('labels') or 0, r.get('compiledNets') or 0, b['parts'], b['parked'], r.get('project')),+ 'parity': True, 'erc': {'pass': not r.get('compileErrors'), 'errors': r.get('compileErrors') or []},+ 'symbols': {'ok': sym['ok'], 'errors': sym['errors'], 'warnings': sym['warnings'], 'checked': sym.get('checked'), 'report': str((RUN / 'schematic-check.json').resolve())}})+ warn = 'Symbol warnings (%d): %s%s' % (len(sym['warnings']), '; '.join(sym['warnings'][:6]), '; ... (all in %s)' % (RUN / 'schematic-check.json') if len(sym['warnings']) > 6 else '') if sym['warnings'] else ''+ nxt = 'Then the board: adom-aiflow start --board %s --spec <spec.json> --engine <you> --target %s --remote-board altium:%s' % (b['out'], TARGET, b['pcb'])+ if not sym['ok']:+ err('schematic %s drawn and compiled to the netlist pin for pin, the board built (%s), but its symbols do not check out:\n %s' % (sch, b['pcb'], '\n '.join(sym['errors'])),+ ['Pins off the body: the symbol spec came from an older altium-symbol-modes; update adom/altium-bridge and import the part again (`altium library --only <MPN>`), then `altium schematic` again.',+ 'Pin types: Altium holds what the SchLib symbol declares (amplifier inputs Input, outputs Output, power pins Power). When the datasheet really says otherwise, add {"ref", "pin", "why"} to spec.schematic.allowPassive; a reviewed polarity drawing goes in spec.schematic.polarityReviewed.',+ 'Recorded in the schematic proof: the verified list says "schematic symbols and pin types NO" and finish refuses until a clean `altium schematic`. Report: %s' % (RUN / 'schematic-check.json'),+ warn, nxt])+ ok('schematic %s: %d part(s) drawn, %d labelled pin(s), compiled to the netlist pin for pin (%d nets), symbols and pin types checked (%d symbol(s), %d warning(s)); the board followed with %d part(s) (%d parked for placement) in project %s' % (+ sch, r.get('placed') or 0, r.get('labels') or 0, r.get('compiledNets') or 0, sym.get('librarySymbols') or 0, len(sym['warnings']), b['parts'], b['parked'], r.get('project')), ['Board-only parts (no symbol, on the board only): %s.' % ', '.join(board_only) if board_only else '',+ warn, 'Look at it: altium_show_schematic {"filePath": "%s"} and a screenshot; every part in its block, every pin labelled with its net.' % sch, 'Next: simulate it before the board (the Adom SPICE dashboard; record it with `evidence add --kind simulation`).',- 'Then the board: adom-aiflow start --board %s --spec <spec.json> --engine <you> --target %s --remote-board altium:%s' % (b['out'], TARGET, b['pcb'])])+ nxt]) +def symbol_check(st, sch):+ """The drawn sheet's symbols and pin types, read back from Altium: altium_schematic_data (every placed+ part's pins with their electrical type and place), altium_symbol_prims per used symbol (its SchLib+ record), the symbol specs the library step kept; judged by check_symbols (tools/altium-lane.py).+ Writes <run>/schematic-check.json."""+ sd = ab('altium_schematic_data', {'filePath': sch, 'pins': True}, timeout=300)+ data = sd.get('data') if isinstance(sd.get('data'), dict) else sd+ if not sd.get('success') or not isinstance(data.get('components'), list):+ rep = {'ok': False, 'errors': ['could not read the drawn sheet back (altium_schematic_data): %s' % clip(str(sd.get('error') or data.get('error') or sd.get('errorCode') or 'no components'), 200)], 'warnings': [], 'checked': {}}+ else:+ comps = data['components']+ specs, prims = {}, {}+ kept = {f.name[:-len('.sym.spec')].upper(): f for f in (STATE / 'symbols').glob('*.sym.spec')} if (STATE / 'symbols').is_dir() else {}+ for lr in sorted({c.get('libReference') for c in comps if c.get('libReference')}):+ if lr.upper() in kept: specs[lr] = kept[lr.upper()].read_text(errors='ignore')+ p = ab('altium_symbol_prims', {'libraryPath': win(st['schLib']), 'symbolName': lr}, timeout=120)+ pd = p.get('data') if isinstance(p.get('data'), dict) else p+ if p.get('success') and pd.get('ok', True) is not False: prims[lr] = pd+ rep = check_symbols(comps, specs, prims, run_spec())+ # a bridge before 0.2.52 makes every library pin Passive and answers symbol_prims' body from+ # any display mode: its pin types and its sheet body say nothing, so those findings warn+ ver = str(sd.get('bridgeVersion') or data.get('bridgeVersion') or '')+ old = tuple(int(x) for x in re.findall(r'\d+', ver)[:3]) < (0, 2, 52) if ver else False+ if old:+ keep, moved = [], []+ for e in rep['errors']:+ (moved if e.startswith('pin type') or ('as drawn on the sheet' in e and not specs) else keep).append(e)+ if moved:+ rep['errors'] = keep+ rep['warnings'] = ['Altium Bridge %s predates 0.2.52 (every library pin Passive, the body from any display mode); update the bridge, then `altium library --again` and `altium symbols`: %s' % (ver, m) for m in moved] + rep['warnings']+ rep['ok'] = not keep+ rep['bridgeVersion'] = ver or None+ (RUN / 'schematic-check.json').write_text(json.dumps(rep, indent=1))+ return rep+++def cmd_symbols(a):+ """The symbol check alone, on the sheet `altium schematic` drew (after a library fix, or on a run from+ before 0.2.10): the schematic proof's symbols result is replaced, the rest of the proof kept."""+ st = load_state()+ sch = a.sch or st.get('schematic')+ if not sch or not st.get('schLib'): err('no Altium schematic yet', ['Run `adom-aiflow altium schematic --netlist <netlist.json> --name <Design>` first: it draws the sheet and runs this check.'])+ pf = RUN / 'proofs' / 'schematic.json'+ data = (json.loads(pf.read_text()).get('data') or {}) if pf.is_file() else {}+ sym = symbol_check(st, sch)+ ab('altium_show_schematic', {'filePath': sch})+ data['symbols'] = {'ok': sym['ok'], 'errors': sym['errors'], 'warnings': sym['warnings'], 'checked': sym.get('checked'), 'report': str((RUN / 'schematic-check.json').resolve())}+ data.setdefault('sch', sch)+ prove('schematic', data)+ warn = 'Symbol warnings (%d): %s' % (len(sym['warnings']), '; '.join(sym['warnings'][:8])) if sym['warnings'] else ''+ if not sym['ok']:+ err('schematic %s: %d symbol error(s):\n %s' % (sch, len(sym['errors']), '\n '.join(sym['errors'])),+ [warn, 'Recorded in the schematic proof (verified list: schematic symbols and pin types NO; finish refuses). Report: %s' % (RUN / 'schematic-check.json')])+ ok('schematic %s: %d placed part(s), %d symbol(s): pins on their bodies, pin types and polarity drawings check out (%d warning(s)); recorded in the schematic proof' % (+ sch, sym.get('symbols') or 0, sym.get('librarySymbols') or 0, len(sym['warnings'])), [warn])++ # ---------------------------------------------------------------- silkscreen def altium_board_model(rs, ss):⋯ 269 unchanged lines ⋯ p = sub.add_parser('reference'); p.add_argument('--kicad', required=True); p.add_argument('--spec', required=True); p.add_argument('--out-dir', default=str(RUN / 'altium')) p = sub.add_parser('board'); p.add_argument('--netlist', required=True); p.add_argument('--name', required=True); p.add_argument('--fixed', help='fixed.json from `altium reference` (default: <run>/altium/fixed.json)'); p.add_argument('--outline', nargs=2, type=float, help='W H in mm when there is no fixed.json') p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--name', required=True); p.add_argument('--fixed', help='fixed.json from `altium reference` (default: <run>/altium/fixed.json)'); p.add_argument('--outline', nargs=2, type=float, help='W H in mm when there is no fixed.json'); p.add_argument('--revision', default='A')+ p = sub.add_parser('symbols'); p.add_argument('--sch', help='the .SchDoc (default: the project schematic)') p = sub.add_parser('silkscreen'); p.add_argument('--design', help='the .PcbDoc (default: the project board)'); p.add_argument('--title', help='the board identity on the bottom (default: the design name)'); p.add_argument('--dry', action='store_true', help='plan only'); p.add_argument('--fresh', action='store_true', help='clear every free overlay text and line first (earlier passes left on the board)') p = sub.add_parser('export'); p.add_argument('--design', help='the .PcbDoc (default: the project board)'); p.add_argument('--out-dir', help='default <run>/manufacturing') p = sub.add_parser('reset'); p.add_argument('--design') p = sub.add_parser('view3d'); p.add_argument('--design'); p.add_argument('--pause', type=float, default=0.7) a = ap.parse_args()- {'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'board': cmd_board, 'export': cmd_export, 'silkscreen': cmd_silkscreen, 'reset': cmd_reset, 'view3d': cmd_view3d}[a.cmd](a)+ {'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'symbols': cmd_symbols, 'board': cmd_board, 'export': cmd_export, 'silkscreen': cmd_silkscreen, 'reset': cmd_reset, 'view3d': cmd_view3d}[a.cmd](a) main()+@@ -1,1751 +1,1769 @@⋯ 145 unchanged lines ⋯ r = ab('shell_execute', {'command': 'echo %USERPROFILE%'}) out = [l.strip() for l in str(r.get('stdout') or r.get('output') or '').splitlines() if l.strip() and '%' not in l] home = out[-1] if out else 'C:/Users/Public'- d = home.replace('\\', '/') + '/Documents/AdomAiFlow/' + RUN.resolve().name+ # one folder per run, never shared: named after the project folder and the run (every run is+ # "aiflow-run", so the run's name alone sent a second project into the first one's folder and+ # its Altium files), and claimed with a marker naming this run; a folder another run claimed+ # gets a numbered sibling+ base = home.replace('\\', '/') + '/Documents/AdomAiFlow/'+ me = str(RUN.resolve())+ stem = re.sub(r'[^A-Za-z0-9._-]+', '-', '%s-%s' % (RUN.resolve().parent.name, RUN.resolve().name)).strip('-') or 'run'+ d = None+ for k in range(1, 50):+ cand = base + stem + ('' if k == 1 else '-%d' % k)+ w = cand.replace('/', '\\')+ r = ab('shell_execute', {'command': 'if exist "%s\\.aiflow-run" (type "%s\\.aiflow-run") else if exist "%s" (echo AIFLOW-TAKEN) else (echo AIFLOW-FREE)' % (w, w, w)})+ o = str(r.get('stdout') or r.get('output') or '').strip()+ if 'AIFLOW-FREE' in o or me in [l.strip() for l in o.splitlines()]:+ d = cand; break+ if d is None: err('no free project folder under %s for %s' % (base, stem), ['Move old AI Flow project folders out of Documents/AdomAiFlow on the desktop.'])+ w = d.replace('/', '\\')+ ab('shell_execute', {'command': 'mkdir "%s" 2>nul & (echo %s)> "%s\\.aiflow-run"' % (w, me, w)}) st['boxDir'] = d; save_state(st) return d ⋯ 1596 unchanged lines ⋯ if __name__ == '__main__': main()+@@ -1,296 +1,314 @@⋯ 105 unchanged lines ⋯ r = ab('shell_execute', {'command': 'echo %USERPROFILE%'}) out = [l.strip() for l in str(r.get('stdout') or r.get('output') or '').splitlines() if l.strip() and '%' not in l] home = out[-1] if out else 'C:/Users/Public'- d = home.replace('\\', '/') + '/Documents/AdomAiFlow/' + RUN.resolve().name+ # one folder per run, never shared: named after the project folder and the run (every run is+ # "aiflow-run", so the run's name alone sent a second project into the first one's folder and+ # its Altium files), and claimed with a marker naming this run; a folder another run claimed+ # gets a numbered sibling+ base = home.replace('\\', '/') + '/Documents/AdomAiFlow/'+ me = str(RUN.resolve())+ stem = re.sub(r'[^A-Za-z0-9._-]+', '-', '%s-%s' % (RUN.resolve().parent.name, RUN.resolve().name)).strip('-') or 'run'+ d = None+ for k in range(1, 50):+ cand = base + stem + ('' if k == 1 else '-%d' % k)+ w = cand.replace('/', '\\')+ r = ab('shell_execute', {'command': 'if exist "%s\\.aiflow-run" (type "%s\\.aiflow-run") else if exist "%s" (echo AIFLOW-TAKEN) else (echo AIFLOW-FREE)' % (w, w, w)})+ o = str(r.get('stdout') or r.get('output') or '').strip()+ if 'AIFLOW-FREE' in o or me in [l.strip() for l in o.splitlines()]:+ d = cand; break+ if d is None: err('no free project folder under %s for %s' % (base, stem), ['Move old AI Flow project folders out of Documents/AdomAiFlow on the desktop.'])+ w = d.replace('/', '\\')+ ab('shell_execute', {'command': 'mkdir "%s" 2>nul & (echo %s)> "%s\\.aiflow-run"' % (w, me, w)}) st['boxDir'] = d; save_state(st) return d ⋯ 181 unchanged lines ⋯ if not dnp_too and d is not None and d < len(row) and row[d].strip(): continue if i is not None and i < len(row): out |= {x.strip() for x in re.split(r'[ ,;]+', row[i]) if x.strip()} return out+@@ -1,660 +1,666 @@⋯ 538 unchanged lines ⋯ return brief or "" -ALTIUM_SECTION = """## Built in Altium Designer+def altium_section(parts):+ """The README's Altium section: where the SchLib and PcbLib come from (the wiki pages, and the board's+ own project library for parts with no public page yet, converted the same way)."""+ proj = sorted({p["mpn"] for p in parts if p.get("projectLib") and not p.get("wiki")})+ src = "the parts' wiki pages above" + (f" and, for {', '.join(proj)} (no public page yet), from the board's own project library: the same KiCad symbol, footprint and STEP model converted the same way" if proj else "")+ return f"""## Built in Altium Designer -AI Flow builds this board's Altium libraries from the parts' wiki pages above: one schematic library (SchLib) and one PCB library (PcbLib) with every symbol, footprint and 3D body, the part numbers and every distributor number on each symbol. The schematic and the board are drawn with them in Altium Designer through the Adom Altium Bridge.+AI Flow builds this board's Altium libraries from {src}: one schematic library (SchLib) and one PCB library (PcbLib) with every symbol, footprint and 3D body, the part numbers and every distributor number on each symbol. The schematic and the board are drawn with them in Altium Designer through the Adom Altium Bridge. """ ⋯ 18 unchanged lines ⋯ |---|---|---|---|---|---| {chr(10).join(rows)} -{ALTIUM_SECTION if cfg.get("eda") == "altium" else ""}{"" if cfg.get("eda") == "altium" else f"""## Install every part in KiCad+{altium_section(cfg["parts"]) if cfg.get("eda") == "altium" else ""}{"" if cfg.get("eda") == "altium" else f"""## 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: ⋯ 85 unchanged lines ⋯ if __name__ == "__main__": main()+
Comments
Log in to comment.
No comments yet.