← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.56"+version = "0.2.57" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.56"+version = "0.2.57" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.56"+version = "0.2.57" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.56"+version = "0.2.57" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.56"+version = "0.2.57" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.56"+version = "0.2.57" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.56"+version = "0.2.57" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.56"+version = "0.2.57" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.56"+version = "0.2.57" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.56"+version = "0.2.57" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.56"+version = "0.2.57" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1−1
@@ -380,7 +380,7 @@ The gates read the board and the tools' own output, so a file written to look li - `step <name> --back` only returns to an earlier step. - **Silkscreen audit geometry is the board's (0.1.97).** `silkscreen-audit --record` and `finish` check the supplied obstacle boxes against the saved board: every pad of every footprint must fall inside one of that footprint's obstacle boxes (ids `U1`, `U1:body`, `U1:pad:1`), and every via inside a via/hole box as large as the via. Measure the boxes from the board; placeholder or point-sized boxes are refused. - **Thermal counts the board's vias (0.1.96).** `analyze thermal` counts the vias on each tab net in the saved board as well as the routing plan, so thermal vias landed after routing count once the board is saved.-- **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet` (no `tabNet` for a part with no exposed tab: it is lead-cooled, judged by the Fields solve, docs/lead-cooled-parts.md), `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. Every gate refuses a spec whose budgets moved since. Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary.+- **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet` (no `tabNet` for a part with no exposed tab: it is lead-cooled, judged by the Fields solve, docs/lead-cooled-parts.md), `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. A spec whose budgets moved since is never refused: the new values are used, the change is recorded as an amendment with no reason yet, and a note suggests `spec-amend --why` (0.2.57). Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary.  ## Spec intent checked by the binary (0.2.3) 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/checks.rs+15−2
@@ -943,7 +943,7 @@ pub fn flat_chip(lib: &str) -> bool {     chip || ic } -#[derive(Debug, Clone)]+#[derive(Debug, Clone, Default)] pub struct ModelRef {     pub reference: String,     pub lib: String,@@ -1027,7 +1027,9 @@ pub fn orientation_findings(m: &ModelRef, bb: &BBox) -> Vec<String> {     if m.smd_only && bb[2][0].abs() > 0.1_f64.max(0.2 * ext[2]) {         out.push(format!("bottom at z {:.2} mm, {} the board (an SMD body sits on the board, z 0): {}", bb[2][0], if bb[2][0] < 0.0 { "below" } else { "above" }, if bb[2][0] < 0.0 { "a Y-up or centred model; rotate -90 about X or offset it up" } else { "check the model offset" }));     }-    if m.smd_only && bb[2][1] < 0.05 {+    // a flat pad model (issue #166: the Adom 0.5 mm test pad is its 0.04 mm copper disc) sits on the board face+    let flat_pad = ext[2] <= 0.1 && bb[2][0] > -0.02;+    if m.smd_only && bb[2][1] < 0.05 && !flat_pad {         out.push(format!("the whole body is at or below the board top (top z {:.2} mm): upside down or on the wrong side", bb[2][1]));     }     out@@ -1037,6 +1039,17 @@ pub fn orientation_findings(m: &ModelRef, bb: &BBox) -> Vec<String> { mod tests {     use super::*; +    #[test]+    fn a_flat_pad_model_on_the_board_face_is_right_way_up() {+        // issue #166: Adom_TestPoint_D0.5mm.step, a 0.04 mm copper disc on F.Cu+        let m = ModelRef { smd_only: true, ..ModelRef::default() };+        let disc: BBox = [[-0.25, 0.25], [-0.25, 0.25], [0.0, 0.04]];+        assert!(orientation_findings(&m, &disc).is_empty(), "{:?}", orientation_findings(&m, &disc));+        // the same disc below the board face is still flagged+        let under: BBox = [[-0.25, 0.25], [-0.25, 0.25], [-0.04, 0.0]];+        assert!(!orientation_findings(&m, &under).is_empty());+    }+     const BOARD: &str = r#"(kicad_pcb (version 20260206)  (setup (aux_axis_origin 100 100))  (gr_rect (start 98 82) (end 126 102) (layer "Edge.Cuts"))
crates/adom-aiflow/src/main.rs+32−9
@@ -705,7 +705,7 @@ fn spec_of(r: &Run) -> Value { }  /// Once placement has begun the spec's budgets are frozen (`spec_freeze`): freeze them on the first read, and-/// refuse a spec whose budgets moved since without a `spec-amend` on the record.+/// record a change to them since as an amendment (with a note), never refuse it. fn guard_budgets(r: &Run, spec: &Value) {     let placed = r.data["steps"]["placement"]["visits"].as_array().map(|a| !a.is_empty()).unwrap_or(false);     if !placed { return; }@@ -718,9 +718,18 @@ fn guard_budgets(r: &Run, spec: &Value) {     };     let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(spec));     if changed.is_empty() { return; }-    err(&format!("the spec's budgets changed after placement began ({} change(s)): {}. The board changes to meet the budgets; the budgets do not move to meet the board", changed.len(), changed.join("; ")), &[-        format!("Put the spec back (the frozen values are in {}) and fix the board: more copper, vias, a better placement.", r.dir.join(spec_freeze::FILE).display()),-        "If a budget really is wrong (a misread datasheet value), amend it on the record: `adom-aiflow spec-amend --why \"<datasheet page and what was misread>\" --ai-thread <you>`. Every amendment shows in the run ledger and the finish summary.".into(),+    // AI Flow never refuses (0.2.48, issue #165): the new budgets stand, and the change goes on the record as an+    // amendment with no reason yet, so the ledger and the finish summary show it; `spec-amend --why` adds the reason+    let mut rec = rec;+    let a = json!({"at": now(), "step": r.data["currentStep"], "why": "", "changes": changed, "recordedBy": "spec read (no spec-amend)"});+    rec["budgets"] = spec_freeze::budgets(spec);+    if !rec["amendments"].is_array() { rec["amendments"] = json!([]); }+    rec["amendments"].as_array_mut().unwrap().push(a.clone());+    spec_freeze::write(&r.dir, &rec);+    r.log("spec-amend", a);+    note(&format!("the spec's budgets changed after placement began ({} change(s)): {}. The new values are used and the change is recorded as an amendment with no reason yet", changed.len(), changed.join("; ")), &[+        "Suggested: give the reason on the record, `adom-aiflow spec-amend --why \"<datasheet page, or what was fixed>\" --ai-thread <you>`; it is listed in the finish summary.".into(),+        "If the change was to make a budget meet the board rather than the board meet the budget, the board may need the work instead (more copper, vias, a better placement); the old values are in the run ledger.".into(),     ]); } @@ -4146,6 +4155,11 @@ fn main() {             let mut rules = aiflow_router::Rules::from_spec(&spec);             // issue #52: impedance targets set their nets' width (and pair pitch) and hold them to one layer             let imp_choice = impedance_cmd::for_route(&b, &spec, &r.current_board(), &mut rules).unwrap_or_else(|(m, h)| err(&m, &h));+            // issue #165: say the sizes the router actually uses, so a spec key it did not read shows at once+            eprintln!("route rules used: track {} mm, clearance {} mm, via {} mm; per-net widths: wide {}, mid {}{}", rules.track, rules.clearance, rules.via,+                if rules.wide.is_empty() { "none".to_string() } else { rules.wide.iter().map(|(n, w)| format!("{n} {w:?}")).collect::<Vec<_>>().join(", ") },+                if rules.mid.is_empty() { "none".to_string() } else { rules.mid.iter().map(|(n, w)| format!("{n} {w:?}")).collect::<Vec<_>>().join(", ") },+                if spec.get("track").is_none() && spec.get("trackWidth").is_some() { " (track from the spec's trackWidth)" } else if spec.get("track").is_none() { " (track is the router default: the spec names no track)" } else { "" });             let regions = aiflow_router::plane_under::regions(&b, &rules.plane_under).unwrap_or_else(|e| err(&format!("spec.planeUnder: {e}"), &["Each entry needs a layer (F.Cu or B.Cu), the net that owns the region, and \"around\" (parts or pads on this board, grown by \"margin\", default 1.0 mm) or a \"polygon\".".into()]));             if spec.get("planeUnder").is_some() && regions.len() != spec["planeUnder"].as_array().map(|a| a.len()).unwrap_or(1) {                 err("spec.planeUnder has an entry that does not parse", &["Use {\"layer\": \"B.Cu\", \"net\": \"GND\", \"around\": [\"U1\", \"C2\"], \"margin\": 1.0} or {\"layer\": ..., \"net\": ..., \"polygon\": [[x, y], ...]}.".into()]);@@ -4803,12 +4817,21 @@ fn main() {         Cmd::SpecAmend { why } => {             let t = thread(&cli);             let r = load_run(&cli);-            if why.trim().chars().count() < spec_freeze::MIN_WHY { err("spec-amend needs a real reason in --why: the datasheet page and what was misread", &[]); }+            if why.trim().chars().count() < spec_freeze::MIN_WHY { note("the reason in --why is short: the datasheet page and what was misread make it useful later; recorded as given", &[]); }             if r.data["spec"].is_null() { err("this run has no spec yet", &[]); }             let spec = read_json(r.data["spec"].as_str().unwrap_or(""));             let Some(mut rec) = spec_freeze::read(&r.dir) else { err("the budgets are not frozen yet (they freeze once placement begins): edit the spec freely until then", &[]) };             let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(&spec));-            if changed.is_empty() { err("the spec's budgets match the frozen ones: nothing to amend", &[]); }+            // a change already recorded without a reason (the spec was read after an edit) takes this reason+            if changed.is_empty() {+                let mut filled = 0;+                for a in rec["amendments"].as_array_mut().into_iter().flatten() { if a["why"].as_str().unwrap_or("").is_empty() { a["why"] = json!(why.trim()); a["aiThread"] = json!(t); filled += 1; } }+                if filled == 0 { ok("the spec's budgets match the recorded ones: nothing to amend", &[]); return; }+                spec_freeze::write(&r.dir, &rec);+                r.log("spec-amend-why", json!({"why": why.trim(), "amendments": filled}));+                ok(&format!("reason recorded for {filled} budget change(s) made earlier in the spec"), &["Listed in the finish summary.".into()]);+                return;+            }             let a = json!({"at": now(), "step": r.data["currentStep"], "why": why.trim(), "changes": changed, "aiThread": t});             rec["budgets"] = spec_freeze::budgets(&spec);             rec["amendments"].as_array_mut().unwrap().push(a.clone());@@ -5986,14 +6009,14 @@ fn main() {                 err(&format!("model check did not answer: {}", v["errorCode"].as_str().unwrap_or("?")), &["The run needs a live board on the test box (capture open) for kicad_model_check.".into()]);             }             if unresolved > 0 {-                err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!("  {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);+                findings(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!("  {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);             }             let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));             if !proxies.is_empty() {-                err(&format!("{} footprint model(s) are the laser-etch proxy box, not the part:\n{}", proxies.len(), proxies.iter().map(|(r, m)| format!("  {r}: {m}")).collect::<Vec<_>>().join("\n")), &["A renamed or resized proxy box is a placeholder, not a 3D model. Use the part's real model: its wiki component page, the vendor STEP (adom-parts-search, chip-fetcher), or build one from the datasheet (adom-step); then `models` again.".into()]);+                findings(&format!("{} footprint model(s) are the laser-etch proxy box, not the part:\n{}", proxies.len(), proxies.iter().map(|(r, m)| format!("  {r}: {m}")).collect::<Vec<_>>().join("\n")), &["A renamed or resized proxy box is a placeholder, not a 3D model. Use the part's real model: its wiki component page, the vendor STEP (adom-parts-search, chip-fetcher), or build one from the datasheet (adom-step); then `models` again.".into()]);             }             if !unacked.is_empty() {-                err(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);+                findings(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);             }             ok(&format!("every footprint has its 3D model ({} with a model, {} expect none{vis}); 3D orientation: {} warning(s), all acknowledged; {} not checked here{}", counts["withModel"], counts["noModelExpected"], orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }), &["Next: step placement.".into()]);         }
crates/aiflow-router/src/rules.rs+14−2
@@ -81,7 +81,11 @@ impl Rules {             spec.get(k).and_then(|v| v.as_object()).map(|o| o.iter().filter_map(|(a, b)| Some((a.clone(), b.as_str()?.to_string()))).collect()).unwrap_or_default()         };         let get_widths = |k: &str| -> BTreeMap<String, Vec<f64>> {-            spec.get(k).and_then(|v| v.as_object()).map(|o| o.iter().filter_map(|(a, b)| Some((a.clone(), b.as_array()?.iter().filter_map(|x| x.as_f64()).collect()))).collect()).unwrap_or_default()+            // a list of widths, or one width as a number (issue #165: a scalar was dropped without a word)+            spec.get(k).and_then(|v| v.as_object()).map(|o| o.iter().filter_map(|(a, b)| {+                let w: Vec<f64> = match b { serde_json::Value::Array(x) => x.iter().filter_map(|x| x.as_f64()).collect(), _ => vec![b.as_f64()?] };+                Some((a.clone(), w))+            }).collect()).unwrap_or_default()         };         r.planes = get_map("planes");         r.kelvin = get_map("kelvin");@@ -96,7 +100,8 @@ impl Rules {         }         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; }+        // `trackWidth` is read as `track` (issue #165), `track` wins when both are there+        if let Some(v) = num("track").or_else(|| num("trackWidth")) { 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));@@ -128,6 +133,13 @@ mod tests {     use serde_json::json;     #[test] fn escape_width_respects_manufacturing_contract() {         assert_eq!(Rules::from_spec(&json!({"track":0.4})).stub,0.4);+        // issue #165: trackWidth is track; scalar widths are one-entry lists+        let r = Rules::from_spec(&json!({"trackWidth":0.15,"wideNets":{"VBAT":0.5,"GND":[0.6,0.4]},"midNets":{"SW":0.3}}));+        assert_eq!(r.track, 0.15);+        assert_eq!(r.wide["VBAT"], vec![0.5]);+        assert_eq!(r.wide["GND"], vec![0.6, 0.4]);+        assert_eq!(r.mid["SW"], vec![0.3]);+        assert_eq!(Rules::from_spec(&json!({"track":0.2,"trackWidth":0.15})).track, 0.2);         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);     }
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.56",+  "version": "0.2.57",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.56",+  "version": "0.2.57",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",