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.
← Commit history
0.1.38: clip posters, capture finds the reopened editor, large clips pulled in parts, KiCad 10 silkscreen application written down
13 files changed
+120−19
Cargo.lock+10−10Cargo.toml+1−1bin/adom-aiflowcrates/adom-aiflow/src/main.rs+3−2crates/adom-aiflow/src/preboard.rs+21−1crates/aiflow-bridge/src/lib.rs+61−2docs/release-0.1.38.md+6package.json+1−1page.json+1−1skills/aiflow-live-clips/SKILL.md+1skills/aiflow-power-layout/SKILL.md+1−1skills/aiflow-silkscreen/SKILL.md+7tools/silkscreen-dashboard/SKILL.md+7Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.37"+version = "0.1.38" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.37"+version = "0.1.38" dependencies = [ "serde", "serde_json",@@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.37"+version = "0.1.38" dependencies = [ "serde", "serde_json",@@ -41,7 +41,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.37"+version = "0.1.38" dependencies = [ "aiflow-board", "serde",@@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.37"+version = "0.1.38" dependencies = [ "aiflow-board", "aiflow-grid",@@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.37"+version = "0.1.38" dependencies = [ "aiflow-board", "serde",@@ -69,7 +69,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.37"+version = "0.1.38" dependencies = [ "aiflow-board", "serde",@@ -78,7 +78,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.37"+version = "0.1.38" dependencies = [ "aiflow-board", "aiflow-copper",@@ -88,7 +88,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.37"+version = "0.1.38" dependencies = [ "aiflow-board", "aiflow-grid",@@ -98,7 +98,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.37"+version = "0.1.38" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.37"+version = "0.1.38" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+3−2@@ -2802,7 +2802,8 @@ fn main() { "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- let hwnd = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd());+ // 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"})),@@ -3189,7 +3190,7 @@ fn main() { _ => bridge_of(&r, &cli), }; let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- let editor = if what == "fields" { 0 } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) };+ 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);
crates/adom-aiflow/src/preboard.rs+21−1@@ -264,6 +264,13 @@ pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str) -> (St let src = dir.join(format!("{stem}{suffix}")); if src.is_file() { let rel = format!("docs/clips/{stem}{suffix}"); media.push((src, rel.clone())); have.insert(suffix, rel); } }+ // the wiki renders <video> with preload="none": without a poster every clip is an empty box until+ // clicked, so each 10x cut gets a still (a frame 40 percent in) as its poster+ if have.contains_key("-10x.mp4") {+ let poster = dir.join(format!("{stem}-poster.jpg"));+ if !poster.is_file() { make_poster(&dir.join(format!("{stem}-10x.mp4")), &poster); }+ if poster.is_file() { let rel = format!("docs/clips/{stem}-poster.jpg"); media.push((poster, rel.clone())); have.insert("-poster.jpg", rel); }+ } let mut state: Vec<String> = Vec::new(); if let Some(s) = c["suspect"].as_str() { state.push(format!("suspect: {s}")); } if let Some(s) = note["state"].as_str().filter(|s| *s != "ok") { state.push(format!("note: {s}")); }@@ -282,7 +289,11 @@ pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str) -> (St b.push(String::new()); if let Some(s) = note["shows"].as_str().filter(|s| !s.is_empty()) { b.push(s.to_string()); b.push(String::new()); } for s in &state { b.push(format!("> {s}")); b.push(String::new()); }- if let Some(rel) = have.get("-10x.mp4") { b.push(format!("<video src=\"{rel}\" controls muted playsinline></video>")); b.push(String::new()); }+ if let Some(rel) = have.get("-10x.mp4") {+ let poster = have.get("-poster.jpg").map(|p| format!(" poster=\"{p}\"")).unwrap_or_default();+ b.push(format!("<video src=\"{rel}\"{poster} controls muted playsinline preload=\"metadata\"></video>"));+ b.push(String::new());+ } if have.contains_key("-action.mp4") { b.push(format!("Motion-only action cut: [docs/clips/{stem}-action.mp4](clips/{stem}-action.mp4)")); b.push(String::new()); } if let Some(rel) = have.get("-sheet.png") { b.push(format!("Contact sheet (nine frames): ")); b.push(String::new()); } if let Some(raw) = clip_local(dir, file) {@@ -331,6 +342,14 @@ pub fn primary_from_targets(v: &Value) -> Option<String> { v["primaryMachine"].as_str().filter(|s| !s.is_empty()).map(str::to_string).or_else(|| v["targets"].as_array()?.iter().find(|t| t["primary"].as_bool() == Some(true)).and_then(|t| t["name"].as_str()).map(str::to_string)) } +/// One still from a clip for its poster: a frame 40 percent in, 1280 wide, JPEG.+fn make_poster(clip: &Path, out: &Path) {+ let dur = std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"]).arg(clip).output().ok()+ .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok()).unwrap_or(0.0);+ let _ = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-threads", "2", "-ss", &format!("{:.2}", dur * 0.4), "-i"]).arg(clip)+ .args(["-frames:v", "1", "-vf", "scale=1280:-2", "-q:v", "3"]).arg(out).status();+}+ #[cfg(test)] mod tests { use super::*;@@ -437,6 +456,7 @@ mod tests { let notes = json!({"window-1-20260929-010000": {"step": "Placement", "shows": "parts land", "state": "ok"}}); let (md, media) = clips_page(&data, &dir, ¬es, "2026-09-29T03:00:00Z"); assert!(md.contains("<video src=\"docs/clips/window-1-20260929-010000-10x.mp4\""), "{md}");+ assert!(md.contains("preload=\"metadata\""), "{md}"); assert!(md.contains("](clips/window-1-20260929-010000-action.mp4)")); assert!(md.contains("")); assert!(md.contains("| 1 | placement | 1 | 2026-09-29 01:00 UTC |"));
crates/aiflow-bridge/src/lib.rs+61−2@@ -213,9 +213,44 @@ impl Bridge { /// at most `attempts` pulls. Returns (landed, pulls made). pub fn pull_verified(&self, remote: &str, save_to: &str, attempts: usize) -> (bool, usize) { let local = landed_path(remote, save_to);- pull_until_landed(&local, attempts,+ let (landed, made) = pull_until_landed(&local, attempts, || { self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to})); },- || std::thread::sleep(std::time::Duration::from_secs(3)))+ || std::thread::sleep(std::time::Duration::from_secs(3)));+ if landed { return (true, made); }+ // a long clip can be gigabytes, more than one pull_file carries: split it on the desktop+ // into 200 MB parts with PowerShell (no extra software), pull each, join and check the size+ (self.pull_in_parts(remote, save_to), made + 1)+ }++ /// Split a Windows file into 200 MB parts beside it, pull the parts, join them into the landed+ /// path and keep it only when its size matches the original. The parts are removed on both sides.+ pub fn pull_in_parts(&self, remote: &str, save_to: &str) -> bool {+ let win = remote.replace('/', "\\");+ let dir = format!("{win}.parts");+ let script = format!("$src='{win}'; $dir='{dir}'; New-Item -ItemType Directory -Force -Path $dir | Out-Null; $fs=[System.IO.File]::OpenRead($src); $len=$fs.Length; $buf=New-Object byte[] (200MB); $i=0; while(($n=$fs.Read($buf,0,$buf.Length)) -gt 0){{ $o=[System.IO.File]::Create(('{{0}}\\part{{1:D3}}' -f $dir,$i)); $o.Write($buf,0,$n); $o.Close(); $i++ }}; $fs.Close(); \"parts=$i size=$len\"");+ let enc: String = { let u: Vec<u8> = script.encode_utf16().flat_map(|c| c.to_le_bytes()).collect(); base64_encode(&u) };+ let r = self.call("shell_execute", &json!({"command": format!("powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {enc}"), "timeoutSeconds": 900, "reason": "aiflow: split a large clip into parts so it can be pulled"}));+ let out = r["stdout"].as_str().unwrap_or("").to_string();+ let grab = |k: &str| out.split(&format!("{k}=")).nth(1).and_then(|x| x.split_whitespace().next()).and_then(|x| x.parse::<u64>().ok());+ let (Some(parts), Some(size)) = (grab("parts"), grab("size")) else { return false };+ let local = landed_path(remote, save_to);+ let tmp = std::path::Path::new(save_to).join(format!(".{}.parts", std::path::Path::new(&local).file_name().and_then(|n| n.to_str()).unwrap_or("clip")));+ let _ = std::fs::create_dir_all(&tmp);+ let tmp_s = tmp.display().to_string();+ for i in 0..parts {+ let part = format!("{}/part{i:03}", dir.replace("\\", "/"));+ let lp = tmp.join(format!("part{i:03}"));+ let ok = pull_until_landed(&lp, 3, || { self.call("pull_file", &json!({"filePaths": [part.clone()], "saveTo": tmp_s.clone()})); }, || std::thread::sleep(std::time::Duration::from_secs(3))).0;+ if !ok { return false; }+ }+ let joined = (|| -> std::io::Result<u64> {+ let mut out = std::fs::File::create(&local)?;+ for i in 0..parts { let mut f = std::fs::File::open(tmp.join(format!("part{i:03}")))?; std::io::copy(&mut f, &mut out)?; }+ Ok(out.metadata()?.len())+ })();+ let _ = std::fs::remove_dir_all(&tmp);+ let _ = self.call("shell_execute", &json!({"command": format!("rmdir /s /q \"{dir}\""), "timeoutSeconds": 60, "reason": "aiflow: remove the clip parts"}));+ match joined { Ok(n) if n == size => true, _ => { let _ = std::fs::remove_file(&local); false } } } // -- landing ------------------------------------------------------------------------------@@ -437,3 +472,27 @@ mod pull_tests { let _ = std::fs::remove_dir_all(&d); } }++/// Standard base64 (for PowerShell -EncodedCommand), no external crate.+fn base64_encode(b: &[u8]) -> String {+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut s = String::with_capacity((b.len() + 2) / 3 * 4);+ for c in b.chunks(3) {+ let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;+ s.push(T[(n >> 18) as usize & 63] as char); s.push(T[(n >> 12) as usize & 63] as char);+ s.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+ s.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });+ }+ s+}++#[cfg(test)]+mod pull_parts_tests {+ #[test]+ fn base64_matches_the_standard() {+ let u: Vec<u8> = "ab\u{e9}cd".encode_utf16().flat_map(|c| c.to_le_bytes()).collect();+ assert_eq!(super::base64_encode(&u), "YQBiAOkAYwBkAA==");+ assert_eq!(super::base64_encode(b"M"), "TQ==");+ assert_eq!(super::base64_encode(b"Ma"), "TWE=");+ }+}
docs/release-0.1.38.mdadded+6@@ -0,0 +1,6 @@+# AI Flow 0.1.38++- **Clips show a still before they play.** The wiki renders `<video>` with `preload="none"`, so a clip with no poster is an empty black box until clicked, and a clips page full of them looks broken. `clips` now writes a poster for each 10x cut (a frame 40 percent in, `<stem>-poster.jpg`), pushes it with the media, and emits `poster="..." preload="metadata"`. The aiflow-live-clips skill says to do the same for any video embedded by hand.+- **`capture start` finds the editor that is open now.** It used the run's stored window handle, which dies when the editor is closed and reopened for an offline edit, so the recorder did not start. It now asks the bridge for the live PCB editor first (the 3D tour does the same).+- **Large clips come across in parts.** When the pulls of a clip fail (a 17-minute clip was 2.7 GB), aiflow splits it on the desktop into 200 MB parts with PowerShell, pulls each, joins them and keeps the result only when its size matches.+- **Silkscreen application in KiCad 10** is written down in aiflow-silkscreen and aiflow-power-layout: the offline and live halves, the text verb's `rotation` field and its stroke cap (0.32 mm is the smallest legal text against a 0.08 mm minimum stroke), readback and native renders of both faces.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.37",+ "version": "0.1.38", "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 today; Altium, Fusion and 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.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.37",+ "version": "0.1.38", "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 today; Altium, Fusion and 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.",
skills/aiflow-live-clips/SKILL.md+1@@ -38,6 +38,7 @@ When a clip stops (`step <next>`, `capture stop`, `finish`, `deliver`, or the cl - **Publish with `adom-wiki repo push --files`**, not the files blob API (it does not update the git repo the Files tab reads). - **Media paths are repo-root-relative**, even inside `docs/clips.md`: `docs/clips/<stem>-10x.mp4`, `docs/clips/<stem>-sheet.png`. The files viewer prepends `files/` to the ref as written; a path relative to the doc's own folder (`clips/x.mp4`) points at the wrong place. Use the same form for plain links to the action cuts.+- **Every video gets a poster.** The wiki renders `<video>` with `preload="none"`, so a clip without a `poster` is an empty black box until clicked and the page looks broken. `clips` writes `<stem>-poster.jpg` (a frame 40 percent into the 10x cut) and emits `poster="docs/clips/<stem>-poster.jpg" preload="metadata"`; do the same for any video you embed by hand (the README's build video too). Check by looking: every video shows a still before it is played. - **Doc links are relative** (`docs/clips.md` from the README), never absolute URLs. A plain link (not an image or a video `src`) resolves from the doc's own folder, so from `docs/clips.md` the action cut is `[...](clips/<stem>-action.mp4)`; checked on the rendered page, it lands on `files/docs/clips/...`. - **Push only what changed** plus `docs/clips.md`; pushing the whole run folder is slow and bloats the page. - **Scrub before every push**: `clips` refuses a page with an em-dash, or with a match of the regex in `$AIFLOW_BANNED_RE` (set it to the confidential names). Say "the 3rd party fab".
skills/aiflow-power-layout/SKILL.md+1−1@@ -67,7 +67,7 @@ On a small board, still air sets a floor no copper can beat. Estimate it before ## 7. Silkscreen text through the bridge -`kicad_silk_text_batch` takes `width`, `height` and `stroke` (not size or thickness), mirrors B.SilkS by itself and refuses a `mirror` field. Its dry run refuses NEW text below the board's minimum text height (`text_height`), so small value sizes are unreachable until that minimum is lowered in the board setup (only as far as the fab profile allows). Footprint reference and value edits are refused over IPC in KiCad 10.0.5; do them offline (close the clean editor, edit the saved board, reopen, refresh 2D and 3D). `tools/silk_place.py` tries candidate spots and sizes with a dry run each and commits the first legal one. The rest is aiflow-silkscreen.+`kicad_silk_text_batch` takes `width`, `height`, `stroke` and `rotation` (not size, thickness or angle: `angle` is refused as "unsupported text field angle"), mirrors B.SilkS by itself and refuses a `mirror` field. It caps the stroke at a quarter of the text height, so on a board whose minimum stroke is 0.08 mm the smallest legal text is 0.32 mm. Its dry run refuses NEW text below the board's minimum text height (`text_height`), so small value sizes are unreachable until that minimum is lowered in the board setup (only as far as the fab profile allows). Footprint reference and value edits are refused over IPC in KiCad 10.0.5; do them offline (close the clean editor, edit the saved board, reopen, refresh 2D and 3D). `tools/silk_place.py` tries candidate spots and sizes with a dry run each and commits the first legal one. The rest is aiflow-silkscreen. ## 8. Resume `land route` by revision
skills/aiflow-silkscreen/SKILL.md+7@@ -90,3 +90,10 @@ Prefer readable documentation blocks in open board interior, not crowded rim spa ## Full-board process recording For a short silkscreen population video, prefer a fixed full-board camera with Follow placement off. Preserve the selected follow setting when recording; do not silently enable it. Record every accepted placement group at a readable event cadence, top and bottom separately, with only the active face visible and the bottom shown from below. Keep the whole board inside the frame. Accelerate the recorded footage with ffmpeg to the requested duration rather than skipping placement events. Distinguish event replay from live solver timing. Finish with refreshed native EDA 3D evidence; for a one-minute film, reserve about ten seconds for that proof. Close-up follow mode remains optional for detailed review. +## Applying in KiCad 10 (from the buck molecule, 2026-09-30)++- Solve offline, then apply in two halves. Offline, with the editor closed: hide the footprints' own Reference/Value fields (KiCad 10.0.5 refuses footprint field edits over its API), draw frames and pointers as `gr_line`/`gr_arc` on the silk layers, delete superseded standalone text, and lower the project's `min_text_height` to what the fab profile allows. Live: place the text with `kicad_silk_text_batch`, one label group at a time in importance order, each group dry-run through KiCad's DRC first, so the step clip shows the silkscreen being built.+- `kicad_silk_text_batch` takes `width`, `height`, `stroke` and `rotation` (not `angle`), and caps the stroke at a quarter of the height; against a 0.08 mm minimum stroke, 0.32 mm is the smallest legal text.+- After reopening the editor, start the clip with `capture start` (0.1.38 finds the reopened editor window); check the clip is recording before the first label.+- Read the saved board back: every label at its designed position, layer and rotation, then the gate's native DRC and a native render of each face (`kicad-cli pcb render --side top|bottom`).+- The dashboard replay opens in a Hydrogen webview on the user's machine; when the work has to stay on the test box, leave board.json and events.jsonl in the run for a later replay and say so.
tools/silkscreen-dashboard/SKILL.md+7@@ -84,3 +84,10 @@ Prefer readable documentation blocks in open board interior, not crowded rim spa ## Full-board process recording For a short silkscreen population video, prefer a fixed full-board camera with Follow placement off. Preserve the selected follow setting when recording; do not silently enable it. Record every accepted placement group at a readable event cadence, top and bottom separately, with only the active face visible and the bottom shown from below. Keep the whole board inside the frame. Accelerate the recorded footage with ffmpeg to the requested duration rather than skipping placement events. Distinguish event replay from live solver timing. Finish with refreshed native EDA 3D evidence; for a one-minute film, reserve about ten seconds for that proof. Close-up follow mode remains optional for detailed review. +## Applying in KiCad 10 (from the buck molecule, 2026-09-30)++- Solve offline, then apply in two halves. Offline, with the editor closed: hide the footprints' own Reference/Value fields (KiCad 10.0.5 refuses footprint field edits over its API), draw frames and pointers as `gr_line`/`gr_arc` on the silk layers, delete superseded standalone text, and lower the project's `min_text_height` to what the fab profile allows. Live: place the text with `kicad_silk_text_batch`, one label group at a time in importance order, each group dry-run through KiCad's DRC first, so the step clip shows the silkscreen being built.+- `kicad_silk_text_batch` takes `width`, `height`, `stroke` and `rotation` (not `angle`), and caps the stroke at a quarter of the height; against a 0.08 mm minimum stroke, 0.32 mm is the smallest legal text.+- After reopening the editor, start the clip with `capture start` (0.1.38 finds the reopened editor window); check the clip is recording before the first label.+- Read the saved board back: every label at its designed position, layer and rotation, then the gate's native DRC and a native render of each face (`kicad-cli pcb render --side top|bottom`).+- The dashboard replay opens in a Hydrogen webview on the user's machine; when the work has to stay on the test box, leave board.json and events.jsonl in the run for a later replay and say so.