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
Verify existing native placements without replaying footprint moves
4 files changed
+131−2
crates/adom-aiflow/src/continuation.rs+7@@ -128,6 +128,13 @@ pub fn assess(data: &Value, pending: &[String], stop: Option<Result<Value, Strin )); } }+ if data["outcomes"]["place-land"]["mode"]=="verified-existing" {+ let board=data["boards"].as_array().and_then(|a|a.last()).and_then(Value::as_str).unwrap_or("");+ let spec=data["spec"].as_str().unwrap_or("");+ if !crate::placement_verify::bound(&data["outcomes"]["place-land"],std::path::Path::new(board),std::path::Path::new(spec)) {+ remaining.push("place verify: board or spec binding changed; verify again".into());+ }+ } if data["outcomes"]["land-pours"]["mode"]=="verified-existing" { let board=data["boards"].as_array().and_then(|a|a.last()).and_then(Value::as_str).unwrap_or(""); let spec=data["spec"].as_str().unwrap_or("");
crates/adom-aiflow/src/main.rs+30−2@@ -13,6 +13,7 @@ mod thermalvias; mod outline; mod browser_access; mod schematic_identity;+mod placement_verify; /// The timeline dashboard (tools/timeline/index.html): it receives state over SSE from its run server. const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html");@@ -120,7 +121,7 @@ enum Cmd { Take { decisions: Vec<String> }, /// Stamp a stage you do yourself: start or end Stage { action: String, name: String, #[arg(long)] note: Option<String>, #[arg(long)] result: Option<String> },- /// Placement helpers: pack (--wish), check (--moves), land (--moves)+ /// Placement helpers: pack (--wish), check (--moves), land (--moves), verify (existing native placement) Place { what: String, #[arg(long)] wish: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value = "moves.json")] out: String, /// milliseconds between parts when landing: each part is its own undo step and its own moment on camera #[arg(long, default_value_t = 600)] pause_ms: u64 }, /// The grid router on the current board@@ -2463,6 +2464,29 @@ fn main() { let mut r = load_run(&cli); let b = board_of(&r); match what.as_str() {+ "verify" => {+ let board_path=r.current_board();+ let spec_path=r.data["spec"].as_str().unwrap_or("").to_string();+ let board_hash=placement_verify::digest(Path::new(&board_path)).unwrap_or_else(|e|err(&e,&[]));+ let spec_hash=placement_verify::digest(Path::new(&spec_path)).unwrap_or_else(|e|err(&e,&[]));+ if let Some(f)=molecule_findings(&spec_of(&r),&b,Some(&r)) {+ if !f.errors.is_empty() { err(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); }+ }+ let rb=r.data["remoteBoard"].as_str().filter(|s|!s.is_empty()).unwrap_or_else(||err("place verify requires the named native board",&[])).to_string();+ let br=bridge_of(&r,&cli);+ let before=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));+ let validation=br.call("kicad_placement_validate",&json!({"filePath":rb}));+ let after=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));+ let report=dir.join("placement-verify.json");+ write_json(&report,&json!({"before":before,"validation":validation,"after":after}));+ let checked=placement_verify::check(&b,&before,&validation,&after,&rb);+ let stable=placement_verify::digest(Path::new(&board_path)).ok().as_deref()==Some(board_hash.as_str()) && placement_verify::digest(Path::new(&spec_path)).ok().as_deref()==Some(spec_hash.as_str());+ let error=if !stable { Some("saved board or spec changed during placement verification".to_string()) } else { checked.as_ref().err().cloned() };+ r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.unwrap_or(0)}else{0},"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error}));+ r.save().unwrap_or_else(|e|err(&e,&[]));+ if let Some(e)=error { err(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); }+ ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]);+ } "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, &[]));@@ -2541,7 +2565,7 @@ fn main() { } ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]); }- other => err(&format!("place {other}: use pack, check or land"), &[]),+ other => err(&format!("place {other}: use pack, check, land or verify"), &[]), } } Cmd::Route { passes, rip_per_net, rip_global } => {@@ -3034,6 +3058,10 @@ fn main() { } let o = r.outcomes().clone(); let mut missing = Vec::new();+ let placement=o.get("place-land").cloned().unwrap_or(Value::Null);+ if placement["mode"]=="verified-existing" && !placement_verify::bound(&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))) {+ missing.push("place verify (saved board or spec changed; verify again)".into());+ } let gate = o.get("gate").cloned().unwrap_or(Value::Null); if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) { missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());
crates/adom-aiflow/src/placement_verify.rsadded+90@@ -0,0 +1,90 @@+//! Read-only acceptance of an already placed native board.+use aiflow_board::Board;+use serde_json::Value;+use std::collections::BTreeSet;+use std::path::Path;+use sha2::{Digest, Sha256};++pub fn digest(path: &Path) -> Result<String,String> {+ std::fs::read(path).map(|b|format!("{:x}",Sha256::digest(b))).map_err(|e|e.to_string())+}+pub fn bound(proof: &Value, board: &Path, spec: &Path) -> bool {+ proof["ok"]==true && digest(board).ok().as_deref()==proof["boardSha256"].as_str()+ && digest(spec).ok().as_deref()==proof["specSha256"].as_str()+ && proof["boardSha256"].as_str().is_some() && proof["specSha256"].as_str().is_some()+}+pub fn check(board: &Board, before: &Value, validation: &Value, after: &Value, remote: &str) -> Result<usize,String> {+ if board.footprints.is_empty() { return Err("no footprints to verify".into()); }+ let revision=before["revision"].as_str().filter(|s| !s.is_empty()).ok_or("missing native revision")?;+ for state in [before,after] {+ if state["success"]!=true || state["source"]!="live-editor" || state["revision"]!=revision || state["stale"]==true || state["boardPath"]!=remote {+ return Err("placement snapshot failed, stale, changed revision or belongs to another board".into());+ }+ let parts=state["footprints"].as_array().ok_or("missing live footprints")?;+ if parts.len()!=board.footprints.len() || state["footprintCount"].as_u64()!=Some(parts.len() as u64) { return Err("live/saved footprint counts differ".into()); }+ let mut refs=BTreeSet::new();+ for p in parts {+ let reference=p["ref"].as_str().ok_or("missing reference")?;+ if !refs.insert(reference) { return Err(format!("duplicate live reference {reference}")); }+ let f=board.footprints.get(reference).ok_or_else(||format!("unexpected live reference {reference}"))?;+ for (key,want) in [("x",f.x),("y",f.y),("rotation",f.rot)] {+ let got=p[key].as_f64().filter(|x|x.is_finite()).ok_or_else(||format!("{reference}: invalid {key}"))?;+ let delta=if key=="rotation" { ((got-want+180.0).rem_euclid(360.0)-180.0).abs() } else { (got-want).abs() };+ if delta>0.001 { return Err(format!("{reference}: live/saved {key} differs")); }+ }+ if p["side"]!=f.layer || p["footprint"]!=f.lib || p["insideOutline"]!=true { return Err(format!("{reference}: side, library or outline mismatch")); }+ }+ }+ let drc=&validation["drc"];+ if validation["success"]!=true || validation["source"]!="live-editor" || validation["stale"]!=false || validation["revision"]!=revision || validation["currentRevision"]!=revision || validation["placed"]!=true {+ return Err("native placement validation failed or changed revision".into());+ }+ if drc["available"]!=true || drc["source"]!="live-editor-snapshot" || drc["placementClean"]!=true || drc["placementViolationCount"].as_u64()!=Some(0) || drc["errors"].as_u64()!=Some(0) || drc["violationReportMayBeTruncated"]!=false {+ return Err("complete native placement DRC is required".into());+ }+ for key in ["outsideOutline","parked","courtyardOverlaps","withoutCourtyard"] {+ if !validation[key].as_array().is_some_and(|a|a.is_empty()) { return Err(format!("native placement has unresolved {key}")); }+ }+ Ok(board.footprints.len())+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;+ fn fixture() -> (Board,Value,Value) {+ let b=Board::from_text("fixture", "(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")) (footprint \"L:R\" (layer \"F.Cu\") (at 5 5 0) (property \"Reference\" \"R1\")))".into()).unwrap();+ let s=json!({"success":true,"source":"live-editor","revision":"rev","boardPath":"board","footprintCount":1,"footprints":[{"ref":"R1","x":5,"y":5,"rotation":360,"side":"F.Cu","footprint":"L:R","insideOutline":true}]});+ let v=json!({"success":true,"source":"live-editor","revision":"rev","currentRevision":"rev","stale":false,"placed":true,"outsideOutline":[],"parked":[],"courtyardOverlaps":[],"withoutCourtyard":[],"drc":{"available":true,"source":"live-editor-snapshot","placementClean":true,"placementViolationCount":0,"errors":0,"violationReportMayBeTruncated":false}});+ (b,s,v)+ }+ #[test]+ fn accepts_verified_current_pose_and_rejects_wrong_position_or_board() {+ let (b,s,v)=fixture();assert_eq!(check(&b,&s,&v,&s,"board").unwrap(),1);+ let mut other=s.clone();other["footprints"][0]["x"]=json!(5.01);assert!(check(&b,&other,&v,&other,"board").is_err());+ assert!(check(&b,&s,&v,&s,"other").is_err());+ }+ #[test]+ fn refuses_stale_file_fallback_missing_refs_and_incomplete_drc() {+ let (b,s,v)=fixture();+ for (key,value) in [("source",json!("file")),("revision",json!("new")),("success",json!(false)),("footprints",json!([]))] {+ let mut after=s.clone();after[key]=value;assert!(check(&b,&s,&v,&after,"board").is_err());+ }+ for (key,value) in [("available",json!(false)),("placementViolationCount",json!(1)),("violationReportMayBeTruncated",json!(true)),("errors",json!(1))] {+ let mut bad=v.clone();bad["drc"][key]=value;assert!(check(&b,&s,&bad,&s,"board").is_err());+ }+ }+ #[test]+ fn proof_is_invalid_after_board_or_spec_change_or_deletion() {+ let dir=std::env::temp_dir().join(format!("aiflow-place-binding-{}",std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();let b=dir.join("b");let s=dir.join("s");+ std::fs::write(&b,"board").unwrap();std::fs::write(&s,"spec").unwrap();+ let proof=json!({"ok":true,"boardSha256":digest(&b).unwrap(),"specSha256":digest(&s).unwrap()});+ assert!(bound(&proof,&b,&s));+ std::fs::write(&b,"changed").unwrap();assert!(!bound(&proof,&b,&s));+ std::fs::write(&b,"board").unwrap();std::fs::write(&s,"changed").unwrap();assert!(!bound(&proof,&b,&s));+ std::fs::remove_file(&s).unwrap();assert!(!bound(&proof,&b,&s));+ assert!(!bound(&serde_json::json!({"ok":true}),&b,&s));+ std::fs::remove_dir_all(dir).unwrap();+ }+}
skills/adom-aiflow/SKILL.md+4@@ -367,6 +367,10 @@ When multiple physical lands share one logical pin number, use the reviewed Fiel `netlist check` records hashes for the root schematic and every reachable child sheet, plus the exported netlist. A missing, cyclic or unresolved sheet refuses the check. The hierarchy must remain unchanged across export. `finish` rejects child-sheet edits, altered exports and older checks without these hashes; rerun the native export and comparison. A manually supplied `--net` is still caller-provided evidence: hashing it does not prove how or when it was exported. Unsupported sheet path variables fail explicitly rather than silently omitting sheets. +## Verify existing placement without replaying moves++After adopting an already placed KiCad board, use `place verify` during the placement step. It compares every live reference, XY position, rotation, side and library binding with the saved board, brackets native placement validation with two matching live revisions, requires complete native placement DRC and no outside/missing-courtyard findings, and records board/spec hashes. Failed or stale verification remains blocking. `finish` and `finish-guard` reject changed inputs. The outcome uses `place-land` with `mode: verified-existing` for sequence compatibility; it does not claim any footprint was moved or created. Other DRC warnings remain in the report; full DRC, netlist, current, thermal and physical qualification are separate requirements. Never send dummy moves just to advance a step.+ ## One live dashboard per run The main timeline uses the app-creator SSE channel: CLI workflow commands update durable run files and the server pushes state to the existing page. `timeline present --json '{"caption":"Current passed; thermal open","image":"review.png"}' --run <run> --ai-thread <name>` sends presentation changes through the server's HTTP control endpoint; it never changes engineering gates. Images must already exist in `<run>/timeline/`. Reconnect replays current state. Static exports and older servers retain polling fallback.