app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
0.2.5: demo mode: a finished run's AI Flow timeline at any earlier moment, for re-filming
8 files changed
+133−5
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.4"+version = "0.2.5" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+2@@ -102,6 +102,8 @@ of it matches. Bottom-side CPL rotations are counted, not compared; check them i The CLI opens or reuses the run's Hydrogen webview automatically on `intake`, `start`, and subsequent run commands. `prompt` surfaces it when resuming work. No separate `timeline open` instruction is needed. Keep the dashboard in a work pane beside the chat; do not move the editor or change pane sizes. Each run has its own verified server identity and stable tab name, so concurrent projects cannot silently share the wrong timeline. A closed tab or stopped server is recovered on the next run command. Every completed command refreshes the timeline; long-running work should use real stage/step events, never fabricated progress or idle heartbeats. +`demo at <step|step@n|ISO>` and `demo serve --port 8894` rebuild a finished run's dashboard as it stood at an earlier moment, into `<run>/demo/`, for re-filming (skill aiflow-process-video); they never write the run.+ `timeline open` remains the explicit show/recovery command. Dashboard failures print `Dashboard unavailable` with recovery guidance and do not change board-gate outcomes. In genuinely headless automation, `AIFLOW_DASHBOARD=off` explicitly suppresses automatic display; this is not the default for Adom users. `--help`, commands without an existing run, and `timeline serve/write` do not create a dashboard recursively. Server lifetime is not charged as an engineering turn. Displayed steps are recorded workflow state, not a substitute for the finish checks.
crates/adom-aiflow/src/demo.rsadded+90@@ -0,0 +1,90 @@+//! `demo`: a finished run's dashboard as it stood at an earlier moment, for filming the build after the fact.+//! The run itself is never touched: its state is copied into `<run>/demo/state/` rewound to that moment+//! (visits, outcomes, evidence, proofs and the ledger cut at T, the finish and delivery clocks cleared), the+//! timeline is built from that copy by the same code the live dashboard uses, and written to+//! `<run>/demo/timeline/`, which `demo serve` serves. Moving `demo at` while the page is open updates it in+//! place, so a director can step the dashboard through the run in front of a camera.++use serde_json::{json, Value};+use std::path::{Path, PathBuf};++/// The moment `at` names: an ISO time (`2026-10-18T19:50:00Z`), or a step visit: `placement` (its first+/// visit), `placement@2` (the second), with an optional `+SECONDS` into that visit (default 20, so the step+/// is the current one and the dashboard shows it working).+pub fn resolve(data: &Value, at: &str) -> Result<String, String> {+ if at.len() >= 20 && at.ends_with('Z') && at.contains('T') { return Ok(at.to_string()); }+ let (spec, offset) = match at.split_once('+') { Some((s, o)) => (s, o.parse::<u64>().map_err(|_| format!("bad offset in {at}"))?), None => (at, 20) };+ let (step, n) = match spec.split_once('@') { Some((s, n)) => (s, n.parse::<usize>().map_err(|_| format!("bad visit number in {at}"))?), None => (spec, 1) };+ let visits = data["steps"][step]["visits"].as_array().ok_or_else(|| format!("the run never visited step `{step}`"))?;+ let v = visits.get(n.saturating_sub(1)).ok_or_else(|| format!("step `{step}` has {} visit(s), not {n}", visits.len()))?;+ let t = v["at"].as_str().and_then(aiflow_run::parse).ok_or_else(|| format!("visit {n} of `{step}` has no time"))?;+ let end = v["end"].as_str().and_then(aiflow_run::parse).unwrap_or(u64::MAX);+ Ok(aiflow_run::iso(t.saturating_add(offset).min(end.saturating_sub(1).max(t))))+}++/// Copy the run's state into `<run>/demo/state/` as it stood at `t` and return that folder.+pub fn rewind(run: &Path, t: &str) -> Result<PathBuf, String> {+ let mut data: Value = serde_json::from_str(&std::fs::read_to_string(run.join("run.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;+ let before = |v: &Value| v.as_str().map(|s| s <= t).unwrap_or(false);+ // each step keeps only the visits begun by t; a visit still open at t has no end yet+ let mut latest: Option<(String, String)> = None;+ if let Some(steps) = data["steps"].as_object_mut() {+ for (name, s) in steps.iter_mut() {+ let kept: Vec<Value> = s["visits"].as_array().cloned().unwrap_or_default().into_iter().filter(|v| before(&v["at"])).map(|mut v| {+ if !before(&v["end"]) { v["end"] = Value::Null; }+ v+ }).collect();+ if let Some(at) = kept.last().and_then(|v| v["at"].as_str()) {+ if latest.as_ref().map(|(a, _)| at > a.as_str()).unwrap_or(true) { latest = Some((at.to_string(), name.clone())); }+ }+ s["visits"] = json!(kept);+ }+ }+ data["currentStep"] = latest.map(|(_, s)| json!(s)).unwrap_or(Value::Null);+ if let Some(o) = data["outcomeAt"].as_object_mut() { o.retain(|_, v| before(v)); }+ if let Some(a) = data["evidence"].as_array_mut() { a.retain(|e| before(&e["at"])); }+ if let Some(a) = data["turns"].as_array_mut() { a.retain(|e| before(&e["at"])); }+ for k in ["finishTime", "deliveredTime"] { if !before(&data["clock"][k]) { data["clock"][k] = Value::Null; } }+ if !before(&data["delivery"]["at"]) { data["delivery"] = Value::Null; }++ let state = run.join("demo").join("state");+ let _ = std::fs::remove_dir_all(&state);+ std::fs::create_dir_all(state.join("proofs")).map_err(|e| e.to_string())?;+ std::fs::write(state.join("run.json"), serde_json::to_string_pretty(&data).unwrap()).map_err(|e| e.to_string())?;+ // the ledger up to t: the per-step minutes come from it+ let ledger: String = std::fs::read_to_string(run.join("run.jsonl")).unwrap_or_default().lines()+ .filter(|l| serde_json::from_str::<Value>(l).ok().map(|e| before(&e["t"])).unwrap_or(false))+ .map(|l| format!("{l}\n")).collect();+ std::fs::write(state.join("run.jsonl"), ledger).map_err(|e| e.to_string())?;+ // proofs recorded by t (a proof is what marks a step proven)+ if let Ok(rd) = std::fs::read_dir(run.join("proofs")) {+ for f in rd.flatten() {+ let keep = std::fs::read_to_string(f.path()).ok().and_then(|x| serde_json::from_str::<Value>(&x).ok()).map(|v| before(&v["at"])).unwrap_or(false);+ if keep { let _ = std::fs::copy(f.path(), state.join("proofs").join(f.file_name())); }+ }+ }+ Ok(state)+}++/// Write the rewound timeline into `<run>/demo/timeline/` (the page, timeline.json with the clock at t, and+/// the run's own header) and return it.+pub fn write(run: &Path, t: &str, tl: &mut Value, page: &str, title: Option<&str>) -> Result<PathBuf, String> {+ tl["updated"] = json!(t);+ let d = run.join("demo").join("timeline");+ std::fs::create_dir_all(&d).map_err(|e| e.to_string())?;+ if std::fs::read_to_string(d.join("index.html")).map(|x| x != page).unwrap_or(true) { std::fs::write(d.join("index.html"), page).map_err(|e| e.to_string())?; }+ for f in ["presentation.json", "favicon.svg"] {+ if run.join("timeline").join(f).exists() { let _ = std::fs::copy(run.join("timeline").join(f), d.join(f)); }+ }+ if let Some(title) = title { // the header's project title, in the demo's copy only+ let f = d.join("presentation.json");+ let mut p: Value = std::fs::read_to_string(&f).ok().and_then(|x| serde_json::from_str(&x).ok()).unwrap_or_else(|| json!({}));+ p["projectTitle"] = json!(title);+ std::fs::write(&f, serde_json::to_string_pretty(&p).unwrap()).map_err(|e| e.to_string())?;+ }+ // write beside, then rename: the server's file watch must never read a half-written file+ let tmp = d.join("timeline.json.tmp");+ std::fs::write(&tmp, serde_json::to_string_pretty(tl).unwrap()).map_err(|e| e.to_string())?;+ std::fs::rename(&tmp, d.join("timeline.json")).map_err(|e| e.to_string())?;+ Ok(d)+}
crates/adom-aiflow/src/main.rs+28−2@@ -41,6 +41,7 @@ mod clipwatch; mod decoupling_check; mod placement_intent; mod rules_card;+mod demo; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -115,6 +116,11 @@ enum Cmd { Outline { #[arg(value_parser = ["round", "check"])] action: String, #[arg(long)] radius: Option<f64> }, /// `timeline open`: the flow's timeline dashboard in a Hydrogen webview (the right pane): every required step in order, the current one, how many to go, a check on each done step and an arc for every return. `timeline serve` serves <run>/timeline/ (open starts it when needed); `timeline write` refreshes timeline.json (every `step` does). Timeline { #[arg(value_parser = ["open", "serve", "write", "present"])] action: String, #[arg(long, default_value_t = 8893)] port: u16, #[arg(long)] json: Option<String> },+ /// `demo at <step|step@n|step@n+SECONDS|ISO>`: the run's dashboard as it stood at that moment, for filming+ /// a finished build (the run itself is never written: the rewound state goes to <run>/demo/). `demo serve+ /// --port N` serves <run>/demo/timeline/; every `demo at` updates an open page in place.+ Demo { #[arg(value_parser = ["at", "serve"])] action: String, at: Option<String>, #[arg(long, default_value_t = 8894)] port: u16, /// the header's project title in the demo (the run's own header otherwise)+ #[arg(long)] title: Option<String> }, Testplan { #[arg(value_parser = ["check"])] action: String, #[arg(long)] tests: Option<String>, #[arg(long)] netlist: Option<String>, #[arg(long)] add: bool }, Rules { #[arg(value_parser = ["use", "install", "check", "list"])] action: String, #[arg(long)] profile: Option<String>, #[arg(long)] board: Option<String>, /// replace an existing .kicad_dru #[arg(long)] force: bool, /// copper layers (default: the profile's -<n>L suffix, else the board's copper layer count, else 2)@@ -2335,13 +2341,13 @@ fn main() { } } // All engines share the same default. Server/write children must never open tabs recursively.- if !matches!(cli.cmd, Cmd::Timeline { .. }) {+ if !matches!(cli.cmd, Cmd::Timeline { .. } | Cmd::Demo { .. }) { let ai_thread = cli.ai_thread.clone().or_else(|| Run::open(&dir).and_then(|r|r.data["aiThread"].as_str().map(str::to_owned))).unwrap_or_else(|| "adom-aiflow".into()); let _ = DASHBOARD.set((dir.clone(), ai_thread.clone())); ensure_dashboard(&dir, &ai_thread, matches!(cli.cmd, Cmd::Prompt { .. })); } // every command is a turn in the run's ledger; the gap before it is the AI's thinking time- if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. }) && !matches!(&cli.cmd, Cmd::Timeline { action, .. } if action == "serve") {+ if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. } | Cmd::Demo { .. }) && !matches!(&cli.cmd, Cmd::Timeline { action, .. } if action == "serve") { if let Some(mut r) = Run::open(&dir) { let idx = r.turn_begin(&command_line()); let _ = r.save();@@ -4404,6 +4410,26 @@ fn main() { } } }+ Cmd::Demo { action, at, port, title } => {+ let dir = run_dir(&cli);+ if action == "serve" {+ let d = dir.join("demo").join("timeline");+ if !d.join("timeline.json").exists() { err("nothing to serve yet", &["Run `demo at <step>` first; it writes <run>/demo/timeline/.".into()]); }+ timeline_server::serve(&d, *port).unwrap_or_else(|e| err(&e, &["Pick another --port; the run's live dashboard keeps its own.".into()]));+ return;+ }+ let r = load_run(&cli);+ let at = at.clone().unwrap_or_else(|| err("demo at needs a moment", &["`demo at placement`, `demo at routing@2+60`, or an ISO time like 2026-10-18T19:50:00Z.".into()]));+ let t = demo::resolve(&r.data, &at).unwrap_or_else(|e| err(&e, &["Step names are the run's (`status` lists them); @n picks the nth visit.".into()]));+ let state = demo::rewind(&dir, &t).unwrap_or_else(|e| err(&e, &[]));+ let rs = Run::open(&state).unwrap_or_else(|| err("the rewound state did not open", &[]));+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));+ let mut tl = sequence::timeline(&state, &rs.data, &flow_full, &seq, &rs.step_report());+ let d = demo::write(&dir, &t, &mut tl, TIMELINE_PAGE, title.as_deref()).unwrap_or_else(|e| err(&e, &[]));+ ok(&format!("demo at {t}: step {} of {}, {} done, {} loop back(s), current {}; written to {}", tl["position"], tl["total"], tl["done"], tl["loops"].as_array().map(|a| a.len()).unwrap_or(0), tl["current"], d.display()),+ &["`demo serve --port 8894` serves it (the run's live dashboard is untouched); an open page updates in place on the next `demo at`.".into()]);+ } Cmd::Testplan { action: _, tests, netlist, add } => { thread(&cli); let mut r = load_run(&cli);
docs/release-0.2.5.mdadded+3@@ -0,0 +1,3 @@+# adom-aiflow 0.2.5++- `demo at <step|step@n|step@n+SECONDS|ISO>` rebuilds a finished run's AI Flow timeline exactly as it stood at that moment (the current step, the loop backs that had happened by then, the minutes and the clock) into `<run>/demo/`, and `demo serve --port 8894` serves it. Each later `demo at` updates an open page in place, so a finished build can be re-filmed full screen in Hydrogen step by step. The run's own state and ledger are never written.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.4",+ "version": "0.2.5", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.4",+ "version": "0.2.5", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/aiflow-process-video/SKILL.md+7@@ -53,6 +53,13 @@ right things while the run happens**. You cannot film the past. thread on the left asking for the run, the app in the webview on the right updating live. Bring the app's tab to the front right before recording, and do not read screenshots during the take: Shotlog surfaces itself in the webview when an AI reads a capture.+- **Re-filming a finished run: demo mode.** When a shot of the AI Flow timeline is missing or wrong, re-film it+ full screen instead of cropping old footage: `adom-aiflow --run <run> demo at placement` rebuilds the+ dashboard exactly as it stood at that moment (current step, the loop backs that had happened by then, the+ minutes, the clock) into `<run>/demo/`, and `demo serve --port 8894` serves it; open that URL in a Hydrogen+ tab of its own. Each later `demo at routing@2`, `demo at pours@2+60` or ISO time updates the open page in+ place, so a director steps it through the run on camera. The run itself is never written. Keep the AI+ thread on the left genuinely working during the take (a real, light task about that step), never a replay. - **Credit the tool behind each detail.** When a frame shows something an Adom tool made (the chip outline in a symbol: adom-symbol and adom-lbr from the part's STEP, on the OpenCascade service), push in on it and say so in the narration and a chip.