← Commit history
.adomignoreadded+4
@@ -0,0 +1,4 @@+target/+docs/runs/+docs/comparisons/+**/__pycache__/
AGENTS.md+1−1
@@ -8,4 +8,4 @@ Run Cargo workspace tests, component/widget integration tests, silkscreen geomet  Read skills/aiflow-silkscreen/SKILL.md, docs/silkscreen-priority-pass.md and docs/silkscreen-dashboard.md for silkscreen work. Native EDA primitives belong in their bridges. Keep toolkit dependencies isolated and package all helper commands used by the binary. -Current release handoff blocker: issue #28 tracks missing current v0.1.25 Rust source. Do not replace the deployed binary with the older source rebuild while that remains unresolved.+Source handoff: issue #28 supplied the v0.1.25 base at f31cc251. Preserve its recording, composition, comparison and AI-time session commands when integrating new work.
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.25"+version = "0.1.26" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -22,7 +22,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.25"+version = "0.1.26" dependencies = [  "serde",  "serde_json",@@ -30,7 +30,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.25"+version = "0.1.26" dependencies = [  "serde",  "serde_json",@@ -38,7 +38,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.25"+version = "0.1.26" dependencies = [  "serde",  "serde_json",@@ -46,7 +46,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.25"+version = "0.1.26" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -56,7 +56,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.25"+version = "0.1.26" dependencies = [  "aiflow-board",  "serde",@@ -65,7 +65,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.25"+version = "0.1.26" dependencies = [  "aiflow-board",  "serde",@@ -74,7 +74,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.25"+version = "0.1.26" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -84,7 +84,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.25"+version = "0.1.26" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -94,7 +94,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.25"+version = "0.1.26" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.25"+version = "0.1.26" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
README.md+1−1
@@ -11,7 +11,7 @@ adom-aiflow --version  ## Silkscreen that helps at the bench -> Source integration is merged; the installable release is pending the [current-source handoff](https://wiki.adom.inc/adom/adom-aiflow/issues/28). The registry remains at v0.1.25.+> Available in v0.1.26 on the insiders channel. Install with `adom-wiki pkg install adom/adom-aiflow`. The KiCad workflow is demonstrated below; Fusion and Altium adapters still require validation.  AI Flow now includes a two-sided **silkscreen** step: grouped references and smaller values, test-point and connector functions, machine-contact frames and curved pointers, prominent board-specific interfaces, and useful board documentation. The optional dashboard shows placements and their font sizes; a fixed full-board replay lets you watch labels appear on each face before verifying the result in native KiCad 3D. 
SKILL.md+2−1
@@ -1,6 +1,7 @@ --- name: adom-aiflow-description: The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+description: >-+  The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. ---  # adom-aiflow: the loop, measured
crates/adom-aiflow/src/main.rs+1428−57
@@ -57,7 +57,8 @@ enum Cmd {     /// Stamp a stage you do yourself: start or end     Stage { action: String, name: String, #[arg(long)] note: Option<String>, #[arg(long)] result: Option<String> },     /// Placement helpers: pack (--wish), check (--moves), land (--moves)-    Place { what: String, #[arg(long)] wish: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value = "moves.json")] out: String },+    Place { what: String, #[arg(long)] wish: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value = "moves.json")] out: String, /// milliseconds between parts when landing: each part is its own undo step and its own moment on camera+    #[arg(long, default_value_t = 600)] pause_ms: u64 },     /// The grid router on the current board     Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize },     /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts)@@ -75,18 +76,46 @@ enum Cmd {     /// 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 { #[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 },     /// 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 { #[arg(long, default_value = "final.mp4")] out: 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> },+    /// 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.+    Models,+    /// 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> },+    /// The AI's answer to the current prompt is complete ("done, here is ..."): the session's clock stops here. `deliver` is the done of the first task.+    Done { #[arg(long)] message: String },+    /// 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 the human away, not the AI thinking).+    Sessions { #[arg(long)] json: bool }, }  static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();@@ -241,6 +270,251 @@ fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> {     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). Costs no quota. Returns [{provider, label, pct}].+fn usage_snapshot() -> Vec<Value> {+    let mut out = Vec::new();+    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+}++/// Snapshot the plan usage into the ledger and run.json (best effort, never fatal).+fn record_usage(r: &mut Run, when: &str) -> Vec<Value> {+    let snap = usage_snapshot();+    if !snap.is_empty() {+        r.log("usage", json!({"when": when, "limits": snap}));+        if !r.data["usage"].is_array() { r.data["usage"] = json!([]); }+        r.data["usage"].as_array_mut().unwrap().push(json!({"t": now(), "when": when, "limits": snap}));+    }+    snap+}++/// 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();+    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));+            last.insert(key, pct);+        }+    }+    first.iter().map(|(k, (p, l, f))| json!({"provider": p, "label": l, "fromPct": f, "toPct": last[k], "usedPct": ((last[k] - f) * 10.0).round() / 10.0})).collect()+}++fn usage_line(r: &Run) -> String {+    let d = usage_delta(r);+    if d.is_empty() { return "plan usage: no sign-in found to read".into(); }+    format!("plan usage: {}", d.iter().map(|x| format!("{} {} {:.0}% -> {:.0}%", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", "))+}+++/// 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() }+}+++/// 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 (p, _) = br.record_stop(&rid, &save_to);+    let cap = &mut r.data["captures"][pos];+    cap["stop"] = json!(now());+    cap["remoteFile"] = json!(p);+    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+    cap["stoppedBy"] = json!(why);+    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+    let local = cap["file"].as_str().map(str::to_string);+    r.log("clip-stop", json!({"step": st, "recordingId": rid, "file": p, "by": why}));+    if let Some(f) = local.clone().filter(|f| Path::new(f).is_file()) { clip_artifacts(r, &st, &f, 10); }+    local+}+++/// 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;+    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; }+    }+    if total == 0 { return Some("could not read frames".into()); }+    if flat * 4 > total * 3 { 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)@@ -249,18 +523,582 @@ fn media_seconds(path: &str) -> f64 { /// 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) {+    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);-    r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0}));+    // 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+    let verdict = blank_check(file);+    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"));-    let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-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);+    // 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 { 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();+    // 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 (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 !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 clip_budget = (max_seconds - n_stills as f64 * still_seconds).max(max_seconds * 0.5);+    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::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 = 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()]).status().map(|s| s.success()).unwrap_or(false);+                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 = 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()]).status().map(|s| s.success()).unwrap_or(false);+                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, _, _)) => 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()]).status().map(|s| s.success()).unwrap_or(false),+            None => 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()]).status().map(|s| s.success()).unwrap_or(false),+        };+        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() { err("nothing to compose: every segment failed to render", &[]); }+    let vlist = workdir.join("video.txt");+    let alist = workdir.join("audio.txt");+    std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", v.display())).collect::<String>()).unwrap();+    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", a.display())).collect::<String>()).unwrap();+    let vcat = workdir.join("video.mp4");+    let acat = workdir.join("audio.m4a");+    let okv = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+    let oka = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+    let okf = okv && oka && std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }+    // the page takes files up to 10 MB: when the cut is bigger, the video is re-encoded once at the+    // bitrate that fits (screen captures are mostly still, so the picture survives it)+    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");+        let okr = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-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()]).status().map(|s| s.success()).unwrap_or(false);+        if okr { 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)); }+    }+    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 | Idle cut 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 {+    std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args).status().map(|s| s.success()).unwrap_or(false)+}++/// 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", p.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("nothing to compose side by side: no step with motion in either run", &[]); }+    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", v.display())).collect::<String>()).unwrap();+    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", a.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 { err("ffmpeg could not assemble the video and its narration", &[]); }+    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 (the human away) 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()@@ -288,6 +1126,8 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {     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}");@@ -300,18 +1140,44 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {     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 from the prompt.", r.delivered_minutes().unwrap_or(0.0)),-        (None, Some(f)) => format!("**Finished** (qualified board) at {f}, {:.1} min from the prompt; the video is being cut.", now_min),-        _ => format!("**Running**: step `{cur}`, {:.1} min since the prompt. This page updates at every step change.", now_min),+        (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** (qualified board) 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).\n\n", r.data["flow"]["name"].as_str().unwrap_or("board")));+    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 (the human away) 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, the idle it did not get charged for.\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\n| Step | Wall min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");+    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];@@ -346,16 +1212,18 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {     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)> = std::collections::BTreeMap::new();+    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));-            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), _ => {} }+            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}");@@ -364,21 +1232,34 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {         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"));-            if Path::new(&fast).is_file() {-                let dest = out.join(format!("{tag}.mp4"));-                let _ = std::fs::copy(&fast, &dest);-                let (real, quick) = secs.get(&tag).copied().unwrap_or((0.0, 0.0));+            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![nine frames of {title}]({blob}/{tag}-sheet.png)\n\n"));+            }+        }         for when in ["start", "end"] {             let src = dir.join(format!("shot-{tag}-{when}.png"));             if src.is_file() {@@ -387,13 +1268,30 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {                 md.push_str(&format!("![the board when {title} {}]({blob}/shot-{tag}-{when}.png)\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![{cap}]({blob}/{name})\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");-        if let Some(v) = d.get("video").and_then(|v| v.as_str()) {-            if Path::new(v).is_file() { let _ = std::fs::copy(v, out.join("final.mp4")); md.push_str(&format!("**Video: the final cut of this run, every step in order.**\n\n<video width=\"100%\" controls><source src=\"{blob}/final.mp4\" type=\"video/mp4\"></video>\n\n")); }-        }-        md.push_str(&format!("> {}\n\n", d.get("message").and_then(|m| m.as_str()).unwrap_or("")));+        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!("| {} | {} | {:.0} % | {:.0} % | {:.1} % |\n", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0), x["usedPct"].as_f64().unwrap_or(0.0))); }+        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");     }     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") }));@@ -410,15 +1308,42 @@ fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, th     let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");     let mut notes = Vec::new();     if push {-        let files: Vec<String> = std::fs::read_dir(stage.join(&rel)).map(|d| d.filter_map(|e| e.ok()).map(|e| format!("{rel}/{}", e.file_name().to_string_lossy())).collect()).unwrap_or_default();-        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 &files { cmd.arg(f); }-        match cmd.output() {-            Ok(o) if o.status.success() => notes.push(format!("pushed {} files to {page}/{rel}", files.len())),-            Ok(o) => notes.push(format!("push failed: {}", String::from_utf8_lossy(&o.stderr).chars().rev().take(200).collect::<String>().chars().rev().collect::<String>())),-            Err(e) => notes.push(format!("push failed: {e}")),+        // 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(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())); }+                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@@ -489,6 +1414,27 @@ fn main() {             let idx = r.turn_begin(&command_line());             let _ = r.save();             let _ = TURN.set((dir.clone(), idx));+            // 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, _ => 10 } as u64;+                    if age > budget_min * 60 * 2 || age > 3600 {+                        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 {@@ -569,6 +1515,7 @@ fn main() {             let mut r = Run::create(&dir, &copy.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()));             let idx = r.turn_begin(&command_line());+            record_usage(&mut r, "start");             r.save().unwrap();             let _ = TURN.set((dir.clone(), idx));             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &[format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "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()]);@@ -690,7 +1637,7 @@ fn main() {                 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())]);             }         }-        Cmd::Place { what, wish, moves, out } => {+        Cmd::Place { what, wish, moves, out, pause_ms } => {             thread(&cli);             let mut r = load_run(&cli);             let b = board_of(&r);@@ -729,14 +1676,21 @@ fn main() {                     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 reply = br.land_moves(&rb, &read_json(m));-                    let good = Bridge::ok(&reply);-                    r.stage_end("place-land", Some(json!({"ok": good})), 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 !good {-                        err(&format!("live placement refused: {} {}", reply["errorCode"], reply["message"].as_str().or(reply["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", reply["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again.".into()]);+                    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!("placement landed live as one undo step (revision {})", reply["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?")), &[]);+                    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 or land"), &[]),             }@@ -852,14 +1806,21 @@ fn main() {                 "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 reply = br.land_moves(&rb, &read_json(m));-                    let good = Bridge::ok(&reply);-                    r.stage_end("place-land", Some(json!({"ok": good})), 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 !good {-                        err(&format!("live placement refused: {} {}", reply["errorCode"], reply["message"]), &[format!("offendingRefs: {}", reply["offendingRefs"])]);+                    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("placement landed live as one undo step", &[]);+                    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" {@@ -891,18 +1852,19 @@ fn main() {                     let stage = format!("land-{what}");                     r.stage_start(&stage, Some("binary"), None);                     let t0 = std::time::Instant::now();-                    let (replies, accepted, stopped) = br.land_plan(&rb, &entries, *pause_ms, |i, n, rep| {-                        eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().unwrap_or(entries[i - 1]["net"].as_str().unwrap_or("?")), entries[i - 1]["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"]);+                    // one trace at a time: a net with several paths lands as several undo steps+                    let (replies, accepted, stopped, traces) = br.land_plan(&rb, &entries, *pause_ms, |i, n, entry, rep| {+                        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"]);                     });-                    write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "accepted": accepted, "replies": replies, "wallSeconds": t0.elapsed().as_secs_f64()}));-                    r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);-                    r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "stoppedAt": stopped}));+                    write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "accepted": accepted, "replies": replies, "wallSeconds": t0.elapsed().as_secs_f64()}));+                    r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);+                    r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "stoppedAt": stopped}));                     r.save().unwrap();                     if let Some(i) = stopped {                         let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live {what} stopped at entry {} of {}: {} {}", i + 1, entries.len(), 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!("Resume from entry {} once fixed: the log has every reply.", i + 1)]);+                        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!("Resume from entry {} once fixed: the log has every reply.", i + 1)]);                     }-                    ok(&format!("{what} landed live: {accepted} entries, one undo step each, {:.1} s", t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);+                    ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries, 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", &[]));@@ -961,10 +1923,22 @@ fn main() {             r.outcome(&name, json!({"pass": passed, "results": res}));             r.save().unwrap();             let 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();+            // 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 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 {-                err(&format!("{what} analysis failed:\n{}", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into()]);+                err(&format!("{what} analysis failed:\n{}", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);             }-            ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &["0.1 judges tracks by IPC-2221 and pours by presence and vias; 0.2 computes cross-sections through the filled copper.".into()]);+            ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &[draw, fields_hint, walk, "0.1 judges tracks by IPC-2221 and pours by presence and vias; 0.2 computes cross-sections through the filled copper.".into()]);         }         Cmd::Finish => {             let t = thread(&cli);@@ -1001,7 +1975,12 @@ fn main() {             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 summary = json!({"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});             r.data["summary"] = summary;             r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));@@ -1046,7 +2025,7 @@ fn main() {                 }                 "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 { err("no running recording in this run", &[]) };+                    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 (p, _) = br.record_stop(&rid, &save_to);@@ -1054,7 +2033,7 @@ fn main() {                     cap["stop"] = json!(now());                     cap["remoteFile"] = json!(p);                     cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                    let st = cap["step"].clone();+                    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));                     let local = cap["file"].as_str().map(str::to_string);                     r.mark("capture-stop");                     if let Some(f) = local.filter(|f| Path::new(f).is_file()) {@@ -1063,7 +2042,20 @@ fn main() {                     r.save().unwrap();                     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()]);                 }-                other => err(&format!("capture {other}: use open, start, mark or stop"), &[]),+                "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 => {@@ -1085,6 +2077,7 @@ fn main() {             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 } => {@@ -1096,6 +2089,359 @@ fn main() {             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 => {+            thread(&cli);+            let mut r = load_run(&cli);+            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);+            let mut r = load_run(&cli);+            let outp = if Path::new(out).is_absolute() { PathBuf::from(out) } else { dir.join(out) };+            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 } => {+            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);+            // the file lives in the run directory so the page and the video can find it later+            let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());+            let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());+            let dest = if canon.starts_with(&rdir) { canon } else { let d = dir.join(src.file_name().unwrap_or_default()); let _ = std::fs::copy(&src, &d); std::fs::canonicalize(&d).unwrap_or(d) };+            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 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}));+            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 { "It goes on the run page under this step with your caption; only images go into the 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: {}", String::from_utf8_lossy(&o.stderr).chars().rev().take(200).collect::<String>().chars().rev().collect::<String>()), &["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"), &["Say `done --message \"...\"` the moment your answer to this prompt is complete; the clock of this session stops there, and what sits idle after it is not charged to you.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into()]);+        }+        Cmd::Done { message } => {+            thread(&cli);+            let mut r = load_run(&cli);+            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}));+            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 idle cut out); {:.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, "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()), &["A session without a `done` ends at its last event; say `done` when an answer is complete so nothing idle is 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::Models => {+            let t = 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 _ = t;+            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);+            let mut missing: Vec<String> = Vec::new();+            for f in d["footprintsWithWarnings"].as_array().cloned().unwrap_or_default() {+                let fp = f["footprint"].as_str().unwrap_or("").to_string();+                let refs = f["references"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();+                let models: Vec<String> = f["models"].as_array().cloned().unwrap_or_default().iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(|s| s.to_string())).collect();+                if !models.is_empty() { missing.push(format!("{fp}{}: {}", if refs.is_empty() { String::new() } else { format!(" ({refs})") }, models.join(", "))); }+            }+            r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);+            r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing}));+            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()]);+            }+            ok(&format!("every footprint has its 3D model ({} with a model, {} expect none)", counts["withModel"], counts["noModelExpected"]), &["Next: step placement.".into()]);+        }+        Cmd::Tour { what, nets, port, on } => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            let br = match on { Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() }, _ => 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()])) };+            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 (p, _) = br.record_stop(&rid, &save_to);+                        r.data["captures"][pos]["stop"] = json!(now());+                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                        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 (p, _) = br.record_stop(&rid, &save_to);+                        r.data["captures"][pos]["stop"] = json!(now());+                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                        let local = r.data["captures"][pos]["file"].as_str().map(str::to_string);+                        r.log("clip-stop", json!({"step": step, "visit": visit, "recordingId": rid, "file": p}));+                        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!({}));+                    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() {+                        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()]); }+                    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("desktop_list_windows", &json!({}));+                        // the relay wraps some answers as a JSON string under `output`+                        let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);+                        let d = lw.get("data").cloned().or_else(|| inner.get("data").cloned()).unwrap_or(lw.clone());+                        let wins = d["windows"].as_array().cloned().or_else(|| lw["windows"].as_array().cloned()).or_else(|| inner["windows"].as_array().cloned()).unwrap_or_default();+                        // the app's window, not a wiki page about it: the app titles itself exactly "Adom Fields"+                        hwnd = wins.iter().find(|w| w["title"].as_str().map(|t| (t == "Adom Fields" || t.starts_with("Adom Fields - ")) && !t.contains("Wiki") && !t.contains("wiki")).unwrap_or(false)).and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())));+                        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 the test box (no window titled Adom Fields{})", 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()]); };+                    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 (p, _) = br.record_stop(&rid, &save_to);+                        r.data["captures"][pos]["stop"] = json!(now());+                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                        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);+                    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 (p, _) = br.record_stop(&rid, &save_to);+                        r.data["captures"][pos]["stop"] = json!(now());+                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                        let local = r.data["captures"][pos]["file"].as_str().map(str::to_string);+                        r.log("clip-stop", json!({"step": step, "visit": visit, "recordingId": rid, "file": p}));+                        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}));+                    // 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);+                    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()]);+                }+                other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),+            }+        }         Cmd::Ledger { json: as_json } => {             let r = load_run(&cli);             let led = r.ledger();@@ -1117,6 +2463,8 @@ fn main() {                         "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("")),+                        "done" => println!("{t}  done   {:>2}  {}", e["n"], e["message"].as_str().unwrap_or("")),                         _ => {}                     }                 }@@ -1129,6 +2477,7 @@ fn main() {             let 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());             let known: Vec<&str> = known.iter().map(String::as_str).collect();             let prev = r.data["currentStep"].as_str().map(str::to_string);+            record_usage(&mut r, &format!("step:{name}"));             r.step_set(name, *back, why.as_deref());             // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)             let mut clip_note = String::new();@@ -1159,7 +2508,8 @@ fn main() {                     }                     clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));                 }-                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 fields step films the app's window through `tour fields`: no editor clip for it+                let hwnd = if name == "fields" { None } 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()) };                 let disk = disk_check(&dir, &br, name);                 if let Err(e) = &disk {                     r.log("clip-refused", json!({"step": name, "why": e}));@@ -1180,8 +2530,19 @@ fn main() {                             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"),                     None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),                 }             }@@ -1196,9 +2557,13 @@ fn main() {             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}") }), &[hint, record_hint, back_hint, report_note]);         }-        Cmd::Deliver { video, message } => {+        Cmd::Deliver { video, message, accept_suspect } => {             thread(&cli);             let mut r = load_run(&cli);+            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 qualified board first, then the video and your message.".into()]);             }@@ -1207,17 +2572,23 @@ fn main() {                 err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);             }             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));             r.data["delivery"] = json!({"at": t, "video": std::fs::canonicalize(vp).map(|p| p.display().to_string()).unwrap_or(video.clone()), "videoBytes": bytes, "message": message});             r.data["summary"]["promptToDeliveredMinutes"] = json!(r.delivered_minutes());             r.data["summary"]["steps"] = Value::Object(r.step_report());             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"]})).collect::<Vec<_>>()).unwrap_or_default());-            r.log("deliver", json!({"minutes": r.delivered_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));+            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).");+            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("; ")));
crates/adom-aiflow/src/silkscreen_sections.rs+1
@@ -432,3 +432,4 @@ mod tests {         assert_eq!(r["sections"][0]["edgeFallback"], false);     } }+
flows/board.json+92−12
@@ -1,27 +1,85 @@ {   "name": "board",   "title": "The board flow: parts placement to a qualified board",-  "scope": "Starts at the 3D models and parts placement on a board whose schematic, libraries and part choices already exist. Ends when the board is 100 percent routed, DRC-clean, poured, its copper measured, its current and thermal analyses passed, its nets and its 3D view walked through on camera, and the AI has delivered the video.",+  "scope": "Starts with component identity, reusable CAD review and native library assembly for an existing schematic/board. Reviews the selected model variants on camera before placement, then routes, pours, analyzes and delivers a bounded final video.",   "steps": [     {       "name": "intake",       "who": "ai",-      "does": "read the board and the spec, write the spec from the schematic if it is missing, plan",-      "record": "nothing on screen yet: the clip is the board opening on the test box (capture open) and the spec being read"+      "does": "read the board and the spec, write the spec from the schematic if it is missing, plan; offer the optional Hydrogen progress widget (default off; enable when requested), reusing saved milestone images without extra AI calls",+      "record": "nothing on screen yet: the clip is the board opening on the test box (capture open) and the spec being read",+      "workflow": [+        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences."+      ]+    },+    {+      "name": "components",+      "who": "ai",+      "does": "Audit wiki component identity, reusable CAD quality and redistribution evidence before placement; offer cached optional MPN-marked variants.",+      "binary": [+        "components"+      ],+      "workflow": [+        "Inventory every reference from the actual board and schematic: manufacturer, MPN, supplier code, package, value and ratings; group repeated exact parts and record their references. Do not invent an MPN or silently substitute a similar value/package. Distinguish populated parts, DNP parts and bare copper/mechanical features; a bare test pad needs no purchased component or fictitious 3D body.",+        "Search the wiki FIRST for each exact manufacturer/MPN and supplier code; reuse the matching component page and inspect its symbol, footprint and STEP/WRL assets. A page existing is not proof its CAD bundle is complete. Record the page URL and missing assets per reference.",+        "For missing identities or assets, use adom-parts-search next, then original manufacturer and distributor websites. Use Pup to navigate and download through the automated browser when curl/fetch is blocked or the site requires JavaScript; do not treat a blocked fetch as proof the part is unavailable. Read the relevant parts-search and Pup skills for current commands.",+        "Verify manufacturer, exact ordering code, package dimensions, pin numbering and required electrical ratings against source evidence before accepting CAD or a candidate part. Preserve source URLs and provenance. Label generated or approximate models as such; never present an approximate body as a verified vendor model.",+        "Global component pages are shared resources for ALL ADOM USERS, never a board-specific BOM dump. Create one only for a distinct reusable manufacturer part or independently specified reusable custom component, after searching for duplicates. Write a part-focused page with portable verified assets and provenance, and improve existing pages. Keep board references, unresolved identities, one-off land patterns, bare copper test pads and project-only assemblies inside the board project wiki page; do not create catalog placeholders or rename a one-off feature to make it appear global.",+        "Use portable project/library model paths, preserve placement and routing, rerun kicad_model_check and inspect the saved board in native KiCad 3D. Report reference coverage, reused/created page URLs, unresolved identities and missing CAD separately. A successful download or wiki publication alone does not close a missing-model finding.",+        "Judge quality per component, not per board screenshot: publish a linked visual register with top, bottom and oblique views. Check dimensions, units, terminal count and pitch, pin-1/polarity, body/pad alignment, standoff, materials and visible details. File resolution alone is not quality. Record pass, needs-work, reference-only or unknown with evidence and limits.",+        "Prefer manufacturer CAD when the source permits the intended redistribution. Keep third-party/Ultra Librarian downloads as private reference-only inputs unless redistribution is explicitly permitted. Compare independently generated models against manufacturer drawings and permitted reference views; retain dimensional deviations and source hashes. Converting, extracting, recoloring, or etching a restricted model does not make it independently authored or license-cleared.",+        "Offer optional MPN marking using adom/adom-chip-laser or the current adom-step2glb laser-etch service. Ask whether to enable it; default to unmarked models until the user chooses. Check the shared page cache first. Preserve the plain STEP and publish an additional marked STEP, derived GLB and reviewed thumbnails only for assets eligible for redistribution. Keep the mark clear of pin-1, polarity, terminals and optical/mechanical features. Marking is an identification aid, not evidence of actual factory top-marking, especially on tiny passives.",+        "Cache reviewed artifacts on the existing global component page: source/plain STEP, optional MPN STEP, GLB, thumbnails and machine-readable provenance. Record input hash, generator/tool version, parameters, units, transforms, marking text/mode, reference evidence and review results. Cache keys must change when geometry, text or generator parameters change. Keep board-only transforms and mappings in the board project. Never claim cross-EDA parity without rendering the variant in the named native viewers.",+        "When publishing or improving a component page, make per-file provenance mandatory even though creating a new page is optional. Record original source URL/file and revision, retrieval date, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator/version and parameters, units/transforms, redistribution evidence and limitations. For AI-created geometry cite the actual datasheet page/figure/table and dimensions used, list simplifications and reference comparisons, and never present copied/extracted CAD as independent work. State which checks ran and which remain unverified; retain plain and marked variant lineage. Put a readable provenance section on the page plus a machine-readable asset record and a link to that component's issue tracker. Unknown provenance stays unknown, not a fabricated source. Reuse/improve existing pages first; offer new global-page publication only for reusable components, keeping board-specific records in the project.",+        "For optional MPN marking, fit the text along the longest usable top-face direction with the largest legible size, respecting pin-1 and mechanical features. Compare both orientations and retain the package coordinate frame. Cache and identify the marked variant explicitly.",+        "Offer the optional Hydrogen progress widget. If enabled, reuse saved component, marked-model, symbol and later board/analysis thumbnails via widget event; do not generate extra screenshots or call a model solely for the widget. Respect widget disable immediately.",+        "Before accepting a wiki component, read its actual native model_3d and STEP bindings and hash the referenced downloads against the reviewed variants. Wait for the native viewer to finish painting, then inspect the visible body and readable mark; nonzero meshes or a successful HTTP request do not prove a painted model. Check the static hero too. Use a descriptive manufacturer/MPN/function title in both page.json and package.json; follow wiki-component and preserve other contributors and original files.",+        "A resolved 3D file may still be hidden by native EDA appearance filters (for example KiCad excludes models not in position files). Check those filters before inventing replacement parts or changing BOM/placement flags. Keep bridge-specific visibility, binding and native audit operations in the owning bridge; report missing capabilities there. Explicit rights-holder permission may authorize a derivative despite a restrictive package default; record who authorized what and its scope, without extending it to third-party assets.",+        "Read eda-component-hero and eda-led-appearance from the shared EDA skillpack. Component wiki heroes need separate nonphysical teal dashed COPPER boundaries (alpha 0.50), actual silk (0.30), and mapped signal labels (0.50); preserve physical pin1, MPN, animations and lights. Keep board-use models clean. Run hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>, then inspect published top/underside/pin1 and native LED surfaces. Raised LED skins must have verified non-coplanar clearance, including the MPN; STEP color is not a light. Record source, footprint, pin map, font and generator hashes. Never claim structural checks establish visual or electrical qualification."+      ]+    },+    {+      "name": "libraries",+      "who": "ai",+      "does": "Assemble and validate the reviewed symbols, footprints and selected model variants in the chosen EDA before placement.",+      "workflow": [+        "Use the components register to build a portable project library for the actual EDA (KiCad, Altium or Fusion). Verify symbol pin numbers against footprint pads, package dimensions and pin-1/polarity; resolve model transforms and selected plain or marked STEP paths. Record native tool/version and evidence per part.",+        "Keep board reference mappings and native project transforms in the board project, while reusable source CAD and provenance remain on their existing global component pages. Do not claim native EDA support from a GLB gallery or a file extension alone.",+        "Return to components with --back --why when identity, rights or geometry cannot be resolved; preserve honest unknowns and report the affected references."+      ],+      "record": "Native library review with the selected symbol, footprint and model; saved verification evidence belongs with the library register."     },     {       "name": "models",       "who": "ai",-      "does": "every footprint on the board has its 3D model resolved (kicad_model_check): fetch the vendor STEP, build one, or fix the path, so the board renders as it will be built; a bare footprint in the 3D view is a missing model",+      "does": "Check native board model resolution and inspect the selected library variants in the chosen EDA; use the component register for unresolved assets.",       "binary": [         "models"       ],-      "record": "nothing to film: the model check's list and the fixes; the 3D walkthrough later is the proof"+      "record": "Native board 3D inspection and model-check evidence; the separate library-tour step reviews each selected component.",+      "workflow": [+        "Use the components register and its reviewed plain or explicitly selected marked variants; fix portable model paths, run kicad_model_check, then inspect the native board render. Do not substitute a model merely to make the missing-file gate pass.",+        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences.",+        "Read eda-component-hero and eda-led-appearance from the shared EDA skillpack. Component wiki heroes need separate nonphysical teal dashed COPPER boundaries (alpha 0.50), actual silk (0.30), and mapped signal labels (0.50); preserve physical pin1, MPN, animations and lights. Keep board-use models clean. Run hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>, then inspect published top/underside/pin1 and native LED surfaces. Raised LED skins must have verified non-coplanar clearance, including the MPN; STEP color is not a light. Record source, footprint, pin map, font and generator hashes. Never claim structural checks establish visual or electrical qualification."+      ]+    },+    {+      "name": "library-tour",+      "who": "ai",+      "does": "Review each selected component model in motion and produce a separate short library overview for the final video.",+      "workflow": [+        "Use tools/library-tour.py with the reviewed library.json, --run and --ai-thread. The manifest must identify the same plain or explicitly chosen marked variants and hashes that the native board uses. A marking choice does not authorize silent plain-model fallback.",+        "Inspect the detailed orbit tour and the moving overview in a rendered browser. Reject blank, clipped, wrong-variant or static clips; retain raw recordings and contact sheets. The detailed tour stays separately linked for component review.",+        "Budget approximately five seconds for the moving overview within the entire final video limit of 120 seconds. Do not insert the whole BOM walkthrough or one wiki-page shot per part into the final video. Optional scrolling wiki-page clips belong in the separate detailed review.",+        "Automatic overview selection in released compose requires the complete maintained release source (issue #16); until integrated, report that limitation explicitly and keep the reviewed clips and selection manifest available for integration.",+        "Read eda-component-hero and eda-led-appearance from the shared EDA skillpack. Component wiki heroes need separate nonphysical teal dashed COPPER boundaries (alpha 0.50), actual silk (0.30), and mapped signal labels (0.50); preserve physical pin1, MPN, animations and lights. Keep board-use models clean. Run hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>, then inspect published top/underside/pin1 and native LED surfaces. Raised LED skins must have verified non-coplanar clearance, including the MPN; STEP color is not a light. Record source, footprint, pin map, font and generator hashes. Never claim structural checks establish visual or electrical qualification."+      ],+      "record": "Two separate raw clips: a detailed component orbit tour and a roughly five-second moving overview. No title pages or baked captions; the final composer supplies its own overlays."     },     {       "name": "placement",       "who": "ai",-      "does": "place the parts for routability, current and heat; the binary packs, checks courtyards and lands moves",+      "does": "place the parts for routability, current and heat; the binary packs, checks courtyards and lands moves; reserve visible space for connector labels and the two-sided service silkscreen",       "binary": [         "place pack",         "place check",@@ -94,6 +152,29 @@       ],       "record": "the editor with one net lit at a time, framed; the markers say which net"     },+    {+      "name": "silkscreen",+      "who": "ai",+      "does": "Make both faces useful in real service: references and values, verified connector and machine-contact pinouts, polarity, board identity and bring-up labels; review native plots and assembled visibility. Offer the optional shared silkscreen dashboard: real solver events, candidate reasons, live/replay separation, fitted-model inspection and separately recorded detailed/5s replays. Audit every fitted body and every via/hole across the whole board, both faces, with coverage counts. Missing Fab geometry is not free space; native DRC does not establish fitted visibility. Keep unresolved labels explicit and do not apply an incomplete layout. Native geometry/edit/undo/refresh belongs to the EDA bridge.",+      "workflow": [+        "Read the InstaPCB silkscreen skill for the selected process; reserve label space during placement and finalize after copper and analysis stabilize. Use both faces, a clear font-size hierarchy, and the approved process profile for small secondary text.",+        "Build a source-backed label manifest from actual schematic, pad numbers, nets and approved requirements. Include reference/value pairs, connector pinouts, power polarity, test points, switch/LED functions, revision and documentation link. Never infer voltage/current ratings from net names or component absolute maxima.",+        "For every machine pin, machine contact and edge-pin connector, repeat its reference/pin number and verified signal or power function on BOTH faces. A mounted board may expose only one side during debugging. Put the repeated labels beside the same physical connection where possible; when crowded, use a short clear leader to the actual connection on that face; a remote keyed legend is supplementary only. A pinout table on the other face alone does not satisfy this check. Review both faces in the mounted-access context, with bottom text correctly mirrored and pin numbering preserved.",+        "Every test point MUST have visible silkscreen identifying both its reference and verified net/signal or measurement function. Prioritize these labels before ordinary component values. Keep them adjacent to the accessible probe pad, or use a short unambiguous leader/key on the same accessible face when crowded. Check complete test-point coverage against the actual board; missing or ambiguous labels are unresolved findings, never silently omitted. Repeat on the opposite face when useful for mounted-board debugging, without implying a probe pad exists there.",+        "Keep each reference unmistakably associated with its own component. Prefer reducing reference font size locally over moving a label farther away. Aim for complete reference coverage; use a short clear leader only when proximity alone is ambiguous. Treat approximately 0.8 mm as an initial reference size, not a minimum. For dense InstaPCB artwork Adam explicitly permits secondary values at 0.3 mm or even 0.2 mm (2026-09-16); try 0.5, 0.3 then 0.2 mm while preserving the ref/value pairing and required stroke/spacing. These tiny sizes are user-requested artwork options, not independently verified laser-process capability. Keep the actual sizes and any unresolved physical legibility/DFM limits in the review; do not silently omit labels or globally weaken fab rules. Inspect the result at actual size and close-up.",+        "Avoid mask openings, contact surfaces, holes, fiducials and bodies that hide labels. Inspect bottom mirroring, actual-size legibility and both native 2D/3D faces. Use EDA bridge text/plot/DRC operations; give missing primitives back to the bridge.",+        "Run native DRC against the chosen fab profile and compare with the baseline. Preserve connectivity, placement, copper, outline and model transforms for silk-only edits. Register the label manifest and top/bottom evidence; return here when placements or pinouts change.",+        "Record BEFORE the first silkscreen mutation: show labels appearing individually or in small meaningful groups, pairing references with smaller values. Film actual moves, resizing, rotations and overlap corrections in order; preserve a timestamped operation/reason sidecar and raw uncaptioned footage. Use native bridge edits and refresh, never invented verbs. If native incremental editing is missing, file the bridge gap; identify any checkpoint reconstruction as a replay, never as original live placement. Keep a detailed action cut and use 3\u20135 seconds of accelerated population/rework in the final 120-second film. Read docs/silkscreen.md for recording and evidence rules.",+        "Require positional contact labels on BOTH faces: a pinout table is supplementary, never a substitute for text beside each actual machine contact, machine pin or edge connection. Separate primary reference (MC10) and smaller secondary function (DSHOT) as independently sized paired text. Prefer consistent reading directions. Where association remains ambiguous, add a short gentle curved silkscreen leader ending outside the intended pad mask opening; avoid crossings and obstacles. Verify one-to-one pad association, bottom mirroring, label/leader clearance and legibility in close-up native views and DRC. Film real additions and rework; retain mapping and unresolved constraints. See docs/silkscreen.md.",+        "For the requested InstaPCB profile, attempt a nearby value for EVERY resistor and capacitor, including rotated components and references. Search both orientations and adjacent sides at 0.5, 0.3 and 0.2 mm as needed, preserving an unmistakable reference/value association. Do not skip values merely because the reference is rotated, an initial placement fails, or a bottom table exists. Repack nearby silk or use a clear short leader when necessary. Audit actual-board value coverage and report each unresolved value explicitly; a back-side key is supplementary, not completion. Retain native mask/overlap checks and distinguish requested artwork sizes from measured physical legibility.",+        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences.",+        "Search space before shrinking text: 0.2 mm is a last resort. Use native text bounds and per-face body/pad/mask/via/silk obstacles, keep separate boxes for ref/value pairs, repack neighboring labels, preserve pin-row order and validate leader paths. Run silkscreen-layout on candidates; bounded search is not native verification. Apply by stable item ID, compare native DRC with baseline, inspect fitted visibility, and refresh BOTH native views. See docs/silkscreen.md."+      ],+      "record": "Native incremental label population on both faces, individual ref/value pairs or meaningful groups, plus real rework. Raw uncaptioned clip and timestamped edit sidecar; detailed action cut separate from the 3\u20135 second final-video excerpt. Clearly label reconstructed replay.",+      "hints": [+        "Final documentation pass: prefer open interior regions for a verified board description and ordered left-aligned service blocks. Physical pin/contact labels remain local. Apply docs/silkscreen-priority-pass.md; inspect interior alternatives at readable fonts and report explicit edge fallbacks. Native Rust silkscreen-sections performs the documentation solve; preflight, native DRC and both EDA views still gate acceptance."+      ]+    },     {       "name": "3d",       "who": "binary",@@ -101,7 +182,10 @@       "binary": [         "tour 3d"       ],-      "record": "the 3D Viewer window itself: the board turning, the components up close; full of motion, so the action cut keeps most of it"+      "record": "the 3D Viewer window itself: the board turning, the components up close; full of motion, so the action cut keeps most of it",+      "workflow": [+        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences."+      ]     },     {       "name": "capture",@@ -129,13 +213,9 @@   "returns": "any step may send the AI back to any earlier step: step <name> --back --why \"...\"",   "later": [     {-      "name": "components",+      "name": "part-selection",       "does": "choose the parts from the requirements; SPICE may send the AI back here"     },-    {-      "name": "libraries",-      "does": "symbols, footprints, 3D chips for every part"-    },     {       "name": "schematic",       "does": "the schematic, and the spec that falls out of it"
install.sh+4−4
@@ -3,8 +3,6 @@ set -e HERE="$(cd "$(dirname "$0")" && pwd)" mkdir -p "$HOME/.local/bin" "$HOME/.claude/skills/adom-aiflow" "$HOME/.claude/skills/aiflow-measurement" "$HOME/.claude/skills/aiflow-comparison-video"-cp "$HERE/bin/adom-aiflow" "$HOME/.local/bin/adom-aiflow"-chmod +x "$HOME/.local/bin/adom-aiflow" cp "$HERE/skills/adom-aiflow/SKILL.md" "$HOME/.claude/skills/adom-aiflow/SKILL.md" cp "$HERE/skills/aiflow-measurement/SKILL.md" "$HOME/.claude/skills/aiflow-measurement/SKILL.md" cp "$HERE/skills/aiflow-comparison-video/SKILL.md" "$HOME/.claude/skills/aiflow-comparison-video/SKILL.md"@@ -18,8 +16,8 @@ ln -sf "$HERE/tools/silkscreen-dashboard/server.py" "$HOME/.local/bin/adom-aiflo  # Isolated geometry dependencies; do not change the user's system Python packages. AIFLOW_PY_ENV="$HOME/.local/share/adom-aiflow/python"-python3 -m venv "$AIFLOW_PY_ENV"-"$AIFLOW_PY_ENV/bin/python3" -m pip install --disable-pip-version-check -r "$HERE/tools/requirements-silkscreen.txt"+python3 -m venv --without-pip "$AIFLOW_PY_ENV"+python3 -m pip --python "$AIFLOW_PY_ENV/bin/python3" install --disable-pip-version-check -r "$HERE/tools/requirements-silkscreen.txt" for helper in aiflow-widget library-tour; do   chmod +x "$HERE/tools/$helper.py" done@@ -33,3 +31,5 @@ for skill in "$HERE"/skills/*; do     cp "$skill/SKILL.md" "$HOME/$consumer/skills/$name/SKILL.md"   done done++install -m 755 "$HERE/bin/adom-aiflow" "$HOME/.local/bin/adom-aiflow"
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.25",+  "version": "0.1.26",   "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/adom-aiflow/SKILL.md+2−1
@@ -1,6 +1,7 @@ --- name: adom-aiflow-description: The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+description: >-+  The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. ---  # adom-aiflow: the loop, measured
skills/aiflow-comparison-video/SKILL.md+2−1
@@ -1,6 +1,7 @@ --- name: aiflow-comparison-video-description: How to make the comparison video and page for two AI Flow runs (two engines, one board) with adom-aiflow: pull the other run, cut the side-by-side with `compose --with`, write the narration script the words lead (the setup, what each step is, who won each step, the overall winner, how the clock was measured), never dead air, then `compare --push` for the sub-README that leads with the video and links both runs. Trigger words: comparison video, side by side, fable vs astra, two engines, compare runs, narration script, comparison page, who won.+description: >-+  How to make the comparison video and page for two AI Flow runs (two engines, one board) with adom-aiflow: pull the other run, cut the side-by-side with `compose --with`, write the narration script the words lead (the setup, what each step is, who won each step, the overall winner, how the clock was measured), never dead air, then `compare --push` for the sub-README that leads with the video and links both runs. Trigger words: comparison video, side by side, fable vs astra, two engines, compare runs, narration script, comparison page, who won. ---  # The comparison video and page (adom-aiflow)
skills/aiflow-measurement/SKILL.md+2−1
@@ -1,6 +1,7 @@ --- name: aiflow-measurement-description: How an adom-aiflow run is measured, in John's words (2026-09-14). The number is the wall clock from the human's first prompt to the AI saying "done, here is your video", and the per-step cost is the AI's thinking, not the binary's seconds. Every command is a turn in run.jsonl with the thinking gap before it in an append-only run.jsonl; every return to an earlier step (placement moved because a pour had bad heatsinking) is recorded with its reason. Read this before you start, run, report, or compare a aiflow run, and before you hand a run to another engine. Trigger words: aiflow stats, measure the run, how long did each step take, prompt to done, AI thinking time, iterative rework, go back to placement, fable vs codex comparison, deliver the video, run.json ledger.+description: >-+  How an adom-aiflow run is measured, in John's words (2026-09-14). The number is the wall clock from the human's first prompt to the AI saying "done, here is your video", and the per-step cost is the AI's thinking, not the binary's seconds. Every command is a turn in run.jsonl with the thinking gap before it in an append-only run.jsonl; every return to an earlier step (placement moved because a pour had bad heatsinking) is recorded with its reason. Read this before you start, run, report, or compare a aiflow run, and before you hand a run to another engine. Trigger words: aiflow stats, measure the run, how long did each step take, prompt to done, AI thinking time, iterative rework, go back to placement, fable vs codex comparison, deliver the video, run.json ledger. ---  # Measuring a aiflow run