← Commit history
AGENTS.mdadded+11
@@ -0,0 +1,11 @@+# AI Flow development++Astra is the development thread for this repository per Adam’s handoff on 2026-09-17. Preserve other contributors’ work and the recorded run ledgers.++Before release, compare the rebuilt CLI against the current installed package help. Missing existing commands are a release blocker. Package version, Cargo version, source commit and installed executable must agree. Source and registry publication are separate steps.++Run Cargo workspace tests, component/widget integration tests, silkscreen geometry tests, and native EDA/read-back and video playback checks appropriate to each change. Preserve unresolved review items; collision checks are not manufacturing qualification.++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.
README.md+8
@@ -9,6 +9,14 @@ adom-aiflow --version  > Screenshots and video clips on this page marked **after the first run** are placeholders until the first measured run under Fable 5.1 lands. The comparison numbers are examples of what the ledger produces, not results. +## 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.++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.++**[Read the silkscreen flow and watch the 58-second ESC demonstration](docs/silkscreen-dashboard.md).** The guide covers the geometry inputs, ordered AI review passes, recording, native validation and remaining adapter limits. This is an AI-assisted workflow with deterministic layout/preflight helpers, not a guarantee that an arbitrary board is production-ready. KiCad was exercised on the ESC; Fusion/Altium adapters remain future work.+ ## The final video  The evidence of a run is its video, two minutes at most: only the moments something moved, every step in order, the AI's own current density and temperature rise drawings fullscreen, the run and step clocks in the corner, the words cut to the picture. Every run's page carries its own; the pair is the piece people share.
SKILL.md+76−1
@@ -24,11 +24,27 @@ The flow is a file, `flows/board.json`: the steps in order (intake, models, plac 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again. 8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`. +## The fields step (adom-fields)++After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`.++## Time: sessions, not the wall clock++The AI's time on a run is the sum of its sessions: one per human prompt, from the prompt to the AI's done, with idle gaps (over 15 min, no command running) cut out. The first prompt is `start --prompt-time`; every follow-up prompt is `prompt --text "..."` (`--at <time>` when you start late); the answer's end is `done --message "..."` (`deliver` is the done of the first task). Say `done` the moment an answer is complete, so nothing idle is charged; work after a done with no `prompt` mark is listed as an unmarked follow-up, one row per idle gap. The delivery number on the page is the AI time from the first prompt to `deliver`; follow-ups have their own rows. `sessions` prints the table. The per-step table and the video's run clock ("AI TIME, THIS RUN") count active seconds only.++## Landing is one at a time, on purpose++`place land` moves ONE PART per call and `land route` draws ONE TRACE per call (a net with several paths lands as several undo steps), each its own moment on camera, `--pause-ms` apart (600 by default). That is what makes the video show the board filling part by part and every trace being drawn, instead of an empty board that is suddenly full. It costs wall time: the ESC lands 122 parts and about 320 traces at roughly five seconds each, so budget half an hour for the routing landing and a few minutes for the placement. Never batch them to save time; the clip is the evidence. `--pause-ms 300` is the shortest pace the recorder still catches.+ ## Capture, so the engines' videos line up  One clip per step. `capture open` puts the board on the test box; from then on every `step <name>` stops the previous step's clip and starts this step's own window recording, tagged with the step, and its hint says what that clip should show (the flow file's `record` line: the parts landing for placement, the nets landing for routing, the pours filling for pours, the return to an earlier step when an analysis fails). `deliver` lists the clips. The final video is cut from them, one segment per step, so two engines' videos line up step for step, and the page can show a little clip beside every step's numbers. -Per-step clips stay RAW: the recording, its 10x cut and its motion-only action cut carry no captions, because the final video (and a side-by-side of two runs) draws its own overlays from the ledger. `compose` builds the final 1920x1080 video: each step visit's 10x cut, decimated to motion, with the step chip, the engine and board, and the run and step timers as small hr / min / sec tables in the lower right (Adom faces), narrated by adom-tts from the ledger. No recording may outlive an hour (a hard cap on every recording), `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget: a recorder left running is a mistake, not evidence.+Per-step clips stay RAW: the recording, its 10x cut and its motion-only action cut carry no captions, because the final video (and a side-by-side of two runs) draws its own overlays from the ledger. `compose` builds the final 1920x1080 video, two minutes at most (`--max-seconds 120`): each step visit's motion only, in order, with the step chip, the engine and board, and the run and step timers as small hr / min / sec tables in the lower right (Adom faces); the AI's analysis drawings fullscreen for five seconds each; adom-tts narration cut to fit each segment (the picture leads, the words are shortened or said faster, never the other way round); when the motion adds up to more than the budget every frame plays faster, evenly. No title pages, no held frames. A visit with under a second of motion stays on the page and out of the video. The page carries each cut under a new name (`final-<time>.mp4`) so a cached one never shows.++After `analyze current` and `analyze thermal`, do the analysis yourself and draw it: a current density map on the copper (a DC conduction solve on the board's filled polygons between each loaded net's terminals) and for heat two drawings: the heat flow in the copper layer by layer, like the current picture (a multi-layer solve, each chip's watts spreading on its layer and going down its vias, the vias drawn, each chip's split between layer, vias and bare dielectric), then the temperature rise over the whole board (the FR4 spreading); each a 1920x1080 PNG, then `artifact --kind analysis-image --file <png> --caption "..."` for each (`--replaces <old png>` when you redo one; it goes fullscreen into the video and onto the page under the step), then `tour nets --nets <the pour nets the hint lists>` under the same step so the clip ends on the copper the analysis judged, lit net by net.++No recording may outlive an hour (a hard cap on every recording), `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget: a recorder left running is a mistake, not evidence.  `capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers. @@ -65,3 +81,62 @@ Every clip stop runs a blank check (nine sampled frames; a clip whose frames are - `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting. - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper.++## Component coverage before placement++The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.++Publish missing identified component pages with verified symbol/footprint/model assets and provenance; improve existing pages instead of duplicating them. Do not invent identities for generic land patterns, DNP parts or bare copper test pads, or silently substitute a similar part. Record unresolved identities and missing assets separately. Generated/approximate bodies must be labelled. Keep model paths portable, preserve layout, rerun `kicad_model_check`, and visibly inspect native KiCad 3D before calling model coverage complete. The AI performs this sourcing audit; these instructions do not add an automatic sourcing or component-identity gate.++Global component pages serve ALL ADOM USERS. Publish only distinct reusable manufacturer parts or independently specified reusable custom components, after duplicate checking. Keep page titles and assets part-focused and portable. Board reference mappings, unresolved identities, one-off footprints, bare copper features and project-only assemblies belong in the board project wiki page, not new component catalog entries. Do not turn project-specific placeholders into generic-looking pages merely by renaming them.++## Early component quality and optional MPN marking++Before placement, declare `step components` and run `components` with your `--run` and `--ai-thread`. It writes `components.json`, inventories every board reference and preserves review notes on rerun. This is an AI review register, not an automated geometry or licensing certification. Record wiki links, classification, dimensions, terminals, pin-1/polarity, plain/marked variants, top/bottom/oblique visual evidence and redistribution evidence. Unknowns remain unknown.++Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project.++Offer `components --etch on` or `--etch off` (initial default `ask` is not consent). Discover adom/adom-chip-laser and the current STEP engine. Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.++## Provenance is required for shared component assets++Creating a new global component page is optional; truthful per-file provenance is required when publishing or improving one. Reuse and improve existing pages first. Keep board-only definitions in the board project. Add a readable provenance section and machine-readable asset record, linked prominently from the component README (including HTML READMEs without removing their existing content).++For every symbol, footprint, STEP, GLB and optional marked variant, record the source URL/file/revision and retrieval time, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator and version, parameters, units, transforms, modifications, redistribution evidence, checks performed and unresolved limits. A hash proves file identity, not quality or permission. Existing wiki availability alone does not establish upstream authorship or rights.++For AI-created geometry, explicitly say it is AI-created and identify the datasheet page/figure/table, dimensional numbers and assumptions used by the generator. Publish the generator or reproducible parameters, disclose simplifications, and distinguish independent geometry from a converted/extracted reference. Keep restricted reference CAD private; document what the comparison tested and did not test. Preserve the lineage from plain input to marked STEP to GLB, including full marking text/mode.++Link to the component page's issue tracker and ask reviewers to include the filename/hash, disputed dimension or pin, source evidence, EDA/version and screenshot. Never fill provenance gaps with plausible guesses. Unknown origins and failed or missing validation remain visible until supported by evidence.++## Optional live progress++Offer the Hydrogen progress widget at intake; default off unless requested. Use `widget enable`, then open AI Flow progress in Hydrogen Widgets. Reuse saved milestone PNGs through `widget event --file <inside-run image> --label <milestone>`; the service also follows saved ledger artifacts and step changes. Use it for component models, longest-axis MPN variants, symbols, placement, pours and Fields. `widget disable` stops updates and thumbnail work. No AI calls or extra captures are required; the progress bar counts planned steps, not remaining time. See widgets/README.md.++## Keep both native views current after every update++After EVERY board, footprint, silkscreen, library-binding or 3D-model update, update BOTH the native 2D board editor and its associated 3D viewer before reporting or showing the result. A file write, successful transfer, DRC result or web preview is not a refreshed native view. Verify the exact saved board path/revision in the editor, then regenerate/reload the 3D view and inspect the changed features after painting settles. Capture evidence from both exact windows; confirm models, markings and layer visibility, not only the window titles.++Use supported native refresh/reload commands through the owning EDA bridge. If an offline edit or model cache requires closing and reopening, inspect unsaved changes and dialogs first, preserve user work, close only task-owned stale windows, reopen the latest board, and open its linked 3D viewer. Never save stale editor contents over a newer disk revision. Keep one current editor/viewer pair rather than accumulating old windows. Re-discover HWNDs after reopening; preserve the user's foreground and view preferences unless showing a view was requested. Refresh a completed edit or coherent batch promptly; do not wait until the final video. If either view cannot be verified, state which one remains stale and resolve it before claiming the update is shown.++## Shared component artwork and label placement++Read `eda-component-hero` and `eda-led-appearance` from `adom/eda-engineering` before creating or revising component heroes or LED variants. The existing wiki-component contract remains applicable. Use `hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>` for source-preservation and structural checks, then perform the published visual review. Never put hero-only footprint helpers in board model files.++For silkscreen, read `docs/silkscreen.md`: measure native stroke bounds, search and repack before shrinking, preserve ref/value association and per-face pin positions, and treat 0.2 mm as exceptional. `silkscreen-layout` runs the shared candidate search; `tools/silkscreen-leaders.py` can reserve leader routes first. Native edits and verification belong to the bridge.++### Complete obstacle coverage is required++Before placing or accepting silkscreen, enumerate every fitted model, via and drilled hole across the entire board, on both faces. Record coverage counts and stable IDs. A local search region does not excuse leaving the rest of the board unchecked. Project the actual fitted 3D model into the board plane with its scale, offset, rotation and footprint transform; include overhangs. Conservative transformed model bounds are acceptable when a tighter silhouette is unavailable. F.Fab, courtyard or pad envelopes are fallbacks, not proof of fitted-body coverage: a footprint can have no Fab drawing, and the space between separated pads can still be occupied by the body. Missing model geometry must be reported and supplied a documented conservative envelope, never a tiny placeholder that silently allows text beneath the component.++Reserve every via hole on both faces, including tented vias when visible labeling is the requirement, and use the larger of the hole keepout and actual exposed mask opening. Also reserve through holes, slots, exposed pads and existing silk. Check actual stroked text extents, not just its anchor. After each re-layout, audit ALL existing labels and leaders against this complete obstacle set. A collision-free position is not enough: keep each reference/value next to its own component or add a clear, noncrossing leader. Do not move a label beside a different component merely to clear an obstacle. Native DRC and fitted 3D visibility are separate acceptance checks; a DRC baseline does not establish readable assembled silkscreen. Refresh and inspect both native 2D and 3D views before presenting the result.++## Optional live silkscreen dashboard++Offer `silkscreen-dashboard show` with the run and ai-thread flags. The shared observer renders real timestamped solver events, sampled candidate bounds and reasons, selected proposals and unresolved labels, in 2D and a fitted-model 3D preview. `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` emits these without AI/provider calls or deliberate solver pauses. Read `tools/silkscreen-dashboard/SKILL.md` for the manifest, transform limits, lifecycle and recording contract. Live and replay are visibly distinct; replay exports are not raw native CAD footage. Selected proposals remain unverified until native application, DRC and assembled visibility checks pass. Fusion/Altium adapters are not implemented merely because the manifest is tool-neutral.+++## Final documentation and priority review+Prefer readable documentation blocks in open board interior, not crowded rim space. Include a verified description of what the board does, based on its project wiki/spec. Physical connector, test-point and contact labels remain local on both faces. Check interior alternatives across readable sizes before using an edge fallback; reflow coherent sections around holes, never scatter their sentences. Preserve left-aligned visible text, heading hierarchy and ordered rows. Record reasons and selected font heights. The complete priority/pass checklist is in AI Flow `docs/silkscreen-priority-pass.md`; its ordered requirements apply before native DRC and both-view visual acceptance.+++Read `skills/aiflow-silkscreen/SKILL.md` and `docs/silkscreen-dashboard.md` for the complete optional dashboard workflow. Full-board process videos default to camera-follow off, separate faces and ffmpeg speed-up; retain final native EDA proof. Keep the recording tab visible and inspect actual playback.
crates/adom-aiflow/src/main.rs+192−1428
@@ -1,5 +1,6 @@ //! adom-aiflow: the board loop as one command, with the clock, the negotiation and the finish line. //! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run.+mod silkscreen_sections; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -9,6 +10,15 @@ use aiflow_run::{now, Run, STAGES}; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; +fn aiflow_python() -> PathBuf {+    if let Some(path) = std::env::var_os("ADOM_AIFLOW_PYTHON") { return PathBuf::from(path); }+    if let Some(home) = std::env::var_os("HOME") {+        let path = PathBuf::from(home).join(".local/share/adom-aiflow/python/bin/python3");+        if path.is_file() { return path; }+    }+    PathBuf::from("python3")+}+ const VERSION: &str = env!("CARGO_PKG_VERSION");  #[derive(Parser)]@@ -26,8 +36,20 @@ struct Cli {  #[derive(Subcommand)] enum Cmd {+    SilkscreenLayout { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] events: Option<PathBuf> },+    SilkscreenDashboard { action: String, #[arg(long, default_value="auto")] port: String, #[arg(long, default_value="wv")] surface: String, #[arg(long)] url: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] json: Option<String>, #[arg(long)] reason: Option<String> },+    SilkscreenAudit { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },+    SilkscreenSections { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },+    SilkscreenPreflight { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },+    SilkscreenMask { #[arg(long)] board: PathBuf, #[arg(long)] out: PathBuf },+    SilkscreenContactPolicy { #[arg(long)] input: PathBuf, #[arg(long)] geometry: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] no_contact_borders: bool },+     /// Start a run: copies the board, stamps the prompt time     Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool },+    /// Inventory every board reference for the early component quality review; optional MPN marking is an explicit preference.+    Components { #[arg(long, value_parser = ["ask", "off", "on"])] etch: Option<String> },+    /// Optional Hydrogen progress widget: enable, disable, status, or publish an existing thumbnail.+    Widget { #[arg(value_parser = ["enable", "disable", "status", "event"])] action: String, #[arg(long)] file: Option<PathBuf>, #[arg(long)] label: Option<String>, #[arg(long)] step: Option<String>, #[arg(long)] progress: Option<f64> },     /// The stages this board needs and who can take each     Plan,     /// Record who takes a stage: route=ai pour=binary@@ -35,8 +57,7 @@ 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, /// 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 },+    Place { what: String, #[arg(long)] wish: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value = "moves.json")] out: String },     /// 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)@@ -54,46 +75,18 @@ 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 even though a clip is flagged as having filmed nothing (say why in --message)-    #[arg(long)] accept_suspect: bool },+    Deliver { #[arg(long)] video: String, #[arg(long)] message: String },     /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/, --refresh reloads it in the human's browser. Runs by itself at every step change and at deliver once a page is set.     Report { #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)     #[arg(long)] surface: Option<String> },     /// Record an issue or PR your AI filed back on adom/adom-aiflow (or any page) during this run; deliver counts them.     Giveback { url: String, #[arg(long)] kind: Option<String>, #[arg(long)] note: Option<String> },-    /// Re-cut every clip of the run (the 10x cut and the motion-only action cut with the real-time clock), for runs recorded before a cut existed or after the cut rules improved.-    Recut,-    /// Compose the run's final video from the raw clips: only the frames where something moved, in order, paced so the whole video fits --max-seconds (2 minutes by default); the AI's analysis drawings as fullscreen shots; the narration cut to fit each segment, never the other way round. Replaces the delivered video when the run is already delivered.-    Compose { #[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();@@ -248,251 +241,6 @@ 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)@@ -501,582 +249,18 @@ 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);-    // 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}");-    }+    r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0}));     let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4"));-    // the recorder's first frames are black before the window paints: they are cut off the head-    // of both cuts (never more than five seconds; longer black is a real clip problem, kept)-    let lead = leading_black(file);-    let ss = format!("{lead:.3}");-    let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},scale=1280:-2"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false);-    if lead > 0.0 { r.log("marker", json!({"label": format!("clip {tag}: {lead:.2} s of black recorder startup cut off the head of its cuts")})); }+    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);     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()@@ -1104,8 +288,6 @@ 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}");@@ -1118,44 +300,18 @@ 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 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),+        (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),     };-    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");+    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")));     // per-step table     let rep = r.step_report();     let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];     let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();     for k in rep.keys() { if !names.contains(k) { names.push(k.clone()); } }-    md.push_str("## Per step\n\nAI time only, the idle cut out.\n\n| Step | AI min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");+    md.push_str("## Per step\n\n| Step | Wall 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];@@ -1190,18 +346,16 @@ 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, f64)> = std::collections::BTreeMap::new(); // real, 10x, action+    let mut secs: std::collections::BTreeMap<String, (f64, f64)> = std::collections::BTreeMap::new();     for e in &led {         if e["event"] == "artifact" {             let key = e["step"].as_str().unwrap_or("").to_string();-            let entry = secs.entry(key).or_insert((0.0, 0.0, 0.0));-            match e["kind"].as_str() { Some("clip") => entry.0 = e["seconds"].as_f64().unwrap_or(0.0), Some("clip10x") => entry.1 = e["seconds"].as_f64().unwrap_or(0.0), Some("clipAction") => entry.2 = e["seconds"].as_f64().unwrap_or(0.0), _ => {} }+            let 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 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}");@@ -1210,34 +364,21 @@ 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"));-            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")));+            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));                 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() {@@ -1246,30 +387,13 @@ 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");-        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");+        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("")));     }     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") }));@@ -1286,42 +410,15 @@ 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 {-        // 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;+        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}")),         }-        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@@ -1392,30 +489,67 @@ 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 {+        Cmd::SilkscreenSections { input, out } => {+            thread(&cli); let _r=load_run(&cli);+            let data:serde_json::Value=serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e|err(&e.to_string(), &[]))).unwrap_or_else(|e|err(&e.to_string(), &[]));+            let result=silkscreen_sections::solve(&data).unwrap_or_else(|e|err(&e,&[]));+            std::fs::write(out,serde_json::to_vec_pretty(&result).unwrap()).unwrap_or_else(|e|err(&e.to_string(), &[]));+            if !result["unresolved"].as_array().unwrap().is_empty(){err("Documentation sections remain unresolved", &["Keep rows together; try a shorter line layout, another interior region or a readable font. Do not scatter sentences.".into()]);}+            let fallbacks=result["sections"].as_array().unwrap().iter().filter(|s|s["edgeFallback"]==true).count();+            ok(&format!("Native Rust placed {} documentation labels; {fallbacks} edge fallbacks",result["placements"].as_array().unwrap().len()), &["Final documentation pass: prefer open board interior, preserve left-aligned sections and include a source-backed board description. Physical pin/contact labels remain local. Review edge fallbacks, font sizes, native DRC and BOTH EDA views.".into()]);+        }+        Cmd::SilkscreenPreflight { input, out } => {+            thread(&cli); let _r=load_run(&cli);+            let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-preflight.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("preflight could not run: {e}"), &[]));+            if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Repack every reported collision before transferring to the EDA. Include full custom-pad mask geometry, fitted bodies, holes and footprint artwork.".into()]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Preflight passed; confirm with native DRC and inspect both faces. Association quality remains a separate visual gate.".into()]);+        }+        Cmd::SilkscreenMask { board, out } => {+            thread(&cli); let _r=load_run(&cli);+            let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-kicad-mask.py")).arg("--board").arg(board).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("mask export could not run: {e}"), &[]));+            if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &["Unsupported mask geometry is a blocking preflight error, never an anchor-box fallback.".into()]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Use these complete native mask polygons with fitted body, hole and artwork obstacles.".into()]);+        }+        Cmd::SilkscreenContactPolicy { input, geometry, out, no_contact_borders } => {+            thread(&cli); let _r=load_run(&cli);+            let mut command=std::process::Command::new(aiflow_python());command.arg("-c").arg(include_str!("../../../tools/silkscreen-contact-policy.py")).arg("--input").arg(input).arg("--geometry").arg(geometry).arg("--out").arg(out);+            if *no_contact_borders{command.arg("--no-contact-borders");}+            let result=command.output().unwrap_or_else(|e|err(&format!("contact policy could not run: {e}"), &[]));+            if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &[]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);+        }+        Cmd::SilkscreenAudit { input, out } => {+            thread(&cli); let _r = load_run(&cli);+            let result = std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));+            if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }+            ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required.".into()]);+        }+        Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {+            let t = thread(&cli); let _r = load_run(&cli);+            let mut command = std::process::Command::new("adom-aiflow-silkscreen");+            command.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t).arg("--port").arg(port).arg("--surface").arg(surface);+            if let Some(v)=url { command.arg("--url").arg(v); }+            if let Some(v)=target { command.arg("--target").arg(v); }+            if let Some(v)=json { command.arg("--json").arg(v); }+            if let Some(v)=reason { command.arg("--reason").arg(v); }+            let result=command.output().unwrap_or_else(|e|err(&format!("silkscreen dashboard helper missing: {e}"), &["Install the matching AI Flow package helper.".into()]));+            if !result.status.success() {err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &[]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Live feed and replay visualize recorded solver events. Proposed labels do not modify the native board.".into()]);+        }+        Cmd::SilkscreenLayout { input, out, events } => {+            thread(&cli);+            let _r = load_run(&cli);+            let source = include_str!("../../../tools/silkscreen-layout.py");+            let mut command = std::process::Command::new(aiflow_python());+            command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out);+            if let Some(v)=events {command.arg("--events").arg(v);}+            let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));+            if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }+            ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);+        }         Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force } => {             let t = thread(&cli);             if !Path::new(board).is_file() {@@ -1435,11 +569,59 @@ 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()]);         }+        Cmd::Widget { action, file, label, step, progress } => {+            let t = thread(&cli);+            let _r = load_run(&cli);+            let mut cmd = std::process::Command::new("adom-aiflow-widget");+            cmd.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t);+            if let Some(v) = file { cmd.arg("--file").arg(v); }+            if let Some(v) = label { cmd.arg("--label").arg(v); }+            if let Some(v) = step { cmd.arg("--step").arg(v); }+            if let Some(v) = progress { cmd.arg("--progress").arg(v.to_string()); }+            let status = cmd.status().unwrap_or_else(|e| err(&format!("widget helper unavailable: {e}"), &["Install the package's adom-aiflow-widget helper; the board run is unchanged.".into()]));+            if !status.success() { err("widget helper failed", &[]); }+            ok("widget preference or artifact updated", &["Optional: open AI Flow progress from Hydrogen Widgets. Reads the run and existing artifacts without AI calls. Disable stops feed updates and new thumbnails.".into()]);+        }+        Cmd::Components { etch } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let b = board_of(&r);+            let path = dir.join("components.json");+            let old: Value = if path.exists() {+                let text = std::fs::read_to_string(&path).unwrap_or_else(|e| err(&format!("read components register: {e}"), &[]));+                serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("invalid components register (left untouched): {e}"), &[]))+            } else { json!({}) };+            let preference = etch.as_deref().or_else(|| old["mpnMarking"].as_str()).unwrap_or("ask");+            let mut entries = Vec::new();+            for fp in b.root.find_all("footprint") {+                let props: BTreeMap<String, String> = fp.find_all("property").filter_map(|p| Some((p.atom(1)?.into(), p.atom(2)?.into()))).collect();+                let Some(reference) = props.get("Reference") else { continue; };+                let mut entry = old["components"].as_array().and_then(|a| a.iter().find(|e| e["reference"].as_str() == Some(reference.as_str()))).cloned().unwrap_or_else(|| json!({"reference": reference, "review": {"status": "pending", "classification": "unreviewed", "wikiPage": null, "geometryEvidence": [], "visualEvidence": [], "redistributionEvidence": [], "assetProvenance": [], "variants": []}}));+                let models = json!(fp.find_all("model").filter_map(|n| n.value()).collect::<Vec<_>>());+                if entry.get("properties").is_some() && (entry["properties"] != json!(props) || entry["footprint"] != json!(fp.value()) || entry["modelReferences"] != models) {+                    entry["review"]["status"] = json!("stale");+                    entry["review"]["staleReason"] = json!("Board properties, footprint or model references changed; re-review retained evidence.");+                }+                entry["properties"] = json!(props);+                entry["footprint"] = json!(fp.value());+                entry["modelReferences"] = models;+                entries.push(entry);+            }+            let report = json!({"schemaVersion": 1, "board": b.path, "mpnMarking": preference, "components": entries,+                "qualityGate": "AI review required; this inventory does not certify identities, geometry, redistribution rights or native rendering"});+            std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()).unwrap_or_else(|e| err(&format!("write component register: {e}"), &[]));+            r.data["components"] = json!({"register": path, "mpnMarking": preference, "reviewStatus": "pending"});+            r.save().unwrap_or_else(|e| err(&e, &[]));+            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            let step = flow["steps"].as_array().unwrap().iter().find(|s| s["name"] == "components").unwrap();+            let mut hints: Vec<String> = step["workflow"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect();+            hints.push(format!("MPN marking preference: {preference}. 'ask' means offer the option, not consent; 'off' preserves plain models; 'on' requests additional reviewed marked variants. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));+            ok(&format!("component register: {} ({} references); AI review is pending", path.display(), report["components"].as_array().unwrap().len()), &hints);+        }         Cmd::Plan => {             let mut r = load_run(&cli);             // the flow is a file: the steps in order, who does each, what the binary offers, what comes later@@ -1466,6 +648,11 @@ fn main() {             lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));             for st in flow["steps"].as_array().cloned().unwrap_or_default() {                 lines.push(format!("  step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));+                if let Some(workflow) = st["workflow"].as_array() {+                    for (i, action) in workflow.iter().filter_map(Value::as_str).enumerate() {+                        lines.push(format!("    {}. {}", i + 1, action));+                    }+                }             }             lines.push(format!("  later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));             lines.push("stages the binary offers:".into());@@ -1503,7 +690,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, pause_ms } => {+        Cmd::Place { what, wish, moves, out } => {             thread(&cli);             let mut r = load_run(&cli);             let b = board_of(&r);@@ -1542,21 +729,14 @@ 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 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}));+                    let reply = br.land_moves(&rb, &read_json(m));+                    let good = Bridge::ok(&reply);+                    r.stage_end("place-land", Some(json!({"ok": good})), None);                     r.save().unwrap();-                    if let Some(i) = stopped {-                        let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+                    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()]);                     }-                    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()]);+                    ok(&format!("placement landed live as one undo step (revision {})", reply["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?")), &[]);                 }                 other => err(&format!("place {other}: use pack, check or land"), &[]),             }@@ -1672,21 +852,14 @@ 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 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}));+                    let reply = br.land_moves(&rb, &read_json(m));+                    let good = Bridge::ok(&reply);+                    r.stage_end("place-land", Some(json!({"ok": good})), None);                     r.save().unwrap();-                    if let Some(i) = stopped {-                        let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+                    if !good {+                        err(&format!("live placement refused: {} {}", reply["errorCode"], reply["message"]), &[format!("offendingRefs: {}", reply["offendingRefs"])]);                     }-                    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()]);+                    ok("placement landed live as one undo step", &[]);                 }                 "route" | "vias" => {                     let planp = if what == "route" {@@ -1718,19 +891,18 @@ fn main() {                     let stage = format!("land-{what}");                     r.stage_start(&stage, Some("binary"), None);                     let t0 = std::time::Instant::now();-                    // one trace at a time: a net with several paths lands as several undo steps-                    let (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"]);+                    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"]);                     });-                    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}));+                    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}));                     r.save().unwrap();                     if let Some(i) = stopped {                         let rep = replies.last().cloned().unwrap_or(Value::Null);-                        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)]);+                        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)]);                     }-                    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() }]);+                    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() }]);                 }                 "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", &[]));@@ -1789,22 +961,10 @@ 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(), draw, walk]);+                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()]);             }-            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()]);+            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()]);         }         Cmd::Finish => {             let t = thread(&cli);@@ -1841,12 +1001,7 @@ 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())}));@@ -1891,7 +1046,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 { ok("no recording running: nothing to stop (a walkthrough stops its own clip)", &["Next: `done --message ...` when the answer is complete.".into()]); return; };+                    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 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);@@ -1899,7 +1054,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 = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+                    let st = cap["step"].clone();                     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()) {@@ -1908,20 +1063,7 @@ 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()]);                 }-                "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"), &[]),+                other => err(&format!("capture {other}: use open, start, mark or stop"), &[]),             }         }         Cmd::Status => {@@ -1943,7 +1085,6 @@ 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 } => {@@ -1955,359 +1096,6 @@ 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();@@ -2329,8 +1117,6 @@ 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("")),                         _ => {}                     }                 }@@ -2343,7 +1129,6 @@ 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();@@ -2374,8 +1159,7 @@ fn main() {                     }                     clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));                 }-                // 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 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());                 let disk = disk_check(&dir, &br, name);                 if let Err(e) = &disk {                     r.log("clip-refused", json!({"step": name, "why": e}));@@ -2396,19 +1180,8 @@ 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)")),                 }             }@@ -2423,13 +1196,9 @@ 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, accept_suspect } => {+        Cmd::Deliver { video, message } => {             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()]);             }@@ -2438,23 +1207,17 @@ 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(), "activeMinutes": r.delivered_active_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));+            r.log("deliver", json!({"minutes": r.delivered_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));             r.save().unwrap();             let gb = r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0);-            let mut report_note = format!("Given back this run: {gb} issue(s) or PR(s). {}.", usage_line(&r));+            let mut report_note = format!("Given back this run: {gb} issue(s) or PR(s).");             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("; ")));@@ -2463,3 +1226,4 @@ fn main() {         }     } }+
docs/silkscreen-dashboard.md+22
@@ -7,3 +7,25 @@ Start/show with `adom-aiflow silkscreen-dashboard show --ai-thread "<name>" --ru Use `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` to emit real timestamped solver decisions. The dashboard samples candidates and explains constraints; it does not expose private reasoning or fabricate intermediate edits. Opt out by omitting `--events` and not starting the dashboard. Solver correctness does not depend on visualization.  Live/replay status and native-review-pending status remain visible. Record replay produces a labelled 2D WebM; retain the detailed walkthrough separately from the short final-video segment. No replay is presented as live native CAD footage. KiCad, Fusion and Altium bridges should supply native geometry/metrics, edits, undo, validation and linked view refresh; the dashboard and search stay shared. Only the KiCad ESC data adapter has been exercised so far.+++## Watch the complete flow++<video controls playsinline preload="metadata" style="width:100%;max-width:1200px" poster="https://wiki.adom.inc/api/pages/adom/esc-g431/files/docs/astra/silkscreen-full-board-v37/sheet.png" src="https://wiki.adom.inc/api/pages/adom/esc-g431/files/docs/astra/silkscreen-full-board-v37/final.mp4"></video>++[Watch the 58-second ESC video](https://wiki.adom.inc/adom/esc-g431/files/docs/astra/silkscreen-full-board-v37/README.md). Top labels accumulate first, then bottom labels viewed from below. The last ten seconds show that revision in native KiCad 3D. These are recorded event replays accelerated with ffmpeg, not the solver's elapsed computation time.++## Run it with your AI++1. Ask your AI to read the `aiflow-silkscreen` skill and [ordered review priorities](silkscreen-priority-pass.md). Inventory both faces: references, values, verified connector/test-point functions, identity, description, revision and wiki URL.+2. Declare `adom-aiflow step silkscreen --run <run> --ai-thread "<thread>"`. Gather fitted-body outlines, full pad/mask polygons, holes, board outline and native glyph metrics. Read [the geometry and manifest contract](../tools/silkscreen-dashboard/SKILL.md). Missing adapter geometry is a blocker, not permission to estimate hidden-body clearance silently.+3. Generate fresh grouped candidates from those inputs. `silkscreen-layout` searches them; `silkscreen-contact-policy` ranks edge-facing contact labels and reserves optional borders. `silkscreen-sections` places ordered documentation blocks in native Rust, preferring interior space. These commands consume explicit JSON inputs; the AI/EDA adapter prepares those inputs.+4. Open `silkscreen-dashboard show --run <run> --ai-thread "<thread>"`. Feed real timestamped decisions through the layout command's `--events <run>/silkscreen-dashboard/events.jsonl`. Show text heights, distinct face colors, readable bottom orientation and dashed calculation guides.+5. Run `silkscreen-preflight` before native edits. Apply accepted text/graphics through the EDA adapter, preserve electrical geometry, compare native DRC with the baseline, then refresh and inspect BOTH the 2D editor and 3D viewer. Collision-free placement does not establish readable associations or fabrication qualification.+6. For a short process video, turn Follow placement OFF and fit the whole board. Record every group on the top, then the bottom separately. Keep the webview visible while recording: hidden browser surfaces may throttle canvas updates. Retain raw clips, inspect progression/contact sheets, accelerate with ffmpeg, and reserve native EDA footage at the end. Verify playback in the actual wiki webview.++## Coverage and boundaries++The ESC example regenerates 441 labels. Six association-quality reviews and four documentation edge fallbacks are disclosed on its review page; the inherited electrical DRC errors remain. Do not use the example as manufacturing approval.++The CLI and documentation-section engine are Rust. Candidate generation, polygon preflight and the optional dashboard currently include Python helpers. Native EDA measurement, insertion, refresh and DRC belong to bridges. Only the KiCad ESC adapter has been exercised end to end; there is no automatic Altium/Fusion importer yet.
install.sh+23−1
@@ -1,5 +1,5 @@ #!/bin/bash-# adompkg install hook for adom-aiflow: the prebuilt binary to ~/.local/bin and the skill to ~/.claude/skills.+# adom-wiki package install hook for adom-aiflow: the prebuilt binary to ~/.local/bin and the skill to ~/.claude/skills. 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"@@ -11,3 +11,25 @@ cp "$HERE/skills/aiflow-comparison-video/SKILL.md" "$HOME/.claude/skills/aiflow- command -v service-kicad >/dev/null 2>&1 || echo "Hint: the offline gate needs service-kicad (adom-wiki pkg install adom/service-kicad)" command -v adom-bridge >/dev/null 2>&1 || echo "Hint: the live stages need adom-bridge (the Adom Bridge CLI) and the KiCad Bridge on a desktop" echo "OK: adom-aiflow installed at ~/.local/bin/adom-aiflow. Run 'adom-aiflow --version'."++chmod +x "$HERE/tools/silkscreen-dashboard/server.py"+ln -sf "$HERE/tools/silkscreen-dashboard/server.py" "$HOME/.local/bin/adom-aiflow-silkscreen"+++# 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"+for helper in aiflow-widget library-tour; do+  chmod +x "$HERE/tools/$helper.py"+done+ln -sf "$HERE/tools/aiflow-widget.py" "$HOME/.local/bin/adom-aiflow-widget"+ln -sf "$HERE/tools/library-tour.py" "$HOME/.local/bin/adom-aiflow-library-tour"+for skill in "$HERE"/skills/*; do+  [ -f "$skill/SKILL.md" ] || continue+  name="$(basename "$skill")"+  for consumer in .claude .codex; do+    mkdir -p "$HOME/$consumer/skills/$name"+    cp "$skill/SKILL.md" "$HOME/$consumer/skills/$name/SKILL.md"+  done+done
skills/adom-aiflow/SKILL.md+59
@@ -81,3 +81,62 @@ Every clip stop runs a blank check (nine sampled frames; a clip whose frames are - `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting. - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper.++## Component coverage before placement++The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.++Publish missing identified component pages with verified symbol/footprint/model assets and provenance; improve existing pages instead of duplicating them. Do not invent identities for generic land patterns, DNP parts or bare copper test pads, or silently substitute a similar part. Record unresolved identities and missing assets separately. Generated/approximate bodies must be labelled. Keep model paths portable, preserve layout, rerun `kicad_model_check`, and visibly inspect native KiCad 3D before calling model coverage complete. The AI performs this sourcing audit; these instructions do not add an automatic sourcing or component-identity gate.++Global component pages serve ALL ADOM USERS. Publish only distinct reusable manufacturer parts or independently specified reusable custom components, after duplicate checking. Keep page titles and assets part-focused and portable. Board reference mappings, unresolved identities, one-off footprints, bare copper features and project-only assemblies belong in the board project wiki page, not new component catalog entries. Do not turn project-specific placeholders into generic-looking pages merely by renaming them.++## Early component quality and optional MPN marking++Before placement, declare `step components` and run `components` with your `--run` and `--ai-thread`. It writes `components.json`, inventories every board reference and preserves review notes on rerun. This is an AI review register, not an automated geometry or licensing certification. Record wiki links, classification, dimensions, terminals, pin-1/polarity, plain/marked variants, top/bottom/oblique visual evidence and redistribution evidence. Unknowns remain unknown.++Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project.++Offer `components --etch on` or `--etch off` (initial default `ask` is not consent). Discover adom/adom-chip-laser and the current STEP engine. Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.++## Provenance is required for shared component assets++Creating a new global component page is optional; truthful per-file provenance is required when publishing or improving one. Reuse and improve existing pages first. Keep board-only definitions in the board project. Add a readable provenance section and machine-readable asset record, linked prominently from the component README (including HTML READMEs without removing their existing content).++For every symbol, footprint, STEP, GLB and optional marked variant, record the source URL/file/revision and retrieval time, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator and version, parameters, units, transforms, modifications, redistribution evidence, checks performed and unresolved limits. A hash proves file identity, not quality or permission. Existing wiki availability alone does not establish upstream authorship or rights.++For AI-created geometry, explicitly say it is AI-created and identify the datasheet page/figure/table, dimensional numbers and assumptions used by the generator. Publish the generator or reproducible parameters, disclose simplifications, and distinguish independent geometry from a converted/extracted reference. Keep restricted reference CAD private; document what the comparison tested and did not test. Preserve the lineage from plain input to marked STEP to GLB, including full marking text/mode.++Link to the component page's issue tracker and ask reviewers to include the filename/hash, disputed dimension or pin, source evidence, EDA/version and screenshot. Never fill provenance gaps with plausible guesses. Unknown origins and failed or missing validation remain visible until supported by evidence.++## Optional live progress++Offer the Hydrogen progress widget at intake; default off unless requested. Use `widget enable`, then open AI Flow progress in Hydrogen Widgets. Reuse saved milestone PNGs through `widget event --file <inside-run image> --label <milestone>`; the service also follows saved ledger artifacts and step changes. Use it for component models, longest-axis MPN variants, symbols, placement, pours and Fields. `widget disable` stops updates and thumbnail work. No AI calls or extra captures are required; the progress bar counts planned steps, not remaining time. See widgets/README.md.++## Keep both native views current after every update++After EVERY board, footprint, silkscreen, library-binding or 3D-model update, update BOTH the native 2D board editor and its associated 3D viewer before reporting or showing the result. A file write, successful transfer, DRC result or web preview is not a refreshed native view. Verify the exact saved board path/revision in the editor, then regenerate/reload the 3D view and inspect the changed features after painting settles. Capture evidence from both exact windows; confirm models, markings and layer visibility, not only the window titles.++Use supported native refresh/reload commands through the owning EDA bridge. If an offline edit or model cache requires closing and reopening, inspect unsaved changes and dialogs first, preserve user work, close only task-owned stale windows, reopen the latest board, and open its linked 3D viewer. Never save stale editor contents over a newer disk revision. Keep one current editor/viewer pair rather than accumulating old windows. Re-discover HWNDs after reopening; preserve the user's foreground and view preferences unless showing a view was requested. Refresh a completed edit or coherent batch promptly; do not wait until the final video. If either view cannot be verified, state which one remains stale and resolve it before claiming the update is shown.++## Shared component artwork and label placement++Read `eda-component-hero` and `eda-led-appearance` from `adom/eda-engineering` before creating or revising component heroes or LED variants. The existing wiki-component contract remains applicable. Use `hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>` for source-preservation and structural checks, then perform the published visual review. Never put hero-only footprint helpers in board model files.++For silkscreen, read `docs/silkscreen.md`: measure native stroke bounds, search and repack before shrinking, preserve ref/value association and per-face pin positions, and treat 0.2 mm as exceptional. `silkscreen-layout` runs the shared candidate search; `tools/silkscreen-leaders.py` can reserve leader routes first. Native edits and verification belong to the bridge.++### Complete obstacle coverage is required++Before placing or accepting silkscreen, enumerate every fitted model, via and drilled hole across the entire board, on both faces. Record coverage counts and stable IDs. A local search region does not excuse leaving the rest of the board unchecked. Project the actual fitted 3D model into the board plane with its scale, offset, rotation and footprint transform; include overhangs. Conservative transformed model bounds are acceptable when a tighter silhouette is unavailable. F.Fab, courtyard or pad envelopes are fallbacks, not proof of fitted-body coverage: a footprint can have no Fab drawing, and the space between separated pads can still be occupied by the body. Missing model geometry must be reported and supplied a documented conservative envelope, never a tiny placeholder that silently allows text beneath the component.++Reserve every via hole on both faces, including tented vias when visible labeling is the requirement, and use the larger of the hole keepout and actual exposed mask opening. Also reserve through holes, slots, exposed pads and existing silk. Check actual stroked text extents, not just its anchor. After each re-layout, audit ALL existing labels and leaders against this complete obstacle set. A collision-free position is not enough: keep each reference/value next to its own component or add a clear, noncrossing leader. Do not move a label beside a different component merely to clear an obstacle. Native DRC and fitted 3D visibility are separate acceptance checks; a DRC baseline does not establish readable assembled silkscreen. Refresh and inspect both native 2D and 3D views before presenting the result.++## Optional live silkscreen dashboard++Offer `silkscreen-dashboard show` with the run and ai-thread flags. The shared observer renders real timestamped solver events, sampled candidate bounds and reasons, selected proposals and unresolved labels, in 2D and a fitted-model 3D preview. `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` emits these without AI/provider calls or deliberate solver pauses. Read `tools/silkscreen-dashboard/SKILL.md` for the manifest, transform limits, lifecycle and recording contract. Live and replay are visibly distinct; replay exports are not raw native CAD footage. Selected proposals remain unverified until native application, DRC and assembled visibility checks pass. Fusion/Altium adapters are not implemented merely because the manifest is tool-neutral.+++## Final documentation and priority review+Prefer readable documentation blocks in open board interior, not crowded rim space. Include a verified description of what the board does, based on its project wiki/spec. Physical connector, test-point and contact labels remain local on both faces. Check interior alternatives across readable sizes before using an edge fallback; reflow coherent sections around holes, never scatter their sentences. Preserve left-aligned visible text, heading hierarchy and ordered rows. Record reasons and selected font heights. The complete priority/pass checklist is in AI Flow `docs/silkscreen-priority-pass.md`; its ordered requirements apply before native DRC and both-view visual acceptance.+++Read `skills/aiflow-silkscreen/SKILL.md` and `docs/silkscreen-dashboard.md` for the complete optional dashboard workflow. Full-board process videos default to camera-follow off, separate faces and ffmpeg speed-up; retain final native EDA proof. Keep the recording tab visible and inspect actual playback.
skills/aiflow-silkscreen/SKILL.mdadded+92
@@ -0,0 +1,92 @@+---+name: aiflow-silkscreen+description: Build and review useful two-sided board silkscreen with AI Flow, native geometry checks, grouped labels and full-board process videos.+user-invocable: true+---++# Silkscreen dashboard++An optional, EDA-neutral observer of placement candidates and recorded solver decisions. It makes no AI/provider calls and never edits a native board. Native geometry, text metrics, undoable edits and 2D/3D refresh belong to each EDA bridge. The ESC integration currently exports KiCad geometry; Fusion and Altium adapters still need implementation and native tests. Do not claim those integrations work from schema compatibility alone.++## Displaying this app (REQUIRED — read before showing anything)++Use `adom-aiflow silkscreen-dashboard show --ai-thread "<name>" --run <run>` to open the named Hydrogen webview. Never use VS Code Simple Browser. `--surface pup` requires an explicit desktop `--target` and desktop-reachable `--url`. Keep standard workspace navigation. Reuse the same run/thread instance; `ls` shows instances and version drift.++## Inputs and live observation++Place `board.json` and an append-only `events.jsonl` in `<run>/silkscreen-dashboard/`. These are separate artifacts; never edit the measurement ledger `run.jsonl`. The board manifest has `bounds` in mm, `labels`, `obstacles`, `viaCount`, source-board identity and validation state. Each label carries text, x/y, size, angle, face and an optional existing-collision flag. Each obstacle carries a stable reference, kind, layer and conservative world-space box. Model obstacles may additionally supply a relative GLB URL, footprint `at` in KiCad XY/degrees, model offset and height. This initial 3D adapter supports top-mounted, unit-scale models without separate model rotations; bake other transforms into the supplied GLB or refuse the preview. The viewport's board slab is an envelope, not a manufactured-board export; pads, routed copper and drilled geometry are not yet native-equivalent. Keep that distinction visible.++`adom-aiflow silkscreen-layout --input candidates.json --out layout.json --events <run>/silkscreen-dashboard/events.jsonl --run <run> --ai-thread "<name>"` emits timestamped start, sampled candidate, selected proposal, unresolved and completion events with a solve ID. Events are generated by real computation, not invented for a film. Candidate samples are bounded: this is not every internal solver iteration. Pre-filtered inputs cannot reconstruct prior rejected candidates. The event reason describes the observable constraint/result, not private internal reasoning.++SSE updates do not reload the DOM. Live follows new events; replay has shared play/pause, scrub and speed controls. Playback is independent of solver speed. A selected proposal is not a native edit. Native application/DRC/assembled-visibility evidence must be recorded separately; the dashboard never promotes proposal success into native acceptance.++## Recording++Record replay exports a WebM of the 2D canvas with a persistent REPLAY label and the current recorded decision. Detailed review and a short final-video segment are separate artifacts. Preserve original event timestamps and raw native per-step clips. Do not call replay footage a live native edit or a KiCad recording. Verify the exported video actually plays. The browser-font preview does not replace KiCad's measured stroke-font geometry. Final-video pacing should not force the solver to sleep or trigger extra AI calls for every label.++## Acceptance++Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list.++## Automated recording++Use `adom-aiflow silkscreen-dashboard control --json '{"record":"overview"}' --run <run> --ai-thread "<name>"` (or `walkthrough`). An open dashboard records through the same UI path and uploads the completed WebM into `<run>/silkscreen-dashboard/recordings/`; read `state.lastRecording` for the path. A queued request is not a completed clip. Verify nonempty decoded frames and visible replay/native-review status. Normalize WebM with ffmpeg `fps=30` before H.264 composition; MediaRecorder can report a 1000 Hz nominal rate. Keep each wiki asset below its cap.++## Full-board silkscreen review++A full placement demonstration starts with no authored labels in the candidate occupancy, then attempts every required label. Reuse text content, native glyph metrics and electrical anchors, not earlier accepted positions. Preserve unresolved attempts and subsequent rework in the event history. Front and back are separate collision/placement passes in one run. Filter candidates, selected labels, leaders and playback events by the active face; never overlay the opposite face during a recording.++Show the active label's native text height in millimetres, including distinct reference/value sizes. Maximize readable size near its physical feature; 0.20 mm is a last-resort InstaPCB value, not a default. Prefer short curved leader lines whenever contact/pin association is unclear. Route leaders to the feature's visible rim, keep them off holes, pads, bodies and other text, and verify both faces in the native EDA. Include leaders as actual placement/rework events.++Record each face as a 1920x1080 detailed replay with an explicit replay label. End the review video with the same revision in the user's native EDA, including top and bottom inspection. A dashboard preview or API success is not proof of a faithful native transfer. Keep native raw clips separate from captioned/replayed dashboard clips.++### Face orientation and label hierarchy+- Show bottom placement from below: reflect board geometry, pad positions, obstacles, leaders and label anchors together; keep lettering readable. Do not display unmirrored bottom text on unchanged top-view geometry. State the viewing direction alongside the face name in the dashboard and video.+- Use distinct face colors plus explicit F.SilkS/B.SilkS text; color alone is insufficient.+- Place each component reference and value as a group: the reference has priority and is larger; the value is mandatory and visibly smaller. A count of placed text objects does not prove this hierarchy or readability.+- Treat machine contacts, machine pins and connector pin labels as structured groups. Preserve ordered pin mapping, nearby placement and clear curved leaders when needed; compare against the previous native layout before accepting a fresh solve.++- 2D wheel/trackpad zoom must preserve the board point beneath the pointer, including aspect-fit margins and bottom-face reflection. Manual zoom or pan cancels camera animation and disables automatic follow until the user enables it again.++- Rendering contract: only manufacturing silkscreen is solid and fully opaque. Calculation-only text bounds, keepouts and obstacle outlines use dashed strokes at approximately 50% opacity, with an explicit legend. Never make a debug bounding box look like a printed enclosure. Captured footage must preserve the distinction.++### Machine-contact grouping (default on)+Prefer the label on the board-edge side of each machine contact, machine pin or edge+connector. Determine that direction in physical board coordinates from the nearest+outline edge, independent of whether the viewer is showing top or bottom. Keep a+column on the same side where feasible, preserve physical pin order, and use an+inside fallback only when the outside cannot fit collision-free. Explain fallbacks.++By default reserve a printed frame around each contact plus its larger reference and+smaller function/value. `contactBorders: false` / `--no-contact-borders` disables+these frames without disabling the outside-first policy. Borders are placement+geometry, not decoration added after the solve. Check their full stroke against+holes, mask openings, existing artwork, text and the board edge. A frame may have+small, explicitly reported clearance gaps; never draw across a hole to close it.+Display printed frames as solid; calculation bounding boxes remain dashed at 50%.++Before solving, perform a board-specific interface importance pass. For ESCs, give+verified PHASE A/B/C motor terminals conspicuously larger connection labels (target+1.0–1.4 mm when space allows) and prioritize battery input, polarity and ground.+A functional terminal name can outrank its MC reference; component values remain+smaller than their component references. Record the chosen semantic priorities in+the input and event explanation, then reserve those groups before secondary notes.+++### Documentation sections are atomic+Board identity, instructions, warnings and service-reference tables must retain semantic sections. Preserve row order, a shared readable alignment, consistent line spacing and a larger heading. Place or resize the whole block; never sort individual lines by length or scatter them on generic center-column anchors. Bottom blocks align in the actual bottom-face reading orientation. Keep reference tables supplementary to labels beside physical connectors. Report an unresolved section rather than silently disassembling it. Replay one section at a time and expose its text heights.++Documentation blocks default to left-aligned visible glyphs, not merely aligned calculation boxes. Use the readable left edge on both faces, including bottom reflection. The dashboard must not recenter a shorter font rendering inside the EDA-measured box. Native exports may use equivalent centered anchors computed from measured glyph bounds, provided the actual ink shares the left edge.++### Visual consistency across connector groups+Treat repeated connector labels as a designed row or column. Prefer shared outer border rails, common widths/heights, padding, reference/value hierarchy and consistent spacing. Solve these at group level before independent placement. Reserve actual strokes against holes, masks and bodies. Never stretch borders after collision checks. Use consistent safe clearance gaps where needed; report any individual inside placement or sizing exception. Apply semantic importance first: ESC PHASE A/B/C are prominent functional labels. Documentation sections retain ordered, left-aligned text and headings rather than scattered rows.++Pin-label alignment is target-facing, not universally centered: right-align ink for labels left of their pad, left-align for labels right of their pad. Evaluate this in the visible face orientation, reversing it for the bottom view. Store the electrical target and alignment intent, keep measured native bounds, and avoid wasted visual gap between ink and the pad. Center only when it improves an intentionally symmetric group.+++## Final documentation and priority review+Prefer readable documentation blocks in open board interior, not crowded rim space. Include a verified description of what the board does, based on its project wiki/spec. Physical connector, test-point and contact labels remain local on both faces. Check interior alternatives across readable sizes before using an edge fallback; reflow coherent sections around holes, never scatter their sentences. Preserve left-aligned visible text, heading hierarchy and ordered rows. Record reasons and selected font heights. The complete priority/pass checklist is in AI Flow `docs/silkscreen-priority-pass.md`; its ordered requirements apply before native DRC and both-view visual acceptance.++## Full-board process recording+For a short silkscreen population video, prefer a fixed full-board camera with Follow placement off. Preserve the selected follow setting when recording; do not silently enable it. Record every accepted placement group at a readable event cadence, top and bottom separately, with only the active face visible and the bottom shown from below. Keep the whole board inside the frame. Accelerate the recorded footage with ffmpeg to the requested duration rather than skipping placement events. Distinguish event replay from live solver timing. Finish with refreshed native EDA 3D evidence; for a one-minute film, reserve about ten seconds for that proof. Close-up follow mode remains optional for detailed review.+
tools/requirements-silkscreen.txtadded+3
@@ -0,0 +1,3 @@+numpy==2.5.3+scipy==1.18.1+shapely==2.1.2
tools/silkscreen-dashboard/index.html+2−1
@@ -50,7 +50,8 @@ canvas.addEventListener('wheel',e=>{e.preventDefault();if(!view||!e.deltaY)retur canvas.onpointerdown=e=>{if(!view)return;manualCamera();drag={x:e.clientX,y:e.clientY,v:[...view]};canvas.setPointerCapture(e.pointerId)};canvas.onpointermove=e=>{if(!drag)return;const a=boardPoint(drag.x,drag.y,drag.v),b=boardPoint(e.clientX,e.clientY,drag.v),dx=b[0]-a[0],dy=b[1]-a[1];view=[drag.v[0]-dx,drag.v[1]-dy,drag.v[2]-dx,drag.v[3]-dy];draw()};canvas.onpointerup=canvas.onpointercancel=()=>drag=null;  const captureCanvas=document.createElement('canvas');captureCanvas.width=1920;captureCanvas.height=1080;const captureCtx=captureCanvas.getContext('2d');function captureExtent(){if(state.summary||!state.playing||!$('#follow').checked)return bounds;const e=history[state.cursor],children=e?.candidate?.children||[];const boxes=children.map(c=>c.box).filter(Boolean);for(const child of children){if(child.reference)for(const o of data.obstacles)if(o.reference===child.reference&&o.layer===state.layer&&o.box)boxes.push(o.box)}if(!boxes.length)return bounds;const b=[Math.min(...boxes.map(b=>b[0])),Math.min(...boxes.map(b=>b[1])),Math.max(...boxes.map(b=>b[2])),Math.max(...boxes.map(b=>b[3]))],cx=(b[0]+b[2])/2,cy=(b[1]+b[3])/2,w=Math.max(18,b[2]-b[0]+6),h=Math.max(10,b[3]-b[1]+6);return [cx-w/2,cy-h/2,cx+w/2,cy+h/2]}function paintCapture(){paint(captureCtx,1920,1080,captureExtent());captureCtx.fillStyle='#0d1117e8';captureCtx.fillRect(0,0,1920,50);captureCtx.fillStyle='#e6edf3';captureCtx.font='22px sans-serif';captureCtx.fillStyle=state.layer==='B.SilkS'?'#79b8ff':'#ffd479';captureCtx.fillText('Adom AI Flow · '+(state.layer==='F.SilkS'?'Top, viewed from above':'Bottom, viewed from below')+' silkscreen · '+$('#textsize').textContent,20,32)}-recordReplay=async function(summary=false){if(recording)return;const latest=history.map((e,i)=>e.kind==='start'?i:-1).filter(i=>i>=0).pop()||0;await api('control',{mode:'replay',playing:false,summary,cursor:latest-1,view:'2d'});frame(bounds);recording=true;const chunks=[],rec=new MediaRecorder(captureCanvas.captureStream(30),{mimeType:'video/webm'});rec.ondataavailable=e=>chunks.push(e.data);rec.onstop=()=>{recording=false;const blob=new Blob(chunks,{type:'video/webm'}),a=document.createElement('a');window.silkscreen.recordingBlob=blob;const reader=new FileReader();reader.onload=()=>api('recording',{kind:summary?'overview':'walkthrough',video:reader.result.split(',')[1]}).catch(e=>$('#error').textContent=e.message);reader.readAsDataURL(blob);a.href=URL.createObjectURL(blob);a.download='silkscreen-replay.webm';a.click();$('#record').textContent='Record replay';draw()};paintCapture();rec.start();$('#record').textContent='Recording replay…';await api('control',{playing:true});const timer=setInterval(()=>{draw();paintCapture();if(!state.playing&&state.cursor>=history.length-1){clearInterval(timer);frame(bounds);const hold=setInterval(paintCapture,33);setTimeout(()=>{clearInterval(hold);paintCapture();rec.stop()},1200)}},100)};$('#record').onclick=()=>recordReplay(false);$('#short').onclick=()=>recordReplay(true);+recordReplay=async function(summary=false){if(recording)return;if(document.hidden)throw Error('Show this dashboard tab before recording; hidden webviews can throttle canvas frames.');const latest=history.map((e,i)=>e.kind==='start'?i:-1).filter(i=>i>=0).pop()||0;await api('control',{mode:'replay',playing:false,summary,cursor:latest-1,view:'2d'});frame(bounds);recording=true;const chunks=[],rec=new MediaRecorder(captureCanvas.captureStream(30),{mimeType:'video/webm'});rec.ondataavailable=e=>chunks.push(e.data);rec.onstop=()=>{recording=false;const blob=new Blob(chunks,{type:'video/webm'}),a=document.createElement('a');window.silkscreen.recordingBlob=blob;const reader=new FileReader();reader.onload=()=>api('recording',{kind:summary?'overview':'walkthrough',video:reader.result.split(',')[1]}).catch(e=>$('#error').textContent=e.message);reader.readAsDataURL(blob);a.href=URL.createObjectURL(blob);a.download='silkscreen-replay.webm';a.click();$('#record').textContent='Record replay';draw()};paintCapture();rec.start();$('#record').textContent='Recording replay…';await api('control',{playing:true});const timer=setInterval(()=>{draw();paintCapture();if(!state.playing&&state.cursor>=history.length-1){clearInterval(timer);frame(bounds);const hold=setInterval(paintCapture,33);setTimeout(()=>{clearInterval(hold);paintCapture();rec.stop()},1200)}},100)};$('#record').onclick=()=>recordReplay(false);$('#short').onclick=()=>recordReplay(true); let infoTimer;const brand=$('.brand'),info=$('#appinfo');brand.onmouseenter=()=>{clearTimeout(infoTimer);infoTimer=setTimeout(()=>info.hidden=false,600)};brand.onmouseleave=()=>{clearTimeout(infoTimer);infoTimer=setTimeout(()=>info.hidden=true,220)};brand.onfocus=()=>info.hidden=false;document.addEventListener('keydown',e=>{if(e.key==='Escape'){if($('#settings').open)$('#settings').close();info.hidden=true}}); new ResizeObserver(draw).observe($('#stage'));draw();}catch(e){$('#error').textContent=e.stack} </script></body></html>+