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
Release 0.1.27: native rule preservation, router and evidence gates, measured external work and reviewed component overview
35 files changed
+864−60
Cargo.lock+95−10Cargo.toml+1−1README.md+6SKILL.md+17bin/adom-aiflowcrates/adom-aiflow/Cargo.toml+2crates/adom-aiflow/src/library_review.rs+45crates/adom-aiflow/src/main.rs+119−30crates/aiflow-analyze/Cargo.toml+2crates/aiflow-analyze/src/lib.rs+63−3crates/aiflow-copper/src/lib.rs+49−6crates/aiflow-copper/tests/fixtures/project-rules.kicad_pcb+163crates/aiflow-copper/tests/fixtures/project-rules.kicad_pro+1crates/aiflow-copper/tests/project_rules.rs+15crates/aiflow-pours/src/lib.rs+32crates/aiflow-router/src/drive.rs+36−1crates/aiflow-router/src/lib.rs+1−1crates/aiflow-router/src/rules.rs+16−1crates/aiflow-router/tests/obstacles.rs+41crates/aiflow-run/src/lib.rs+24−1docs/library-overview-review.md+25docs/release-0.1.27.md+13docs/time.md+9docs/video.md+2install.sh+2−1package.json+1−1page.json+2−2skills/adom-aiflow/SKILL.md+17skills/aiflow-measurement/SKILL.md+9tests/component-workflow.py+1tests/widget-workflow.py+1tools/silkscreen-dashboard/index.html+23−1tools/silkscreen-dashboard/recording_check.py+19tools/silkscreen-dashboard/server.py+4−1tools/test-silkscreen-recording.py+8Cargo.lock+95−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.26"+version = "0.1.27" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -18,19 +18,21 @@ dependencies = [ "clap", "serde", "serde_json",+ "sha2", ] [[package]] name = "aiflow-analyze"-version = "0.1.26"+version = "0.1.27" dependencies = [ "serde", "serde_json",+ "sha2", ] [[package]] name = "aiflow-board"-version = "0.1.26"+version = "0.1.27" dependencies = [ "serde", "serde_json",@@ -38,7 +40,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.26"+version = "0.1.27" dependencies = [ "serde", "serde_json",@@ -46,7 +48,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.26"+version = "0.1.27" dependencies = [ "aiflow-board", "aiflow-grid",@@ -56,7 +58,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.26"+version = "0.1.27" dependencies = [ "aiflow-board", "serde",@@ -65,7 +67,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.26"+version = "0.1.27" dependencies = [ "aiflow-board", "serde",@@ -74,7 +76,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.26"+version = "0.1.27" dependencies = [ "aiflow-board", "aiflow-copper",@@ -84,7 +86,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.26"+version = "0.1.27" dependencies = [ "aiflow-board", "aiflow-grid",@@ -94,7 +96,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.26"+version = "0.1.27" dependencies = [ "serde", "serde_json",@@ -150,6 +152,21 @@ dependencies = [ "windows-sys", ] +[[package]]+name = "block-buffer"+version = "0.10.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"+dependencies = [+ "generic-array",+]++[[package]]+name = "cfg-if"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"+ [[package]] name = "clap" version = "4.6.6"@@ -196,6 +213,45 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]]+name = "cpufeatures"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"+dependencies = [+ "libc",+]++[[package]]+name = "crypto-common"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"+dependencies = [+ "generic-array",+ "typenum",+]++[[package]]+name = "digest"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"+dependencies = [+ "block-buffer",+ "crypto-common",+]++[[package]]+name = "generic-array"+version = "0.14.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"+dependencies = [+ "typenum",+ "version_check",+]+ [[package]] name = "heck" version = "0.5.0"@@ -214,6 +270,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]]+name = "libc"+version = "0.2.189"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"+ [[package]] name = "memchr" version = "2.8.3"@@ -287,6 +349,17 @@ dependencies = [ "zmij", ] +[[package]]+name = "sha2"+version = "0.10.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"+dependencies = [+ "cfg-if",+ "cpufeatures",+ "digest",+]+ [[package]] name = "strsim" version = "0.11.1"@@ -304,6 +377,12 @@ dependencies = [ "unicode-ident", ] +[[package]]+name = "typenum"+version = "1.20.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"+ [[package]] name = "unicode-ident" version = "1.0.24"@@ -316,6 +395,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"+ [[package]] name = "windows-link" version = "0.2.1"
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.26"+version = "0.1.27" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
README.md+6@@ -167,3 +167,9 @@ Everything below is what John has asked for, in the order it is likely to land. - A run without `deliver` is not a result, and its minutes are still counting. - A run assembled from earlier work with a back-dated prompt is a proving run of the tool, not a measured run of the AI. Say so. - 0.1's analyses are conservative heuristics (IPC-2221 for tracks; presence, connection style, area and vias for pours and tabs) and say so in their output. 0.2 computes cross-sections through the filled copper.++### Trustworthy current and thermal screens++A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification.++Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. Manufacturing-rule warnings, including hole spacing, remain blocking.
SKILL.md+17@@ -141,3 +141,20 @@ Prefer readable documentation blocks in open board interior, not crowded rim spa 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.+++## External work and unknown gaps++Wrap actual external work with `adom-aiflow --ai-thread "<thread>" --run <run> exec -- <program> <args>`. The child inherits the terminal, its success or failure is recorded, and the full running interval is measured, including commands longer than the idle threshold. Use it for builds, browser automation, CAD generation and wiki operations. Do not run a heartbeat while idle.++A long interval without ledger events is **unobserved**, not proof the human was away. Session reports label these excluded intervals explicitly; the legacy JSON `idleMinutes` field remains for compatibility, alongside `unobservedMinutes`. Existing delivery timestamps and ledger events are never rewritten. Historical reports with unwrapped external work can undercount AI activity. Do not claim harness-complete measurement or invent retrospective times.++Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage=binary` records an explicit owner.++### Trustworthy current and thermal screens++A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification.++Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. Manufacturing-rule warnings, including hole spacing, remain blocking.++Component library footage: keep the full `library-tour` separate. A reviewed five-second `library-overview` artifact may enter compose, with `--review` binding the exact selected STEP/GLB variants, current board, clip and native readback/screenshot. See [the review contract](https://wiki.adom.inc/adom/adom-aiflow/files/docs/library-overview-review.md). Never claim native model matching from a tour alone.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/Cargo.toml+2@@ -22,3 +22,5 @@ aiflow-pours = { workspace = true } aiflow-analyze = { workspace = true } aiflow-run = { workspace = true } aiflow-bridge = { workspace = true }++sha2 = "0.10"
crates/adom-aiflow/src/library_review.rsadded+45@@ -0,0 +1,45 @@+//! Review contract for a short component overview; full tours remain separate artifacts.+use std::path::{Path,PathBuf};+use serde_json::Value;+use sha2::{Digest,Sha256};+pub fn hash(path:&Path)->Result<String,String>{Ok(format!("{:x}",Sha256::digest(std::fs::read(path).map_err(|e|format!("{}: {e}",path.display()))?)))}+fn file(base:&Path,v:&Value)->Result<PathBuf,String>{let p=PathBuf::from(v.as_str().ok_or("review needs a file path")?);Ok(if p.is_absolute(){p}else{base.join(p)})}+fn checked(base:&Path,v:&Value)->Result<PathBuf,String>{let p=file(base,&v["file"])?;if v["sha256"].as_str()!=Some(hash(&p)?.as_str()){return Err(format!("review hash mismatch: {}",p.display()))}Ok(p)}+pub fn verify(path:&Path,board:&Path,clip:&Path)->Result<Value,String>{+ let review:Value=serde_json::from_slice(&std::fs::read(path).map_err(|e|e.to_string())?).map_err(|e|e.to_string())?;+ let base=path.parent().unwrap_or(Path::new("."));+ if review["boardSha256"].as_str()!=Some(hash(board)?.as_str()){return Err("library review belongs to another board snapshot".into())}+ if review["clipSha256"].as_str()!=Some(hash(clip)?.as_str()){return Err("library review belongs to another clip".into())}+ if review["visualReview"]!="passed" || review["nativeLibraryReview"]!="passed"{return Err("visual and native-library reviews must both pass; pending is not evidence".into())}+ for k in ["nativeReadback","nativeScreenshot"] { checked(base,&review[k])?; }+ let selection_path=checked(base,&review["selection"])?;+ let selection:Value=serde_json::from_slice(&std::fs::read(&selection_path).map_err(|e|e.to_string())?).map_err(|e|e.to_string())?;+ let selected_base=selection_path.parent().unwrap_or(base);+ let variant=match selection["mpnMarking"].as_str(){Some("on")=>"mpn",Some("off")=>"plain",_=>return Err("resolve MPN marking before review".into())};+ let rows=selection["components"].as_array().filter(|r|!r.is_empty()).ok_or("empty component selection")?;+ for row in rows {+ let model=&row["selectedModel"];+ if model["variant"]!=variant{return Err(format!("{}: selected variant disagrees with marking preference",row["mpn"]))}+ for kind in ["step","glb"] {let p=file(selected_base,&model[kind])?;if model[format!("{kind}Sha256")].as_str()!=Some(hash(&p)?.as_str()){return Err(format!("{}: stale selected {kind}",row["mpn"]))}}+ }+ Ok(review)+}++#[cfg(test)]+mod tests {+ use super::*;use serde_json::json;+ #[test]+ fn pending_or_changed_library_evidence_is_refused(){+ let d=std::env::temp_dir().join(format!("aiflow-library-review-{}",std::process::id()));std::fs::create_dir_all(&d).unwrap();+ for n in ["board","clip","readback","screenshot","step","glb"]{std::fs::write(d.join(n),n).unwrap();}+ let item=|n:&str|json!({"file":n,"sha256":hash(&d.join(n)).unwrap()});+ let selection=json!({"mpnMarking":"on","components":[{"mpn":"fixture","selectedModel":{"variant":"mpn","step":"step","stepSha256":hash(&d.join("step")).unwrap(),"glb":"glb","glbSha256":hash(&d.join("glb")).unwrap()}}]});+ std::fs::write(d.join("selection"),selection.to_string()).unwrap();+ let mut r=json!({"boardSha256":hash(&d.join("board")).unwrap(),"clipSha256":hash(&d.join("clip")).unwrap(),"visualReview":"passed","nativeLibraryReview":"passed","selection":item("selection"),"nativeReadback":item("readback"),"nativeScreenshot":item("screenshot")});+ let save=|v:&Value|std::fs::write(d.join("review"),v.to_string()).unwrap();save(&r);+ assert!(verify(&d.join("review"),&d.join("board"),&d.join("clip")).is_ok());+ r["nativeLibraryReview"]=json!("pending");save(&r);assert!(verify(&d.join("review"),&d.join("board"),&d.join("clip")).is_err());+ r["nativeLibraryReview"]=json!("passed");save(&r);std::fs::write(d.join("glb"),"changed").unwrap();assert!(verify(&d.join("review"),&d.join("board"),&d.join("clip")).unwrap_err().contains("stale selected glb"));+ std::fs::remove_dir_all(d).unwrap();+ }+}
crates/adom-aiflow/src/main.rs+119−30@@ -1,6 +1,7 @@ //! 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;+mod library_review; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -36,6 +37,8 @@ struct Cli { #[derive(Subcommand)] enum Cmd {+ /// Measure external work as a real running command, including its output and exit status. Use: exec -- <program> <args>+ Exec { #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] command: Vec<String> }, 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 },@@ -47,6 +50,8 @@ enum Cmd { /// 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.+ /// Adopt a native-saved board after AI-owned edits, preserving project rules and checking baseline DRC.+ AdoptBoard { #[arg(long)] board: String, #[arg(long)] evidence: String }, 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> },@@ -70,7 +75,7 @@ enum Cmd { /// Filled copper per layer from the live board Measure, /// current or thermal- Analyze { what: String, #[arg(long)] plan: Option<String> },+ Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf> }, /// The finish line; refuses until every check passes Finish, /// 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.@@ -93,7 +98,7 @@ enum Cmd { #[arg(long)] script: Option<PathBuf> }, /// Register a file the AI made during the current step (an analysis drawing, a plot): it goes on the run page under the step and, for images, into the final video as a fullscreen shot at that point of the step. Artifact { #[arg(long)] file: String, #[arg(long, default_value = "analysis-image")] kind: String, #[arg(long)] caption: String, #[arg(long)] step: Option<String>, /// an earlier artifact (its file path) this one replaces: the redone drawing takes its place on the page and in the video- #[arg(long)] replaces: Option<String> },+ #[arg(long)] replaces: Option<String>, #[arg(long)] review: Option<PathBuf> }, /// Snapshot the engine's plan usage (percent used per limit window) into the ledger now; the binary also does this at start, every step, finish and deliver. Usage, /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement.@@ -114,7 +119,7 @@ enum Cmd { 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).+ /// The run as sessions: each human prompt, the AI's active time on it, the idle gaps cut out (a gap over --idle-minutes with no command running is unobserved activity, not proven human idle). Sessions { #[arg(long)] json: bool }, } @@ -575,7 +580,7 @@ fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) { /// 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 } }+enum Piece { Library { file:String, caption:String, at:u64, seconds:f64 }, Clip { cap: Value, idx: usize, motion: f64 }, Still { file: String, caption: String, step: String, visit: u64, at: u64 } } /// Say `tiers` (the fullest first) and keep the first that fits `vlen` seconds; the last tier is /// said faster when even it runs long. The picture sets the length; the words are cut to it.@@ -635,6 +640,14 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec pieces.push((at, Piece::Clip { cap: c.clone(), idx: i, motion })); } for e in &standing_artifacts(&led) {+ if e["kind"]=="library-overview" {+ let Some(file)=e["file"].as_str() else {continue};+ let Some(review)=e["review"].as_str() else {err("library overview has no review contract", &["Re-register it with artifact --kind library-overview --review <review.json>.".into()])};+ library_review::verify(Path::new(review),Path::new(&r.current_board()),Path::new(file)).unwrap_or_else(|e|err(&e,&["Re-review the current native board and selected component variants before composing.".into()]));+ let at=e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ pieces.push((at,Piece::Library{file:file.into(),caption:e["caption"].as_str().unwrap_or("Component library").into(),at,seconds:media_seconds(file).min(5.0)}));+ continue;+ } if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; } let Some(f) = e["file"].as_str() else { continue }; if !Path::new(f).is_file() { continue; }@@ -646,7 +659,10 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec // 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 library_seconds: f64=pieces.iter().map(|p|if let Piece::Library{seconds,..}=p.1{seconds}else{0.0}).sum();+ let still_seconds=still_seconds.min((max_seconds-library_seconds).max(0.0)*0.5/(n_stills.max(1) as f64));+ if library_seconds>=max_seconds {err("component overviews exceed the video budget", &["Replace superseded overviews with artifact --replaces, or raise compose --max-seconds.".into()]);}+ let clip_budget = (max_seconds - library_seconds - n_stills as f64 * still_seconds).max(0.1); let rate = if motion_total > clip_budget { 4.0 * motion_total / clip_budget } else { 4.0 }; let engine = r.data["engine"].as_str().unwrap_or("").to_string(); let minutes = r.active_minutes_total();@@ -666,6 +682,13 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec let seg = workdir.join(format!("seg-{k}.mp4")); let aseg = workdir.join(format!("aud-{k}.m4a")); let (vlen, tiers): (f64, Vec<String>) = match piece {+ Piece::Library{file,caption,at,seconds}=>{+ let offset=r.active_offset(*at) as f64;+ let vf=format!("scale=1280:-2,{},{fit}",overlay_filters(dir,"Component library",&engine_board,offset,0.0,run_has_hours));+ let good=std::process::Command::new("ffmpeg").args(["-v","error","-y","-i",file,"-t",&format!("{seconds:.3}"),"-vf",&vf,"-r","30","-an","-pix_fmt","yuv420p","-c:v","libx264","-preset","veryfast","-crf","20",seg.to_str().unwrap()]).status().map(|s|s.success()).unwrap_or(false);+ if !good{err("component overview failed to render", &["Check the reviewed clip, then compose again.".into()]);}+ n_clips+=1;(media_seconds(seg.to_str().unwrap()),vec![caption.clone(),"The reviewed component library.".into()])+ } Piece::Clip { cap: c, idx, motion } => { let (f, step, visit) = (c["file"].as_str().unwrap_or(""), c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)); let tag = format!("{step}-{visit}");@@ -752,7 +775,7 @@ fn standing_artifacts(led: &[Value]) -> Vec<Value> { /// 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");+ let mut out = String::from("| Session | Prompt at | Done at | AI time min | Unobserved gap min | What |\n|---|---|---|---|---|---|\n"); for x in &ss { out.push_str(&format!("| {} | {} | {} | {:.1} | {:.0} | {} |\n", x.n, aiflow_run::iso(x.prompt_at), x.done_at.map(aiflow_run::iso).unwrap_or_else(|| if x.open { "(open)".into() } else { "".into() }), x.active_seconds() as f64 / 60.0, x.idle_seconds as f64 / 60.0, x.text.chars().take(90).collect::<String>().replace('|', "/"))); }@@ -1049,7 +1072,7 @@ fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String 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");+ md.push_str("AI time is the sum of the run's sessions, one per human prompt, from the prompt to the AI's done, with every idle gap over 15 minutes (unobserved, not proven idle) cut out. Follow-up prompts after delivery (the walkthroughs, the drawings, the re-cuts) are on each run's page under Sessions and are not in the delivery number.\n\n"); // per step let mut steps: Vec<String> = a.data["flow"]["steps"].as_array().map(|x| x.iter().filter_map(|s| s["name"].as_str().map(str::to_string)).collect()).unwrap_or_default(); for k in ra.keys().chain(rb.keys()) { if !steps.contains(k) { steps.push(k.clone()); } }@@ -1147,7 +1170,7 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) { (None, Some(f)) => format!("**Finished** (qualified board) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()), _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min), };- md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). 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()));+ md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (unobserved, not proven idle) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes())); if let Some(d) = r.data["delivery"].as_object() { if let Some(v) = d.get("video").and_then(|v| v.as_str()) { if Path::new(v).is_file() {@@ -1169,7 +1192,7 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) { } } // 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\n\nOne row per human prompt: the AI's time on it, unobserved gaps excluded by the heuristic, which may include external AI work.\n\n"); md.push_str(&sessions_table(r)); md.push_str("\n"); // per-step table@@ -1438,6 +1461,21 @@ fn main() { } } match &cli.cmd {+ Cmd::Exec { command } => {+ thread(&cli); let mut r=load_run(&cli);+ r.log("external-start",json!({"program":command[0],"args":&command[1..]}));+ r.save().unwrap();+ let status=std::process::Command::new(&command[0]).args(&command[1..]).status();+ let mut r=load_run(&cli);+ match status {+ Ok(status)=>{+ r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success()}));r.save().unwrap();+ if !status.success(){err(&format!("external command failed: {} ({status})",command[0]), &["Inspect the command output above, fix the reported failure and retry with adom-aiflow exec -- <program> <args>. The failed command's time is retained.".into()]);}+ ok("external command completed; its running time is in this session", &["Use exec for build, browser automation and other work outside AI Flow. Do not start an idle heartbeat; only actual work counts.".into()]);+ }+ Err(e)=>err(&format!("could not start external command {}: {e}",command[0]), &["Check the executable path, then retry adom-aiflow exec -- <program> <args>.".into()])+ }+ } Cmd::SilkscreenSections { input, out } => { thread(&cli); let _r=load_run(&cli); let data:serde_json::Value=serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e|err(&e.to_string(), &[]))).unwrap_or_else(|e|err(&e.to_string(), &[]));@@ -1510,6 +1548,7 @@ fn main() { std::fs::create_dir_all(&dir).unwrap(); let copy = dir.join("board-0.kicad_pcb"); std::fs::copy(board, ©).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));+ aiflow_copper::copy_project_rules(board, ©.display().to_string()).unwrap_or_else(|e|err(&e,&[])); let pt = prompt_time.clone().unwrap_or_else(now); let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone()); let mut r = Run::create(&dir, ©.display().to_string(), &specp, engine, &pt, &t, target.as_deref(), remote_board.as_deref()).unwrap_or_else(|e| err(&e, &[]));@@ -1533,6 +1572,23 @@ fn main() { 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::AdoptBoard { board, evidence } => {+ thread(&cli); let mut r=load_run(&cli);+ if evidence.trim().is_empty(){err("adopt-board needs --evidence describing native save/refill/read-back", &["Save and refill the board in its EDA, then pass --board <file> --evidence <review notes or report path>.".into()]);}+ Board::load(board).unwrap_or_else(|e|err(&e,&[]));+ let base=aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e|err(&e,&[]));+ let candidate=aiflow_copper::run_drc(board).unwrap_or_else(|e|err(&e,&[]));+ let summary=aiflow_copper::summarize(&candidate,Some(&base));+ if summary["newErrors"].as_u64()!=Some(0){err(&format!("adopt-board adds native DRC violations: {}",summary["newErrorList"]), &["Correct the new violations and rerun adopt-board. Existing connectivity may remain for later routing.".into()]);}+ let n=r.data["boards"].as_array().map(|a|a.len()).unwrap_or(1);+ let dest=dir.join(format!("board-{n}.kicad_pcb"));+ std::fs::copy(board,&dest).unwrap_or_else(|e|err(&e.to_string(),&[]));+ aiflow_copper::copy_project_rules(board,&dest.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+ r.add_board(&dest.display().to_string());+ r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary}));+ r.save().unwrap();+ ok("native-saved board adopted; historical snapshots preserved", &["Run plan, then route or gate against the adopted board. Re-solve Fields before finish.".into()]);+ } Cmd::Components { etch } => { thread(&cli); let mut r = load_run(&cli);@@ -1614,8 +1670,8 @@ fn main() { let mut r = load_run(&cli); for kv in decisions { let Some((stage, engine)) = kv.split_once('=') else { err(&format!("bad decision {kv}; use stage=engine"), &[]) };- if !STAGES.contains(&stage) {- err(&format!("unknown stage {stage}"), &[format!("Stages: {}", STAGES.join(", "))]);+ if stage.is_empty() || !stage.chars().all(|c| c.is_ascii_alphanumeric() || c=='-' || c=='_') || !["ai","binary"].contains(&engine) {+ err("invalid stage decision", &["Use adom-aiflow take <stage>=ai or <stage>=binary; custom stage names may contain letters, digits, hyphens and underscores.".into()]); } r.data["decisions"][stage] = json!(engine); }@@ -1626,7 +1682,7 @@ fn main() { thread(&cli); let mut r = load_run(&cli); if action == "start" {- let e = r.engine_for(name);+ let e = r.data["decisions"][name].as_str().unwrap_or("ai").to_string(); r.stage_start(name, Some(&e), note.as_deref()); r.save().unwrap(); ok(&format!("stage {name} started at {} by {e}", now()), &[]);@@ -1645,7 +1701,8 @@ fn main() { "pack" => { let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[])); let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));- let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, 0.5);+ let courtyard_edge=spec_of(&r)["placement"]["courtyardEdgeClearance"].as_f64().unwrap_or(0.0);+ let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge); write_json(Path::new(out), &serde_json::to_value(&mv).unwrap()); let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count(); ok(&format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n")), &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }]);@@ -1657,15 +1714,19 @@ fn main() { let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1); let outp = dir.join(format!("board-{n}.kicad_pcb")); std::fs::write(&outp, &text).unwrap();+ aiflow_copper::copy_project_rules(&r.current_board(), &outp.display().to_string()).unwrap_or_else(|e|err(&e,&[])); let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[])); let moved: Vec<String> = mv.keys().cloned().collect(); let problems = aiflow_place::check(&nb, &moved, 0.05); let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));- let s = aiflow_copper::summarize(&drc, None);- let base = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);+ let baseline = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));+ let s = aiflow_copper::summarize(&drc, Some(&baseline)); let errors = s["errors"].as_u64().unwrap_or(0);- if !problems.is_empty() || errors > base {- err(&format!("the moved board is not clean: {} courtyard/outline problems, DRC {errors} errors (baseline {base}): {}", problems.len(), s["errorTypes"]), &[problems.join("; "), format!("Change the wishes and pack again; the copy stays at {} for inspection.", outp.display())]);+ // Bounding boxes are conservative packing hints, not exact courtyard geometry.+ // Native KiCad DRC decides whether the actual courtyards overlap.+ if !problems.is_empty() { eprintln!("Hint: conservative bounding-box advisory: {}. Native courtyard DRC is authoritative.", problems.join("; ")); }+ if s["newErrors"].as_u64() != Some(0) {+ err(&format!("the moved board adds {} native DRC violations: {}", s["newErrors"], s["newErrorTypes"]), &[format!("Change the wishes and pack again; inspect {}. Findings are matched by identity, not an inherited-error count.", outp.display())]); } r.add_board(&outp.display().to_string()); r.save().unwrap();@@ -1703,9 +1764,12 @@ fn main() { } let spec = spec_of(&r); let b = board_of(&r);+ let missing=aiflow_pours::missing_planes(&b,&spec);+ if !missing.is_empty(){err(&format!("declared reference planes do not exist: {}",missing.join(", ")), &["Create the listed net/layer zones through the EDA bridge, fill and save them, then `adom-aiflow adopt-board --board <saved.kicad_pcb> --evidence <native review notes>` before route. A spec.planes declaration alone is not copper.".into()]);} let rules = aiflow_router::Rules::from_spec(&spec);+ if spec["stub"].as_f64().map(|s|s<rules.stub).unwrap_or(false){eprintln!("Hint: escape width raised to {} mm to satisfy minTrackWidth (default: track width). Declare a smaller manufacturing minimum only when the board process supports it.",rules.stub);} r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));- let mut router = aiflow_router::Router::new(b, rules, false);+ let mut router = aiflow_router::Router::new(b, rules, true); let (plan, report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global); std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok(); let planp = dir.join("plan-route.json");@@ -1763,6 +1827,7 @@ fn main() { let (text, stats) = aiflow_copper::apply(&b, &plan_v, true).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[])); let routed = dir.join("board-routed.kicad_pcb"); std::fs::write(&routed, &text).unwrap();+ aiflow_copper::copy_project_rules(&r.current_board(), &routed.display().to_string()).unwrap_or_else(|e|err(&e,&[])); let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[])); let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[])); let mut s = aiflow_copper::summarize(&data, Some(&base));@@ -1908,7 +1973,7 @@ fn main() { r.save().unwrap(); ok(&format!("copper kept per layer (KiCad's fills): {}; coverage {}", zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]); }- Cmd::Analyze { what, plan } => {+ Cmd::Analyze { what, plan, fields } => { thread(&cli); let mut r = load_run(&cli); let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));@@ -1918,9 +1983,18 @@ fn main() { let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None }; let name = format!("analyze-{what}"); r.stage_start(&name, Some("binary"), None);- let (passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_of(&r)))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+ let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_of(&r)))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+ let fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));+ let screen=if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(()) } else if fields_file.is_file() {+ let f=read_json(&fields_file.display().to_string());+ let board_bytes=std::fs::read(r.current_board()).unwrap_or_else(|e|err(&e.to_string(),&[]));+ let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[]));+ aiflow_analyze::field_screen(&f,&spec,&board_bytes,&spec_bytes,what)+ } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),r.current_board(),r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };+ let passed=heuristic_passed && screen.is_ok();+ let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into()); r.stage_end(&name, Some(json!({"pass": passed})), None);- r.outcome(&name, json!({"pass": passed, "results": res}));+ r.outcome(&name, json!({"pass": passed, "heuristicPass":heuristic_passed, "fieldReport":fields_file, "fieldNote":field_note, "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@@ -1936,9 +2010,9 @@ fn main() { 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 not passed: {field_note}\n{}", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]); }- ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &[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")), &[draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()]); } Cmd::Finish => { let t = thread(&cli);@@ -1961,6 +2035,14 @@ fn main() { for k in ["analyze-current", "analyze-thermal"] { if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) { missing.push(format!("{k} (pass)"));+ } else {+ if !aiflow_analyze::needs_field_screen(&spec_of(&r),k.trim_start_matches("analyze-")){continue;}+ let report=o[k]["fieldReport"].as_str().and_then(|p|std::fs::read(p).ok()).and_then(|b|serde_json::from_slice::<Value>(&b).ok());+ let board_bytes=std::fs::read(r.current_board()).unwrap_or_default();+ let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();+ if let Some(report)=report {+ if let Err(reason)=aiflow_analyze::field_screen(&report,&spec_of(&r),&board_bytes,&spec_bytes,k.trim_start_matches("analyze-")) { missing.push(format!("{k}: {reason}")); }+ } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); } } } let mut live = Value::Null;@@ -1985,7 +2067,7 @@ fn main() { r.data["summary"] = summary; r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())})); r.save().unwrap();- ok(&format!("finished: {} from the prompt to a qualified board, {:.1} min\n{}", r.data["engine"], r.elapsed_minutes(), step_table(&r)), &["The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);+ ok(&format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\n{}", r.data["engine"], r.elapsed_minutes(), step_table(&r)), &["The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]); } Cmd::Capture { action, label, reason } => { let mut r = load_run(&cli);@@ -2130,7 +2212,7 @@ fn main() { 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 } => {+ Cmd::Artifact { file, kind, caption, step, replaces, review } => { thread(&cli); let mut r = load_run(&cli); let src = PathBuf::from(file);@@ -2142,11 +2224,18 @@ fn main() { 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 review_path=if kind=="library-overview" {+ let rp=review.as_ref().unwrap_or_else(||err("library-overview needs --review <review.json>", &["Review exact selected STEP/GLB variants in the native EDA and the overview. See docs/library-overview-review.md.".into()]));+ library_review::verify(rp,Path::new(&r.current_board()),&dest).unwrap_or_else(|e|err(&e,&["Update the actual selection/native evidence, then re-register; do not mark pending evidence passed.".into()]));+ let seconds=media_seconds(dest.to_str().unwrap());+ if !(1.0..=6.0).contains(&seconds){err("library overview must be a moving clip between one and six seconds", &["Keep the full component tour separate; cut a roughly five-second overview.".into()]);}+ Some(std::fs::canonicalize(rp).unwrap_or(rp.clone()).display().to_string())+ }else{None}; let replaced = replaces.as_ref().map(|p| std::fs::canonicalize(p).unwrap_or(PathBuf::from(p)).display().to_string());- r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced}));+ r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced, "review":review_path})); 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()]);+ ok(&format!("{kind} registered for {step} (visit {visit}): {}", dest.display()), &[if is_image { "It goes on the run page under this step with your caption above it, and into the final video as a fullscreen shot at this point of the step (the one place the video leaves the EDA window).".to_string() } else if kind=="library-overview" { "The reviewed moving overview goes into compose; the full tour remains separate.".into() } else { "It goes on the run page under this step with your caption; this artifact kind does not automatically enter the final video.".to_string() }, "Next: if this was an analysis, walk the copper it judged on camera under this same step: `tour nets --nets <the pour nets>`.".into()]); } Cmd::Compare { with, page, push, refresh } => { let t = thread(&cli);@@ -2181,7 +2270,7 @@ fn main() { 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()]);+ 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(), "Measure external work with adom-aiflow exec -- <program> <args>. Unwrapped long gaps are unobserved and may undercount active work, not proof of idle time.".into()]); } Cmd::Done { message } => { thread(&cli);@@ -2194,14 +2283,14 @@ fn main() { 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()]);+ ok(&format!("session {} done at {t}: {:.1} min of AI time on it ({:.0} min unobserved gaps excluded); {:.1} min of AI time on the run so far", cur.n, cur.active_seconds() as f64 / 60.0, cur.idle_seconds as f64 / 60.0, r.active_minutes_total()), &["report --push --refresh puts the session on the run page.".into()]); } Cmd::Sessions { json: as_json } => { thread(&cli); let r = load_run(&cli); let ss = r.sessions(); if *as_json {- println!("{}", serde_json::to_string_pretty(&ss.iter().map(|x| json!({"n": x.n, "promptAt": aiflow_run::iso(x.prompt_at), "doneAt": x.done_at.map(aiflow_run::iso), "text": x.text, "activeMinutes": (x.active_seconds() as f64 / 6.0).round() / 10.0, "idleMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "spans": x.spans.iter().map(|(a, b)| json!([aiflow_run::iso(*a), aiflow_run::iso(*b)])).collect::<Vec<_>>(), "open": x.open})).collect::<Vec<_>>()).unwrap());+ println!("{}", serde_json::to_string_pretty(&ss.iter().map(|x| json!({"n": x.n, "promptAt": aiflow_run::iso(x.prompt_at), "doneAt": x.done_at.map(aiflow_run::iso), "text": x.text, "activeMinutes": (x.active_seconds() as f64 / 6.0).round() / 10.0, "idleMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "unobservedMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "measurement": "ledger activity with heuristic gap exclusion; not harness-complete", "spans": x.spans.iter().map(|(a, b)| json!([aiflow_run::iso(*a), aiflow_run::iso(*b)])).collect::<Vec<_>>(), "open": x.open})).collect::<Vec<_>>()).unwrap()); return; } println!("{}", sessions_table(&r));
crates/aiflow-analyze/Cargo.toml+2@@ -8,3 +8,5 @@ description = "adom-aiflow: analyze" [dependencies] serde = { workspace = true } serde_json = { workspace = true }++sha2 = "0.10"
crates/aiflow-analyze/src/lib.rs+63−3@@ -104,9 +104,9 @@ pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, p let via_amps = if w.via_drill >= 0.4 { 1.5 } else { 1.0 }; let via_capacity = w.vias as f64 * via_amps; let mut r = json!({"net": net, "amps": amps, "maxRiseC": max_rise, "narrowestTrackMm": narrowest, "widestTrackMm": widest, "riseAtNarrowestC": (rise_narrow * 10.0).round() / 10.0, "riseAtWidestC": (rise_wide * 10.0).round() / 10.0, "vias": w.vias, "viaCapacityA": via_capacity, "pourLayers": pour_layers, "pourFilledMm2": pour_area});- if rise_wide <= max_rise {+ if amps == 0.0 || (narrowest > 0.0 && rise_narrow <= max_rise) { r["status"] = json!("pass");- r["how"] = json!(format!("the widest track carries the load within {max_rise:.0} C"));+ r["how"] = json!(format!("the narrowest listed track carries the full specified load within {max_rise:.0} C")); } else if !pour_layers.is_empty() && pour_area > 0.0 { let one_layer = pad_layers.and_then(|m| m.get(&net)).filter(|l| l.len() == 1).map(|l| l[0].clone()).filter(|l| pour_layers.contains(l)); if let (true, Some(l)) = (pour_layers.len() >= 2 && via_capacity < amps, one_layer.clone()) {@@ -123,7 +123,7 @@ pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, p } else { r["status"] = json!("fail"); let need = [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0].iter().find(|w| rise_for(**w, t_um, amps, true) <= max_rise).map(|w| format!("{w}")).unwrap_or_else(|| ">6".into());- r["hint"] = json!(format!("{net} carries {amps:.1} A on a {widest:.2} mm track ({rise_wide:.0} C rise, limit {max_rise:.0} C) and has no pour: pour it on the outer layers or widen to {need} mm"));+ r["hint"] = json!(format!("{net} carries {amps:.1} A on a {narrowest:.2} mm bottleneck ({rise_narrow:.0} C rise, limit {max_rise:.0} C) and has no pour: pour it on the outer layers or widen to {need} mm")); ok = false; } results.push(r);@@ -189,3 +189,63 @@ mod tests { assert!(rise_for(0.25, 35.0, 20.0, true) > 100.0); } }++#[cfg(test)]+mod regression_tests {+ use super::*;+ #[test] fn wide_series_segment_cannot_hide_a_narrow_bottleneck() {+ let plan=json!({"nets":[{"net":"POWER","width":0.25},{"net":"POWER","width":40.0}]});+ let spec=json!({"loads":{"POWER":{"amps":20.0,"maxRiseC":10.0}},"copperUm":35.0});+ let (pass,result)=analyze_current(&plan,&spec,None,None);+ assert!(!pass); assert_eq!(result[0]["status"],"fail");+ assert!(result[0]["riseAtWidestC"].as_f64().unwrap()<10.0);+ let (pass,_)=analyze_current(&json!({"nets":[{"net":"POWER","width":40.0}]}),&spec,None,None);+ assert!(pass);+ }+}++/// Bind a field screen to the exact saved board and specification, and require coverage.+pub fn field_screen(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes: &[u8], kind: &str) -> Result<(), String> {+ use sha2::{Digest, Sha256};+ for (key,bytes) in [("board",board_bytes),("spec",spec_bytes)] {+ let expected=format!("{:x}",Sha256::digest(bytes));+ if fields["inputIdentity"][key].as_str()!=Some(expected.as_str()) {+ return Err(format!("Fields {key} identity is missing or stale; run adom-fields analyze on this saved board and spec again"));+ }+ }+ let (group, results, key, demand) = if kind=="current" { ("loads","nets","net","amps") } else { ("hot","chips","reference","watts") };+ let entries = fields[results].as_array().ok_or_else(||format!("Fields report has no {results}"))?;+ for (name,value) in spec[group].as_object().into_iter().flatten() {+ if value[demand].as_f64().unwrap_or(0.0)>0.0 && !entries.iter().any(|r|r[key].as_str()==Some(name.as_str())) {+ return Err(format!("Fields did not solve {group}.{name}; resolve its missing terminals or copper and rerun analyze"));+ }+ }+ let issues=fields["issues"].as_array().ok_or("Fields report has no issues array")?;+ let relevant: Vec<_>=issues.iter().filter(|i| if kind=="current" { i["kind"]=="current-neck" } else { i["kind"]=="tab-rise" || i["kind"]=="thermal-vias" }).collect();+ if !relevant.is_empty() { return Err(relevant.iter().map(|i|i["what"].as_str().unwrap_or("unresolved field finding")).collect::<Vec<_>>().join("; ")); }+ Ok(())+}++#[cfg(test)]+mod field_screen_tests {+ use super::*;+ use sha2::{Digest,Sha256};+ fn report() -> Value { serde_json::json!({"inputIdentity":{"board":format!("{:x}",Sha256::digest(b"board")),"spec":format!("{:x}",Sha256::digest(b"spec"))},"nets":[{"net":"VCC"}],"chips":[{"reference":"Q1"}],"issues":[]}) }+ #[test]+ fn fresh_solve_requires_identity_coverage_and_no_relevant_findings() {+ let spec=serde_json::json!({"loads":{"VCC":{"amps":2}},"hot":{"Q1":{"watts":1}}});+ assert!(field_screen(&report(),&spec,b"board",b"spec","current").is_ok());+ assert!(field_screen(&report(),&spec,b"changed",b"spec","current").is_err());+ let mut f=report();f["nets"]=serde_json::json!([]);+ assert!(field_screen(&f,&spec,b"board",b"spec","current").unwrap_err().contains("VCC"));+ let mut f=report();f["issues"]=serde_json::json!([{"kind":"tab-rise","what":"Q1 exceeds its rise budget"}]);+ assert!(field_screen(&f,&spec,b"board",b"spec","thermal").is_err());+ assert!(field_screen(&f,&spec,b"board",b"spec","current").is_ok());+ }+}++/// A board with no specified positive loads/losses has no corresponding field screen to run.+pub fn needs_field_screen(spec:&Value,kind:&str)->bool {+ let (group,demand)=if kind=="current"{("loads","amps")}else{("hot","watts")};+ spec[group].as_object().into_iter().flatten().any(|(_,v)|v[demand].as_f64().unwrap_or(0.0)>0.0)+}
crates/aiflow-copper/src/lib.rs+49−6@@ -437,13 +437,35 @@ pub fn apply(board: &Board, plan: &Value, fill: bool) -> Result<(String, Value), // ----------------------------------------------------------------------------- DRC +/// Keep native project constraints beside each board snapshot, under its new basename.+pub fn copy_project_rules(source: &str, destination: &str) -> Result<(), String> {+ for ext in ["kicad_pro", "kicad_dru"] {+ let src=std::path::Path::new(source).with_extension(ext);+ let dst=std::path::Path::new(destination).with_extension(ext);+ if src.is_file() && src != dst {+ std::fs::copy(&src,&dst).map_err(|e| format!("copy project rules {}: {e}",src.display()))?;+ }+ }+ Ok(())+}+ pub fn run_drc(path: &str) -> Result<Value, String> { let dir = std::env::temp_dir().join(format!("aiflow-drc-{}", std::process::id())); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let report = dir.join(format!("drc-{}.json", uuid()));- let out = Command::new("service-kicad").args(["pcb", "drc", "--format", "json", "--out", report.to_str().unwrap(), path]).output().map_err(|e| format!("service-kicad not runnable: {e}"))?;+ let native = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+ let has_native = Command::new(&native).arg("--version").output().map(|o|o.status.success()).unwrap_or(false);+ let has_rules = ["kicad_pro", "kicad_dru"].iter().any(|ext|std::path::Path::new(path).with_extension(ext).is_file());+ if has_rules && !has_native {+ return Err("project rules are present but service-kicad accepts only the PCB and would lose them. Set ADOM_AIFLOW_KICAD_CLI to a native kicad-cli executable and retry this command; do not strip the project files to obtain a pass".into());+ }+ let out = if has_native {+ Command::new(&native).args(["pcb","drc","--format","json","--output",report.to_str().unwrap(),path]).output()+ } else {+ Command::new("service-kicad").args(["pcb", "drc", "--format", "json", "--out", report.to_str().unwrap(), path]).output()+ }.map_err(|e|format!("DRC backend not runnable: {e}"))?; if !out.status.success() || !report.is_file() {- return Err(format!("service-kicad drc failed: {} {}", String::from_utf8_lossy(&out.stdout).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>(), String::from_utf8_lossy(&out.stderr).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>()));+ return Err(format!("KiCad DRC failed: {} {}", String::from_utf8_lossy(&out.stdout).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>(), String::from_utf8_lossy(&out.stderr).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>())); } let data: Value = serde_json::from_str(&std::fs::read_to_string(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&report);@@ -464,17 +486,23 @@ fn count_types(list: &[&Value]) -> BTreeMap<String, usize> { m } -/// The gate's summary: errors, new versus inherited against a baseline report, unconnected.+// Manufacturing constraints remain blocking even when a project downgrades them to warnings.+fn blocking_violation(v: &Value) -> bool {+ v["severity"] == "error" || (v["severity"] == "warning" && matches!(v["type"].as_str(),+ Some("hole_to_hole" | "holes_co_located" | "drill_out_of_range" | "annular_width" | "track_width" | "clearance" | "copper_edge_clearance" | "courtyards_overlap" | "courtyard_overlap")))+}++/// The gate counts errors and manufacturing warnings, matching inherited findings by identity. pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value { let viol: Vec<&Value> = data.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();- let errors: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("error")).collect();+ let errors: Vec<&Value> = viol.iter().copied().filter(|v| blocking_violation(v)).collect(); let warnings_all: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("warning")).collect(); let mut new: Vec<&Value> = errors.clone(); let mut inherited: Vec<&Value> = Vec::new(); if let Some(base) = baseline { let mut existing: HashMap<String, usize> = HashMap::new(); for v in base.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect::<Vec<_>>()).unwrap_or_default() {- if v.get("severity").and_then(|s| s.as_str()) == Some("error") {+ if blocking_violation(v) { *existing.entry(fingerprint(v)).or_insert(0) += 1; } }@@ -494,10 +522,25 @@ pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value { let warnings: Vec<&Value> = warnings_all.iter().copied().filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("isolated_copper")).collect(); let desc = |v: &Value| -> Vec<Value> { v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("description").cloned().unwrap_or(Value::Null)).collect()).unwrap_or_default() }; json!({- "errors": errors.len(), "newErrors": new.len(), "inheritedErrors": inherited.len(), "warnings": warnings.len(), "unconnected": unconnected.len(),+ "blockingIncludesManufacturingWarnings": true, "errors": errors.len(), "newErrors": new.len(), "inheritedErrors": inherited.len(), "warnings": warnings.len(), "unconnected": unconnected.len(), "rasterFillIslandWarnings": fill_artifacts, "errorTypes": count_types(&errors), "newErrorTypes": count_types(&new), "warningTypes": count_types(&warnings), "newErrorList": new.iter().map(|v| json!({"type": v.get("type"), "description": v.get("description"), "items": desc(v), "pos": v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>())})).collect::<Vec<_>>(), "unconnectedList": unconnected.iter().map(|u| desc(u)).collect::<Vec<_>>(), "kicadVersion": data.get("kicad_version"), }) }++#[cfg(test)]+mod gate_tests {+ use super::*;+ #[test]+ fn manufacturing_warnings_block_but_library_warnings_do_not() {+ let hole = json!({"type":"hole_to_hole","severity":"warning","description":"too close","items":[{"pos":{"x":1,"y":2}}]});+ let library = json!({"type":"lib_footprint_mismatch","severity":"warning","description":"local library differs"});+ let candidate=json!({"violations":[hole.clone(),library]});+ assert_eq!(summarize(&candidate,None)["newErrors"],1);+ assert_eq!(summarize(&candidate,Some(&json!({"violations":[hole]})))["newErrors"],0);+ let moved=json!({"violations":[{"type":"hole_to_hole","severity":"warning","description":"too close","items":[{"pos":{"x":2,"y":2}}]}]});+ assert_eq!(summarize(&candidate,Some(&moved))["newErrors"],1);+ }+}
crates/aiflow-copper/tests/fixtures/project-rules.kicad_pcbadded+163@@ -0,0 +1,163 @@+(kicad_pcb+(version 20260206)+(generator "pcbnew")+(generator_version "10.0")+(general+ (thickness 1.6166)+ (legacy_teardrops no)+ )+(paper "A4")+(layers+ (0 "F.Cu" signal)+ (4 "In1.Cu" signal)+ (6 "In2.Cu" signal)+ (2 "B.Cu" signal)+ (9 "F.Adhes" user "F.Adhesive")+ (11 "B.Adhes" user "B.Adhesive")+ (13 "F.Paste" user)+ (15 "B.Paste" user)+ (5 "F.SilkS" user "F.Silkscreen")+ (7 "B.SilkS" user "B.Silkscreen")+ (1 "F.Mask" user)+ (3 "B.Mask" user)+ (17 "Dwgs.User" user "User.Drawings")+ (19 "Cmts.User" user "User.Comments")+ (21 "Eco1.User" user "User.Eco1")+ (23 "Eco2.User" user "User.Eco2")+ (25 "Edge.Cuts" user)+ (27 "Margin" user)+ (31 "F.CrtYd" user "F.Courtyard")+ (29 "B.CrtYd" user "B.Courtyard")+ (35 "F.Fab" user)+ (33 "B.Fab" user)+ (39 "User.1" user)+ (41 "User.2" user)+ (43 "User.3" user)+ (45 "User.4" user)+ )+(setup+ (stackup+ (layer "F.SilkS"+ (type "Top Silk Screen")+ (color "White")+ )+ (layer "F.Paste"+ (type "Top Solder Paste")+ )+ (layer "F.Mask"+ (type "Top Solder Mask")+ (color "Green")+ (thickness 0.0152)+ )+ (layer "F.Cu"+ (type "copper")+ (thickness 0.035)+ )+ (layer "dielectric 1"+ (type "prepreg")+ (color "FR4 natural")+ (thickness 0.2104)+ (material "FR4")+ (epsilon_r 4.1)+ (loss_tangent 0.02)+ )+ (layer "In1.Cu"+ (type "copper")+ (thickness 0.0152)+ )+ (layer "dielectric 2"+ (type "core")+ (color "FR4 natural")+ (thickness 1.065)+ (material "FR4")+ (epsilon_r 4.1)+ (loss_tangent 0.02)+ )+ (layer "In2.Cu"+ (type "copper")+ (thickness 0.0152)+ )+ (layer "dielectric 3"+ (type "prepreg")+ (color "FR4 natural")+ (thickness 0.2104)+ (material "FR4")+ (epsilon_r 4.1)+ (loss_tangent 0.02)+ )+ (layer "B.Cu"+ (type "copper")+ (thickness 0.035)+ )+ (layer "B.Mask"+ (type "Bottom Solder Mask")+ (color "Green")+ (thickness 0.0152)+ )+ (layer "B.Paste"+ (type "Bottom Solder Paste")+ )+ (layer "B.SilkS"+ (type "Bottom Silk Screen")+ (color "White")+ )+ (copper_finish "None")+ (dielectric_constraints no)+ )+ (pad_to_mask_clearance 0.09)+ (solder_mask_min_width 0.1)+ (allow_soldermask_bridges_in_footprints yes)+ (tenting+ (front yes)+ (back yes)+ )+ (covering+ (front no)+ (back no)+ )+ (plugging+ (front no)+ (back no)+ )+ (capping no)+ (filling no)+ (pcbplotparams+ (layerselection 0x00000000_00000000_55555555_5755f5ff)+ (plot_on_all_layers_selection 0x00000000_00000000_00000000_00000000)+ (disableapertmacros no)+ (usegerberextensions no)+ (usegerberattributes yes)+ (usegerberadvancedattributes yes)+ (creategerberjobfile yes)+ (dashed_line_dash_ratio 12)+ (dashed_line_gap_ratio 3)+ (svgprecision 4)+ (plotframeref no)+ (mode 1)+ (useauxorigin no)+ (pdf_front_fp_property_popups yes)+ (pdf_back_fp_property_popups yes)+ (pdf_metadata yes)+ (pdf_single_document no)+ (dxfpolygonmode yes)+ (dxfimperialunits yes)+ (dxfusepcbnewfont yes)+ (psnegative no)+ (psa4output no)+ (plot_black_and_white yes)+ (sketchpadsonfab no)+ (plotpadnumbers no)+ (hidednponfab no)+ (sketchdnponfab yes)+ (crossoutdnponfab yes)+ (subtractmaskfromsilk no)+ (outputformat 1)+ (mirror no)+ (drillshape 1)+ (scaleselection 1)+ (outputdirectory "")+ )+ )+(gr_rect (start 0 0) (end 10 10) (stroke (width 0.05) (type default)) (fill none) (layer "Edge.Cuts") (uuid "bc7ecaf0-adc1-4b70-9287-d5d07f5cf7a4"))+(segment (start 0.5 4) (end 0.5 6) (width 0.2) (layer "F.Cu") (net "SIG") (uuid "0d359d97-774b-4984-b97b-d399793a5ec7"))+)
crates/aiflow-copper/tests/fixtures/project-rules.kicad_proadded+1@@ -0,0 +1 @@+{"board":{"design_settings":{"rules":{"min_copper_edge_clearance":0.3}}}}
crates/aiflow-copper/tests/project_rules.rsadded+15@@ -0,0 +1,15 @@+use std::path::Path;+#[test]+#[ignore = "requires a native KiCad CLI, optionally ADOM_AIFLOW_KICAD_CLI"]+fn native_project_edge_rule_survives_snapshot_copy() {+ let dir=std::env::temp_dir().join(format!("aiflow-native-rules-{}",std::process::id()));std::fs::create_dir_all(&dir).unwrap();+ let src=Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/project-rules.kicad_pcb");+ let dst=dir.join("snapshot.kicad_pcb");std::fs::copy(&src,&dst).unwrap();+ aiflow_copper::copy_project_rules(src.to_str().unwrap(),dst.to_str().unwrap()).unwrap();+ let with=aiflow_copper::run_drc(dst.to_str().unwrap()).unwrap();+ let defaults=dir.join("default.kicad_pcb");std::fs::copy(&src,&defaults).unwrap();+ let without=aiflow_copper::run_drc(defaults.to_str().unwrap()).unwrap();+ let edge_count=|v:&serde_json::Value|v["violations"].as_array().unwrap().iter().filter(|v|v["type"]=="copper_edge_clearance").count();+ assert_eq!(edge_count(&with),0);assert!(edge_count(&without)>0);+ std::fs::remove_dir_all(dir).unwrap();+}
crates/aiflow-pours/src/lib.rs+32@@ -269,3 +269,35 @@ pub fn describe(z: &Value) -> String { let poly = poly_of(z); format!("{:<10} {layers:<12} prio {} {} {}", z.get("net").and_then(|n| n.as_str()).unwrap_or("?"), z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0), z.get("connection").and_then(|c| c.as_str()).unwrap_or("thermal"), if z.get("outline").is_some() { "outline".to_string() } else { format!("{:.0} mm2", geom::polygon_area(&poly).abs()) }) }++/// Declared planes must be real copper zones before routing treats their pads as connected by stubs.+pub fn missing_planes(board: &Board, spec: &Value) -> Vec<String> {+ let mut missing=Vec::new();+ for (net, layer) in spec["planes"].as_object().into_iter().flatten() {+ let Some(layer)=layer.as_str() else { missing.push(format!("{net}: plane layer must be a string")); continue };+ let found=board.root.find_all("zone").any(|z| {+ if z.find("keepout").is_some(){return false}+ let zn=z.find("net_name").and_then(|n|n.value()).map(str::to_string).or_else(||z.find("net").and_then(|n|n.value()).map(|n|n.parse::<i64>().ok().and_then(|i|board.net_table.get(&i).cloned()).unwrap_or_else(||n.into())));+ let layers=z.find("layers").or_else(||z.find("layer"));+ let on_layer=layers.map(|n|(1..=n.items.len()).any(|i|n.atom(i)==Some(layer))).unwrap_or(false);+ zn.as_deref()==Some(net.as_str()) && on_layer && z.find("polygon").is_some()+ });+ if !found{missing.push(format!("{net} on {layer}"))}+ }+ missing+}++#[cfg(test)]+mod plane_tests {+ use super::*;+ #[test]+ fn declaration_without_copper_is_not_a_plane(){+ let make=|extra:&str|Board::from_text("fixture",format!("(kicad_pcb (layers (0 \"F.Cu\" signal)(4 \"In1.Cu\" power)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 10 10)(layer \"Edge.Cuts\")) {extra})")).unwrap();+ let spec=json!({"planes":{"GND":"In1.Cu"}});+ assert_eq!(missing_planes(&make(""),&spec),vec!["GND on In1.Cu"]);+ let zone="(zone (net \"GND\") (layer \"In1.Cu\") (polygon (pts (xy 0 0)(xy 10 0)(xy 10 10))))";+ assert!(missing_planes(&make(zone),&spec).is_empty());+ let keepout=zone.replacen("(zone", "(zone (keepout (copperpour not_allowed))",1);+ assert_eq!(missing_planes(&make(&keepout),&spec).len(),1);+ }+}
crates/aiflow-router/src/drive.rs+36−1@@ -23,6 +23,23 @@ pub struct Search<'a> { pub vip: Vec<Term>, } +// Existing copper is in via_ok. Also check layer transitions in this unfinished path.+fn path_vias_clear(n: usize, parent: &[usize], layer_cells: usize, width: usize, gap_mm: f64) -> bool {+ let m = n % layer_cells;+ let mut cursor = n;+ while parent[cursor] != usize::MAX {+ let prev = parent[cursor];+ if cursor / layer_cells != prev / layer_cells {+ let via = cursor % layer_cells;+ let dx = (m % width) as f64 - (via % width) as f64;+ let dy = (m / width) as f64 - (via / width) as f64;+ if dx.hypot(dy) * RES < gap_mm { return false; }+ }+ cursor = prev;+ }+ true+}+ impl Router { /// A* from sources to targets over the window. Returns (cells, blockers). pub fn search(&mut self, net: usize, width: f64, s: Search) -> (Option<Vec<Term>>, HashSet<usize>) {@@ -169,7 +186,9 @@ impl Router { heap.push(Reverse((ng + h(ni, nj), nn))); } }- if s.allow_via && (via_ok.bits[m] || vipset.contains(&m)) {+ if s.allow_via && (via_ok.bits[m] || vipset.contains(&m))+ && path_vias_clear(n, &parent, nl, ww, self.nets[net].drill + self.rules.hole_to_hole + slop()) {+ let l2 = 1 - l; if !obs_o[l2].bits[m] { let nn = l2 * nl + m;@@ -497,6 +516,9 @@ impl Router { } self.stamp_stub(net, cluster, t, 1); self.nets[net].clusters[cluster].released.remove(&t);+ if !self.nets[net].clusters[cluster].terminals.contains(&t) {+ self.nets[net].clusters[cluster].terminals.push(t);+ } } else { self.stamp_stub_channel(net, cluster, t); }@@ -1154,3 +1176,16 @@ pub fn route_board(router: &mut Router, passes: usize, rip_per_net: usize, rip_g #[allow(dead_code)] fn _unused(_: HashMap<u8, u8>) {}++#[cfg(test)]+mod path_via_tests {+ use super::*;+ #[test]+ fn nearby_transitions_in_one_path_are_rejected() {+ let mut parent=vec![usize::MAX;200];+ parent[122]=22; parent[123]=122; parent[128]=123;+ assert!(!path_vias_clear(123,&parent,100,10,2.0*RES));+ assert!(path_vias_clear(128,&parent,100,10,2.0*RES));+ assert!(path_vias_clear(22,&parent,100,10,2.0*RES));+ }+}
crates/aiflow-router/src/lib.rs+1−1@@ -232,7 +232,7 @@ impl Router { let g = self.grid.clone(); let pads: Vec<Pad> = self.board.pads.clone(); for p in &pads {- let nid = self.net_ids.get(&p.net).map(|&k| k as i16).unwrap_or(-1);+ let nid = self.net_ids.get(&p.net).map(|&k| k as i16).unwrap_or(-2); // -1 is empty; -2 is physical, unassigned copper. let mut m = g.mask(); for poly in &p.polys { g.poly_mask(poly, &mut m);
crates/aiflow-router/src/rules.rs+16−1@@ -34,7 +34,7 @@ impl Default for Rules { fn default() -> Rules { Rules { track: 0.25, clearance: 0.2, net_clearance: Default::default(), via: 0.8, drill: 0.4, via_small: 0.6, drill_small: 0.3, edge: 0.5,- stub: 0.2, narrow: 0.5, escape: 0.45, corridor: 0.5, ic_zone: 2.0, channel: 2.5,+ stub: 0.25, narrow: 0.5, escape: 0.45, corridor: 0.5, ic_zone: 2.0, channel: 2.5, planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), } }@@ -60,7 +60,11 @@ impl Rules { for (k, v) in m { if let Some(x) = v.as_f64() { r.net_clearance.insert(k.clone(), x); } } } if let Some(v) = num("edgeClearance") { r.edge = v; }+ if let Some(v) = num("holeToHoleClearance").or_else(||num("holeToHole")) { r.hole_to_hole = v; } if let Some(v) = num("track") { r.track = v; }+ // Escape necks inherit the requested track width. An explicit smaller+ // stub must still satisfy the separately declared manufacturing minimum.+ r.stub = num("stub").unwrap_or(r.track).max(num("minTrackWidth").unwrap_or(r.track)); if let Some(v) = num("viaSignal") { r.via_small = v; } if let Some(v) = num("drillSignal") { r.drill_small = v; } if let Some(v) = num("viaPower") { r.via = v; }@@ -68,3 +72,14 @@ impl Rules { r } }++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;+ #[test] fn escape_width_respects_manufacturing_contract() {+ assert_eq!(Rules::from_spec(&json!({"track":0.4})).stub,0.4);+ assert_eq!(Rules::from_spec(&json!({"track":0.4,"stub":0.1})).stub,0.4);+ assert_eq!(Rules::from_spec(&json!({"track":0.4,"stub":0.2,"minTrackWidth":0.15})).stub,0.2);+ }+}
crates/aiflow-router/tests/obstacles.rsadded+41@@ -0,0 +1,41 @@+use aiflow_board::Board;+use aiflow_router::{Router, Rules};++fn fixture() -> Router {+ let board = Board::from_text("regression.kicad_pcb", r#"(kicad_pcb+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+ (net 1 "SIG")+ (gr_rect (start 0 0) (end 12 12) (layer "Edge.Cuts"))+ (footprint "test" (layer "F.Cu") (at 5 5) (property "Reference" "U1")+ (pad "1" smd rect (at -2 0) (size 0.25 1) (layers "F.Cu") (net 1 "SIG"))+ (pad "2" smd rect (at 0 0) (size 1 1) (layers "F.Cu"))+ (pad "3" smd rect (at 2 0) (size 0.25 1) (layers "F.Cu") (net 1 "SIG"))))"#.into()).unwrap();+ Router::new(board, Rules::default(), false)+}++#[test]+fn no_net_copper_blocks_tracks_and_vias_without_inventing_a_net() {+ let mut r = fixture();+ assert_eq!(r.nets.len(), 1);+ assert_eq!(r.board.pad_by_key("U1.2").unwrap().net, "");+ let (i,j) = r.grid.to_cell(5.,5.);+ let win = (0,0,r.grid.w as i64-1,r.grid.h as i64-1);+ let (orth, diagonal, _, via) = r.obstacle_maps(0,0.25,win,false);+ assert!(orth[0].get(i as usize,j as usize));+ assert!(diagonal[0].get(i as usize,j as usize));+ assert!(!via.get(i as usize,j as usize));+}++#[test]+fn released_clear_escape_becomes_searchable_again() {+ let mut r=fixture();+ let c=r.nets[0].clusters.iter().position(|c| !c.escapes.is_empty()).expect("narrow-pad escape");+ let t=*r.nets[0].clusters[c].escapes.keys().next().unwrap();+ r.nets[0].clusters[c].terminals.retain(|x| *x!=t);+ r.nets[0].clusters[c].released.insert(t);+ r.reserve_escapes(0,c);+ assert!(r.nets[0].clusters[c].terminals.contains(&t));+ assert!(!r.nets[0].clusters[c].released.contains(&t));+ r.reserve_escapes(0,c);+ assert_eq!(r.nets[0].clusters[c].terminals.iter().filter(|x| **x==t).count(),1);+}
crates/aiflow-run/src/lib.rs+24−1@@ -203,6 +203,13 @@ impl Run { } pub fn add_board(&mut self, path: &str) {+ let mut invalidated=Vec::new();+ if let Some(outcomes)=self.data["outcomes"].as_object_mut() {+ for key in ["route","gate","land-route","land-vias","land-pours","measure","analyze-current","analyze-thermal"] {+ if outcomes.remove(key).is_some(){invalidated.push(key);}+ }+ }+ if !invalidated.is_empty(){self.log("board-evidence-invalidated",json!({"newBoard":path,"outcomes":invalidated,"reason":"board snapshot changed; historical ledger preserved"}));} self.data["boards"].as_array_mut().unwrap().push(json!(path)); } @@ -420,7 +427,7 @@ impl Run { } /// The idle threshold in minutes: a gap longer than this between two ledger events, with no- /// command running, is the human away, not the AI thinking; it is cut out of the AI's time.+ /// command running, is unobserved (not proven human idle); it is cut out of the AI's time. pub fn idle_minutes(&self) -> u64 { self.data["idleMinutes"].as_u64().unwrap_or(15) }@@ -568,6 +575,22 @@ impl Run { mod tests { use super::*; #[test]+ fn external_turn_covers_a_long_gap_without_inventing_idle_activity() {+ let dir=std::env::temp_dir().join(format!("aiflow-session-{}",std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let t=1_789_299_600;+ let r=Run{dir:dir.clone(),data:json!({"clock":{"promptTime":iso(t)},"idleMinutes":15})};+ let events=vec![json!({"event":"start","t":iso(t)}),json!({"event":"turn","t":iso(t),"n":0}),json!({"event":"turn-end","t":iso(t+3600),"n":0}),json!({"event":"done","t":iso(t+3600)})];+ let write=|events:Vec<Value>|std::fs::write(dir.join("run.jsonl"),events.iter().map(|v|v.to_string()+"\n").collect::<String>()).unwrap();+ write(events);+ assert_eq!(r.sessions()[0].active_seconds(),3600);+ assert_eq!(r.sessions()[0].idle_seconds,0);+ write(vec![json!({"event":"start","t":iso(t)}),json!({"event":"done","t":iso(t+3600)})]);+ assert_eq!(r.sessions()[0].active_seconds(),0);+ assert_eq!(r.sessions()[0].idle_seconds,3600);+ std::fs::remove_dir_all(dir).unwrap();+ }+ #[test] fn iso_round_trips() { assert_eq!(iso(0), "1970-01-01T00:00:00Z"); let t = 1_789_299_600; // some 2026 second
docs/library-overview-review.mdadded+25@@ -0,0 +1,25 @@+# Component library overview in the final video++Keep the full component tour as a separate `library-tour` artifact. Register only its roughly five-second moving overview as `library-overview`. Compose includes that overview in ledger order and reserves time for it within the final cut. Review selected variants in the native EDA first: an MPN-marked tour is not evidence that the board uses those same models.++```+adom-aiflow --ai-thread <thread> --run <run> artifact --kind library-overview --file overview.mp4 --review review.json --caption "The selected component library, including the marked models used on this board."+```++The review JSON is explicit human/AI review evidence, not an automatic visual-quality certificate:++```json+{+ "boardSha256": "SHA256 of the run's current saved PCB",+ "clipSha256": "SHA256 of overview.mp4",+ "visualReview": "passed",+ "nativeLibraryReview": "passed",+ "selection": {"file": "selection.json", "sha256": "SHA256"},+ "nativeReadback": {"file": "native-model-readback.json", "sha256": "SHA256"},+ "nativeScreenshot": {"file": "native-board.png", "sha256": "SHA256"}+}+```++Use the same selection manifest accepted by `tools/library-tour.py build`: target `eda`, `mpnMarking` on/off, and each component's `selectedModel` with variant, STEP and GLB paths and hashes. Paths are relative to the containing manifest or absolute. All model hashes are checked. Registration and composition both recheck the board, clip, selection, and native evidence. A pending render review or an old screenshot must not be marked passed. Review the visible native bodies, pin-one indicators, orientation, selected marking/light variants and the tour contact sheet/playback before attesting. If anything changes, redo the affected review and use `--replaces <old clip>` to retain the history without repeating the old overview.++The full tour does not enter the final cut automatically. No static wiki/title cards are added. This does not validate electrical function, footprint dimensions or redistribution rights; those reviews and provenance remain on the reusable global component pages.
docs/release-0.1.27.mdadded+13@@ -0,0 +1,13 @@+# AI Flow 0.1.27++This maintenance release strengthens the checks behind the workflow. It preserves the silkscreen dashboard, component library, widget, recording and comparison commands released in 0.1.26.++- Router: no-net copper remains an obstacle; released escape terminals rejoin the search; escape width inherits the requested track width and respects the declared minimum; same-path via transitions obey hole spacing; routing progress streams while solving.+- Gates: native manufacturing warnings block, inherited findings match by identity, exact native courtyard DRC supersedes conservative box advisories, and project rules travel with snapshots. Set `ADOM_AIFLOW_KICAD_CLI` when needed. The single-file service is refused when it would discard project constraints.+- Planes: routing refuses missing declared planes early. `adopt-board --board ... --evidence ...` incorporates a native-saved, checked snapshot without editing the ledger. New snapshots invalidate dependent outcomes while preserving history.+- Analysis: Adom Fields 0.3.1 stamps exact input hashes. `analyze ... --fields fields/fields.json` requires the relevant field screen as well as heuristic checks, and finish checks freshness again. Old heuristic passes are not independent thermal qualification.+- Measurement: `exec -- <program> <args>` includes actual external work in the session. Unobserved gaps are labelled as such, rather than asserted to be human idle time. Custom stage ownership is supported.+- Recording: hidden-tab events reject a take, and encoded frame coverage/spacing checks catch sparse recordings even where native webviews do not emit visibility changes. The real full-board replay was reviewed.+- Component overview: a reviewed moving overview enters compose with a manifest binding selected models, board, clip and native readback/screenshot. The full tour remains separate. See [the review contract](library-overview-review.md).++Validation includes Cargo workspace regressions, the real native KiCad project-rule fixture, component/widget integration, silkscreen geometry checks, the gentle Fields capture and the dashboard's encoded frame review. Bridge PR5 remains a separate owner-reviewed change; this release does not replace any shared bridge runtime. Remaining native bridge primitives and later Fields signals/heatsink features are tracked separately.
docs/time.md+9@@ -9,3 +9,12 @@ The number we want is how long the AI ran on a task, not how long the wall clock - **The benchmark number** is the AI time from the first prompt to `deliver`: the delivery figure on the run page and the chart. Follow-up sessions are listed under Sessions with their own minutes; none of them are in the delivery number. - **Everything else follows the AI's time.** The per-step table counts only active seconds. The video's run clock ("AI TIME, THIS RUN") is the active seconds from the first prompt to the clip, so a walkthrough recorded the next morning reads as minute 60, not hour 10. A clip recorded entirely while the human was away (a recorder left on) is left out of the video. - `adom-aiflow sessions` prints the table; `--json` gives the spans.+++## External work and unknown gaps++Wrap actual external work with `adom-aiflow --ai-thread "<thread>" --run <run> exec -- <program> <args>`. The child inherits the terminal, its success or failure is recorded, and the full running interval is measured, including commands longer than the idle threshold. Use it for builds, browser automation, CAD generation and wiki operations. Do not run a heartbeat while idle.++A long interval without ledger events is **unobserved**, not proof the human was away. Session reports label these excluded intervals explicitly; the legacy JSON `idleMinutes` field remains for compatibility, alongside `unobservedMinutes`. Existing delivery timestamps and ledger events are never rewritten. Historical reports with unwrapped external work can undercount AI activity. Do not claim harness-complete measurement or invent retrospective times.++Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage=binary` records an explicit owner.
docs/video.md+2@@ -32,3 +32,5 @@ The plan-usage snapshots are the provider's per-account numbers. Every AI thread ## The clip guard No recording may outlive an hour: every recording is started with a hard cap. `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget and writes a marker saying so. A recorder left running once recorded nine hours of a static editor; that cannot happen again.++Component library footage: keep the full `library-tour` separate. A reviewed five-second `library-overview` artifact may enter compose, with `--review` binding the exact selected STEP/GLB variants, current board, clip and native readback/screenshot. See [the review contract](https://wiki.adom.inc/adom/adom-aiflow/files/docs/library-overview-review.md). Never claim native model matching from a tour alone.
install.sh+2−1@@ -32,4 +32,5 @@ for skill in "$HERE"/skills/*; do done done -install -m 755 "$HERE/bin/adom-aiflow" "$HOME/.local/bin/adom-aiflow"+install -m 755 "$HERE/bin/adom-aiflow" "$HOME/.local/bin/.adom-aiflow-new"+mv -f "$HOME/.local/bin/.adom-aiflow-new" "$HOME/.local/bin/adom-aiflow"
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.26",+ "version": "0.1.27", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+2−2@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.26",+ "version": "0.1.27", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",@@ -86,4 +86,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-aiflow/SKILL.md+17@@ -141,3 +141,20 @@ Prefer readable documentation blocks in open board interior, not crowded rim spa 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.+++## External work and unknown gaps++Wrap actual external work with `adom-aiflow --ai-thread "<thread>" --run <run> exec -- <program> <args>`. The child inherits the terminal, its success or failure is recorded, and the full running interval is measured, including commands longer than the idle threshold. Use it for builds, browser automation, CAD generation and wiki operations. Do not run a heartbeat while idle.++A long interval without ledger events is **unobserved**, not proof the human was away. Session reports label these excluded intervals explicitly; the legacy JSON `idleMinutes` field remains for compatibility, alongside `unobservedMinutes`. Existing delivery timestamps and ledger events are never rewritten. Historical reports with unwrapped external work can undercount AI activity. Do not claim harness-complete measurement or invent retrospective times.++Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage=binary` records an explicit owner.++### Trustworthy current and thermal screens++A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification.++Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. Manufacturing-rule warnings, including hole spacing, remain blocking.++Component library footage: keep the full `library-tour` separate. A reviewed five-second `library-overview` artifact may enter compose, with `--review` binding the exact selected STEP/GLB variants, current board, clip and native readback/screenshot. See [the review contract](https://wiki.adom.inc/adom/adom-aiflow/files/docs/library-overview-review.md). Never claim native model matching from a tour alone.
skills/aiflow-measurement/SKILL.md+9@@ -50,3 +50,12 @@ Each engine writes its token counts and the dollar figure at API rates into run. ## Sessions (2026-09-15) The clock of record is AI time, not wall time: the sum of the run's sessions (a human prompt to the AI's done) with idle gaps over 15 min cut out. Mark follow-up prompts with `prompt --text` and answers with `done --message`; `deliver` closes the first task. The benchmark is the AI time from the first prompt to `deliver`. docs/time.md on the page has the rules.+++## External work and unknown gaps++Wrap actual external work with `adom-aiflow --ai-thread "<thread>" --run <run> exec -- <program> <args>`. The child inherits the terminal, its success or failure is recorded, and the full running interval is measured, including commands longer than the idle threshold. Use it for builds, browser automation, CAD generation and wiki operations. Do not run a heartbeat while idle.++A long interval without ledger events is **unobserved**, not proof the human was away. Session reports label these excluded intervals explicitly; the legacy JSON `idleMinutes` field remains for compatibility, alongside `unobservedMinutes`. Existing delivery timestamps and ledger events are never rewritten. Historical reports with unwrapped external work can undercount AI activity. Do not claim harness-complete measurement or invent retrospective times.++Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage=binary` records an explicit owner.
tests/component-workflow.py+1@@ -24,3 +24,4 @@ with tempfile.TemporaryDirectory(prefix='aiflow-components-') as d: p.write_text('{invalid');r=subprocess.run(base+['components'],capture_output=True,text=True);assert r.returncode!=0;assert p.read_text()=='{invalid' print(result.stdout) print('PASS: early plan order, 149 references, explicit marking preference, preserved review notes, stale source invalidation, malformed register untouched.')+
tests/widget-workflow.py+1@@ -27,3 +27,4 @@ with tempfile.TemporaryDirectory() as td: assert call('disable').returncode==0 assert json.loads(call('status').stdout)['thumbs']==[] print('PASS: large cached preview, named URL, outside-run rejection and disabled cache avoidance')+
tools/silkscreen-dashboard/index.html+23−1@@ -50,7 +50,29 @@ 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;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);+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=[],stream=captureCanvas.captureStream(30),rec=new MediaRecorder(stream,{mimeType:'video/webm'});+ let timer,hold,finishTimer,rejectedReason=null;const recordingStarted=performance.now();+ const reject=reason=>{rejectedReason=reason;$('#error').textContent=reason;api('control',{playing:false}).catch(()=>{});if(rec.state!=='inactive')rec.stop()};+ const visibility=()=>{if(document.hidden)reject('Rejected recording: dashboard became hidden. Keep the tab visible and re-record this face.')};+ rec.ondataavailable=e=>chunks.push(e.data);+ rec.onerror=e=>reject('Rejected recording: '+(e.error?.message||'MediaRecorder failed'));+ rec.onstop=()=>{+ clearInterval(timer);clearInterval(hold);clearTimeout(finishTimer);document.removeEventListener('visibilitychange',visibility);stream.getTracks().forEach(t=>t.stop());recording=false;+ const blob=new Blob(chunks,{type:'video/webm'});window.silkscreen.recordingBlob=blob;+ const reader=new FileReader();reader.onload=()=>api('recording',{kind:summary?'overview':'walkthrough',video:reader.result.split(',')[1],accepted:!rejectedReason,rejectedReason,elapsedSeconds:(performance.now()-recordingStarted)/1000}).then(r=>{if(!r.recording.accepted)$('#error').textContent='Rejected recording: '+r.recording.rejectedReason;else{const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='silkscreen-replay.webm';a.click();}}).catch(e=>$('#error').textContent=e.message);reader.readAsDataURL(blob);+ $('#record').textContent='Record replay';draw();+ };+ paintCapture();rec.start();document.addEventListener('visibilitychange',visibility);$('#record').textContent='Recording replay…';+ try{await api('control',{playing:true})}catch(e){reject('Rejected recording: '+e.message);return}+ if(document.hidden){visibility();return}+ timer=setInterval(()=>{draw();paintCapture();if(!state.playing&&state.cursor>=history.length-1){clearInterval(timer);frame(bounds);hold=setInterval(paintCapture,33);finishTimer=setTimeout(()=>{paintCapture();if(rec.state!=='inactive')rec.stop()},1200)}},100);+};$('#record').onclick=()=>recordReplay(false).catch(e=>$('#error').textContent=e.message);$('#short').onclick=()=>recordReplay(true).catch(e=>$('#error').textContent=e.message); 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>
tools/silkscreen-dashboard/recording_check.pyadded+19@@ -0,0 +1,19 @@+"""Validate encoded frames, since native webviews may never dispatch visibilitychange."""+import json,subprocess++def assess(timestamps,elapsed):+ points=sorted(float(t) for t in timestamps)+ if elapsed < 1 or len(points)<3:return {'accepted':False,'reason':'too few encoded frames to review'}+ span=points[-1]-points[0]+ gap=max((b-a for a,b in zip(points,points[1:])),default=0)+ reasons=[]+ if len(points)/elapsed<2:reasons.append('fewer than two encoded frames per recorded second')+ if span<elapsed*.7:reasons.append('encoded duration covers less than 70 percent of the recording')+ if gap>2:reasons.append('encoded frames have a gap longer than two seconds')+ return {'accepted':not reasons,'reason':'; '.join(reasons),'frames':len(points),'encodedSpanSeconds':span,'elapsedSeconds':elapsed,'maxFrameGapSeconds':gap}++def check(path,elapsed):+ try:+ p=subprocess.run(['ffprobe','-v','error','-select_streams','v:0','-show_entries','frame=best_effort_timestamp_time','-of','json',str(path)],capture_output=True,text=True,timeout=60,check=True)+ return assess([f['best_effort_timestamp_time'] for f in json.loads(p.stdout)['frames'] if 'best_effort_timestamp_time' in f],float(elapsed))+ except Exception as e:return {'accepted':False,'reason':'encoded frame validation failed: '+str(e)}
tools/silkscreen-dashboard/server.py+4−1@@ -1,5 +1,6 @@ #!/usr/bin/env python3 """AI Flow silkscreen observer: persisted solver events, no provider calls or CAD edits."""+from recording_check import check as check_recording import argparse,base64,hashlib,json,os,pathlib,subprocess,sys,time,threading,http.server,urllib.request,urllib.parse,signal P=pathlib.Path;ROOT=P(__file__).resolve().parent;REG=P.home()/'.adom/instances/aiflow-silkscreen';REG.mkdir(parents=True,exist_ok=True) def atomic(p,d):@@ -90,7 +91,9 @@ class Handler(http.server.BaseHTTPRequestHandler): if self.path=='/recording': video=base64.b64decode(body['video'],validate=True) if len(video)<1024 or video[:4]!=bytes.fromhex('1a45dfa3'):raise ValueError('Refused empty or invalid WebM recording')- clips=folder/'recordings';clips.mkdir(exist_ok=True);name='replay-'+str(time.time_ns())+'.webm';(clips/name).write_bytes(video);state['lastRecording']={'file':str(clips/name),'url':'recordings/'+name,'kind':body.get('kind','walkthrough'),'bytes':len(video),'replay':True};state['recordRequest']=None;save();broadcast();self.response({'ok':True,'recording':state['lastRecording']});return+ clips=folder/'recordings';clips.mkdir(exist_ok=True);name='replay-'+str(time.time_ns())+'.webm';(clips/name).write_bytes(video)+ quality=check_recording(clips/name,body.get('elapsedSeconds',0));accepted=body.get('accepted') is not False and quality['accepted']+ state['lastRecording']={'file':str(clips/name),'url':'recordings/'+name,'kind':body.get('kind','walkthrough'),'bytes':len(video),'replay':True,'accepted':accepted,'rejectedReason':body.get('rejectedReason') or quality.get('reason'),'frameValidation':quality};state['recordRequest']=None;save();broadcast();self.response({'ok':True,'recording':state['lastRecording']});return if self.path=='/shutdown': if not body.get('reason'):raise ValueError('reason_required') self.response({'ok':True});threading.Thread(target=shutdown,daemon=True).start();return
tools/test-silkscreen-recording.pyadded+8@@ -0,0 +1,8 @@+import pathlib,sys+sys.path.insert(0,str(pathlib.Path(__file__).parent/'silkscreen-dashboard'))+from recording_check import assess+assert assess([i/10 for i in range(100)],10)['accepted']+assert not assess([0,.1,8,9],10)['accepted']+assert not assess([i/10 for i in range(10)],10)['accepted']+assert not assess([],10)['accepted']+print('PASS: encoded frame coverage, sparse capture and long-gap rejection')