← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.27"+version = "0.1.28" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -23,7 +23,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.27"+version = "0.1.28" dependencies = [  "serde",  "serde_json",@@ -32,7 +32,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.27"+version = "0.1.28" dependencies = [  "serde",  "serde_json",@@ -40,7 +40,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.27"+version = "0.1.28" dependencies = [  "serde",  "serde_json",@@ -48,7 +48,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.27"+version = "0.1.28" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -58,7 +58,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.27"+version = "0.1.28" dependencies = [  "aiflow-board",  "serde",@@ -67,7 +67,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.27"+version = "0.1.28" dependencies = [  "aiflow-board",  "serde",@@ -76,7 +76,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.27"+version = "0.1.28" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -86,7 +86,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.27"+version = "0.1.28" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -96,7 +96,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.27"+version = "0.1.28" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.27"+version = "0.1.28" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
README.md+2
@@ -173,3 +173,5 @@ Everything below is what John has asked for, in the order it is likely to land. 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.++Latest maintenance: [0.1.28 lifecycle and native IPC readiness](docs/release-0.1.28.md).
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+9−1
@@ -1744,11 +1744,15 @@ fn main() {                         eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });                     });                     let total = mv.as_object().map(|o| o.len()).unwrap_or(0);+                    write_json(&dir.join("place-land-log.json"), &json!({"moves":m,"accepted":accepted,"replies":replies,"stoppedAt":stopped}));                     r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);                     r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));                     r.save().unwrap();                     if let Some(i) = stopped {                         let rep = replies.last().cloned().unwrap_or(Value::Null);+                        if rep["errorCode"] == "board_mismatch" {+                            err(&format!("live placement refused before part {}: requested {rb}; IPC openBoards {}",i+1,rep["openBoards"]), &["Save and gracefully close only your own conflicting KiCad editors, then `capture open` to reopen this board first. Never close another user's editor or restart a shared bridge. Inspect place-land-log.json; no failed part was moved.".into()]);+                        }                         err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);                     }                     ok(&format!("{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()]);@@ -2079,6 +2083,11 @@ fn main() {                     let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));                     r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));                     r.save().unwrap();+                    let (revision, reply) = br.revision_with_reply(&rb);+                    write_json(&dir.join("capture-open-ipc.json"), &reply);+                    if revision.is_none() {+                        err(&format!("board window opened (hwnd {hwnd}) but IPC is not ready for {rb}: {} {}; openBoards {}",reply["errorCode"],reply["error"],reply["openBoards"]), &["Read capture-open-ipc.json. For board_mismatch, save and gracefully close only your task-owned conflicting editors, then `capture open` so this board owns the IPC socket. Preserve other users' windows and shared bridges.".into()]);+                    }                     let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum();                     let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) };                     let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };@@ -2686,4 +2695,3 @@ fn main() {         }     } }-
crates/aiflow-bridge/src/lib.rs+22−2
@@ -6,6 +6,12 @@ use std::process::Command;  use serde_json::{json, Value}; +// Waiting cannot repair an explicit identity/configuration refusal. In particular,+// never conceal the requested/open board identities behind a generic no_revision.+fn retryable_read(reply: &Value) -> bool {+    !matches!(reply["errorCode"].as_str(), Some("board_mismatch" | "invalid_socket" | "invalid_path" | "api_server_off" | "unsupported_kicad" | "no_pcb_frame" | "permission_denied"))+}+ /// Shoelace area of a zone entry's `polygon` ([[x, y], ...]). fn polygon_area_of(z: &Value) -> f64 {     let pts: Vec<(f64, f64)> = z.get("polygon").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|q| Some((q[0].as_f64()?, q[1].as_f64()?))).collect()).unwrap_or_default();@@ -59,6 +65,7 @@ impl Bridge {             }             eprintln!("revision read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());             last = r;+            if !retryable_read(&last) { break; }             std::thread::sleep(std::time::Duration::from_secs(6));         }         (None, last)@@ -133,7 +140,8 @@ impl Bridge {     /// single frame. Returns (replies, accepted, stopped-at).     pub fn land_moves(&self, board: &str, moves: &Value, pause_ms: u64, mut progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {         let items: Vec<(String, Value)> = moves.as_object().map(|o| o.iter().map(|(r, m)| (r.clone(), m.clone())).collect()).unwrap_or_default();-        let mut rev = match self.revision(board) { Some(r) => r, None => return (vec![json!({"success": false, "errorCode": "no_revision", "message": "kicad_routing_state gave no revision; is the board open with the IPC API on?"})], 0, Some(0)) };+        let (revision, reply) = self.revision_with_reply(board);+        let mut rev = match revision { Some(r) => r, None => return (vec![reply], 0, Some(0)) };         let mut replies = Vec::new();         let mut ok = 0;         for (i, (reference, m)) in items.iter().enumerate() {@@ -164,7 +172,8 @@ impl Bridge {             _ => vec![e.clone()],         }).collect();         let entries: &[Value] = &traces;-        let mut rev = match self.revision(board) { Some(r) => r, None => return (vec![json!({"success": false, "errorCode": "no_revision"})], 0, Some(0), traces.len()) };+        let (revision, reply) = self.revision_with_reply(board);+        let mut rev = match revision { Some(r) => r, None => return (vec![reply], 0, Some(0), traces.len()) };         let mut replies = Vec::new();         let mut ok = 0;         for (i, e) in entries.iter().enumerate() {@@ -292,6 +301,7 @@ impl Bridge {     pub fn zone_state(&self, board: &str, refill: bool) -> Value {         let mut r = self.call("kicad_zone_state", &json!({"filePath": board, "refill": refill}));         for attempt in 0..15 {+            if !retryable_read(&r) { break; }             if r.get("filledAreaByLayerMm2").is_some() {                 break;             }@@ -321,6 +331,16 @@ impl Bridge { mod tests {     use serde_json::json; +    #[test]+    fn permanent_identity_refusal_is_not_retried() {+        for code in ["board_mismatch", "invalid_socket", "api_server_off", "no_pcb_frame"] {+            assert!(!super::retryable_read(&json!({"errorCode":code})));+        }+        for code in ["ipc_timeout", "busy", "ipc_unavailable"] {+            assert!(super::retryable_read(&json!({"errorCode":code})));+        }+    }+     /// The rule behind "one trace at a time": an entry with several paths becomes one call per     /// path, an entry with one path stays one call, so a plan's call count is its path count.     fn expand(entries: &[serde_json::Value]) -> Vec<serde_json::Value> {
docs/release-0.1.28.mdadded+9
@@ -0,0 +1,9 @@+# AI Flow 0.1.28++A dashboard stop now waits for its own server lease to be released before returning, so an immediate restart does not race the old server. Three real sequential stop/start cycles passed on the maintenance run. No shared server was killed.++Capture setup verifies the requested board through native IPC after opening its window. A visible second KiCad editor may not own the single IPC server. Permanent board identity or configuration refusals stop immediately and retain the full reply, instead of wasting two minutes and reporting a generic no_revision. Placement replies are retained in place-land-log.json.++For board_mismatch, save and gracefully close only the caller's own conflicting editors, then reopen the requested board first. Never close another user's work or restart a shared bridge. The failed diagnostic take remains in the ledger and is rejected from the final video.++Validation: Rust application and Bridge-client tests pass; three real dashboard restart cycles pass. Isolated native placement acceptance is recorded separately from the original delivered ESC benchmark.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.27",+  "version": "0.1.28",   "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.27",+  "version": "0.1.28",   "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]"   }-}\ No newline at end of file+}
skills/adom-aiflow/SKILL.md+4
@@ -158,3 +158,7 @@ A pour's area or presence is not proof of current capacity or acceptable tempera 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.++## Native editor readiness++`capture open` must confirm native IPC for the exact board, not only a visible window. KiCad may give its single IPC server to the first editor. On `board_mismatch`, read `capture-open-ipc.json` or `place-land-log.json`, save and gracefully close only your task-owned conflicting editors, and reopen the intended board first. Never close another user's work or restart a shared bridge. Permanent identity refusals are not retryable. Reject a static failed take and record the reason before re-taking it.
tools/silkscreen-dashboard/server.py+11−1
@@ -38,7 +38,17 @@ if a.action in ['start','show']: if a.action in ['state','control','stop']:  if not e:raise SystemExit('ERROR: not running; use start')  if a.action=='stop' and not a.reason:raise SystemExit('ERROR reason_required: explain why this task-owned server should stop')- print(json.dumps(request(e,'/shutdown' if a.action=='stop' else '/control' if a.action=='control' else '/state',dict(json.loads(a.json),reason=a.reason) if a.action!='state' else None)));sys.exit()+ result=request(e,'/shutdown' if a.action=='stop' else '/control' if a.action=='control' else '/state',dict(json.loads(a.json),reason=a.reason) if a.action!='state' else None)+ if a.action=='stop':+  import fcntl+  deadline=time.monotonic()+10+  with open(REG/(key+'.lock'),'a') as lease:+   while True:+    try:fcntl.flock(lease,fcntl.LOCK_EX|fcntl.LOCK_NB);fcntl.flock(lease,fcntl.LOCK_UN);break+    except BlockingIOError:+     if time.monotonic()>=deadline:raise SystemExit('ERROR: shutdown accepted but this server still holds its lease. Hint: check state before restarting; do not kill a shared process.')+     time.sleep(.1)+ print(json.dumps(result));sys.exit() if e:print('OK: already running');sys.exit() settingspath=REG/'settings.json';settings=json.loads(settingspath.read_text()) if settingspath.exists() else dict(ttlHours=24,speed=4,showObstacles=True) state=json.loads(snap.read_text()) if snap.exists() else dict(mode='live',cursor=-1,playing=False,layer='F.SilkS',view='2d');clients=set();lock=threading.RLock();last=time.time();revision=0;history=[];offset=0