← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.49"+version = "0.2.50" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.49"+version = "0.2.50" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.49"+version = "0.2.50" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.49"+version = "0.2.50" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.49"+version = "0.2.50" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.49"+version = "0.2.50" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.49"+version = "0.2.50" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.49"+version = "0.2.50" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.49"+version = "0.2.50" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.49"+version = "0.2.50" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.49"+version = "0.2.50" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+2
@@ -593,6 +593,8 @@ A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a  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. +**What needs a look (issue #141).** Every command that ends with findings, notes or an error is kept per command (run.json `checks`, with its step, count and first lines) until that command runs cleanly again, and the last `route` and `gate` numbers (`lastRoute`, `lastGate`: routed/unrouted connections, nets unrouted, vias, warnings, new DRC and unconnected) survive a board change, labelled "measured on the previous board" until the next run. `status` prints them, the step table marks the step "needs a look" or "error", and the dashboard shows a chip on the step and a route card. None of it blocks anything.+ Do not reopen the webview to show progress. `timeline open` activates the existing named tab; a changed proxy URL navigates that tab. Repeated status/step/artifact calls reuse it. Tests must set `AIFLOW_DASHBOARD=off` unless Hydrogen is explicitly mocked (a test-owned `adom-cli` on PATH and its own `AIFLOW_HOME`); never let a temporary fixture open the user's workspace.  **Names.** The tab is `AI Flow · <label>`. The label says what the run is building, from the first of: the requirements title, the spec `title`, the project page (slug, plus its folder), the original board's file stem (the file the run started from, never a `board-N` snapshot), the brief's first line, the run folder's name. It is cut to about 40 characters with `...` and keeps only plain characters. When another run's tab already has that name, the AI thread is added (`AI Flow · Buck · astra`), then the run folder. The dashboard header shows the full label, then the board file, the lane (KiCad, Fusion or Altium), `AI thread: <name>` (the thread that last drove the run, recorded in run.json as `lastAiThread` on every command) and the run folder. A run whose label changes (a board attached, a requirements title added) has its tab renamed in place.
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/attention.rsadded+240
@@ -0,0 +1,240 @@+//! What the last commands said, kept where `status` and the dashboard can show it (issue #141).+//!+//! AI Flow never refuses, so nothing here is a pass/fail gate. Two records live in run.json:+//!+//! * `checks`: per command (its leading words, `route`, `gate`, `land route`, `analyze current`), the+//!   latest run of it that ended with findings, notes or an error, with the step it was charged to,+//!   a count and its first lines. The entry stays until that same command ends cleanly again, so a+//!   finding survives board changes and later commands; it is never a block.+//! * `lastRoute` / `lastGate`: the latest route's and gate's numbers (connections routed and+//!   unrouted, nets unrouted, vias, warnings, the route-quality headline; new DRC errors and+//!   unconnected), with the board revision they were measured on. Unlike `outcomes`, a new board+//!   does not remove them: they are labelled "measured on the previous board" until the next run.+use serde_json::{json, Value};++/// The command's name: its leading words, up to two, before the first flag or argument.+pub fn command_key(command: &str) -> String {+    let mut out = Vec::new();+    for w in command.split_whitespace() {+        if out.len() == 2 || w.starts_with('-') || w.contains(['/', '\\', '.', '=', ':']) || w.chars().any(|c| c.is_ascii_digit()) { break; }+        out.push(w);+    }+    if out.is_empty() { command.split_whitespace().next().unwrap_or("").to_string() } else { out.join(" ") }+}++fn clip(s: &str) -> String { aiflow_run::clip_text(s, 240) }++/// How a command ended, for the record: `ok`, `findings` (findings() or notes) or `error` (err()).+pub struct Ending<'a> {+    pub kind: &'a str,+    pub msg: &'a str,+    pub hints: &'a [String],+    pub notes: &'a [String],+}++impl Ending<'_> {+    /// The number of findings: list lines under a findings message (one when it has none), plus notes.+    fn count(&self) -> usize {+        let notes = self.notes.len();+        match self.kind {+            "findings" if !self.msg.trim().is_empty() => self.msg.lines().skip(1).filter(|l| !l.trim().is_empty()).count().max(1) + notes,+            "error" => 1,+            _ => notes,+        }+    }+    /// The effective kind: a clean ending with notes ended with findings.+    fn effective(&self) -> &str {+        if self.kind == "ok" && !self.notes.is_empty() { "findings" } else { self.kind }+    }+    fn lines(&self) -> Vec<String> {+        let mut out: Vec<String> = if self.kind == "ok" { Vec::new() } else { self.msg.lines().map(str::trim).filter(|l| !l.is_empty()).take(4).map(clip).collect() };+        for n in self.notes { if out.len() >= 5 { break; } if let Some(l) = n.lines().next() { out.push(clip(&format!("note: {l}"))); } }+        out+    }+}++/// Record how the command at turn `turn` ended. Returns the record (Null for a clean ending).+pub fn record(data: &mut Value, turn: &Value, end: &Ending, at: &str) -> Value {+    let command = turn["command"].as_str().unwrap_or("");+    let key = command_key(command);+    if key.is_empty() { return Value::Null; }+    if !data["checks"].is_object() { data["checks"] = json!({}); }+    let kind = end.effective().to_string();+    let board = data["boards"].as_array().and_then(|a| a.last()).cloned().unwrap_or(Value::Null);+    let checks = data["checks"].as_object_mut().unwrap();+    if kind == "ok" { checks.remove(&key); return Value::Null; }+    let rec = json!({+        "key": key, "command": clip(command), "step": turn["step"], "turn": turn["n"], "kind": kind,+        "label": label(&kind), "count": end.count(), "lines": end.lines(),+        "suggestion": end.hints.iter().find(|h| !h.trim().is_empty()).map(|h| clip(h)), "at": at,+        "board": board,+    });+    checks.insert(key, rec.clone());+    rec+}++pub fn label(kind: &str) -> &'static str {+    match kind { "error" => "error: could not run", "findings" => "needs a look: ended with findings", _ => "ok" }+}++/// Every open record, oldest first.+pub fn open_items(data: &Value) -> Vec<Value> {+    let mut v: Vec<Value> = data["checks"].as_object().map(|o| o.values().cloned().collect()).unwrap_or_default();+    v.sort_by(|a, b| a["at"].as_str().cmp(&b["at"].as_str()));+    v+}++/// One step's badge: Null when nothing is open on it, else the worst kind, the total count and the items.+pub fn step_attention(data: &Value, step: &str) -> Value {+    let items: Vec<Value> = open_items(data).into_iter().filter(|c| c["step"].as_str() == Some(step)).collect();+    if items.is_empty() { return Value::Null; }+    let kind = if items.iter().any(|c| c["kind"] == "error") { "error" } else { "findings" };+    let count: u64 = items.iter().map(|c| c["count"].as_u64().unwrap_or(1)).sum();+    json!({"kind": kind, "label": label(kind), "count": count, "items": items})+}++/// The route record kept in run.json `lastRoute`, from the router's report and the plan's numbers.+pub fn route_record(report: &Value, vias: Option<u64>, warnings: Vec<String>, quality_headline: Option<String>, board: &str, revision: usize, at: &str, plan: &str, passes: usize) -> Value {+    let mut connections = 0u64;+    let mut nets_unrouted: Vec<String> = Vec::new();+    for (name, n) in report["nets"].as_object().cloned().unwrap_or_default() {+        connections += n["clusters"].as_u64().unwrap_or(0).saturating_sub(1);+        if n["failed"].as_array().map(|a| !a.is_empty()).unwrap_or(false) || n["failed"].as_object().map(|o| !o.is_empty()).unwrap_or(false) { nets_unrouted.push(name); }+    }+    let unrouted_list = report["unrouted"].as_array().cloned().unwrap_or_default();+    for u in &unrouted_list { if let Some(n) = u[0].as_str() { if !nets_unrouted.iter().any(|x| x == n) { nets_unrouted.push(n.to_string()); } } }+    nets_unrouted.sort();+    let unrouted = unrouted_list.len() as u64;+    json!({+        "at": at, "board": board, "boardRevision": revision, "plan": plan, "passes": passes,+        "connections": connections.max(unrouted), "routed": connections.saturating_sub(unrouted), "unrouted": unrouted,+        "netsUnrouted": nets_unrouted, "vias": vias, "warnings": warnings.iter().map(|w| clip(w)).collect::<Vec<_>>(),+        "routeQuality": quality_headline.map(|h| clip(&h)),+        "detail": unrouted_list.iter().take(6).map(|u| clip(&format!("{} at {} ({})", u[0].as_str().unwrap_or("?"), u[1][0], u[1][1].as_str().unwrap_or("")))).collect::<Vec<_>>(),+    })+}++fn revision_of(data: &Value, board: &str) -> Option<usize> {+    data["boards"].as_array().and_then(|a| a.iter().rposition(|b| b.as_str() == Some(board))).map(|i| i + 1)+}++/// Where a kept record was measured, against the run's current board: Null when there is none.+fn placed(data: &Value, rec: &Value, outcome_key: &str) -> Value {+    if !rec.is_object() { return Value::Null; }+    let current = data["boards"].as_array().and_then(|a| a.last()).and_then(|b| b.as_str()).unwrap_or("");+    let boards = data["boards"].as_array().map(|a| a.len()).unwrap_or(0);+    let board = rec["board"].as_str().unwrap_or("");+    let rev = rec["boardRevision"].as_u64().map(|r| r as usize).or_else(|| revision_of(data, board)).unwrap_or(0);+    // copper carried to the new board (copper_carry): the outcome survived with the same timestamp+    let carried = board != current && data["outcomes"].get(outcome_key).is_some() && data["outcomeAt"][outcome_key] == rec["at"];+    let on_current = board == current || carried;+    let file = board.rsplit(['/', '\\']).next().unwrap_or(board);+    let measured = if board == current { format!("measured on the current board ({file}, revision {rev})") }+        else if carried { format!("measured on revision {rev} ({file}); copper unchanged since, carried to the current board (revision {boards})") }+        else { format!("measured on the previous board ({file}, revision {rev}; the board is now revision {boards}): run {outcome_key} again to measure this one") };+    let mut v = rec.clone();+    v["onCurrentBoard"] = json!(on_current);+    v["currentRevision"] = json!(boards);+    v["measuredOn"] = json!(measured);+    v+}++/// The route card for `status` and the dashboard: the last route and the last gate, each placed.+pub fn route_card(data: &Value) -> Value {+    let route = placed(data, &data["lastRoute"], "route");+    let gate = placed(data, &data["lastGate"], "gate");+    if route.is_null() && gate.is_null() { return Value::Null; }+    json!({"route": route, "gate": gate})+}++/// `status` lines: the route card and every open record.+pub fn status_lines(data: &Value) -> Vec<String> {+    let mut out = Vec::new();+    let card = route_card(data);+    let r = &card["route"];+    if r.is_object() {+        let nets: Vec<String> = r["netsUnrouted"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+        let warn = r["warnings"].as_array().map(|a| a.len()).unwrap_or(0);+        out.push(format!("last route ({}, {}): {} of {} connection(s) routed, {} unrouted{}; {} via(s); {} warning(s)",+            r["at"].as_str().unwrap_or("?"), r["measuredOn"].as_str().unwrap_or(""),+            r["routed"], r["connections"], r["unrouted"],+            if nets.is_empty() { String::new() } else { format!(" in {} net(s): {}", nets.len(), nets.iter().take(8).cloned().collect::<Vec<_>>().join(", ")) },+            r["vias"].as_u64().map(|v| v.to_string()).unwrap_or("-".into()), warn));+        for d in r["detail"].as_array().into_iter().flatten().take(3) { out.push(format!("  unrouted: {}", d.as_str().unwrap_or(""))); }+        for w in r["warnings"].as_array().into_iter().flatten().take(3) { out.push(format!("  warning: {}", w.as_str().unwrap_or(""))); }+        if let Some(q) = r["routeQuality"].as_str() { out.push(format!("  route quality: {q}")); }+    }+    let g = &card["gate"];+    if g.is_object() {+        out.push(format!("last gate ({}, {}): {} new DRC error(s), {} unconnected, {} inherited", g["at"].as_str().unwrap_or("?"), g["measuredOn"].as_str().unwrap_or(""), g["newErrors"], g["unconnected"], g["inheritedErrors"]));+    }+    let items = open_items(data);+    if items.is_empty() {+        out.push("needs a look: nothing (every command's last run ended cleanly)".into());+    } else {+        out.push(format!("needs a look ({} command(s); suggestions, nothing is blocked; each stays until that command runs cleanly again):", items.len()));+        for c in &items {+            out.push(format!("  {} [{}] `{}` {} ({}) at {}: {}", c["step"].as_str().unwrap_or("?"), c["kind"].as_str().unwrap_or(""), c["key"].as_str().unwrap_or(""),+                if c["kind"] == "error" { "ended with an error".to_string() } else { "ended with findings".to_string() }, c["count"], c["at"].as_str().unwrap_or(""),+                c["lines"][0].as_str().unwrap_or("")));+            for l in c["lines"].as_array().into_iter().flatten().skip(1).take(2) { out.push(format!("      {}", l.as_str().unwrap_or(""))); }+        }+    }+    out+}++/// Strip the route-quality line's preamble to its headline.+pub fn quality_headline(lines: &[String]) -> Option<String> {+    lines.first().map(|l| l.split_once(": ").map(|(_, r)| r.to_string()).unwrap_or(l.clone()))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn command_keys_stop_at_flags_and_arguments() {+        assert_eq!(command_key("route --passes 8"), "route");+        assert_eq!(command_key("land route --plan /x/p.json"), "land route");+        assert_eq!(command_key("analyze current"), "analyze current");+        assert_eq!(command_key("route --pick 2 --why x"), "route");+        assert_eq!(command_key("step routing --back --why y"), "step routing");+        assert_eq!(command_key("gate"), "gate");+    }++    #[test]+    fn findings_stay_until_the_same_command_ends_cleanly() {+        let mut d = json!({"boards": ["/r/board-1.kicad_pcb"]});+        let t = json!({"n": 3, "step": "routing", "command": "route --passes 2"});+        let rec = record(&mut d, &t, &Ending { kind: "findings", msg: "routing closed 2 connection(s) short", hints: &["More passes.".into()], notes: &[] }, "2026-11-04T10:00:00Z");+        assert_eq!(rec["count"], 1);+        assert_eq!(step_attention(&d, "routing")["kind"], "findings");+        // another command ending cleanly leaves it+        record(&mut d, &json!({"n": 4, "step": "routing", "command": "status"}), &Ending { kind: "ok", msg: "x", hints: &[], notes: &[] }, "t");+        assert_eq!(open_items(&d).len(), 1);+        // a gate error adds a second, worst kind wins on the step+        record(&mut d, &json!({"n": 5, "step": "routing", "command": "gate"}), &Ending { kind: "error", msg: "no plan", hints: &[], notes: &[] }, "t2");+        let a = step_attention(&d, "routing");+        assert_eq!((a["kind"].as_str(), a["count"].as_u64()), (Some("error"), Some(2)));+        // notes on an ok ending count as findings; the next clean route clears its entry+        let n = record(&mut d, &json!({"n": 6, "step": "pours", "command": "pour"}), &Ending { kind: "ok", msg: "done", hints: &[], notes: &["a".into(), "b".into()] }, "t3");+        assert_eq!((n["kind"].as_str(), n["count"].as_u64()), (Some("findings"), Some(2)));+        record(&mut d, &json!({"n": 7, "step": "placement", "command": "route"}), &Ending { kind: "ok", msg: "closed", hints: &[], notes: &[] }, "t4");+        assert!(open_items(&d).iter().all(|c| c["key"] != "route"));+    }++    #[test]+    fn the_route_card_says_when_the_board_changed() {+        let report = json!({"nets": {"A": {"clusters": 2, "failed": []}, "B": {"clusters": 3, "failed": [["J3", "blocked"]]}}, "unrouted": [["B", ["J3", "blocked by J4"]]]});+        let rec = route_record(&report, Some(4), vec!["w".into()], Some("q".into()), "/r/board-1.kicad_pcb", 1, "2026-11-04T10:00:00Z", "/r/plan.json", 8);+        assert_eq!((rec["connections"].as_u64(), rec["routed"].as_u64(), rec["unrouted"].as_u64()), (Some(3), Some(2), Some(1)));+        assert_eq!(rec["netsUnrouted"], json!(["B"]));+        let mut d = json!({"boards": ["/r/board-1.kicad_pcb"], "lastRoute": rec, "outcomes": {}, "outcomeAt": {}});+        assert_eq!(route_card(&d)["route"]["onCurrentBoard"], true);+        d["boards"].as_array_mut().unwrap().push(json!("/r/board-2.kicad_pcb"));+        let c = route_card(&d);+        assert_eq!(c["route"]["onCurrentBoard"], false);+        assert!(c["route"]["measuredOn"].as_str().unwrap().contains("previous board"), "{c}");+        assert!(status_lines(&d).iter().any(|l| l.contains("2 of 3 connection(s) routed, 1 unrouted in 1 net(s): B")));+    }+}
crates/adom-aiflow/src/decoupling_check.rs+3−1
@@ -154,11 +154,13 @@ pub fn record(r: &mut Run, dir: &Path, d: &Option<Review>) { }  /// The stage fails on an error-severity finding.+/// Says the decoupling errors as findings and lets the command go on (AI Flow never refuses, 0.2.48);+/// `finish` lists them as open items. pub fn refuse_errors(stage: &str, d: &Option<Review>) {     if let Some(d) = d {         if d.errors > 0 {             let first: Vec<String> = d.value["entries"].as_array().into_iter().flatten().flat_map(|e| e["findings"].as_array().cloned().unwrap_or_default()).filter(|f| f["severity"] == "error").filter_map(|f| f["text"].as_str().map(str::to_string)).take(6).collect();-            crate::err(&format!("{stage}: {} decoupling error(s):\n  {}", d.errors, first.join("\n  ")), &[HINT.into(), format!("The full review is {}.", d.value["stage"].as_str().map(|s| format!("decoupling-{s}.json in the run folder")).unwrap_or_default())]);+            crate::note(&format!("{stage}: {} decoupling finding(s) of error severity:\n  {}", d.errors, first.join("\n  ")), &[HINT.into(), format!("The full review is {}.", d.value["stage"].as_str().map(|s| format!("decoupling-{s}.json in the run folder")).unwrap_or_default())]);         }     } }
crates/adom-aiflow/src/main.rs+71−15
@@ -61,6 +61,7 @@ mod deviations; mod prefs; mod handson; mod screen;+mod attention; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -357,11 +358,25 @@ fn ensure_dashboard(dir: &Path, ai_thread: &str, force_show: bool) { }  -/// Close the ledger entry for this command (see Run::turn_begin).-fn close_turn(ok_: bool, first_line: &str) {+/// Every `note` of this command, so its turn is recorded as ending with findings (issue #141).+static NOTES: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());++/// Close the ledger entry for this command (see Run::turn_begin), and record how it ended+/// (ok, findings or error, with a count and its first lines) where `status` and the dashboard read it.+fn close_turn(kind: &str, msg: &str, hints: &[String]) {+    let ok_ = kind != "error";+    let first_line = msg.lines().next().unwrap_or("");     if let Some((dir, idx)) = TURN.get() {         if let Some(mut r) = Run::open(dir) {             r.turn_end(*idx, ok_, first_line);+            // a usage slip (no thread name, a bad flag) says nothing about the board+            let usage = kind == "error" && (msg.starts_with("usage") || hints.iter().any(|h| h.starts_with("Re-run with --ai-thread")));+            if let (false, Some(turn)) = (usage, r.data["turns"].get(*idx).cloned()) {+                let notes = NOTES.lock().map(|n| n.clone()).unwrap_or_default();+                let rec = attention::record(&mut r.data, &turn, &attention::Ending { kind, msg, hints, notes: &notes }, &now());+                let ending = if rec.is_null() { "ok" } else { rec["kind"].as_str().unwrap_or("ok") };+                r.log("command-outcome", json!({"n": turn["n"], "step": turn["step"], "key": attention::command_key(turn["command"].as_str().unwrap_or("")), "outcome": ending, "count": rec["count"], "lines": rec["lines"]}));+            }             let _ = r.save();         }     }@@ -373,7 +388,7 @@ fn close_turn(ok_: bool, first_line: &str) { }  fn ok(msg: &str, hints: &[String]) {-    close_turn(true, msg.lines().next().unwrap_or(""));+    close_turn("ok", msg, hints);     println!("OK: {msg}");     for h in hints {         if !h.is_empty() {@@ -392,7 +407,7 @@ const CONTRIBUTE: &str = "If this check is wrong for your board (AI Flow has not /// AI Flow is a suggestion engine and never refuses (John, 2026-11-04): a check that finds problems reports /// them with suggestions and the command completes. `findings` ends a command that IS the check. fn findings(msg: &str, hints: &[String]) -> ! {-    close_turn(true, msg.lines().next().unwrap_or(""));+    close_turn("findings", msg, hints);     println!("FINDINGS (suggestions, nothing is blocked): {msg}");     for h in hints { if !h.is_empty() { println!("Suggestion: {h}"); } }     println!("Suggestion: {CONTRIBUTE}");@@ -401,12 +416,13 @@ fn findings(msg: &str, hints: &[String]) -> ! {  /// A check in front of an action: say what it found and carry on with the action. fn note(msg: &str, hints: &[String]) {+    if let Ok(mut n) = NOTES.lock() { n.push(msg.to_string()); }     eprintln!("NOTE (a suggestion, not a stop): {msg}");     for h in hints { if !h.is_empty() { eprintln!("Suggestion: {h}"); } } }  fn err(msg: &str, hints: &[String]) -> ! {-    close_turn(false, msg.lines().next().unwrap_or(""));+    close_turn("error", msg, hints);     println!("ERROR: {msg}");     for h in hints {         if !h.is_empty() {@@ -934,7 +950,9 @@ fn step_table(r: &Run) -> String {         let e = &rep[&n];         let (w, t, b, k) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0), e["turns"].as_u64().unwrap_or(0));         tw += w; tt += t; tb += b; tn += k;-        lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8}  {:.1} min", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+        let a = attention::step_attention(&r.data, &n);+        let look = if a.is_null() { String::new() } else if a["kind"] == "error" { format!("  error ({})", a["count"]) } else { format!("  needs a look ({} finding(s))", a["count"]) };+        lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8}  {:.1} min{look}", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));     }     lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6}", "total", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0, tn));     lines.join("\n")@@ -3701,7 +3719,17 @@ fn main() {                     write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());                     let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();                     let verdicts = aiflow_place::chain_report(&b, &mv, &chain_plans);-                    let mut msg = format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n"));+                    // issue #140: name the parts left without a spot (they stay where they are, maybe off the board)+                    let left: Vec<String> = wishes.iter().map(|(r, _)| r.clone()).filter(|r| !mv.contains_key(r)).collect();+                    let mut msg = format!("legal spots for {} of {} parts{}:\n{}", mv.len(), wishes.len(), if left.is_empty() { String::new() } else { format!(" (no spot for {}; left where {} now)", left.join(", "), if left.len() == 1 { "it is" } else { "they are" }) }, lines.join("\n"));+                    // and every part the packed board would still have outside the outline+                    let packed = aiflow_place::move_in_text(&b, &mv).ok().and_then(|(t, _)| Board::from_text(&b.path, t).ok());+                    let overhang_refs = edge_overhang::allowed(&spec, allow_outside).map(|o| edge_overhang::refs(&o)).unwrap_or_default();+                    let outside = aiflow_place::parts_outside(packed.as_ref().unwrap_or(&b), &overhang_refs);+                    if !outside.is_empty() {+                        msg.push_str(&format!("\nFINDING: {} part(s) outside the board outline after this pack:", outside.len()));+                        for (_, w) in &outside { msg.push_str(&format!("\n  {w}")); }+                    }                     if !chains.is_empty() {                         let honoured = verdicts.iter().filter(|v| v.1).count();                         msg.push_str(&format!("\nchains: {honoured} of {} honoured", chains.len()));@@ -3709,7 +3737,7 @@ fn main() {                         for (name, _, v) in &verdicts { msg.push_str(&format!("\n  chain {name}: {v}")); }                     }                     let chain_hint = if verdicts.iter().any(|v| !v.1) || chain_lines.iter().any(|l| l.contains("not applied")) { "A chain not honoured: give its first member a wish with room along the chain's direction (or set \"direction\" in spec.chains), put the chain early in the wishes, and pack again.".to_string() } else { String::new() };-                    ok(&msg, &[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() }, chain_hint]);+                    ok(&msg, &[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() }, if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.to_string() }, chain_hint]);                 }                 "check" => {                     let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));@@ -3725,6 +3753,11 @@ fn main() {                     let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));                     let overhang_lines = edge_overhang::check_board(&nb, &overhang).unwrap_or_else(|e| err(&e, &[format!("Move the part inward until every pad is inside; the run's current board is unchanged ({}).", outp.display())]));                     let problems = aiflow_place::check_allowing(&nb, &moved, 0.05, &edge_overhang::refs(&overhang));+                    // issue #140: every part outside the outline, moved or not (a part pack found no spot for is not in the moves)+                    let outside = aiflow_place::parts_outside(&nb, &edge_overhang::refs(&overhang));+                    if !outside.is_empty() {+                        note(&format!("{} part(s) outside the board outline on the moved board:\n  {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n  ")), &[aiflow_place::OUTSIDE_HINT.into()]);+                    }                     if let Some(f) = molecule_findings(&spec_of(&r), &nb, Some(&r)) {                         if !f.errors.is_empty() {                             let mut h = f.hints.clone();@@ -3755,8 +3788,9 @@ fn main() {                     if let Some(n) = reviews::void_on_service_change(&mut r, Path::new(&old_board), &outp) { eprintln!("{n}"); }                     r.add_board(&outp.display().to_string());                     r.save().unwrap();-                    let lines: Vec<String> = lines.into_iter().chain(overhang_lines).collect();-                    ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line} and is now the run's current board", lines.join("\n"), outp.display()), &[if overhang.is_empty() { "Land the same moves live with `place land --moves ...`.".into() } else { "Land the same moves live with `place land --moves ...` (the edge overhang parts are sent with allowOutside, the others are not).".into() }]);+                    let lines: Vec<String> = lines.into_iter().chain(overhang_lines).chain(outside.iter().map(|(_, w)| format!("FINDING: {w}"))).collect();+                    let outside_hint = if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.to_string() };+                    ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line}{} and is now the run's current board", lines.join("\n"), outp.display(), if outside.is_empty() { String::new() } else { format!("; {} part(s) outside the outline (FINDING lines above)", outside.len()) }), &[outside_hint, if overhang.is_empty() { "Land the same moves live with `place land --moves ...`.".into() } else { "Land the same moves live with `place land --moves ...` (the edge overhang parts are sent with allowOutside, the others are not).".into() }]);                 }                 "land" => {                     let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));@@ -3765,6 +3799,12 @@ fn main() {                     let mv = read_json(m);                     let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));                     for l in edge_overhang::check_moves(&b, &mv, &overhang).unwrap_or_else(|e| err(&e, &["Nothing was landed. Move the part inward until every pad is inside.".into()])) { eprintln!("{l}"); }+                    // issue #140: parts that will still be outside the outline once these moves land (named, then landed anyway)+                    let landed_board = aiflow_place::moves_from_json(&mv).ok().and_then(|m| aiflow_place::move_in_text(&b, &m).ok()).and_then(|(t, _)| Board::from_text(&b.path, t).ok());+                    let outside = aiflow_place::parts_outside(landed_board.as_ref().unwrap_or(&b), &edge_overhang::refs(&overhang));+                    if !outside.is_empty() {+                        note(&format!("{} part(s) will be outside the board outline after this landing:\n  {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n  ")), &[aiflow_place::OUTSIDE_HINT.into()]);+                    }                     r.stage_start("place-land", Some("binary"), None);                     let t0 = std::time::Instant::now();                     // one part at a time: the camera sees each part land, not a board that changes at once@@ -3783,7 +3823,8 @@ fn main() {                         }                         err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), if rep["errorCode"] == "outside_outline" { "An edge connector whose body overhangs the outline on purpose: name it in spec placement.edgeOverhang [{\"ref\", \"why\"}] or pass --allow-outside REF (its pads must stay inside), then land again; the parts before it are already placed.".to_string() } else { "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.".to_string() }]);                     }-                    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()]);+                    let outside_line = if outside.is_empty() { String::new() } else { format!("\nFINDING: {} part(s) outside the board outline: {}", outside.len(), outside.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>().join(", ")) };+                    ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s{outside_line}", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into(), if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.into() }]);                 }                 other => err(&format!("place {other}: use pack, check, land or verify"), &[]),             }@@ -3807,6 +3848,11 @@ fn main() {             }             let spec = spec_of(&r);             let b = board_of(&r);+            // issue #140: a part outside the outline cannot be routed; name it before routing (the router reports its nets as unrouted)+            let outside = aiflow_place::parts_outside(&b, &edge_overhang::allowed(&spec, &[]).map(|o| edge_overhang::refs(&o)).unwrap_or_default());+            if !outside.is_empty() {+                note(&format!("{} part(s) outside the board outline; their connections are left unrouted:\n  {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n  ")), &[aiflow_place::OUTSIDE_HINT.into()]);+            }             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 mut rules = aiflow_router::Rules::from_spec(&spec);@@ -3850,7 +3896,16 @@ fn main() {                 None => Vec::new(),             };             for l in &quality_lines { eprintln!("{l}"); }+            // issue #141: the route's numbers, kept across board changes until the next route+            let mut route_warnings: Vec<String> = bc["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str().map(str::to_string)).collect();+            route_warnings.extend(imp_lines.iter().filter(|l| l.starts_with("WARNING")).map(|l| l.trim_start_matches("WARNING").trim_start_matches(':').trim().to_string()));+            if let Some(d) = dec.as_ref().filter(|d| d.errors + d.warnings > 0) { route_warnings.push(format!("decoupling: {} error(s), {} warning(s) (decoupling-route.json)", d.errors, d.warnings)); }+            let plan_vias = aiflow_copper::apply(&board_of(&r), &plan_doc, false).ok().and_then(|(_, st)| st["vias"].as_u64());+            let revision = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);+            r.data["lastRoute"] = attention::route_record(&report, plan_vias, route_warnings, attention::quality_headline(&quality_lines), &r.current_board(), revision, &now(), &planp.display().to_string(), *passes);             r.outcome("route", route_o);+            // the same timestamp as the outcome: a carried outcome (copper_carry) is recognised by it+            r.data["lastRoute"]["at"] = r.data["outcomeAt"]["route"].clone();             r.save().unwrap();             let policy_hint = if regions.is_empty() { String::new() } else if preexisting.is_empty() {                 format!("planeUnder: {} kept free of every other net's copper.", regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>().join("; "))@@ -3986,6 +4041,7 @@ fn main() {             let npu = pu_issues.len() as u64;             r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);             r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routeQuality": s["routeQuality"], "silkAdvisories": s["silkAdvisories"], "silkBlocking": s["silkBlocking"], "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));+            r.data["lastGate"] = json!({"at": r.data["outcomeAt"]["gate"], "board": r.current_board(), "boardRevision": r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1), "newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu, "plan": planp});             r.save().unwrap();             if !sw_issues.is_empty() {                 r.outcome("switch-node", json!({"issues": sw_issues}));@@ -4017,7 +4073,7 @@ fn main() {                 hair.sort(); hair.dedup();                 let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };                 let pu_hint = if npu > 0 { format!("Also {npu} planeUnder issue(s): {}", pu_issues.iter().take(4).cloned().collect::<Vec<_>>().join("; ")) } else { String::new() };-                findings(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[pu_hint, format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);+                findings(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[pu_hint, format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Suggested: fix the plan (route again, or move parts) and gate again; landing it as it is lands these errors too.".into()]);             }             decoupling_check::refuse_errors("gate", &dec);             if let Some(m) = &mech {@@ -4866,11 +4922,11 @@ fn main() {         Cmd::Status => {             let r = load_run(&cli);             let o = r.outcomes();-            ok(&format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",+            ok(&(format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",                 dir.display(), r.data["engine"], r.elapsed_minutes(), r.data["clock"]["promptTime"], Value::Object(r.stage_minutes()), r.data["decisions"],-                o.get("route").map(|x| x["unrouted"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),+                o.get("route").map(|x| x["unrouted"].to_string()).or_else(|| r.data["lastRoute"]["unrouted"].as_u64().map(|u| format!("{u} (previous board)"))).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),                 if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { pourverify::mode_label(&o["land-pours"]) } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),-                o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);+                o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]) + "\n" + &attention::status_lines(&r.data).join("\n")), &[step_table(&r)]);         }         Cmd::Report { page, page_dir, push, refresh, user_machine, surface } => {             let t = thread(&cli);
crates/adom-aiflow/src/route_variants.rs+60−6
@@ -59,10 +59,13 @@ pub fn route_once(b: Board, rules: &Rules, levers: &Levers, rip_per_net: usize, }  /// The rank key of one variant: lower is better, compared in this order.-/// unrouted, gate issues (None when DRC did not run for every variant: then all equal),-/// copper length in 0.01 mm, vias, failed Fields screens (0 when no same-input solve), variant number.-pub fn rank_key(v: &Value) -> (u64, u64, i64, u64, u64, u64) {+/// whether its plan applies to the board (issue #139: a plan that does not apply cannot be gated or+/// landed, so it ranks after every plan that does, however much it routed), unrouted, gate issues+/// (None when DRC did not run for every variant: then all equal), copper length in 0.01 mm, vias,+/// failed Fields screens (0 when no same-input solve), variant number.+pub fn rank_key(v: &Value) -> (u64, u64, u64, i64, u64, u64, u64) {     (+        u64::from(!v["planError"].is_null()),         v["unrouted"].as_u64().unwrap_or(u64::MAX),         if v["gate"].is_null() { if v["gateError"].is_null() { 0 } else { u64::MAX } } else { v["gate"]["issues"].as_u64().unwrap_or(u64::MAX) },         (v["copperLengthMm"].as_f64().unwrap_or(f64::MAX / 1e3) * 100.0).round() as i64,@@ -72,7 +75,8 @@ pub fn rank_key(v: &Value) -> (u64, u64, i64, u64, u64, u64) {     ) } -pub const SCORING: [&str; 6] = [+pub const SCORING: [&str; 7] = [+    "the plan applies to the board (a plan that does not apply ranks last)",     "unrouted connections (the router)",     "gate issues: new DRC errors + unconnected + planeUnder + switch node + courtyard/body + decoupling errors (the checks `gate` runs)",     "copper length of the plan, mm",@@ -115,11 +119,19 @@ pub fn table(rec: &Value) -> String {     for v in rec["variants"].as_array().into_iter().flatten() {         let k = v["variant"].as_u64().unwrap_or(0);         let mark = if rec["chosen"].as_u64() == Some(k) { "*" } else { " " };-        let gate = if v["gate"].is_null() { if v["gateError"].is_null() { "-".to_string() } else { "error".into() } } else { v["gate"]["issues"].to_string() };+        let gate = if !v["planError"].is_null() { "no-apply".to_string() } else if v["gate"].is_null() { if v["gateError"].is_null() { "-".to_string() } else { "error".into() } } else { v["gate"]["issues"].to_string() };         let fields = if v["fields"].is_null() { "-".to_string() } else { format!("{} fail", v["fields"]["failed"]) };         out.push_str(&format!("{:<4} {:<44} {:>8} {:>6} {:>10.2} {:>5} {:>7} {:>4}{}\n", format!("{k}{mark}"), v["label"].as_str().unwrap_or(""), v["unrouted"].to_string(), gate, v["copperLengthMm"].as_f64().unwrap_or(0.0), v["vias"].to_string(), fields, v["rank"].to_string(), v["sameCopperAs"].as_u64().map(|j| format!("  (same copper as v{j})")).unwrap_or_default()));     }+    for v in rec["variants"].as_array().into_iter().flatten() {+        if let Some(e) = v["planError"].as_str() {+            out.push_str(&format!("note: v{}'s plan does not apply to the board ({}); ranked last\n", v["variant"], aiflow_run::clip_text(e, 160)));+        }+    }     out.push_str(&format!("chosen: v{} by {}{}", rec["chosen"], rec["chosenBy"].as_str().unwrap_or("the gates"), rec["why"].as_str().map(|w| format!(" ({w})")).unwrap_or_default()));+    if let Some(v) = rec["variants"].as_array().and_then(|a| a.iter().find(|v| v["variant"] == rec["chosen"])).filter(|v| !v["planError"].is_null()) {+        out.push_str(&format!("\nnote: the chosen v{}'s plan does not apply either (no variant's does); `gate` and `land route` will report the same, so look at the pad it names before landing", v["variant"]));+    }     if let Some(d) = rec["drc"].as_str().filter(|d| d.starts_with("not run")) { out.push_str(&format!("\nDRC {}; ranked without the gate column", aiflow_run::clip_text(d, 140))); }     out }@@ -157,6 +169,9 @@ pub fn run(r: &Run, dir: &Path, rules: &Rules, plane_under: &[String], n: usize,         let b = crate::board_of(r);         let (plan_v, _) = crate::read_plan_normalised(&planp.display().to_string(), &b);         let routed = dir.join(format!("board-routed-v{k}.kicad_pcb"));+        // issue #139: whether the plan applies at all, with or without DRC; a plan that does not is ranked last and noted+        let plan_error = aiflow_copper::apply(&b, &plan_v, false).err();+        if let Some(e) = &plan_error { eprintln!("  NOTE: v{k}'s plan does not apply to the board: {e}; ranked after every variant whose plan applies"); }         let (gate, gate_error, stats, routed_s) = match &base {             Ok(base) => match gate_eval::evaluate(r, &b, &plan_v, &routed, base, false) {                 Ok(g) => (gate_summary(&g), Value::Null, g.s["copper"].clone(), json!(routed.display().to_string())),@@ -168,7 +183,7 @@ pub fn run(r: &Run, dir: &Path, rules: &Rules, plane_under: &[String], n: usize,         let v = json!({             "variant": k, "label": lv.label(), "levers": lv.to_json(rules),             "plan": planp.display().to_string(), "report": repp.display().to_string(), "planSha256": sha, "sameCopperAs": same,-            "routedBoard": routed_s, "unrouted": rt.failed, "gate": gate, "gateError": gate_error,+            "routedBoard": routed_s, "unrouted": rt.failed, "gate": gate, "gateError": gate_error, "planError": plan_error,             "copperLengthMm": stats["copperLengthMm"], "vias": stats["vias"], "segments": stats["segments"],             "fields": fields, "simplify": rt.simp_line, "routeSeconds": (route_s * 10.0).round() / 10.0, "seconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,         });@@ -245,6 +260,45 @@ mod tests {         assert!(rank_key(&v(2, 0, Some(3), 10.0, 3)) < rank_key(&e));     } +    /// Issue #139: a variant whose plan does not apply ranks after every variant whose plan does, even+    /// when it routed more, and the table says so.+    #[test]+    fn a_plan_that_does_not_apply_ranks_last_and_is_noted() {+        let mut full = v(1, 0, None, 10.0, 3);+        full["planError"] = json!("pad U3.1 has no copper on F.Cu");+        let partial = v(2, 4, None, 50.0, 9);+        assert!(rank_key(&partial) < rank_key(&full));+        let mut a = full.clone(); a["rank"] = json!(2); a["label"] = json!("x");+        let mut b = partial.clone(); b["rank"] = json!(1); b["label"] = json!("y");+        let t = table(&json!({"variants": [a.clone(), b], "chosen": 2, "drc": "kicad-cli"}));+        assert!(t.contains("no-apply") && t.contains("note: v1's plan does not apply to the board (pad U3.1 has no copper on F.Cu); ranked last"), "{t}");+        let t = table(&json!({"variants": [a], "chosen": 1, "drc": "kicad-cli"}));+        assert!(t.contains("the chosen v1's plan does not apply either"), "{t}");+    }++    /// Issue #139 end to end in the binary's own path: route (router + 45 degree simplify), the gate's+    /// normalisation, and the plan applied to the board file (what `gate` and every variant's gate do+    /// first) for a board with parts on B.Cu. Before the fix this failed with "pad U1.x has no copper on F.Cu".+    #[test]+    fn a_board_with_bottom_parts_routes_and_its_plan_applies() {+        let load = || Board::from_text("bottom.kicad_pcb", include_str!("../../../tests/fixtures/bottom-side.kicad_pcb").to_string()).unwrap();+        let b = load();+        let rt = route_once(load(), &aiflow_router::Rules::default(), &variants::Levers::baseline(3), 4, 40, &[], false);+        assert_eq!(rt.failed, 0, "{}", rt.report["unrouted"]);+        let mut plan = aiflow_copper::normalize_plan(rt.plan_doc.clone());+        let _ = aiflow_copper::normalise_plan(&b, &mut plan);+        let (text, stats) = aiflow_copper::apply(&b, &plan, false).unwrap_or_else(|e| panic!("plan does not apply: {e}\n{}", serde_json::to_string_pretty(&plan).unwrap()));+        assert!(stats["segments"].as_u64().unwrap() > 0);+        assert!(text.contains("(layer \"B.Cu\")"), "no copper on B.Cu");+        // a hand-written path from a bottom pad with no layer named is read on B.Cu and says so for land+        let mut hand = json!({"nets": [{"net": "B", "width": 0.25, "paths": [["U1.2", [13.65, 6.0], [20.2, 6.0], "R2.1"]]}]});+        let notes = aiflow_copper::normalise_plan(&b, &mut hand);+        assert_eq!(hand["nets"][0]["paths"][0][0], json!({"pad": "U1.2", "layer": "B.Cu"}), "{notes:?}");+        assert!(notes.iter().any(|n| n.contains("bottom-side pad")), "{notes:?}");+        let (segs, vias) = aiflow_copper::plan_to_copper(&b, &hand["nets"][0]).unwrap();+        assert!(segs.iter().all(|s| s.layer == "B.Cu") && vias.is_empty());+    }+     #[test]     fn fields_screens_count_only_after_vias() {         let mut a = v(1, 0, Some(0), 10.0, 3);
crates/adom-aiflow/src/sequence.rs+5
@@ -285,6 +285,8 @@ pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes:         "minutes": minutes.get(&s.name).and_then(|m| m["wallSeconds"].as_f64()).map(|x| (x / 6.0).round() / 10.0).unwrap_or(0.0),         "unproven": s.unproven,         "mode": crate::handson::mode_of(data, &s.name),+        // issue #141: the step's open findings or error (a suggestion, never a block), else null+        "attention": crate::attention::step_attention(data, &s.name),     })).collect();     // the loops: every return, from the step the run was in to the step it went back to     let mut events: Vec<(String, String, bool, Option<String>)> = Vec::new();@@ -327,6 +329,9 @@ pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes:         "done": steps.iter().filter(|s| s["status"] == "done").count(),         "steps": steps, "loops": loops,         "handsOn": crate::handson::timeline_block(data),+        // issue #141: the last route and gate (labelled with the board they measured) and every open check+        "route": crate::attention::route_card(data),+        "checks": crate::attention::open_items(data),     }) } 
crates/adom-aiflow/src/timeline_server.rs+5
@@ -320,6 +320,11 @@ fn handle(mut stream: TcpStream, dir: &Path, live: &Live) {         // a custom header makes a cross-site form or fetch need a preflight this server never grants         let header = |name: &str| head.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim().eq_ignore_ascii_case(name)).map(|(_, v)| v.trim().to_string()));         if header("x-aiflow-handson").as_deref() != Some("1") || !header("content-type").is_some_and(|v| v.eq_ignore_ascii_case("application/json")) {+            // read the (bounded) body first: closing with unread bytes resets the socket before the client reads the answer+            let want = header("content-length").and_then(|v| v.parse::<usize>().ok()).unwrap_or(0).min(65536);+            while request.len() < header_end + want {+                match stream.read(&mut chunk) { Ok(0) | Err(_) => break, Ok(n) => request.extend_from_slice(&chunk[..n]) }+            }             response(&mut stream, 415, json!({"ok":false,"error":"json_with_x_aiflow_handson_header_required"}));             return;         }
crates/aiflow-board/src/lib.rs+31
@@ -600,6 +600,37 @@ impl Board {         let pts: Vec<Pt> = self.pads.iter().filter(|p| p.reference == reference).flat_map(|p| p.polys.iter().flatten().copied()).collect();         if pts.is_empty() { None } else { Some(geom::bbox(&pts)) }     }++    /// Issue #139: the layer a plan path with no layer of its own starts on. A path's first layer is the+    /// entry's `layer` or its first waypoint's; when it names neither, the pads before its first layer+    /// marker say it: the first single-sided pad among them decides (a bottom part's pad is on B.Cu only),+    /// else F.Cu. `wps` is each waypoint's pad (None for a point) and whether it carries a layer.+    pub fn path_start_layer<'a>(wps: impl IntoIterator<Item = (Option<&'a Pad>, bool)>) -> &'static str {+        for (pad, explicit) in wps {+            if explicit { break; }+            if let Some(p) = pad {+                if p.thru || (p.on_f && p.on_b) { continue; }+                return if p.on_f { "F.Cu" } else if p.on_b { "B.Cu" } else { continue };+            }+        }+        "F.Cu"+    }++    /// A point outside the board: outside the Edge.Cuts outline (when there is one) or inside a cutout.+    pub fn point_outside(&self, x: f64, y: f64) -> bool {+        (self.outline.len() >= 3 && !geom::point_in_poly(x, y, &self.outline)) || self.outline_cutouts.iter().any(|c| geom::point_in_poly(x, y, c))+    }++    /// Issue #140: the footprints with copper pads that are wholly off the board, every pad centre outside+    /// the outline (a part `place pack` found no spot for and left where it was parked). A part that only+    /// overhangs the edge (an edge connector) keeps a pad inside and is not listed.+    pub fn parts_outside(&self) -> Vec<String> {+        if self.outline.len() < 3 { return Vec::new(); }+        self.footprints.keys().filter(|r| {+            let mut pads = self.pads.iter().filter(|p| &p.reference == *r).peekable();+            pads.peek().is_some() && pads.all(|p| self.point_outside(p.x, p.y))+        }).cloned().collect()+    } }  #[cfg(test)]
crates/aiflow-copper/src/lib.rs+18−1
@@ -143,6 +143,21 @@ pub fn normalise_plan(board: &Board, plan: &mut Value) -> Vec<String> {             let where_ = if key == "paths" { format!("{net} (entry {}, path {})", ei + 1, pi + 1) } else { format!("{net} (entry {})", ei + 1) };             let mut pts = pts.clone();             notes.extend(disambiguate_pads(board, &net, &mut pts, &where_));+            // issue #139: a path that names no layer and starts at (or runs first through) a bottom part's+            // pad is on B.Cu; say so on its first waypoint, so `land route` sends the layer the gate saw+            if entry_layer.is_none() && !pts.is_empty() {+                let res: Vec<Option<(Option<usize>, bool)>> = pts.iter().map(|w| point(w, board).ok().map(|(_, _, pad, ex)| (pad, ex.is_some()))).collect();+                if res.iter().all(|r| r.is_some()) && !res[0].unwrap().1 && Board::path_start_layer(res.iter().map(|r| { let (pad, ex) = r.unwrap(); (pad.map(|i| &board.pads[i]), ex) })) == "B.Cu" {+                    let first = pts[0].clone();+                    pts[0] = match &first {+                        Value::String(k) => json!({"pad": k, "layer": "B.Cu"}),+                        Value::Array(a) => json!({"x": a[0], "y": a[1], "layer": "B.Cu"}),+                        Value::Object(o) => { let mut m = o.clone(); m.insert("layer".into(), json!("B.Cu")); Value::Object(m) }+                        _ => first.clone(),+                    };+                    notes.push(format!("{where_}: starts on a bottom-side pad with no layer named; set B.Cu on its first waypoint (the pad has no copper on F.Cu)"));+                }+            }             let pts = &pts;             let mut layer: Option<String> = entry_layer.clone();             let mut prev: Option<(f64, f64)> = None;@@ -207,7 +222,9 @@ pub fn plan_to_copper(board: &Board, entry: &Value) -> Result<(Vec<Seg>, Vec<Via                 }             }         }-        let mut layer = entry.get("layer").and_then(|l| l.as_str()).map(str::to_string).or_else(|| resolved[0].3.clone()).unwrap_or_else(|| "F.Cu".to_string());+        // issue #139: with no layer named, a path from a bottom part's pad starts on B.Cu+        let mut layer = entry.get("layer").and_then(|l| l.as_str()).map(str::to_string).or_else(|| resolved[0].3.clone())+            .unwrap_or_else(|| Board::path_start_layer(resolved.iter().map(|(_, _, pad, ex)| (pad.map(|i| &board.pads[i]), ex.is_some()))).to_string());         for (i, (x, y, pad, explicit)) in resolved.iter().enumerate() {             let nxt = explicit.clone().unwrap_or_else(|| layer.clone());             if i > 0 {
crates/aiflow-place/src/lib.rs+49
@@ -512,6 +512,40 @@ pub fn copper_outside(board: &Board, refs: &[String]) -> Vec<String> {     out } +/// Issue #140: every footprint on the board (moved or not) whose pads or courtyard lie outside the+/// Edge.Cuts outline, as (ref, what is outside). A part `place pack` found no spot for stays where it+/// was parked, often off the board; `place check`, `place land` and `route` report these as findings+/// with a suggestion, never as a refusal. Parts in `overhang` (an edge connector allowed past the edge)+/// are left to `copper_outside`.+pub fn parts_outside(board: &Board, overhang: &[String]) -> Vec<(String, String)> {+    if board.outline.len() < 3 { return Vec::new(); }+    let whole = board.parts_outside();+    let boxes = boxes(board);+    let (x0, y0, x1, y1) = board.bbox;+    let mut out = Vec::new();+    for (r, fp) in &board.footprints {+        if overhang.contains(r) { continue; }+        if whole.contains(r) {+            out.push((r.clone(), format!("{r} is outside the board outline (at {:.2}, {:.2}; the outline spans x {:.2}..{:.2}, y {:.2}..{:.2})", fp.x, fp.y, x0, x1, y0, y1)));+            continue;+        }+        let pad_out = board.pads.iter().filter(|p| &p.reference == r).find_map(|p| p.polys.iter().flatten().find(|q| board.point_outside(q.0, q.1)).map(|q| (p.key.clone(), *q)));+        if let Some((k, q)) = pad_out {+            out.push((r.clone(), format!("{r} reaches past the board outline: pad {k} at ({:.2}, {:.2})", q.0, q.1)));+            continue;+        }+        if let Some(b) = boxes.get(r) {+            if b.0 < x0 - 1e-6 || b.1 < y0 - 1e-6 || b.2 > x1 + 1e-6 || b.3 > y1 + 1e-6 {+                out.push((r.clone(), format!("{r}'s courtyard reaches past the board outline (x {:.2}..{:.2}, y {:.2}..{:.2}; the outline spans x {:.2}..{:.2}, y {:.2}..{:.2})", b.0, b.2, b.1, b.3, x0, x1, y0, y1)));+            }+        }+    }+    out+}++/// The suggestion that goes with `parts_outside` findings.+pub const OUTSIDE_HINT: &str = "Move each part named above inside the outline: give it a wish in the wishes file (or a wider radius) and run `place pack` again (a pack that reports N-1 of N parts placed left the missing part where it was), then `place check` and `place land`. An edge connector meant to overhang: name it in spec placement.edgeOverhang [{\"ref\", \"why\"}].";+ /// Courtyard overlaps and outside-the-outline parts among the moved refs on a board. pub fn check(board: &Board, moved: &[String], gap: f64) -> Vec<String> {     check_allowing(board, moved, gap, &[])@@ -589,6 +623,21 @@ mod tests {         Wish { x, y, rot: rot.to_vec(), radius: 3.0, keepout: Vec::new(), gap: None }     } +    /// Issue #140: a part parked off the board is a finding that names it; one straddling the edge+    /// names its pad; parts inside, and an allowed overhang, are not listed.+    #[test]+    fn parts_outside_the_outline_are_named() {+        let b = board(&[("R1", "A", "B", 10.0, 10.0), ("U7", "A", "B", 45.0, 10.0), ("R2", "A", "B", 29.8, 5.0), ("J1", "A", "B", 29.8, 15.0)]);+        let f = parts_outside(&b, &["J1".to_string()]);+        let refs: Vec<&str> = f.iter().map(|(r, _)| r.as_str()).collect();+        assert_eq!(refs, ["R2", "U7"], "{f:?}");+        assert!(f[1].1.starts_with("U7 is outside the board outline"), "{f:?}");+        assert!(f[0].1.contains("pad R2.2"), "{f:?}");+        assert_eq!(b.parts_outside(), ["U7"]);+        // the pack never moved U7 (no wish): its line is the finding, not the moved parts' advisory+        assert!(check(&b, &["R1".to_string()], 0.05).is_empty());+    }+     #[test]     fn chains_parse_from_the_spec() {         let c = chains_from_spec(&json!({"chains": [["R1", "R2"], {"refs": ["R3", "R4", "R5"], "direction": "left", "gap": 0.3}]})).unwrap();
crates/aiflow-router/src/board_copper.rs+3−2
@@ -235,8 +235,8 @@ impl Router {                 if v.on_copper(F_CU) { self.fixed_raw[F_CU][c] = nid; }                 if v.on_copper(B_CU) { self.fixed_raw[B_CU][c] = nid; }             }-            if nid >= 0 {-                let (i, j) = g.to_cell(v.x, v.y);+            let (i, j) = g.to_cell(v.x, v.y);+            if nid >= 0 && g.in_bounds(i, j) {                 self.vias.push((i as i32, j as i32, nid as usize, v.size, v.drill));             }         }@@ -276,6 +276,7 @@ impl Router {             let Some(&net) = self.net_ids.get(&v.net) else { continue };             if !with_pad.contains(&root) { continue; }             let (i, j) = g.to_cell(v.x, v.y);+            if !g.in_bounds(i, j) { continue; }   // a via off the board is no landing cell (issue #140)             let e = self.fixed_targets.entry(net).or_default();             for l in [F_CU, B_CU] {                 if v.on_copper(l) { e.push((root, Term::new(l, i, j))); }
crates/aiflow-router/src/drive.rs+109−13
@@ -42,7 +42,11 @@ fn path_vias_clear(n: usize, parent: &[usize], layer_cells: usize, width: usize,  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>) {+    pub fn search(&mut self, net: usize, width: f64, mut s: Search) -> (Option<Vec<Term>>, HashSet<usize>) {+        // the window inside the grid, whoever built it (issue #140)+        let (wx, hy) = (self.grid.w as i64 - 1, self.grid.h as i64 - 1);+        let (a, b) = (s.win.0.clamp(0, wx), s.win.1.clamp(0, hy));+        s.win = (a, b, s.win.2.clamp(a, wx), s.win.3.clamp(b, hy));         let (i0, j0, i1, j1) = s.win;         let ww = (i1 - i0 + 1) as usize;         let hw = (j1 - j0 + 1) as usize;@@ -80,9 +84,11 @@ impl Router {         let mut tgt = vec![false; 2 * nl];         let vipset: HashSet<usize> = s.vip.iter().filter(|t| t.i as i64 >= i0 && t.i as i64 <= i1 && t.j as i64 >= j0 && t.j as i64 <= j1).map(|t| (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize).collect();         if s.via_target {+            // a via site on the sources' layer: F.Cu, or B.Cu when every source is a bottom pad's (issue #139)+            let vl = if !s.sources.is_empty() && s.sources.iter().all(|(t, _)| t.layer() == B_CU) { B_CU } else { F_CU };             for m in 0..nl {-                if via_ok.bits[m] && !obs_o[F_CU].bits[m] {-                    tgt[F_CU * nl + m] = true;+                if via_ok.bits[m] && !obs_o[vl].bits[m] {+                    tgt[vl * nl + m] = true;                 }             }         }@@ -250,7 +256,11 @@ impl Router {         let m = (margin_mm / RES).round() as i64;         let xs: Vec<i64> = pts.iter().map(|p| p.0).collect();         let ys: Vec<i64> = pts.iter().map(|p| p.1).collect();-        ((xs.iter().min().unwrap() - m).max(0), (ys.iter().min().unwrap() - m).max(0), (xs.iter().max().unwrap() + m).min(self.grid.w as i64 - 1), (ys.iter().max().unwrap() + m).min(self.grid.h as i64 - 1))+        // every bound inside the grid (issue #140: a point off the grid, a part outside the outline, made a+        // window past the grid's end and obstacle_maps indexed out of bounds); search skips any source or+        // target outside the window+        let (wx, hy) = (self.grid.w as i64 - 1, self.grid.h as i64 - 1);+        ((xs.iter().min().unwrap() - m).clamp(0, wx), (ys.iter().min().unwrap() - m).clamp(0, hy), (xs.iter().max().unwrap() + m).clamp(0, wx), (ys.iter().max().unwrap() + m).clamp(0, hy))     }      pub fn copper_targets(&self, net: usize) -> HashSet<Term> {@@ -338,9 +348,13 @@ impl Router {                     }                     let mut route = Route::new(cells.clone(), width, start, end.clone());                     if let End::Pad(ec, _) = end {-                        if end_cell.layer() == B_CU && !self.board.pads[self.nets[net].clusters[ec].primary].thru {+                        // issue #139: a via only where the end pad has no copper on the arriving layer (a+                        // top pad reached on B.Cu through via-in-pad); a bottom part's pad reached on B.Cu+                        // is already joined, and a via there put the path's end on F.Cu, where it has none+                        let ep = &self.board.pads[self.nets[net].clusters[ec].primary];+                        if !ep.thru && !ep.on_layer(end_cell.layer()) {                             route.via_end = true;-                            route.via_end_layer = F_CU;+                            route.via_end_layer = if ep.on_f { F_CU } else { B_CU };                             self.stat("viaInPad", 1);                             let key = self.nets[net].clusters[ec].key(&self.board).to_string();                             self.nets[net].via_in_pad.push(key);@@ -650,7 +664,7 @@ impl Router {             }         }         // a synthetic net state just for the search-        let fake = NetState { idx: kid, name: self.nets[net].name.clone(), pads: Vec::new(), clusters: Vec::new(), routes: Vec::new(), widths: vec![self.rules.stub + 0.05], plane: None, mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(), rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: self.nets[net].via, drill: self.nets[net].drill, done_by_board: false };+        let fake = NetState { idx: kid, name: self.nets[net].name.clone(), pads: Vec::new(), clusters: Vec::new(), routes: Vec::new(), widths: vec![self.rules.stub + 0.05], plane: None, mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(), rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: self.nets[net].via, drill: self.nets[net].drill, done_by_board: false, outside: Vec::new() };         self.nets.push(fake);         let fidx = self.nets.len() - 1;         let sources: Vec<(Term, i64)> = self.nets[net].clusters[src].terminals.iter().map(|t| (*t, 0)).collect();@@ -725,7 +739,11 @@ impl Router {             let sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();             let pts: Vec<(i64, i64)> = cl.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();             let win = self.window_for(&pts, self.rules.via_search_mm);-            let own: HashSet<Term> = self.copper_targets(net).into_iter().filter(|t| t.layer() == F_CU).collect();+            // the pad's own layer: a bottom part's pad (issue #139) reaches its via on B.Cu, and the via's+            // marker names the other layer so the plan carries a through via, not a point on the same layer+            let pl = { let p = &self.board.pads[cl.primary]; if p.thru || p.on_f { F_CU } else { B_CU } };+            let other = if pl == F_CU { B_CU } else { F_CU };+            let own: HashSet<Term> = self.copper_targets(net).into_iter().filter(|t| t.layer() == pl).collect();             let track = self.rules.track;             let (cells, _) = self.search(net, track, Search { sources, targets: Some(&own), heur: Vec::new(), win, allow_via: false, soft: false, via_target: true, max_nodes: None, vip: Vec::new() });             match cells {@@ -733,8 +751,9 @@ impl Router {                     let p = self.board.pads[cl.primary].clone();                     let (i, j) = g.to_cell(p.x, p.y);                     if self.via_clear_exact(net, p.x, p.y) {-                        let mut r = Route::new(vec![Term::new(F_CU, i, j)], track, End::Pad(c, Term::new(F_CU, i, j)), End::Via);+                        let mut r = Route::new(vec![Term::new(pl, i, j)], track, End::Pad(c, Term::new(pl, i, j)), End::Via);                         r.via_end = true;+                        r.via_end_layer = other;                         self.commit(net, r);                         self.nets[net].clusters[c].connected = true;                         self.nets[net].via_in_pad.push(key.clone());@@ -748,6 +767,7 @@ impl Router {                     let joined = own.contains(cells.last().unwrap());                     let mut r = Route::new(cells, track, start, if joined { End::Copper } else { End::Via });                     r.via_end = !joined;+                    r.via_end_layer = other;                     self.commit(net, r);                     self.nets[net].clusters[c].connected = true;                 }@@ -1158,7 +1178,8 @@ impl Router {                 let pad = &self.board.pads[net.clusters[cidx].primary];                 let (ax, ay) = pad.long_axis;                 let norm = (ax * ax + ay * ay).sqrt();-                let mut pts = vec![json!(key)];+                // issue #139: a bottom pad's stub is on B.Cu; a stub with no layer lands on F.Cu+                let mut pts = vec![if term.layer() == B_CU { self.pad_waypoint(n, cidx, Some("B.Cu")) } else { json!(key) }];                 if norm > 1e-9 {                     let (ux, uy) = (ax / norm, ay / norm);                     let t = (p.0 - pad.x) * ux + (p.1 - pad.y) * uy;@@ -1186,7 +1207,7 @@ impl Router {         let mut nets = serde_json::Map::new();         let mut unrouted = Vec::new();         for net in &self.nets {-            if net.clusters.len() < 2 && net.plane.is_none() {+            if net.clusters.len() < 2 && net.plane.is_none() && net.outside.is_empty() {                 continue;             }             let mut widths: Vec<f64> = net.routes.iter().map(|r| r.width).collect();@@ -1194,10 +1215,10 @@ impl Router {             widths.dedup();             nets.insert(net.name.clone(), json!({                 "clusters": net.clusters.len(), "pads": net.pads.len(), "routes": net.routes.len(), "widths": widths, "plane": net.plane,-                "failed": net.failed, "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order, "mstMm": (net.mst_len * 100.0).round() / 100.0,+                "failed": net.failed.iter().chain(&net.outside).collect::<Vec<_>>(), "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order, "mstMm": (net.mst_len * 100.0).round() / 100.0,                 "doneByBoard": net.done_by_board,             }));-            for (k, why) in &net.failed {+            for (k, why) in net.failed.iter().chain(&net.outside) {                 unrouted.push(json!([net.name, [k, why]]));             }         }@@ -1293,6 +1314,13 @@ pub fn route_board(router: &mut Router, passes: usize, rip_per_net: usize, rip_g     let (failed, snap, k) = best.unwrap();     router.restore(&snap);     router.log(format!("keeping pass {k} ({failed} failed connections)"));+    // issue #140: pads off the board were never routed; each is an unrouted connection with its reason+    let outside: usize = router.nets.iter().map(|n| n.outside.len()).sum();+    if outside > 0 {+        let why: Vec<String> = router.nets.iter().flat_map(|n| n.outside.iter().map(|(_, w)| w.clone())).collect::<std::collections::BTreeSet<_>>().into_iter().collect();+        router.log(format!("{outside} more connection(s) not routed, off the board: {}", why.join("; ")));+    }+    let failed = failed + outside;     let plan = router.plan();     let report = router.report();     (plan, report, failed)@@ -1321,6 +1349,74 @@ mod path_via_tests {             assert_eq!(points[0]["y"], expected.1);         }     }+    const BOTTOM: &str = include_str!("../../../tests/fixtures/bottom-side.kicad_pcb");++    /// Every pad a plan path reaches, with the layer the path is on there (as plan_to_copper reads it):+    /// the pads with no copper on that layer.+    fn pads_off_their_layer(b: &aiflow_board::Board, plan: &[Value]) -> Vec<String> {+        let mut bad = Vec::new();+        for e in plan {+            for path in e["paths"].as_array().unwrap() {+                let pts = path.as_array().unwrap();+                let wp = |v: &Value| -> (Option<&aiflow_board::Pad>, Option<String>) {+                    let key = v.as_str().or_else(|| v.get("pad").and_then(|p| p.as_str()));+                    (key.and_then(|k| b.pad_by_key(k)), v.get("layer").and_then(|l| l.as_str()).map(str::to_string))+                };+                let mut layer = e["layer"].as_str().map(str::to_string).or_else(|| wp(&pts[0]).1)+                    .unwrap_or_else(|| aiflow_board::Board::path_start_layer(pts.iter().map(|v| { let (p, l) = wp(v); (p, l.is_some()) })).to_string());+                for v in pts {+                    let (pad, l) = wp(v);+                    if let Some(l) = l { layer = l; }+                    if let Some(p) = pad {+                        let on = p.thru || (layer == "F.Cu" && p.on_f) || (layer == "B.Cu" && p.on_b);+                        if !on { bad.push(format!("{} on {layer} ({} {})", p.key, e["net"], path)); }+                    }+                }+            }+        }+        bad+    }++    /// Issue #139: parts on the bottom (B.Cu-only SMD pads, one with narrow pads and escapes) route,+    /// and every path reaches each bottom pad on B.Cu (a via where it comes from F.Cu).+    #[test]+    fn bottom_side_pads_are_routed_and_reached_on_b_cu() {+        let b = aiflow_board::Board::from_text("bottom.kicad_pcb", BOTTOM.to_string()).unwrap();+        let u1 = b.pad_by_key("U1.1").unwrap();+        assert!(u1.on_b && !u1.on_f && !u1.thru);+        let mut r = Router::new(aiflow_board::Board::from_text("bottom.kicad_pcb", BOTTOM.to_string()).unwrap(), crate::Rules::default(), false);+        let (plan, report, failed) = route_board(&mut r, 3, 4, 40);+        assert_eq!(failed, 0, "{}", report["unrouted"]);+        assert!(!plan.is_empty());+        let bad = pads_off_their_layer(&b, &plan);+        assert!(bad.is_empty(), "paths reach pads on a layer they have no copper on: {bad:?}");+        // B (U1.2 to R2.1) is bottom to bottom: no via needed, so B.Cu all the way+        let text = serde_json::to_string(&plan).unwrap();+        assert!(text.contains("B.Cu"), "{text}");+    }++    /// Issue #140: a part parked outside the outline (pads off the routing grid) no longer panics in+    /// obstacle_maps; its connections are reported unrouted with the part named, the rest route.+    #[test]+    fn a_part_off_the_board_is_reported_not_a_panic() {+        let text = BOTTOM.replace("(footprint \"T:R2\"\n\t\t(layer \"B.Cu\")\n\t\t(at 21 6)", "(footprint \"T:R2\"\n\t\t(layer \"B.Cu\")\n\t\t(at 61 46)");+        assert_ne!(text, BOTTOM);+        let b = aiflow_board::Board::from_text("off.kicad_pcb", text).unwrap();+        assert_eq!(b.parts_outside(), ["R2"]);+        let mut r = Router::new(b, crate::Rules::default(), false);+        let (_, report, failed) = route_board(&mut r, 2, 4, 40);+        let un = report["unrouted"].as_array().unwrap();+        let named: Vec<&Value> = un.iter().filter(|u| u[1][1].as_str().unwrap_or("").starts_with("R2 is outside the board outline")).collect();+        assert_eq!(named.len(), 2, "{un:?}");   // R2.1 on B, R2.2 on D+        assert!(failed >= 2);+        assert!(report["nets"]["D"]["failed"].as_array().unwrap().iter().any(|f| f[0] == "R2.2"), "{}", report["nets"]["D"]);+        // a window built from a point off the grid stays inside the grid, and maps from it are window-sized+        let w = r.window_for(&[(r.grid.w as i64 + 300, r.grid.h as i64 + 900)], 5.0);+        assert!(w.0 <= w.2 && w.1 <= w.3 && w.2 < r.grid.w as i64 && w.3 < r.grid.h as i64, "{w:?}");+        let (o, _, _, via) = r.obstacle_maps(0, 0.25, (r.grid.w as i64 + 10, -40, r.grid.w as i64 + 400, 3), false);+        assert_eq!(o[0].bits.len(), via.bits.len());+    }+     #[test]     fn nearby_transitions_in_one_path_are_rejected() {         let mut parent=vec![usize::MAX;200];
crates/aiflow-router/src/lib.rs+44−3
@@ -157,6 +157,10 @@ pub struct NetState {     pub drill: f64,     /// Every cluster of the net is already joined by the board's own copper: nothing to route.     pub done_by_board: bool,+    /// Issue #140: pads of this net the router cannot reach because they lie off the board (their part+    /// is outside the outline, or the pad is off the routing grid), with the reason; never routed,+    /// always reported as unrouted.+    pub outside: Vec<(String, String)>, }  #[derive(Clone)]@@ -238,7 +242,7 @@ impl Router {             nets.push(NetState {                 idx: k, name: (*name).clone(), pads: board.nets[*name].clone(), clusters: Vec::new(), routes: Vec::new(),                 widths: vec![rules.track], plane: rules.planes.get(*name).cloned(), mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(),-                rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: rules.via_small, drill: rules.drill_small, done_by_board: false,+                rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: rules.via_small, drill: rules.drill_small, done_by_board: false, outside: Vec::new(),             });         }         let mut refs: Vec<&String> = board.footprints.keys().collect();@@ -269,6 +273,7 @@ impl Router {         };         r.big_clr_val = r.rules.clearance;         let t0 = std::time::Instant::now();+        r.set_aside_outside_pads();         r.stamp_pads();         r.stamp_holes();         r.stamp_board_copper();@@ -281,6 +286,38 @@ impl Router {         r     } +    /// Issue #140: a part left outside the outline (pack found it no spot) has pads off the routing grid,+    /// and a window built from them indexed past the grid (a panic in obstacle_maps). Such pads never+    /// join a cluster: each is recorded on its net's `outside` list, with the part named, and reported+    /// as an unrouted connection. A part that only overhangs the edge keeps its on-grid pads.+    fn set_aside_outside_pads(&mut self) {+        let off_board = self.board.parts_outside();+        for net in 0..self.nets.len() {+            let routable = self.nets[net].pads.len() >= 2 || self.nets[net].plane.is_some();+            let pads = std::mem::take(&mut self.nets[net].pads);+            let mut keep = Vec::with_capacity(pads.len());+            for pi in pads {+                let p = &self.board.pads[pi];+                let (i, j) = self.grid.to_cell(p.x, p.y);+                let why = if off_board.contains(&p.reference) {+                    Some(format!("{} is outside the board outline; move it inside (did `place pack` place one part fewer than asked?), then route again", p.reference))+                } else if !self.grid.in_bounds(i, j) {+                    Some(format!("pad {} of {} lies outside the board outline (off the routing grid); move {} inside, then route again", p.key, p.reference, p.reference))+                } else { None };+                match why {+                    Some(w) => if routable { self.nets[net].outside.push((p.key.clone(), w)); },+                    None => keep.push(pi),+                }+            }+            self.nets[net].pads = keep;+        }+        let n: usize = self.nets.iter().map(|n| n.outside.len()).sum();+        if n > 0 {+            let parts: Vec<String> = off_board.clone();+            self.log(format!("router: {n} pad(s) set aside off the board{}", if parts.is_empty() { String::new() } else { format!(" (outside the outline: {})", parts.join(", ")) }));+        }+    }+     // -- stamping --------------------------------------------------------------------     fn stamp_pads(&mut self) {         let g = self.grid.clone();@@ -780,7 +817,8 @@ impl Router {             if (q.x - b.0).abs() > reach + q.half_long || (q.y - b.1).abs() > reach + q.half_long {                 continue;             }-            if l == F_CU && !q.on_f {+            // only copper on the escape's own layer is in its way (a bottom pad's escape passes over top pads)+            if !q.thru && !q.on_layer(l) {                 continue;             }             let clr = self.rules.clearance.max(q.clearance);@@ -810,7 +848,10 @@ impl Router {     /// (obs_o, obs_d, soft, via_ok) for the window (i0, j0, i1, j1 inclusive), each a Mask of window size.     pub fn obstacle_maps(&mut self, net: usize, width: f64, win: (i64, i64, i64, i64), soft: bool) -> ([Mask; 2], [Mask; 2], [Option<Mask>; 2], Mask) {         let g = self.grid.clone();-        let (i0, j0, i1, j1) = win;+        // a window always lies inside the grid (window_for clamps it); a hand-made one is clamped here too+        let (wx, hy) = (g.w as i64 - 1, g.h as i64 - 1);+        let (i0, j0) = (win.0.clamp(0, wx), win.1.clamp(0, hy));+        let (i1, j1) = (win.2.clamp(i0, wx), win.3.clamp(j0, hy));         let clr = self.rules.clearance.max(self.rules.net_clearance.get(&self.nets[net].name).copied().unwrap_or(0.0));         let r_o = (clr + width / 2.0 + slop()) / RES;         let r_d = r_o + 0.0707 / RES;
crates/aiflow-router/src/simplify.rs+5−1
@@ -240,7 +240,11 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value {             if rs.iter().any(|w| w.is_none()) || raw.len() < 3 { entry.1 += raw.len(); out_paths.push(Value::Array(raw)); continue; }             let rs: Vec<Wp> = rs.into_iter().map(Option::unwrap).collect();             // the layer of the segment leaving each waypoint, as plan_to_copper reads it-            let mut layer = entry_layer.clone().or_else(|| rs[0].layer.clone()).unwrap_or_else(|| "F.Cu".into());+            let mut layer = entry_layer.clone().or_else(|| rs[0].layer.clone()).unwrap_or_else(|| {+                // issue #139: no layer named, a bottom part's pad first: B.Cu, as plan_to_copper reads it+                let pad_of = |v: &Value| v.as_str().or_else(|| v.get("pad").and_then(|p| p.as_str())).and_then(|k| router.board.pad_by_key(k));+                Board::path_start_layer(raw.iter().zip(&rs).map(|(v, w)| (pad_of(v), w.layer.is_some()))).into()+            });             let mut seg_layer = Vec::with_capacity(rs.len());             let mut anchor = vec![false; rs.len()];             for (i, w) in rs.iter().enumerate() {
crates/aiflow-router/tests/obstacles.rs+18
@@ -95,3 +95,21 @@ fn edge_cuts_bore_is_outside_the_board_and_keeps_edge_clearance() {     let (i, j) = r.grid.to_cell(3.0, 3.0);     assert!(r.inside.get(i as usize, j as usize)); }++/// Issue #140, the reported panic (lib.rs:842 in 0.2.49, "index out of bounds" in obstacle_maps): a plane+/// net's pad on a part parked outside the outline made a via-search window wholly past the grid. Now the+/// part's pads are set aside and reported, with the part named.+#[test]+fn plane_pad_on_a_part_off_the_board_does_not_panic() {+    let t = include_str!("../../../tests/fixtures/bottom-side.kicad_pcb").replace("(footprint \"T:R3\"\n\t\t(layer \"F.Cu\")\n\t\t(at 21 15)", "(footprint \"T:R3\"\n\t\t(layer \"F.Cu\")\n\t\t(at 61 46)");+    let b = aiflow_board::Board::from_text("x.kicad_pcb", t).unwrap();+    let mut rules = aiflow_router::Rules::default();+    rules.planes.insert("GND".into(), "B.Cu".into());+    let mut r = aiflow_router::Router::new(b, rules, false);+    let (_, report, failed) = aiflow_router::drive::route_board(&mut r, 2, 4, 40);+    let un = report["unrouted"].as_array().unwrap();+    for key in ["R3.1", "R3.2"] {+        assert!(un.iter().any(|u| u[1][0] == key && u[1][1].as_str().unwrap().starts_with("R3 is outside the board outline")), "{key}: {un:?}");+    }+    assert!(failed >= 2);+}
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.49",+  "version": "0.2.50",   "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.49",+  "version": "0.2.50",   "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.",
tests/attention-workflow.pyadded+126
@@ -0,0 +1,126 @@+"""Issue #141: the last route's numbers and every command that ended with findings or an error show+in `status`, in run.json and on the timeline's data endpoint (GET /state), and stay there across a+board change until the command runs again. A small synthetic board whose net J7-J8 cannot be routed+(J8 sits outside the outline), a fake kicad-cli that reports one unconnected item on the routed board;+no dashboard, no bridge. Usage: python3 tests/attention-workflow.py <adom-aiflow binary>"""+import json, os, pathlib, socket, subprocess, sys, tempfile, time, urllib.request++binary = str(pathlib.Path(sys.argv[1]).resolve())+++def board_text(shift=0.0, j8=(40, 40)):+    pads = [("J1", "A", 2 + shift, 2), ("J2", "A", 6, 2), ("J3", "B", 2, 8), ("J4", "B", 18, 8), ("J7", "C", 10, 5), ("J8", "C", *j8)]+    t = '(kicad_pcb\n\t(version 20260206)\n\t(generator "test")\n\t(layers\n\t\t(0 "F.Cu" signal)\n\t\t(2 "B.Cu" signal)\n\t\t(25 "Edge.Cuts" user)\n\t\t(31 "F.CrtYd" user)\n\t)\n'+    for r, net, x, y in pads:+        t += f'\t(footprint "T:{r}"\n\t\t(layer "F.Cu")\n\t\t(at {x} {y})\n\t\t(property "Reference" "{r}")\n\t\t(fp_rect\n\t\t\t(start -0.9 -0.9)\n\t\t\t(end 0.9 0.9)\n\t\t\t(stroke\n\t\t\t\t(width 0.05)\n\t\t\t\t(type solid)\n\t\t\t)\n\t\t\t(fill no)\n\t\t\t(layer "F.CrtYd")\n\t\t)\n\t\t(pad "1" thru_hole circle\n\t\t\t(at 0 0)\n\t\t\t(size 1.2 1.2)\n\t\t\t(drill 0.6)\n\t\t\t(layers "*.Cu")\n\t\t\t(net "{net}")\n\t\t)\n\t)\n'+    t += '\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 20 10)\n\t\t(layer "Edge.Cuts")\n\t)\n)\n'+    return t+++with tempfile.TemporaryDirectory(prefix='aiflow-attention-') as td:+    p = pathlib.Path(td)+    (p / 'b.kicad_pcb').write_text(board_text())+    (p / 'b2.kicad_pcb').write_text(board_text(0.5))+    (p / 'b3.kicad_pcb').write_text(board_text(0.5, (14, 5)))+    (p / 'spec.json').write_text(json.dumps({"board": "attention fixture", "clearance": 0.2}))+    fake = p / 'kicad-cli'+    # one unconnected item on the routed board only, so the gate ends with findings+    fake.write_text('#!/usr/bin/env python3\nimport sys, json\na = sys.argv[1:]\n'+                    'if "--help" in a: print("--refill-zones"); sys.exit(0)\n'+                    'out = a[a.index("--output") + 1]\nrouted = any("routed" in x for x in a)\n'+                    'json.dump({"violations": [], "unconnected_items": ([{"type": "unconnected_items", "description": "Missing connection between items", "items": [{"description": "PTH pad 1 [C] of J8"}]}] if routed else [])}, open(out, "w"))\n')+    fake.chmod(0o755)+    run = p / 'run'+    env = dict(os.environ, AIFLOW_DASHBOARD='off', ADOM_AIFLOW_KICAD_CLI=str(fake))+    env.pop('KICAD_REMOTE_TARGET', None)++    def call(*args):+        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'attention-test', *args], env=env, capture_output=True, text=True, timeout=180)++    def data():+        return json.loads((run / 'run.json').read_text())++    def timeline():+        # with the dashboard off nothing refreshes the file by itself: `timeline write` does+        assert call('timeline', 'write').returncode == 0+        return json.loads((run / 'timeline' / 'timeline.json').read_text())++    r = call('start', '--board', str(p / 'b.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test')+    assert r.returncode == 0, r.stdout + r.stderr+    # route ends with findings (J8 is off the board): exit 0, never a refusal+    r = call('route', '--passes', '2')+    assert r.returncode == 0, r.stdout + r.stderr+    assert r.stdout.startswith('FINDINGS'), r.stdout+    d = data()+    lr = d['lastRoute']+    assert lr['unrouted'] >= 1 and lr['connections'] >= lr['unrouted'] and lr['routed'] == lr['connections'] - lr['unrouted'], lr+    assert 'C' in lr['netsUnrouted'] and lr['vias'] is not None and lr['boardRevision'] == 1, lr+    chk = d['checks']['route']+    assert chk['kind'] == 'findings' and chk['step'] == 'routing' and chk['count'] >= 1 and 'routing closed' in chk['lines'][0], chk+    led = [json.loads(l) for l in (run / 'run.jsonl').read_text().splitlines()]+    assert any(e.get('event') == 'command-outcome' and e['key'] == 'route' and e['outcome'] == 'findings' for e in led)+    # an error (could not run) is recorded as an error, distinct from findings+    r = call('gate', '--plan', str(p / 'missing-plan.json'))+    assert r.returncode != 0 and data()['checks']['gate']['kind'] == 'error', r.stdout+    # the gate on the route's plan ends with findings and replaces the error+    r = call('gate')+    assert r.returncode == 0 and r.stdout.startswith('FINDINGS'), r.stdout + r.stderr+    d = data()+    assert d['checks']['gate']['kind'] == 'findings' and d['lastGate']['unconnected'] == 1, (d['checks'], d.get('lastGate'))+    # status names the numbers and the open checks; a status call itself clears nothing+    r = call('status')+    if os.environ.get('SHOW'): print(r.stdout)+    assert r.returncode == 0, r.stdout + r.stderr+    assert 'last route' in r.stdout and f"{lr['routed']} of {lr['connections']} connection(s) routed, {lr['unrouted']} unrouted" in r.stdout, r.stdout+    assert 'measured on the current board' in r.stdout and 'needs a look (2 command(s)' in r.stdout and '[findings] `route`' in r.stdout, r.stdout+    assert 'needs a look (' in r.stdout.split('Hint:', 1)[1], 'the step table names the routing step'+    # the timeline: the routing step carries the badge, the route card the numbers+    tl = timeline()+    if os.environ.get('SHOW'): print(json.dumps({k: tl[k] for k in ('route', 'checks')}, indent=1), [(s['name'], s['attention'] and s['attention']['kind']) for s in tl['steps']])+    routing = [s for s in tl['steps'] if s['name'] == 'routing']+    assert routing, [s['name'] for s in tl['steps']]+    if routing:+        a = routing[0]['attention']+        assert a and a['kind'] == 'findings' and a['count'] >= 2 and {i['key'] for i in a['items']} == {'route', 'gate'}, a+    assert tl['route']['route']['unrouted'] == lr['unrouted'] and tl['route']['route']['onCurrentBoard'] is True, tl['route']+    assert {c['key'] for c in tl['checks']} == {'route', 'gate'}, tl['checks']+    # the board changes: outcomes are voided, the route card stays, labelled with its board+    r = call('adopt-board', '--board', str(p / 'b2.kicad_pcb'), '--evidence', 'test: moved J1 by 0.5 mm')+    assert r.returncode == 0, r.stdout + r.stderr+    d = data()+    assert d['lastRoute'] == lr, 'a new board keeps the last route record'+    r = call('status')+    assert 'previous board' in r.stdout and f"{lr['unrouted']} unrouted" in r.stdout and '[findings] `route`' in r.stdout, r.stdout+    tl = timeline()+    assert tl['route']['route']['onCurrentBoard'] is False and 'previous board' in tl['route']['route']['measuredOn'], tl['route']+    # the data endpoint serves the same: GET /state from `timeline serve`+    with socket.socket() as sock:+        sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]+    proc = subprocess.Popen([binary, '--run', str(run), 'timeline', 'serve', '--port', str(port)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+    try:+        for _ in range(100):+            try: state = json.load(urllib.request.urlopen(f'http://127.0.0.1:{port}/state', timeout=3)); break+            except OSError: time.sleep(.05)+        else: raise AssertionError('server did not start')+        st = state['timeline']+        assert st['route']['route']['unrouted'] == lr['unrouted'] and st['route']['route']['onCurrentBoard'] is False, st['route']+        assert any(c['key'] == 'route' and c['kind'] == 'findings' for c in st['checks']), st['checks']+    finally:+        proc.terminate(); proc.wait(timeout=5)+    # the next route replaces the record and, ending with findings again, keeps the check; a clean+    # run of a command clears its own entry only+    r = call('route', '--passes', '2')+    d = data()+    assert r.returncode == 0 and d['lastRoute']['boardRevision'] == 2 and d['lastRoute']['board'] != lr['board'], d['lastRoute']+    assert d['checks']['route']['kind'] == 'findings', d['checks']+    tl = timeline()+    assert tl['route']['route']['onCurrentBoard'] is True, tl['route']+    # J8 moved onto the board: the route closes, its check clears; the gate's stays until the gate runs+    assert call('adopt-board', '--board', str(p / 'b3.kicad_pcb'), '--evidence', 'test: J8 onto the board').returncode == 0+    r = call('route', '--passes', '4')+    assert r.returncode == 0 and r.stdout.startswith('OK'), r.stdout + r.stderr+    d = data()+    assert d['lastRoute']['unrouted'] == 0 and 'route' not in d['checks'] and 'gate' in d['checks'], (d['lastRoute'], d['checks'])+    r = call('status')+    assert 'last route' in r.stdout and ', 0 unrouted;' in r.stdout and '`route`' not in r.stdout and '`gate`' in r.stdout, r.stdout+    print('PASS: route findings, gate error then findings, status lines, step badge, route card, board change labelled "previous board", GET /state carries them, a clean route clears its check only')
tests/bottom-side-workflow.pyadded+138
@@ -0,0 +1,138 @@+"""Issues #139 and #140 end to end on tests/fixtures/bottom-side.kicad_pcb: parts on both sides of the+board (U1 and R2 on B.Cu, SMD pads with copper on B.Cu only).++#139: `route` plans every net, `gate` applies the plan (no "pad U1.x has no copper on F.Cu"), `route+--variants 2` gates every variant, and `land route` sends the bridge a plan whose bottom-pad paths name+B.Cu. #140: with R2 parked outside the outline, `place check`, `route` and `land` name it as a finding+(never a refusal), `route` reports its nets unrouted with the reason (no panic), and `place pack` names+the part it found no spot for.++A fake kicad-cli answers DRC with no violations and a fake adom-bridge records every call, so this runs+with no CAD and no desktop. Usage: python3 tests/bottom-side-workflow.py <adom-aiflow binary>"""+import json, os, pathlib, subprocess, sys, tempfile++binary = str(pathlib.Path(sys.argv[1]).resolve())+fixture = pathlib.Path(__file__).resolve().parent / 'fixtures' / 'bottom-side.kicad_pcb'++FAKE_BRIDGE = r'''#!/usr/bin/env python3+import json, os, sys+a = sys.argv[1:]+i = a.index("--target")+verb, args = a[i + 2], json.loads(a[i + 3])+with open(os.environ["FAKE_LOG"], "a") as f: f.write(json.dumps({"verb": verb, "args": args}) + "\n")+print(json.dumps({"success": True, "status": "ok", "revision": "r1"}))+'''+FAKE_CLI = ('#!/usr/bin/env python3\nimport sys, json\na = sys.argv[1:]\n'+            'if "--help" in a: print("--refill-zones"); sys.exit(0)\n'+            'out = a[a.index("--output") + 1]\njson.dump({"violations": [], "unconnected_items": []}, open(out, "w"))\n')++passed = 0+++def check(cond, what, out=''):+    global passed+    if not cond:+        sys.exit(f'FAIL: {what}\n{out}')+    passed += 1+++def layer_walk(board_pads, plan):+    """Every pad a path reaches with the layer the path is on there; returns the ones with no copper there."""+    bad = []+    for e in plan['nets']:+        for path in e['paths']:+            def wp(v):+                key = v if isinstance(v, str) else (v.get('pad') if isinstance(v, dict) else None)+                return key, (v.get('layer') if isinstance(v, dict) else None)+            layer = e.get('layer') or wp(path[0])[1]+            if layer is None:+                layer = 'F.Cu'+                for v in path:+                    k, l = wp(v)+                    if l: break+                    if k and board_pads[k] in ('F', 'B'):+                        layer = board_pads[k] + '.Cu'; break+            for v in path:+                k, l = wp(v)+                if l: layer = l+                if k and board_pads[k] != 'both' and board_pads[k] + '.Cu' != layer:+                    bad.append((k, layer, e['net']))+    return bad+++# pad sides of the fixture: the THT pads of J1/J2 are on both, U1 and R2 on B, R3 on F+sides = {f'{r}.{n}': s for r, s, ns in (('J1', 'both', '12'), ('J2', 'both', '12'), ('U1', 'B', '1234'), ('R2', 'B', '12'), ('R3', 'F', '12')) for n in ns}++with tempfile.TemporaryDirectory(prefix='aiflow-bottom-') as td:+    p = pathlib.Path(td)+    (p / 'b.kicad_pcb').write_text(fixture.read_text())+    (p / 'spec.json').write_text(json.dumps({"board": "bottom-side fixture", "clearance": 0.2}))+    bindir = p / 'bin'; bindir.mkdir()+    (bindir / 'adom-bridge').write_text(FAKE_BRIDGE); (bindir / 'adom-bridge').chmod(0o755)+    (bindir / 'kicad-cli').write_text(FAKE_CLI); (bindir / 'kicad-cli').chmod(0o755)+    log = p / 'bridge.jsonl'+    env = dict(os.environ, AIFLOW_DASHBOARD='off', AIFLOW_NO_PART_SEARCH='1', AIFLOW_NO_PART_LOOKUP='1',+               ADOM_AIFLOW_KICAD_CLI=str(bindir / 'kicad-cli'), FAKE_LOG=str(log), PATH=f"{bindir}:{os.environ['PATH']}")+    env.pop('KICAD_REMOTE_TARGET', None)++    def call(run, *args):+        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'bottom-test', *args], env=env, capture_output=True, text=True, timeout=300)++    # ---- issue #139: route, gate, variants, land on a board with bottom parts+    run = p / 'run'+    r = call(run, 'start', '--board', str(p / 'b.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', 'C:/boards/b.kicad_pcb')+    check(r.returncode == 0, 'start', r.stdout + r.stderr)+    r = call(run, 'route')+    check(r.returncode == 0, 'route on a board with bottom parts', r.stdout + r.stderr)+    o = json.loads((run / 'run.json').read_text())['outcomes']['route']+    check(o['unrouted'] == 0, 'every connection routed', json.dumps(o))+    plan = json.loads((run / 'plan-route.json').read_text())+    check(not layer_walk(sides, plan), 'every pad reached on a layer it has copper on', json.dumps(layer_walk(sides, plan)))+    check('B.Cu' in json.dumps(plan), 'bottom copper in the plan')+    r = call(run, 'gate')+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'no copper on' not in out, 'gate applies the plan', out)+    g = json.loads((run / 'run.json').read_text())['outcomes']['gate']+    check(g.get('newErrors') == 0 and g.get('unconnected') == 0, 'gate clean with the fake DRC', json.dumps(g)[:600])+    r = call(run, 'route', '--variants', '2')+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'no-apply' not in out and 'does not apply' not in out, 'every variant applies', out)+    v = json.loads((run / 'run.json').read_text())['outcomes']['route']['variants']+    check(all(x['planError'] is None and x['gate'] is not None for x in v['variants']), 'variants gated', json.dumps(v)[:800])+    check(call(run, 'gate').returncode == 0, 'gate after variants')+    r = call(run, 'land', 'route', '--pause-ms', '0')+    out = r.stdout + r.stderr+    check(r.returncode == 0, 'land route', out)+    sent = [json.loads(l) for l in log.read_text().splitlines() if l.strip()]+    routes = [s['args'] for s in sent if s['verb'] == 'kicad_route_net']+    check(routes, 'kicad_route_net sent', json.dumps(sent)[:800])+    landed = {'nets': [{k: a[k] for k in a if k in ('net', 'layer', 'paths')} for a in routes]}+    check(not layer_walk(sides, landed), 'landed paths reach bottom pads on B.Cu', json.dumps(layer_walk(sides, landed)))++    # ---- issue #140: R2 parked outside the outline+    off = fixture.read_text().replace('(footprint "T:R2"\n\t\t(layer "B.Cu")\n\t\t(at 21 6)', '(footprint "T:R2"\n\t\t(layer "B.Cu")\n\t\t(at 61 46)')+    check(off != fixture.read_text(), 'fixture edit')+    (p / 'off.kicad_pcb').write_text(off)+    run2 = p / 'run2'+    check(call(run2, 'start', '--board', str(p / 'off.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', 'C:/boards/off.kicad_pcb').returncode == 0, 'start off')+    # pack: R2's wish has no room (radius 0 on top of J2), so pack places 1 of 2 and names R2+    (p / 'wish.json').write_text(json.dumps([{"ref": "R3", "x": 21, "y": 15, "rot": [0], "radius": 1.0}, {"ref": "R2", "x": 27, "y": 8, "rot": [0], "radius": 0.0}]))+    r = call(run2, 'place', 'pack', '--wish', str(p / 'wish.json'), '--out', str(p / 'moves.json'))+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'legal spots for 1 of 2 parts (no spot for R2' in out, 'pack names the part it left out', out)+    check('R2 is outside the board outline' in out, 'pack names the part outside', out)+    r = call(run2, 'place', 'check', '--moves', str(p / 'moves.json'))+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'R2 is outside the board outline' in out and 'FINDING' in out, 'place check reports R2, never refuses', out)+    r = call(run2, 'route')+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'panicked' not in out, 'route does not panic', out)+    check('R2 is outside the board outline' in out, 'route names R2 before routing', out)+    o = json.loads((run2 / 'run.json').read_text())['outcomes']['route']+    reasons = [d[1][1] for d in o['detail']]+    check(o['unrouted'] >= 2 and sum(w.startswith('R2 is outside the board outline') for w in reasons) == 2, 'R2 nets unrouted with the reason', json.dumps(o['detail']))+    r = call(run2, 'place', 'land', '--moves', str(p / 'moves.json'), '--pause-ms', '0')+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'R2 is outside the board outline' in out, 'place land names R2 and lands', out)++print(f'OK bottom-side-workflow: {passed} checks passed')
tests/fixtures/bottom-side.kicad_pcbadded+206
@@ -0,0 +1,206 @@+(kicad_pcb+	(version 20260206)+	(generator "aiflow-test")+	(layers+		(0 "F.Cu" signal)+		(2 "B.Cu" signal)+		(13 "F.Paste" user)+		(15 "B.Paste" user)+		(1 "F.Mask" user)+		(3 "B.Mask" user)+		(5 "F.SilkS" user)+		(7 "B.SilkS" user)+		(25 "Edge.Cuts" user)+		(31 "F.CrtYd" user)+		(29 "B.CrtYd" user)+	)+	(footprint "T:J1"+		(layer "F.Cu")+		(at 4 10)+		(property "Reference" "J1"+			(at 0 0 0)+			(layer "F.SilkS")+			(hide yes)+		)+		(fp_rect+			(start -1.2 -1.2)+			(end 1.2 3.8)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+		)+		(pad "1" thru_hole circle+			(at 0 0)+			(size 1.7 1.7)+			(drill 0.8)+			(layers "*.Cu" "*.Mask")+			(net "A")+		)+		(pad "2" thru_hole circle+			(at 0 2.54)+			(size 1.7 1.7)+			(drill 0.8)+			(layers "*.Cu" "*.Mask")+			(net "GND")+		)+	)+	(footprint "T:U1"+		(layer "B.Cu")+		(at 13 10)+		(property "Reference" "U1"+			(at 0 0 0)+			(layer "B.SilkS")+			(hide yes)+		)+		(fp_rect+			(start -1.4 -3.4)+			(end 1.4 3.4)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "B.CrtYd")+		)+		(pad "1" smd roundrect+			(at -0.65 -2.4)+			(size 0.4 1.4)+			(layers "B.Cu" "B.Paste" "B.Mask")+			(roundrect_rratio 0.25)+			(net "A")+		)+		(pad "2" smd roundrect+			(at 0.65 -2.4)+			(size 0.4 1.4)+			(layers "B.Cu" "B.Paste" "B.Mask")+			(roundrect_rratio 0.25)+			(net "B")+		)+		(pad "3" smd roundrect+			(at 0.65 2.4)+			(size 0.4 1.4)+			(layers "B.Cu" "B.Paste" "B.Mask")+			(roundrect_rratio 0.25)+			(net "C")+		)+		(pad "4" smd roundrect+			(at -0.65 2.4)+			(size 0.4 1.4)+			(layers "B.Cu" "B.Paste" "B.Mask")+			(roundrect_rratio 0.25)+			(net "GND")+		)+	)+	(footprint "T:R2"+		(layer "B.Cu")+		(at 21 6)+		(property "Reference" "R2"+			(at 0 0 0)+			(layer "B.SilkS")+			(hide yes)+		)+		(fp_rect+			(start -1.6 -0.8)+			(end 1.6 0.8)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "B.CrtYd")+		)+		(pad "1" smd roundrect+			(at -0.8 0)+			(size 0.9 1.0)+			(layers "B.Cu" "B.Paste" "B.Mask")+			(roundrect_rratio 0.25)+			(net "B")+		)+		(pad "2" smd roundrect+			(at 0.8 0)+			(size 0.9 1.0)+			(layers "B.Cu" "B.Paste" "B.Mask")+			(roundrect_rratio 0.25)+			(net "D")+		)+	)+	(footprint "T:R3"+		(layer "F.Cu")+		(at 21 15)+		(property "Reference" "R3"+			(at 0 0 0)+			(layer "F.SilkS")+			(hide yes)+		)+		(fp_rect+			(start -1.6 -0.8)+			(end 1.6 0.8)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+		)+		(pad "1" smd roundrect+			(at -0.8 0)+			(size 0.9 1.0)+			(layers "F.Cu" "F.Paste" "F.Mask")+			(roundrect_rratio 0.25)+			(net "C")+		)+		(pad "2" smd roundrect+			(at 0.8 0)+			(size 0.9 1.0)+			(layers "F.Cu" "F.Paste" "F.Mask")+			(roundrect_rratio 0.25)+			(net "GND")+		)+	)+	(footprint "T:J2"+		(layer "F.Cu")+		(at 27 8)+		(property "Reference" "J2"+			(at 0 0 0)+			(layer "F.SilkS")+			(hide yes)+		)+		(fp_rect+			(start -1.2 -1.2)+			(end 1.2 3.8)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+		)+		(pad "1" thru_hole circle+			(at 0 0)+			(size 1.7 1.7)+			(drill 0.8)+			(layers "*.Cu" "*.Mask")+			(net "D")+		)+		(pad "2" thru_hole circle+			(at 0 2.54)+			(size 1.7 1.7)+			(drill 0.8)+			(layers "*.Cu" "*.Mask")+			(net "GND")+		)+	)+	(gr_rect+		(start 0 0)+		(end 30 20)+		(stroke+			(width 0.05)+			(type solid)+		)+		(fill no)+		(layer "Edge.Cuts")+	)+)
tests/route-card.mjsadded+36
@@ -0,0 +1,36 @@+// Render the dashboard's route card and step badges (issue #141) with stub elements: no browser, no dashboard.+import fs from 'node:fs';+import vm from 'node:vm';+import assert from 'node:assert/strict';+const html=fs.readFileSync(new URL('../tools/timeline/index.html',import.meta.url),'utf8');+const start=html.indexOf('  // issue #141: a step whose last command');+const end=html.indexOf('  // ---------------- build',start);+assert(start>0 && end>start);+assert(html.includes('id="route-card"') && html.includes('class="look-chip"'),'the card and the step chip are in the page');+const elements=new Map();+const node=()=>({hidden:true,textContent:'',innerHTML:'',classList:{s:new Set(),toggle(c,on){on?this.s.add(c):this.s.delete(c)}}});+const $=id=>{if(!elements.has(id))elements.set(id,node());return elements.get(id)};+const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));+const fmtClock=iso=>String(iso||'');+const titleOf=(m,n)=>({routing:'Routing'}[n]||n);+const ctx={$,esc,fmtClock,titleOf};+vm.createContext(ctx);vm.runInContext(html.slice(start,end)+'\nObject.assign(globalThis,{renderRouteCard,lookOf,lookText});',ctx);+ctx.renderRouteCard({route:null,checks:[]});+assert.equal($('route-card').hidden,true,'no route and no checks, no section');+assert.equal(ctx.lookOf(null),null);assert.equal(ctx.lookOf({kind:'ok'}),null,'only findings and errors make a badge');+assert.equal(ctx.lookText('findings',3),'needs a look · 3');assert.equal(ctx.lookText('error',1),'error');+const route={route:{at:'t1',measuredOn:'measured on the previous board (board-0.kicad_pcb, revision 1; the board is now revision 2)',onCurrentBoard:false,+  routed:2,connections:3,unrouted:1,netsUnrouted:['C'],vias:4,warnings:['<w>'],detail:['C at J7.1 (no path)'],routeQuality:'2 segments'},+  gate:{at:'t2',measuredOn:'x',onCurrentBoard:false,newErrors:0,unconnected:1}};+const checks=[{key:'route',step:'routing',kind:'findings',count:1,lines:['routing closed 1 connection(s) short']},{key:'gate',step:'routing',kind:'error',count:1,lines:['no plan <b>']}];+ctx.renderRouteCard({route,checks});+assert.equal($('route-card').hidden,false);+assert.equal($('rt-title').textContent,'Last route');+assert.match($('rt-on').textContent,/previous board/);assert($('rt-on').classList.s.has('prev'),'previous board is flagged');+assert.match($('rt-facts').innerHTML,/routed <b>2 \/ 3<\/b>/);assert.match($('rt-facts').innerHTML,/unrouted <b>1<\/b> <span>in C<\/span>/);+assert.match($('rt-facts').innerHTML,/vias <b>4<\/b>/);assert.match($('rt-facts').innerHTML,/gate <b>0<\/b> new DRC, <b>1<\/b> unconnected/);+assert($('rt-lines').innerHTML.includes('warning: &lt;w&gt;'),'warnings escaped');+assert($('rt-checks').innerHTML.includes('look-chip error') && $('rt-checks').innerHTML.includes('Routing · route') && $('rt-checks').innerHTML.includes('no plan &lt;b&gt;'));+ctx.renderRouteCard({route:null,checks});+assert.equal($('route-card').hidden,false);assert.equal($('rt-title').textContent,'Checks that need a look');+console.log('PASS: route card hidden without data, numbers and previous-board label shown, checks listed with error/findings chips, text escaped');
tools/timeline/README.md+16
@@ -110,3 +110,19 @@ Answers go to `POST handson` (relative URL) with `Content-Type: application/json - `{"action":"approved"|"changes"|"done","step":"placement","note":"..."}`  The server queues it in `handson-inbox.json`; the CLI applies it. The page never changes run.json or a gate.++### Last route and checks that need a look (issue #141, attention.rs)++Every command that ends with findings (`FINDINGS`, or an `OK` after `NOTE` lines) or an error is kept in run.json `checks`, keyed by the command's name (`route`, `gate`, `land route`, `analyze current`), with the step it was charged to, a count and its first lines. It stays until that same command ends cleanly again; it is a suggestion, never a block. The ledger gets a `command-outcome` event for every command (`outcome`: ok, findings or error). `route` keeps `lastRoute` (connections routed and unrouted, nets unrouted, vias, warnings, the route-quality headline) and `gate` keeps `lastGate` (new DRC errors, unconnected); a new board does not remove them, they are labelled with the board revision they were measured on.++`timeline.json` carries them:++```json+{"steps": [{"name": "routing", "status": "current", "attention": {"kind": "findings|error", "count": 2, "items": [ /* checks on this step */ ]}}],+ "route": {"route": {"routed": 2, "connections": 3, "unrouted": 1, "netsUnrouted": ["C"], "vias": 0, "warnings": [], "routeQuality": "...",+                     "onCurrentBoard": false, "measuredOn": "measured on the previous board (board-0.kicad_pcb, revision 1; the board is now revision 2): ..."},+           "gate": {"newErrors": 0, "unconnected": 1, "onCurrentBoard": true, "measuredOn": "..."}},+ "checks": [{"key": "route", "step": "routing", "kind": "findings", "count": 1, "lines": ["routing closed 1 connection(s) short ..."], "suggestion": "...", "at": "..."}]}+```++The page shows a "needs a look · N" (or "error") chip on the step row, the lines in the step's tooltip, and a card with the last route's numbers and the open checks. `status` prints the same as text.
tools/timeline/index.html+82−2
@@ -29,6 +29,10 @@   --purple: #8c6bf7;   --purple-wash: rgba(140, 107, 247, .13);   --blue: #64abff;+  --amber: #d9a441;+  --amber-wash: rgba(217, 164, 65, .12);+  --danger: #f47067;+  --danger-wash: rgba(244, 112, 103, .12);   --sig: linear-gradient(100deg, #00e6dc, #39b8ff 60%, #8c6bf7);    --f-display: 'Familjen Grotesk', 'DM Sans', system-ui, sans-serif;@@ -520,6 +524,21 @@ body.wide .stage { margin-top: 14px; } .ho-discover .mine { margin-top: 8px; font-size: 13px; color: var(--text-2); } .mode-chip { margin-left: 8px; border-radius: 999px; padding: 0 7px; font-size: 11px; line-height: 17px; font-family: var(--f-body); font-weight: 600; border: 1px solid var(--border); color: var(--text-2); white-space: nowrap; vertical-align: 1px; } .mode-chip[hidden] { display: none; }+.look-chip { border-radius: 999px; padding: 0 7px; font-size: 11px; line-height: 17px; font-family: var(--f-body); font-weight: 600; white-space: nowrap; vertical-align: 1px; border: 1px solid rgba(217, 164, 65, .6); color: var(--amber); background: var(--amber-wash); }+.look-chip.error { border-color: rgba(244, 112, 103, .6); color: var(--danger); background: var(--danger-wash); }+.look-chip[hidden] { display: none; }+.route-card { margin: 0 var(--pad) 24px; }+.route-card .card { padding: 18px 22px; }+.route-card .rt-on { font-size: 12px; color: var(--text-2); margin-left: 6px; font-weight: 400; text-transform: none; letter-spacing: 0; }+.route-card .rt-on.prev { color: var(--amber); }+.route-card .rt-facts { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 13px; color: var(--text-2); }+.route-card .rt-facts b { font-family: var(--f-mono); color: var(--text); font-weight: 500; }+.route-card .rt-facts li.warn b { color: var(--amber); }+.route-card .rt-lines, .route-card .rt-checks { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 5px; font-size: 13px; overflow-wrap: anywhere; color: var(--text-body); }+.route-card .rt-lines li { color: var(--text-2); }+.route-card .rt-checks li .k { font-family: var(--f-mono); font-size: 12px; color: var(--text); margin-right: 6px; }+.route-card .rt-checks li .look-chip { margin-right: 6px; }+.route-card .rt-note { margin: 10px 0 0; font-size: 12px; color: var(--text-2); } .mode-chip.checkin { border-color: rgba(0, 184, 177, .6); color: var(--accent-bright); } .mode-chip.human { border-color: rgba(140, 107, 247, .7); color: var(--text); background: var(--purple-wash); } @media (max-width: 680px) {@@ -608,6 +627,16 @@ body.wide .stage { margin-top: 14px; } </section>  +<section class="route-card" id="route-card" hidden aria-label="Last route and checks that need a look" aria-live="polite">+  <div class="card">+    <div class="eyebrow"><svg class="i"><use href="#i-loop"/></svg><span id="rt-title">Last route</span><span class="rt-on" id="rt-on"></span></div>+    <ul class="rt-facts" id="rt-facts"></ul>+    <ul class="rt-lines" id="rt-lines"></ul>+    <ul class="rt-checks" id="rt-checks"></ul>+    <p class="rt-note">Suggestions, nothing is blocked. A check stays here until its command runs cleanly again; the route stays until the next route.</p>+  </div>+</section>+ <section class="rules-card" id="rules-card" hidden aria-label="Design rules at start">   <div class="card">     <div class="eyebrow"><svg class="i"><use href="#i-cpu"/></svg>Design rules at start <span id="rc-lane"></span></div>@@ -826,6 +855,7 @@ body.wide .stage { margin-top: 14px; }         visits: Math.max(0, parseInt(s.visits, 10) || 0),         minutes: Math.max(0, +s.minutes || 0),         mode: ['checkin', 'human', 'custom'].includes(s.mode) ? s.mode : 'auto',+        attention: lookOf(s.attention),       });     });     if (!steps.length) return null;@@ -853,8 +883,50 @@ body.wide .stage { margin-top: 14px; }       finish: String(d.finish || 'Finished'),       updated: d.updated || '',       current, steps, loops, idx,+      route: d.route && typeof d.route === 'object' ? d.route : null,+      checks: Array.isArray(d.checks) ? d.checks.filter(c => c && typeof c === 'object') : [],     };   }+  // issue #141: a step whose last command ended with findings or an error (never a block)+  function lookOf(a) {+    if (!a || typeof a !== 'object' || !['findings', 'error'].includes(a.kind)) return null;+    const items = Array.isArray(a.items) ? a.items.filter(i => i && typeof i === 'object') : [];+    return { kind: a.kind, count: Math.max(0, parseInt(a.count, 10) || 0), items };+  }+  function lookText(kind, count) { return kind === 'error' ? 'error' : 'needs a look' + (count ? ' \u00b7 ' + count : ''); }+  function firstLine(c) { return Array.isArray(c.lines) && c.lines.length ? String(c.lines[0]) : ''; }+  function renderRouteCard(m) {+    const sec = $('route-card');+    const rt = m.route && m.route.route && typeof m.route.route === 'object' ? m.route.route : null;+    const gt = m.route && m.route.gate && typeof m.route.gate === 'object' ? m.route.gate : null;+    if (!rt && !gt && !m.checks.length) { sec.hidden = true; return; }+    sec.hidden = false;+    const n = v => (typeof v === 'number' && isFinite(v)) ? v : '-';+    const rec = rt || gt;+    $('rt-title').textContent = rt ? 'Last route' : (gt ? 'Last gate' : 'Checks that need a look');+    const on = $('rt-on');+    on.textContent = rec ? '\u00b7 ' + fmtClock(rec.at) + ' \u00b7 ' + String(rec.measuredOn || '') : '';+    on.classList.toggle('prev', !!rec && rec.onCurrentBoard === false);+    const facts = [];+    if (rt) {+      const nets = Array.isArray(rt.netsUnrouted) ? rt.netsUnrouted.map(String) : [];+      const warns = Array.isArray(rt.warnings) ? rt.warnings.map(String) : [];+      facts.push('<li>routed <b>' + esc(n(rt.routed)) + ' / ' + esc(n(rt.connections)) + '</b></li>');+      facts.push('<li class="' + (rt.unrouted > 0 ? 'warn' : '') + '">unrouted <b>' + esc(n(rt.unrouted)) + '</b>' + (nets.length ? ' <span>in ' + esc(nets.slice(0, 6).join(', ')) + (nets.length > 6 ? ' +' + (nets.length - 6) : '') + '</span>' : '') + '</li>');+      facts.push('<li>vias <b>' + esc(n(rt.vias)) + '</b></li>');+      facts.push('<li class="' + (warns.length ? 'warn' : '') + '">warnings <b>' + warns.length + '</b></li>');+    }+    if (gt) facts.push('<li class="' + ((gt.newErrors > 0 || gt.unconnected > 0) ? 'warn' : '') + '">gate <b>' + esc(n(gt.newErrors)) + '</b> new DRC, <b>' + esc(n(gt.unconnected)) + '</b> unconnected' + (rt && gt.onCurrentBoard === false && rt.onCurrentBoard !== false ? ' <span>(previous board)</span>' : '') + '</li>');+    $('rt-facts').innerHTML = facts.join('');+    const lines = [];+    if (rt) {+      (Array.isArray(rt.detail) ? rt.detail : []).slice(0, 2).forEach(d => lines.push('unrouted: ' + d));+      (Array.isArray(rt.warnings) ? rt.warnings : []).slice(0, 2).forEach(w => lines.push('warning: ' + w));+      if (rt.routeQuality) lines.push('route quality: ' + rt.routeQuality);+    }+    $('rt-lines').innerHTML = lines.map(l => '<li>' + esc(l) + '</li>').join('');+    $('rt-checks').innerHTML = m.checks.slice(-5).map(c => '<li title="' + esc((Array.isArray(c.lines) ? c.lines : []).join('\n') + (c.suggestion ? '\nSuggestion: ' + c.suggestion : '')) + '"><span class="look-chip ' + (c.kind === 'error' ? 'error' : '') + '">' + esc(lookText(c.kind, c.count)) + '</span><span class="k">' + esc(titleOf(m, String(c.step || ''))) + ' \u00b7 ' + esc(String(c.key || '')) + '</span>' + esc(firstLine(c)) + '</li>').join('');+  }    // ---------------- build ----------------   const ICON_CK = '<svg class="ck" viewBox="0 0 24 24"><path d="M5.5 12.5 10 17l8.5-9.5"/></svg>';@@ -875,13 +947,13 @@ body.wide .stage { margin-top: 14px; }       li.innerHTML =         '<div class="rowbg"></div>' +         '<div class="node">' + ICON_CK + ICON_RD + ICON_CU + '</div>' +-        '<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span><span class="mode-chip" hidden></span></div><div class="does"></div></div>' ++        '<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span><span class="mode-chip" hidden></span><span class="look-chip" hidden></span></div><div class="does"></div></div>' +         '<div class="meta"></div>';       ol.appendChild(li);       rows.set(s.name, {         li, node: li.querySelector('.node'), nm: li.querySelector('.nm'), badge: li.querySelector('.badge'),         v: li.querySelector('.badge .v'), does: li.querySelector('.does'), meta: li.querySelector('.meta'), status: null,-        mode: li.querySelector('.mode-chip'),+        mode: li.querySelector('.mode-chip'), look: li.querySelector('.look-chip'),       });     });     finishLi = document.createElement('li');@@ -987,12 +1059,19 @@ body.wide .stage { margin-top: 14px; }       const chip = { checkin: 'check-in', human: 'you', custom: 'custom' }[s.mode] || '';       r.mode.hidden = !chip;       if (r.mode.textContent !== chip) { r.mode.textContent = chip; r.mode.className = 'mode-chip ' + s.mode; r.mode.title = { checkin: 'The AI checks in with you after this step', human: 'You do this step', custom: 'Your custom instructions' }[s.mode] || ''; }+      const a = s.attention;+      const lt = a ? lookText(a.kind, a.count) : '';+      r.look.hidden = !a;+      if (r.look.textContent !== lt) r.look.textContent = lt;+      r.look.className = 'look-chip' + (a && a.kind === 'error' ? ' error' : '');+      r.look.title = a ? a.items.map(c => String(c.key || '') + ': ' + firstLine(c)).join('\n') : '';       let meta = '';       if (s.status === 'redo') meta = '<span class="redo-tag">rework</span>';       else if (s.minutes > 0) meta = fmtMin(s.minutes) + ' min';       if (r.meta._v !== meta) { r.meta.innerHTML = meta; r.meta._v = meta; }     });     finishLi.querySelector('.fx').textContent = m.finish;+    renderRouteCard(m);     finishLi.classList.toggle('won', allDone);      // phases@@ -1333,6 +1412,7 @@ body.wide .stage { margin-top: 14px; }       if (s.does) h += '<div>' + esc(s.does) + '</div>';       sentFrom.forEach(l => { h += '<div style="margin-top:6px;color:var(--purple)">Sent the AI back to ' + esc(titleOf(m, l.to)) + ': <span style="color:var(--text-body)">' + esc(l.why) + '</span></div>'; });       sentBack.forEach(l => { h += '<div style="margin-top:6px;color:var(--purple)">Reworked after ' + esc(titleOf(m, l.from)) + ': <span style="color:var(--text-body)">' + esc(l.why) + '</span></div>'; });+      if (s.attention) s.attention.items.forEach(c => { h += '<div style="margin-top:6px;color:' + (c.kind === 'error' ? 'var(--danger)' : 'var(--amber)') + '">' + esc(String(c.key || '')) + (c.kind === 'error' ? ' ended with an error' : ' ended with findings') + (c.count ? ' (' + c.count + ')' : '') + ': <span style="color:var(--text-body)">' + esc(firstLine(c)) + '</span></div>'; });       h += '<div class="ft">' + esc(s.phase || 'No phase') + ' · visited ' + s.visits + (s.visits === 1 ? ' time' : ' times') + ' · ' + fmtMin(s.minutes) + ' AI min</div>';       return { h };     }