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
Freeze spec budgets at placement; spec-amend records real corrections (0.1.95)
8 files changed
+177−14
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.94"+version = "0.1.95" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.94"+version = "0.1.95" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.94"+version = "0.1.95" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.94"+version = "0.1.95" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.94"+version = "0.1.95" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.94"+version = "0.1.95" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.94"+version = "0.1.95" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.94"+version = "0.1.95" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.94"+version = "0.1.95" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.94"+version = "0.1.95" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.94"+version = "0.1.95" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1@@ -288,6 +288,7 @@ The gates read the board and the tools' own output, so a file written to look li - **3D models are the parts.** A renamed or resized laser-etch proxy box is refused by `models` and `finish`. Use the wiki component page's model, the vendor STEP, or a model built from the datasheet. - **Silkscreen is printed.** `finish` refuses a board with fewer than 0.5 visible silkscreen labels per footprint (finished boards carry about 1 to 3.5): references, values and contact labels go on the silkscreen. - `step <name> --back` only returns to an earlier step.+- **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet`, `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. ## Component coverage before placement
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+43−1@@ -15,6 +15,7 @@ mod browser_access; mod schematic_identity; mod placement_verify; mod provenance;+mod spec_freeze; mod export; mod silkproof; @@ -155,6 +156,8 @@ enum Cmd { Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf> }, /// The finish line; refuses until every check passes Finish,+ /// Record a change to the spec's budgets (loads, hot, rise limits, switch-node length, net widths, fab) after placement began. Budgets are frozen at placement; this is the only way to move one, and it stays on the record.+ SpecAmend { #[arg(long)] why: String }, /// 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. Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> }, /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.@@ -292,7 +295,29 @@ fn read_json(p: &str) -> Value { fn spec_of(r: &Run) -> Value { if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); }- read_json(r.data["spec"].as_str().unwrap_or(""))+ let spec = read_json(r.data["spec"].as_str().unwrap_or(""));+ guard_budgets(r, &spec);+ spec+}++/// 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.+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; }+ let Some(rec) = spec_freeze::read(&r.dir) else {+ let step = r.data["currentStep"].as_str().unwrap_or("").to_string();+ let rec = spec_freeze::frozen(spec, &now(), &step);+ spec_freeze::write(&r.dir, &rec);+ r.log("spec-budgets-frozen", json!({"step": step, "budgets": rec["budgets"]}));+ return;+ };+ 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(),+ ]); } /// The spec when there is one, Null before the board (an intake run) or when it does not read.@@ -3258,6 +3283,22 @@ fn main() { } 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::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 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", &[]); }+ 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());+ spec_freeze::write(&r.dir, &rec);+ r.log("spec-amend", a);+ ok(&format!("spec budgets amended on the record ({} change(s)): {}", changed.len(), changed.join("; ")), &["The amendment is listed in the finish summary; the gates now measure against the new values.".into()]);+ } Cmd::Finish => { let t = thread(&cli); let mut r = load_run(&cli);@@ -3417,6 +3458,7 @@ fn main() { record_usage(&mut r, "finish"); let summary = json!({"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true}); r.data["summary"] = summary;+ if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); } 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 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()]);
crates/adom-aiflow/src/spec_freeze.rsadded+120@@ -0,0 +1,120 @@+//! Spec budgets freeze once placement begins: the board changes to meet them, they do not move to meet the board.+//!+//! A run (2026-10-15) missed the switch-node budget and raised `switchNodeMaxLengthMm` from 12 to 22 to 24 mm,+//! then failed thermal on L1's switch-node copper and moved L1's heat (`hot.L1.tabNet`) onto the big VOUT pour.+//! Every gate passed on numbers the run had changed. The budgets are snapshotted at placement+//! (`<run>/spec-budgets.json`); a later read that disagrees is refused, and a real correction goes through+//! `spec-amend --why`, which keeps it on the record (dashboard ledger and the finish summary).++use serde_json::{json, Map, Value};+use std::path::Path;++/// Spec keys that are budgets or physical models: what the gates measure the board against.+/// Layout keys (pours, thermalVias, fixed coordinates, planeUnder) stay editable: they describe the board.+pub const BUDGET_KEYS: &[&str] = &[+ "loads", "hot", "hotMaxRiseC", "maxRiseC", "switchNodeMaxLengthMm", "switchNodes", "wideNets", "midNets",+ "fab", "copperUm", "copperUmByLayer", "viaPower", "drillPower", "branchCurrentModel", "packageCooling",+];++pub const FILE: &str = "spec-budgets.json";++/// The budget keys present in a spec.+pub fn budgets(spec: &Value) -> Value {+ let mut m = Map::new();+ for k in BUDGET_KEYS { if let Some(v) = spec.get(*k) { if !v.is_null() { m.insert(k.to_string(), v.clone()); } } }+ Value::Object(m)+}++fn short(v: Option<&Value>) -> String {+ let s = v.map(|v| v.to_string()).unwrap_or_else(|| "(absent)".into());+ if s.chars().count() > 140 { format!("{}...", s.chars().take(137).collect::<String>()) } else { s }+}++/// Changed leaves between two budget sets, as "path: old -> new" (objects are walked, arrays and scalars compared whole).+pub fn diff(old: &Value, new: &Value) -> Vec<String> {+ let mut out = Vec::new();+ walk("", old, new, &mut out);+ out+}++fn walk(path: &str, old: &Value, new: &Value, out: &mut Vec<String>) {+ match (old, new) {+ (Value::Object(a), Value::Object(b)) => {+ let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect();+ keys.sort(); keys.dedup();+ for k in keys {+ let p = if path.is_empty() { k.clone() } else { format!("{path}.{k}") };+ match (a.get(k), b.get(k)) {+ (Some(x), Some(y)) => walk(&p, x, y, out),+ (x, y) => out.push(format!("{p}: {} -> {}", short(x), short(y))),+ }+ }+ }+ _ => if old != new { out.push(format!("{path}: {} -> {}", short(Some(old)), short(Some(new)))); },+ }+}++pub fn read(dir: &Path) -> Option<Value> {+ std::fs::read_to_string(dir.join(FILE)).ok().and_then(|t| serde_json::from_str(&t).ok())+}++pub fn write(dir: &Path, v: &Value) {+ let tmp = dir.join(format!("{FILE}.tmp"));+ if std::fs::write(&tmp, serde_json::to_string_pretty(v).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, dir.join(FILE)); }+}++/// A new freeze record.+pub fn frozen(spec: &Value, at: &str, step: &str) -> Value {+ json!({"at": at, "step": step, "budgets": budgets(spec), "amendments": []})+}++/// The amendments on record, as "why: changes" lines for the finish summary.+pub fn amendments(rec: &Value) -> Vec<Value> {+ rec["amendments"].as_array().cloned().unwrap_or_default()+}++/// The fewest characters a `--why` may have: a reason, not a word.+pub const MIN_WHY: usize = 20;++#[cfg(test)]+mod tests {+ use super::*;++ fn spec() -> Value {+ json!({"switchNodeMaxLengthMm": 12, "hot": {"L1": {"watts": 0.066, "tabNet": "SW"}, "U1": {"watts": 0.233, "tabNet": "GND"}},+ "hotMaxRiseC": 50, "pours": [{"net": "SW", "layers": ["F.Cu"]}], "fixed": {"MP1": [0, 0]}})+ }++ #[test]+ fn the_2026_10_15_edits_are_caught() {+ let a = budgets(&spec());+ let mut s = spec();+ s["switchNodeMaxLengthMm"] = json!(24);+ s["hot"]["L1"]["tabNet"] = json!("VOUT");+ let d = diff(&a, &budgets(&s));+ assert_eq!(d.len(), 2, "{d:?}");+ assert!(d.iter().any(|l| l.starts_with("hot.L1.tabNet: \"SW\" -> \"VOUT\"")));+ assert!(d.iter().any(|l| l.starts_with("switchNodeMaxLengthMm: 12 -> 24")));+ }++ #[test]+ fn layout_edits_stay_free() {+ let a = budgets(&spec());+ let mut s = spec();+ s["pours"] = json!([{"net": "SW", "layers": ["F.Cu", "B.Cu"]}]);+ s["fixed"]["MP1"] = json!([2, 2]);+ s["thermalVias"] = json!([{"at": "L1.1", "count": 4}]);+ assert!(diff(&a, &budgets(&s)).is_empty());+ }++ #[test]+ fn removing_or_adding_a_budget_counts() {+ let a = budgets(&spec());+ let mut s = spec();+ s.as_object_mut().unwrap().remove("hotMaxRiseC");+ s["maxRiseC"] = json!(80);+ let d = diff(&a, &budgets(&s));+ assert!(d.iter().any(|l| l == "hotMaxRiseC: 50 -> (absent)"), "{d:?}");+ assert!(d.iter().any(|l| l == "maxRiseC: (absent) -> 80"), "{d:?}");+ }+}
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.94",+ "version": "0.1.95", "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+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.94",+ "version": "0.1.95", "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.",