app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Publish 0.2.48
18 files changed
+231−131
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+9−7bin/adom-aiflowcrates/adom-aiflow/src/deviations.rs+15−10crates/adom-aiflow/src/main.rs+74−50package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+9−7tests/board-kind-workflow.py+3−3tests/continuation-workflow.py+4−5tests/deviation-workflow.py+22−22tests/review-workflow.py+5−5tests/rules-layers-workflow.py+3−3tests/rules-workflow.py+2−2tests/sourcing-suggestions.py+1−1tools/fusion-project.py+60−1tools/silkscreen-plan.py+11−2Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.47"+version = "0.2.48" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.47"+version = "0.2.48" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.47"+version = "0.2.48" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.47"+version = "0.2.48" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.47"+version = "0.2.48" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.47"+version = "0.2.48" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.47"+version = "0.2.48" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.47"+version = "0.2.48" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.47"+version = "0.2.48" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.47"+version = "0.2.48" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.47"+version = "0.2.48" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+9−7@@ -8,6 +8,8 @@ description: >- This tool is the place-route-pour-analyze skill made executable: the step order is a file, the checks report what is wrong, the hints arrive as the answer to the command you just ran, and the ledger records what you did. Follow the commands and the skill follows itself. +**AI Flow is a suggestion engine. It never refuses.** Every check reports what it found (DRC errors stay prominent) and suggests a next action; every command completes; you and the human decide. `step` suggests the order and notes a step you skip; `finish` is a report card (what passed, what is still open, what it suggests), not a gate.+ **AI Flow is a helper, not a wall.** It gives you ready-made tools (the router, pours, DRC and analysis gates, linters) so you don't write them from scratch, hints for the next step, and the Adom habits: public wiki pages for new parts that every Adom user then shares, and a project page with the 3D, schematic and board viewers and the datasheets. You are still the engineer. When a check refuses a board that is right, because AI Flow has not seen a design like it, don't stop and don't fake a pass: improve AI Flow (see "Contribute to AI Flow" at the end) and finish the board with your build. Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kicad` for the offline DRC gate, and the KiCad Bridge on a desktop for the live stages). `adom-aiflow --version`.@@ -32,7 +34,7 @@ Run `adom-aiflow --run <run> finish-guard --json` before ending a turn. Exit 2 / A failed engineering gate, an inaccurate heuristic, missing automation, a source bug, a slow solve, or a fix that needs implementation is work to do. Inspect evidence, repair the design/tool, test it, synchronize the dashboard and continue. A pending owner review is not a global blocker while isolated experiments, a safe alternative, or other requested work remains. Do not classify your own unfinished implementation as an external dependency. Never lower acceptance thresholds, fabricate receipts or mark work passed to satisfy this guard. -`done` now refuses ordinary closure until the sequence, finish and delivery are complete. It is no longer a way to close an intermediate progress update. The guard checks recorded gates and delivery artifacts; the AI must also verify the user's full scope, including firmware, molecule, wiki, dashboard and edited video when requested. Track additional deliverables in the project's completion contract; optional `run.json` `continuation.requiredDeliverables` rows use `name`, `complete` and an existing `evidence` file. Evidence existence is not independent validation of its contents.+`done` closes a session at any time and notes when the finish line has not been reached yet. The guard checks recorded gates and delivery artifacts; the AI must also verify the user's full scope, including firmware, molecule, wiki, dashboard and edited video when requested. Track additional deliverables in the project's completion contract; optional `run.json` `continuation.requiredDeliverables` rows use `name`, `complete` and an existing `evidence` file. Evidence existence is not independent validation of its contents. Only the user's explicit pause/cancellation, or an unavoidable external dependency after exhausting useful authorized work, permits an incomplete handoff. Use `finish-guard --stop-evidence <json>` and `done --stop-evidence <json> --message ...`. See `docs/continuation.md` for the evidence schemas. Preserve the exact user instruction when pausing. Stop evidence never completes a gate and must not be reused after a resume or an external-state change. @@ -56,7 +58,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.-8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`.+8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` reports whether the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`. ### High-speed boards: your own router, inside the flow @@ -82,16 +84,16 @@ If the binary should do this step itself, file it (see Give it back). A run carries its lane's required steps in order (`flows/board.json` `sequence`; Fusion: intake, rules, design, test-plan, sourcing, library, schematic, simulation, placement, routing, thermal-vias, pours,-outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` refuses while any+outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` notes (and logs) any required step before x is not done, and a step is done only when its own command ran during its latest visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` for the test plan, `land moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `silkscreen-audit --record` (Fusion: `fusion silkscreen`), `export` (Fusion: `fusion export`) ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it draws an arc on the timeline, and every later step becomes a redo that must be done again in order.-`finish` refuses until the whole chain is in order, or until you accept what it lists on purpose.+`finish` lists what is still open in the chain as a report card, with suggestions; it never refuses. **When a check is wrong for this board, deviate on purpose instead of stopping.** AI Flow has not seen-every kind of board. When a step does not apply, or `finish` refuses something that is right for this+every kind of board. When a step does not apply, or `finish` lists something as open that is right for this design, record it: `adom-aiflow deviate "<item>" --why "<why this board is right anyway>"`. The item is the one the refusal names (`gate`, `heatsink vias`, `analyze-thermal`, `step test-plan`, ...); the refusal's hint lists them. A deviation is bound to the saved board and spec (any change to either@@ -277,7 +279,7 @@ The first `step` (or `capture start`) asks the test box once which recorder its ## Design gates (schematic, molecule, fab rules, 3D models, copper pads) -Deterministic checks that refuse, each an OK/ERROR with hints:+Deterministic checks that report, each an OK or FINDINGS with suggestions (never a refusal): - **`netlist check [--sch design.kicad_sch] [--board B] [--net exported.net]`**: exports the schematic's netlist with kicad-cli (`sch export netlist --format kicadsexpr`, through `ADOM_AIFLOW_KICAD_CLI`; `adom-aiflow-kicad-cli-remote` does it on a desktop's KiCad 10 and carries the sub-sheets, lib tables and `${KIPRJMOD}` symbol libraries) and compares it pin by pin with the board's pads: missing and extra references, and every pin whose net differs (`/VIN` equals `VIN`; unlabelled `Net-(...)` nets compare by their pins). In a run it is recorded; `finish` re-checks the stored netlist against the board it finishes on and asks again when the schematic changed. - **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Corner-pin class (0.2.17, issue #87): an error when the pins are medium but the board needs large ones (seats in the base scaffold, over 100 g with its parts, a motor, actuator, heatsink over 20 g, cabled connector or hard-pressed button, longest side over 100 mm, or a pin net over 3.5 A in `spec.loads`); a warning when the mass is unknown or a part looks like an undeclared mechanical load; large pins with no reason are fine. The rule, its data and the spec fields are in skill aiflow-molecule. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}, "seat": "base|lrgmed|board", "massG": 42, "mechanicalLoads": [{"ref": "M1", "kind": "motor"}], "pinCurrentA": 1.2}` (mm from MP1, y up; all optional); `--bom <csv>` (default: the run's BOM) gives a `mass_g` column.@@ -293,7 +295,7 @@ The judgement is in these skills, which this tool executes: kicad-place-route-lo - **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board. - **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate.-- **Courtyards and bodies are errors.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.+- **Courtyards and bodies are reported prominently.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout. - **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule. - **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then. - **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/deviations.rs+15−10@@ -30,16 +30,19 @@ pub fn expired(data: &Value, board_sha: &str, spec_sha: &str) -> Vec<Value> { } /// Record (or replace) the deviation for `item`. `known` are the items the last refusal named.-pub fn record(data: &mut Value, item: &str, why: &str, thread: &str, board_sha: &str, spec_sha: &str, at: &str, known: &[String]) -> Result<Value, String> {+/// Never refused (John, 2026-11-04): a short reason or an item the last report did not name is still+/// recorded, with notes saying so.+pub fn record(data: &mut Value, item: &str, why: &str, thread: &str, board_sha: &str, spec_sha: &str, at: &str, known: &[String]) -> (Value, Vec<String>) { let item = key(item);+ let mut notes = Vec::new(); if why.trim().chars().count() < MIN_WHY {- return Err(format!("--why needs a real reason (at least {MIN_WHY} characters): why this board is right even though the check refuses it"));+ notes.push("a reason a person can follow later helps: say why this board is right as it is".to_string()); } if !known.contains(&item) {- return Err(if known.is_empty() {- "nothing to deviate from yet: run `finish` (or the `step` that refused) first; deviate names one of the items it lists".into()+ notes.push(if known.is_empty() {+ "nothing has been reported yet to match it against; `finish` (or `step`) lists the items".to_string() } else {- format!("\"{item}\" is not an item the last refusal named; it named: {}", known.join(", "))+ format!("\"{item}\" is not an item the last report named (it named: {}), so it matches nothing until one does", known.join(", ")) }); } let rec = json!({"item": item, "why": why.trim(), "aiThread": thread, "at": at, "boardSha256": board_sha, "specSha256": spec_sha});@@ -47,7 +50,7 @@ pub fn record(data: &mut Value, item: &str, why: &str, thread: &str, board_sha: let mut list: Vec<Value> = list.into_iter().filter(|d| d["item"] != item.as_str()).collect(); list.push(rec.clone()); data["deviations"] = json!(list);- Ok(rec)+ (rec, notes) } /// Remove the deviation for `item`; whether one was there.@@ -97,10 +100,12 @@ mod tests { fn a_deviation_is_deliberate_named_and_bound_to_the_board_and_spec() { let mut d = json!({}); let known = vec!["gate".to_string(), "heatsink vias".to_string()];- assert!(record(&mut d, "gate", "skip", "t", "b1", "s1", "now", &known).is_err());- assert!(record(&mut d, "pours", "the pours are deliberately absent on this RF board", "t", "b1", "s1", "now", &known).is_err());- assert!(record(&mut d, "gate", "a reason that is long enough here", "t", "b1", "s1", "now", &[]).unwrap_err().contains("finish"));- record(&mut d, "Heatsink vias", "LEDs at 20 mW each; the Fields solve shows +3 C", "t", "b1", "s1", "now", &known).unwrap();+ // never refused: a short reason or an unnamed item is recorded, with a note+ assert!(!record(&mut d, "gate", "skip", "t", "b1", "s1", "now", &known).1.is_empty());+ assert!(record(&mut d, "pours", "the pours are deliberately absent on this RF board", "t", "b1", "s1", "now", &known).1[0].contains("not an item"));+ assert!(record(&mut d, "gate", "a reason that is long enough here", "t", "b1", "s1", "now", &[]).1[0].contains("finish"));+ assert!(clear(&mut d, "gate") && clear(&mut d, "pours"));+ assert!(record(&mut d, "Heatsink vias", "LEDs at 20 mW each; the Fields solve shows +3 C", "t", "b1", "s1", "now", &known).1.is_empty()); let lines = vec!["heatsink vias: D1 has 0 of 6 (thermal-vias plan, land)".to_string(), "gate: 1 new DRC error".to_string()]; let (open, accepted) = split(lines.clone(), &live(&d, "b1", "s1")); assert_eq!(open, vec!["gate: 1 new DRC error".to_string()]);
crates/adom-aiflow/src/main.rs+74−50@@ -259,7 +259,7 @@ enum Cmd { Measure, /// current, thermal or impedance (spec impedanceTargets: each target's impedance from the landed copper, compared with a same-input Fields Signals solve when there is one). `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited peak over the Fields density screen (repeat --ack for more); it covers only that place (the pad or 0.5 mm), keeps the number reported and expires when the copper fingerprint or the spec changes. spec.boardSink declares a board-face heat sink for named parts (thermal). Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf>, #[arg(long)] ack: Vec<String>, #[arg(long)] why: Option<String> },- /// The finish line; refuses until every check passes+ /// The finish line, as a report card: what passed, what is still open and what to do next (it never refuses) Finish, /// Impedance targets (spec impedanceTargets, or adom-fields' impedance map): `table` prints the trace width (and pair gap) for each target on every copper layer from the stackup, refusing a layer the target cannot reach within the fab's minimums; `check` judges the board's copper (or --plan) against them. On the run's board and spec, or on --board and --spec. Impedance { #[arg(value_parser = ["table", "check"])] action: String, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String>, #[arg(long)] plan: Option<String>, #[arg(long)] json: bool },@@ -383,6 +383,22 @@ static INPUT_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new(); /// Every refusal says how to change AI Flow itself when the check, not the board, is what is wrong. const CONTRIBUTE: &str = "If this check is wrong for your board (AI Flow has not seen a design like it), improve AI Flow rather than stopping or working around it: `adom-wiki repo clone adom/adom-aiflow`, fix it, `cargo build --release`, finish this board with your build (that tests the change), then send it back with `adom-wiki pr create adom/adom-aiflow --title \"...\" --files <changed files> --body-file <why, base hash, how you tested>`. Every Adom user gets the fix."; +/// 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(""));+ println!("FINDINGS (suggestions, nothing is blocked): {msg}");+ for h in hints { if !h.is_empty() { println!("Suggestion: {h}"); } }+ println!("Suggestion: {CONTRIBUTE}");+ std::process::exit(0)+}++/// A check in front of an action: say what it found and carry on with the action.+fn note(msg: &str, hints: &[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("")); println!("ERROR: {msg}");@@ -3015,7 +3031,7 @@ fn main() { command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out); if let Some(v)=events {command.arg("--events").arg(v);} let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));- if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }+ if !result.status.success() { findings(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); } ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]); } Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force, sch, page, page_dir } => {@@ -3179,7 +3195,7 @@ fn main() { let errs: Vec<String> = out["errors"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!(" {s}"))).collect(); let mut hints = vec![format!("Profile {rule}. Fix the BOM (aiflow-sourcing: search by spec and in stock, record stock and the date), then check again."), "Every electrical reference needs a row with an MPN; contacts and test pads the footprint does not exclude from the BOM take a `no part` row.".into(), override_note]; hints.extend(warnings);- err(&format!("sourcing check failed on the {prof} profile ({} problem(s)):\n{}", errs.len(), errs.join("\n")), &hints);+ findings(&format!("sourcing check failed on the {prof} profile ({} problem(s)):\n{}", errs.len(), errs.join("\n")), &hints); } let diff = if out["boardChecked"] == true { format!("every one of the board's {} BOM references is in the BOM ({} excluded by their footprints)", out["boardRefs"], out["excludedByBoard"].as_array().map(|a| a.len()).unwrap_or(0)) } else { "no board yet, so no reference diff: check again after `start --board` (finish requires the diff)".into() }; let mut hints = vec![format!("Profile {rule}; build quantity {qty} ({qty_src})."), override_note];@@ -3204,7 +3220,7 @@ fn main() { if kind == "simulation" && pass == Some(false) { r.log("evidence-refused", json!({"kind": kind, "file": canon.display().to_string(), "sha256": sha, "failing": failing, "pass": top})); r.save().unwrap();- err(&format!("simulation evidence refused: {} says it fails{}", file, if failing.is_empty() { " (top-level pass: false)".to_string() } else { format!(": {}", failing.iter().map(|c| format!("{} {} against {}", c["name"].as_str().unwrap_or("?"), c["value"], c["limit"])).collect::<Vec<_>>().join("; ")) }), &["A failing simulation is a design result, not evidence of a working design: change the values (aiflow-circuit-design), simulate again, and add the passing result. Obey a datasheet limit over a fitted model (aiflow-simulate).".into()]);+ findings(&format!("simulation evidence refused: {} says it fails{}", file, if failing.is_empty() { " (top-level pass: false)".to_string() } else { format!(": {}", failing.iter().map(|c| format!("{} {} against {}", c["name"].as_str().unwrap_or("?"), c["value"], c["limit"])).collect::<Vec<_>>().join("; ")) }), &["A failing simulation is a design result, not evidence of a working design: change the values (aiflow-circuit-design), simulate again, and add the passing result. Obey a datasheet limit over a fitted model (aiflow-simulate).".into()]); } // a copy in the run keeps the record when the design folder moves on let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());@@ -3364,11 +3380,11 @@ fn main() { return; } let parts = r.data["outcomes"]["parts"].clone();- if parts["pass"] != true { err("the parts are not all on the wiki yet: run `parts` until it passes", &[]); }+ if parts["pass"] != true { note("the parts are not all on the wiki yet: the page lists what is there; run `parts` until it passes to add the rest", &[]); } if !page.contains('/') { err("--page is <owner>/<slug>", &[]); } let bom_path = parts["bom"].as_str().unwrap_or("").to_string(); let stale = project_lib::stale(&parts, &bom_path, std::fs::read_to_string(&bom_path).ok().as_deref());- if !stale.is_empty() { err(&format!("the parts record is stale: {}", stale.join("; ")), &[format!("Run `parts` again (`parts --bom <current BOM>{}`), then `project-page` again.", parts["projectLib"].as_str().map(|d| format!(" --project-lib {d}")).unwrap_or_default())]); }+ if !stale.is_empty() { note(&format!("the parts record is stale: {}", stale.join("; ")), &[format!("Run `parts` again (`parts --bom <current BOM>{}`), then `project-page` again.", parts["projectLib"].as_str().map(|d| format!(" --project-lib {d}")).unwrap_or_default())]); } let (spec, req) = (spec_soft(&r), requirements_of(&r)); let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::label(&dir, &r.data)); let brief = brief.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());@@ -3461,7 +3477,7 @@ fn main() { let mut hints = vec!["Fix it in KiCad's schematic editor on camera (kicad_sch_place_symbol / kicad_sch_wire / kicad_sch_label / kicad_sch_set_property), save, and check again.".to_string()]; hints.extend(schematic_symbols::render(&symbols, true).1); hints.push(schematic_symbols::warning_hint(&symbols));- err(&format!("schematic check failed:\n {}", why.join("\n ")), &hints);+ findings(&format!("schematic check failed:\n {}", why.join("\n ")), &hints); } ok(&format!("schematic {canon}: {} symbols, ERC clean, every BOM reference placed from {}{}", on_sheet.len(), lib.unwrap_or_else(|| "the project library".into()), if extra.is_empty() { String::new() } else { format!(" ({} not in the BOM: {})", extra.len(), extra.join(", ")) }), &[schematic_symbols::warning_hint(&symbols), "Next: make the board from it (KiCad: Update PCB from Schematic), draw the outline, save, bind it (`bind-remote-board` / `start --board`), then `step components`.".into()]);@@ -3520,7 +3536,7 @@ fn main() { "measure": {"engines": ["binary"], "default": "binary", "why": "request refill once, then read two delayed matching native revision/copper snapshots; acknowledgement is not measurement and post-commit errors forbid blind replay"}, "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"}, "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},- "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},+ "finish": {"engines": ["binary"], "default": "binary", "why": "the report card: what passed, what is still open, and suggestions; it never refuses"}, }); r.data["plan"] = json!({"at": now(), "stages": stages, "board": {"footprints": b.footprints.len(), "nets": b.nets.len(), "parked": parked, "layers": b.copper_layers}}); r.save().unwrap_or_else(|e| err(&e, &[]));@@ -3582,7 +3598,7 @@ fn main() { let board_hash=placement_verify::digest(Path::new(&board_path)).unwrap_or_else(|e|err(&e,&[])); let spec_hash=placement_verify::digest(Path::new(&spec_path)).unwrap_or_else(|e|err(&e,&[])); if let Some(f)=molecule_findings(&spec_of(&r),&b,Some(&r)) {- if !f.errors.is_empty() { err(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); }+ if !f.errors.is_empty() { note(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); } } let spec_i=spec_of(&r); let intent_lines=placement_intent::check(&mut r,&dir,&spec_i,&b,"place verify").unwrap_or_else(|e|{r.save().ok();err(&e,&[placement_intent::HINT.into()])});@@ -3600,7 +3616,7 @@ fn main() { let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()||!v.approved_contacts.is_empty()).map(|v|v.to_json()); r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.as_ref().map(|v|v.accepted).unwrap_or(0)}else{0},"excused":excused,"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error})); r.save().unwrap_or_else(|e|err(&e,&[]));- if let Some(e)=error { err(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); }+ if let Some(e)=error { findings(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); } let excused_line=excused.as_ref().map(|e|format!("; reviewed assembly contacts: {}; fixedRefs box exceptions: {}; padless courtyard exceptions: {} (original native findings retained)",e["approvedCourtyardContacts"].as_array().map_or(0,|a|a.len()),e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()))).unwrap_or_default(); ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved{excused_line}",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]); }@@ -3660,14 +3676,14 @@ fn main() { // Native KiCad DRC decides whether the actual courtyards overlap. if !problems.is_empty() { eprintln!("Hint: conservative bounding-box advisory: {}. Native courtyard DRC is authoritative.", problems.join("; ")); } if s["newErrors"].as_u64() != Some(0) {- err(&format!("the moved board adds {} native DRC violations: {}", s["newErrors"], s["newErrorTypes"]), &[format!("Change the wishes and pack again; inspect {}. Findings are matched by identity, not an inherited-error count.", outp.display())]);+ note(&format!("the moved board adds {} native DRC violations: {}", s["newErrors"], s["newErrorTypes"]), &[format!("Change the wishes and pack again; inspect {}. Findings are matched by identity, not an inherited-error count.", outp.display())]); } // issue #39: courtyards present and clear, part bodies clear, whatever the project severities say let mech_line = if mechanical::applies(&outp.display().to_string()) { let mech = mechanical::check_board(&nb, &outp.display().to_string(), &project_dir_of(&r), &spec_soft(&r)); for n in &mech.severity_notes { eprintln!("Warning: {n}"); }- if !mech.errors().is_empty() { err(&format!("the moved board fails the courtyard and body checks:\n {}", mech.errors().join("\n ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); }- format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked)+ if !mech.errors().is_empty() { note(&format!("the moved board fails the courtyard and body checks:\n {}", mech.errors().join("\n ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); }+ if mech.errors().is_empty() { format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked) } else { format!("; {} courtyard/body finding(s) to look at (above)", mech.errors().len()) } } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() }; let old_board = r.current_board(); if let Some(n) = reviews::void_on_service_change(&mut r, Path::new(&old_board), &outp) { eprintln!("{n}"); }@@ -3785,7 +3801,7 @@ fn main() { } let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect(); let region_hint = if regions.is_empty() { String::new() } else { "A planeUnder region is a keepout for every other net on its layer: a pin inside it can only leave on the other layer. Shrink the margin, list fewer parts, or give the region a polygon that leaves that pin a way out.".to_string() };- err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);+ findings(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]); } decoupling_check::refuse_errors("route", &dec); if has_copper && plan.is_empty() {@@ -3875,10 +3891,10 @@ fn main() { let spec_g = spec_of(&r); if let Some((p, layers, warning)) = checks::rules_target(&spec_g, &r.data["fabRules"]) { if let Some(w) = warning { eprintln!("Warning: {w}"); }- if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { err(&format!("gate refused: {e}"), &[hint, "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }+ if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { findings(&format!("gate refused: {e}"), &[hint, "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); } } if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {- if !f.errors.is_empty() { err(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); }+ if !f.errors.is_empty() { findings(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); } } r.stage_start("gate", Some("binary"), None); // the checks themselves are gate_eval::evaluate, shared with `route --variants` (issue #55)@@ -3897,10 +3913,10 @@ fn main() { if !sw_issues.is_empty() { r.outcome("switch-node", json!({"issues": sw_issues})); r.save().unwrap();- err(&format!("gate failed: the switch node:\n {}", sw_issues.join("\n ")), &[switchnode::HINT.into()]);+ findings(&format!("gate failed: the switch node:\n {}", sw_issues.join("\n ")), &[switchnode::HINT.into()]); } if npu > 0 && newe == 0 && unc == 0 {- err(&format!("gate failed: {npu} planeUnder issue(s), other-net copper inside a plane region:\n{}", pu_issues.join("\n")), &["spec.planeUnder keeps these regions for one net's plane. Route again (the router keeps other nets out of them), or move the copper by hand and adopt the board; if the region is wrong, change spec.planeUnder.".into()]);+ findings(&format!("gate failed: {npu} planeUnder issue(s), other-net copper inside a plane region:\n{}", pu_issues.join("\n")), &["spec.planeUnder keeps these regions for one net's plane. Route again (the router keeps other nets out of them), or move the copper by hand and adopt the board; if the region is wrong, change spec.planeUnder.".into()]); } if newe > 0 || unc > 0 { // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever@@ -3924,12 +3940,12 @@ 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() };- err(&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, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]); } decoupling_check::refuse_errors("gate", &dec); if let Some(m) = &mech { for n in &m.severity_notes { eprintln!("Warning: {n}"); }- if !m.errors().is_empty() { err(&format!("gate failed: courtyards or part bodies:\n {}", m.errors().join("\n ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); }+ if !m.errors().is_empty() { findings(&format!("gate failed: courtyards or part bodies:\n {}", m.errors().join("\n ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); } } else { eprintln!("Warning: courtyards and part bodies not checked: a Fusion board carries no courtyard geometry aiflow reads; check them in Fusion's DRC"); } let silk_n = s["silkAdvisories"].as_u64().unwrap_or(0) as usize; if silk_n > 0 { eprintln!("Warning: {}", silk_policy::advisory_line(silk_n, &s["silkAdvisoryList"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect::<Vec<_>>())); }@@ -3996,7 +4012,7 @@ fn main() { let planp = if what == "route" { let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("planeUnderIssues")).and_then(|v| v.as_u64()).unwrap_or(0) == 0 && r.outcomes().get("gate").map(mechanical::gate_clear).unwrap_or(false); if !gate_ok && plan.is_none() {- err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);+ note("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]); } plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[])) } else {@@ -4283,7 +4299,7 @@ fn main() { if !passed { let why = if fields_file.is_file() { let bt = std::fs::read_to_string(live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default(); refine_hints(&aiflow_analyze::fields_report::load(&fields_file).unwrap_or_default(), &bt, &spec) } else { Vec::new() }; if !why.is_empty() { eprintln!("why:\n {}", why.join("\n ")); }- err(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);+ findings(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]); } let mut hints = vec![draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()]; hints.extend(converter_hints);@@ -4315,8 +4331,9 @@ fn main() { return; } let known: Vec<String> = r.data["lastRefusalItems"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();- let w = why.clone().unwrap_or_else(|| err("deviate needs --why: why this board is right even though the check refuses it", &[]));- let rec = deviations::record(&mut r.data, &item, &w, &t, &bs, &ss, &now(), &known).unwrap_or_else(|e| err(&e, &[]));+ let w = why.clone().unwrap_or_default();+ let (rec, dnotes) = deviations::record(&mut r.data, &item, &w, &t, &bs, &ss, &now(), &known);+ for n in &dnotes { note(n, &[]); } r.log("deviate", rec.clone()); r.save().unwrap(); ok(&format!("deviation recorded for {}: \"{}\"", rec["item"].as_str().unwrap_or(""), rec["why"].as_str().unwrap_or("")), &["It holds for this saved board and spec only; any change to either expires it. Run the refused command again (finish, or the step). Tell the human about every deviation: finish lists them.".into()]);@@ -4618,9 +4635,10 @@ fn main() { let (missing, deviated) = deviations::split(missing, &deviations::live(&r.data, &bs, &ss)); r.data["lastRefusalItems"] = json!(items); r.save().ok();- if !missing.is_empty() {- let open: Vec<String> = missing.iter().map(|m| deviations::key(m)).collect();- err(&format!("not finished: {}", missing.join("; ")), &["Keep going: the hints of the failing stage say what to change.".into(), deviations::hint(&open)]);+ // a report card, never a refusal (John, 2026-11-04): what is still open is listed with suggestions+ let open_items = missing.clone();+ if !open_items.is_empty() {+ eprintln!("Open items (suggestions, the board is not blocked):\n {}", open_items.join("\n ")); } if !deviated.is_empty() { // finish-guard honours the steps these deviations left unproven, on this board and spec only@@ -4650,10 +4668,11 @@ fn main() { } r.data["summary"]["notApplicable"] = json!(not_applicable); r.data["summary"]["deviations"] = json!(deviated);+ r.data["summary"]["open"] = json!(open_items); if nothing_to_source { r.data["summary"]["notApplicable"].as_array_mut().unwrap().push(json!("sourcing check: not applicable (no footprint in the BOM)")); } r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())})); r.save().unwrap();- ok(&(format!("finished: {} from the prompt to a board {}, {:.1} min\npours: {}\n{}{}", r.data["engine"], if deviated.is_empty() { "passing the configured screens".to_string() } else { format!("passing the configured screens except {} recorded deviation(s), listed below", deviated.len()) }, r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &na_text(&r.data["summary"]["notApplicable"]) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n {}", reviewed.join("\n ")) } + &if deviated.is_empty() { String::new() } else { format!("\nfinished with {} recorded deviation(s) (checks this board did not meet, accepted on purpose):\n {}", deviated.len(), deviated.iter().map(|d| format!("{}: {} (why: {})", d["item"].as_str().unwrap_or(""), d["line"].as_str().unwrap_or(""), d["why"].as_str().unwrap_or(""))).collect::<Vec<_>>().join("\n ")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);+ ok(&(format!("finished: {} from the prompt to a board {}, {:.1} min\npours: {}\n{}{}", r.data["engine"], match (open_items.len(), deviated.len()) { (0, 0) => "passing the configured screens".to_string(), (0, d) => format!("passing the configured screens except {d} recorded deviation(s), listed below"), (o, d) => format!("with {o} open item(s) still worth a look{} (listed above; finish again after any change)", if d > 0 { format!(" and {d} recorded deviation(s)") } else { String::new() }) }, r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &na_text(&r.data["summary"]["notApplicable"]) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n {}", reviewed.join("\n ")) } + &if deviated.is_empty() { String::new() } else { format!("\nfinished with {} recorded deviation(s) (checks this board did not meet, accepted on purpose):\n {}", deviated.len(), deviated.iter().map(|d| format!("{}: {} (why: {})", d["item"].as_str().unwrap_or(""), d["line"].as_str().unwrap_or(""), d["why"].as_str().unwrap_or(""))).collect::<Vec<_>>().join("\n ")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]); } Cmd::Capture { action, label, reason } => { let mut r = load_run(&cli);@@ -4897,7 +4916,7 @@ fn main() { let mut r = load_run(&cli); let verdict = continuation_report(&r, stop_evidence.as_deref()); if verdict["mayStop"] != true {- err("finish line not reached; session remains open", &[verdict["reason"].as_str().unwrap_or("").into(), verdict["nextAction"].as_str().unwrap_or("").into(), "A status update, a passing diagnostic, a fixable tool bug or a failed engineering gate is not a stopping point. Run finish-guard before any final answer.".into()]);+ note("closing this session before the finish line (the run stays open for later prompts)", &[verdict["reason"].as_str().unwrap_or("").into(), verdict["nextAction"].as_str().unwrap_or("").into(), "A status update, a passing diagnostic, a fixable tool bug or a failed engineering gate is not a stopping point. Run finish-guard before any final answer.".into()]); } let ss = r.sessions(); let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };@@ -4995,7 +5014,7 @@ fn main() { r.save().unwrap_or_else(|e| err(&e, &[])); } let text = format!("molecule check on {board_path}: {} error(s), {} warning(s)\n{}", f.errors.len(), f.warnings.len(), f.lines.join("\n"));- if !f.errors.is_empty() { err(&text, &f.hints); }+ if !f.errors.is_empty() { findings(&text, &f.hints); } let mut hints = f.hints.clone(); hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish, then adom-aiflow board-glb copper so pads and test points are real copper (skill aiflow-molecule).".into()); ok(&text, &hints);@@ -5049,7 +5068,7 @@ fn main() { let mut r = r2; r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked, "leftOutAcked": left_out, "countedOn": counted_on})); r.save().unwrap();- if !short.is_empty() { err(&format!("{n} heatsink vias landed, but parts are still short (counted on the {counted_on}): {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); }+ if !short.is_empty() { findings(&format!("{n} heatsink vias landed, but parts are still short (counted on the {counted_on}): {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); } ok(&format!("{n} heatsink vias landed; every hot part at its target"), &["Next: `step pours` (the pours connect the vias), then the analyses read the heat path through them.".into()]); } }@@ -5077,7 +5096,7 @@ fn main() { let after = outline::sharp_corners(&read(&r)); r.outcome("outline", json!({"radius": rad, "rounded": done, "sharp": after.len()})); r.save().unwrap();- if !after.is_empty() { err(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); }+ if !after.is_empty() { findings(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); } ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]); } Cmd::Timeline { action, port, json: presentation, reuse_tab, new_tab, why, name, finished, stale } => {@@ -5158,7 +5177,8 @@ fn main() { let mut r = load_run(&cli); let res = reviews::run(&mut r, kind, input.as_deref(), glb.as_deref()); r.save().unwrap();- match res { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ // a review that ran and found problems is findings; a review that could not run (no tool, bad input) is an error+ match res { Ok((m, h)) => ok(&m, &h), Err((m, h)) if m.contains("did not pass") => findings(&m, &h), Err((m, h)) => err(&m, &h) } } Cmd::Testplan { action: _, tests, netlist, add } => { thread(&cli);@@ -5194,7 +5214,7 @@ fn main() { let body = format!("{head}\n{}", table.join("\n")); if !gaps.is_empty() { let tether_hint = "A ground test needs a free MachineContactMedium on that net for the tether's medium pin: add one to the design (a test point or a large contact cannot take it).".to_string();- if testplan::ground_gap(&rows) && testplan::addable(&rows).is_empty() { err(&body, &[tether_hint]); }+ if testplan::ground_gap(&rows) && testplan::addable(&rows).is_empty() { findings(&body, &[tether_hint]); } err(&body, &[if mode == "board" { "Each gap needs a spot: move a part off the pad, add a test point on the net (adom/testpoint-pad-0-5mm, schematic first: `step schematic --back --why \"test point for <net>\"`), or widen the copper so a 0.5 mm landing fits. Then `testplan check` again.".into() } else { "Add a test point per gap: `testplan check --netlist <netlist.json> --add` writes TP<n> (0.5 mm round, adom/testpoint-pad-0-5mm) into the netlist.".into() }]); } let mut hints = vec!["Read the table to the human: what you will measure on the workcell and where the probe lands. Every added test point goes into the library and the schematic like any part.".to_string()];@@ -5257,7 +5277,7 @@ fn main() { let same_pack = cur_pack.as_ref().is_some_and(|(s, l)| *s == slug && l.is_none_or(|l| l == layers)); if cur.is_some() && !*force && !same_pack { let what = cur_pack.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "other rules".into());- err(&format!("{} already carries {what}", dru.display()), &[format!("Pass --force to replace them with {slug}-{layers}L (the old file is kept as .kicad_dru.bak).")]);+ note(&format!("{} carried {what}: replaced with {slug}-{layers}L, the old file kept as .kicad_dru.bak", dru.display()), &[]); } if cur.is_some() { let _ = std::fs::copy(&dru, dru.with_extension("kicad_dru.bak"));@@ -5343,7 +5363,7 @@ fn main() { if let Some(c) = &cur { if cur_pack == Some(("jlcpcb".into(), Some(n))) { let _ = c; ok(&format!("{} already carries the jlcpcb-{n}L profile", dru.display()), &[]); return; } if !*force {- err(&format!("{} already exists and carries {}", dru.display(), describe(&cur_pack)), &[format!("Keep it (it may be the 3rd party fab's rules or a pack `rules use` loaded) or pass --force to replace it with the jlcpcb-{n}L profile (the old file is kept as .kicad_dru.bak).")]);+ note(&format!("{} already carried {}: replaced, the old file kept as .kicad_dru.bak beside it", dru.display(), describe(&cur_pack)), &[format!("If the old rules were the ones to keep (the 3rd party fab's, or a pack `rules use` loaded), copy the .kicad_dru.bak back over the jlcpcb-{n}L file.")]); } let bak = dru.with_extension("kicad_dru.bak"); std::fs::copy(&dru, &bak).unwrap_or_else(|e| err(&format!("{}: {e}", bak.display()), &[]));@@ -5389,7 +5409,7 @@ fn main() { } match result { Ok(m) => ok(&m, &[]),- Err(e) => err(&e, &[checks::rules_hint_layers(&profile, checks::effective_layers(&profile, &board_path, fallback_layers))]),+ Err(e) => findings(&e, &[checks::rules_hint_layers(&profile, checks::effective_layers(&profile, &board_path, fallback_layers))]), } } }@@ -5411,7 +5431,7 @@ fn main() { if *offline { r.save().unwrap(); let text = format!("3D orientation (offline): {} warning(s), {} not checked here{}{}", orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }, if orient_notes.is_empty() { String::new() } else { format!("\n {}", orient_notes.join("\n ")) });- if !unacked.is_empty() { err(&text, &[orient_hint]); }+ if !unacked.is_empty() { findings(&text, &[orient_hint]); } ok(&text, &["The live resolution check is `models` without --offline (kicad_model_check on the test box).".into()]); return; }@@ -5624,7 +5644,7 @@ fn main() { if let Some((msg, hints)) = converter::check(&spec, calcs.as_ref(), Some(&served), Board::load(&r.current_board()).ok().as_ref()) { r.log("fields-refused", json!({"why": msg})); r.save().unwrap();- err(&format!("the fields step cannot pass: {msg}"), &hints);+ note(&format!("the fields step cannot pass: {msg}"), &hints); } } let proxy = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| err("no VSCODE_PROXY_URI in this container: the test box cannot reach the app", &["Run inside an Adom container (the proxy URL is how a browser outside reaches a port inside), or set VSCODE_PROXY_URI to https://<slug>.adom.cloud/proxy/{{port}}/.".into()]));@@ -5783,6 +5803,8 @@ fn main() { println!("{}", step_table(&r)); } Cmd::Step { name, back, why } => {+ // never refused: an out-of-order step is noted and recorded (a return when it goes back)+ let mut back_eff = *back; thread(&cli); let mut r = load_run(&cli); let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());@@ -5820,7 +5842,7 @@ fn main() { let last = |s: &str| r.data["steps"][s]["visits"].as_array().and_then(|a| a.iter().filter_map(|v| v["at"].as_str()).last()).map(str::to_string); if let Some(sch) = last("schematic") { if last("simulation").map(|sim| sim < sch).unwrap_or(true) {- err("simulate the schematic before the board: no simulation since the schematic was drawn", &[+ note("no simulation since the schematic was drawn: simulating before the board catches value mistakes early", &[ "`step simulation`, then run the circuit in the Adom SPICE dashboard and show it to the human: `adom-spice start`, `adom-spice study <study.json>`, `adom-spice run ngspice`, `adom-spice show` (a Hydrogen webview in the right pane, beside the chat).".into(), "Check start-up, ripple and the load step against the requirements; change values in the schematic when a measurement misses, and say what changed. Record it: `evidence add --kind simulation --file <result.json>`. Then `step placement`.".into()]); }@@ -5836,25 +5858,26 @@ fn main() { if sequence::may_resequence(&r.data, &want) && r.data["sequence"] != json!(want) { r.data["sequence"] = json!(want); } for s in reviews::sync_sequence(&mut r.data) { eprintln!("{s}: declared in the spec or requirements, required before finish (`review {}`)", s.trim_end_matches("-review")); } }- // the pattern lane is for boards with nothing to connect (issue #84): refuse it for one that needs routing+ // the pattern lane is for boards with nothing to connect (issue #84): say so when this one needs routing if boardkind::is_pattern(&r.data) && !r.board_pending() && sequence::sequence_key(&dir, &r.data) == boardkind::PATTERN { let calcs = converter::calcs_of_run(&r.data); if let Some(why) = boardkind::pattern_refusal(&board_of(&r)).or_else(|| boardkind::pattern_spec_refusal(&spec_soft(&r), calcs.as_ref())) {- err(&format!("this run is a pattern board (`intake --kind pattern` or requirements boardKind), but {why}"), &["The pattern lane leaves out placement, routing, pours, current, thermal and fields. A board with nets to connect, loads or hot parts takes the electrical lane: start a new run without --kind pattern (and without requirements boardKind).".into()]);+ note(&format!("this run is a pattern board (`intake --kind pattern` or requirements boardKind), but {why}"), &["The pattern lane leaves out placement, routing, pours, current, thermal and fields. A board with nets to connect, loads or hot parts takes the electrical lane: start a new run without --kind pattern (and without requirements boardKind).".into()]); } } let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); // --back is a return: it may only go to an earlier step of the flow, never jump ahead past the checks if !seq.is_empty() && *back { if let (Some(pos), Some(cp)) = (sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))) {- if pos > cp { err(&format!("--back goes to an earlier step, but {name} comes after {} in the flow", seq[cp]), &[format!("`step {name}` without --back: every step before it must be done first.")]); }+ if pos > cp { note(&format!("--back goes to an earlier step, but {name} comes after {} in the flow: recorded as a step forward", seq[cp]), &[]); back_eff = false; } } } if !seq.is_empty() && !*back { if let Some(pos) = sequence::position(&seq, name) { let cur = r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c)); if let Some(cp) = cur { if pos < cp {- err(&format!("{name} comes before {} in the flow: going back is a return", seq[cp]), &[format!("`step {name} --back --why \"<what has to change>\"`: the return is filmed and shown on the timeline as a loop, and every later step is redone after it.")]);+ back_eff = true;+ note(&format!("{name} comes before {} in the flow: recorded as a return (pass --why to say what sent you back; it shows on the timeline)", seq[cp]), &[format!("`step {name} --back --why \"<what has to change>\"`: the return is filmed and shown on the timeline as a loop, and every later step is redone after it.")]); } } let st = sequence::states(&dir, &r.data, &flow_full, &seq); let (bs, ss) = run_hashes(&r);@@ -5864,14 +5887,15 @@ fn main() { r.data["lastRefusalItems"] = json!(b.iter().map(|x| format!("step {}", deviations::key(x))).collect::<Vec<_>>()); r.save().ok(); let first = b[0].split(' ').next().unwrap_or("").to_string();- err(&format!("not yet: {name} needs every required step before it done, and these are not:\n {}", b.join("\n ")), &[- format!("`step {first}` and do it: a step is done when its own command has run during its latest visit (flows/board.json proofs). No step of the flow can be skipped."),- format!("If a step truly does not apply to this board, skip it on purpose: `adom-aiflow deviate \"step {first}\" --why \"<why it does not apply>\"`, then `step {name}` again. finish lists it, and you tell the human.")]);+ r.log("steps-skipped", json!({"to": name, "skipped": b}));+ note(&format!("moving to {name} with earlier steps not done yet (finish lists them as open):\n {}", b.join("\n ")), &[+ format!("Suggested: come back to `step {first}` when it helps this board (a step counts as done when its own command ran during its latest visit, flows/board.json proofs)."),+ format!("If a step does not apply to this board, say why for the record: `adom-aiflow deviate \"step {first}\" --why \"...\"`, and tell the human.")]); } } } record_usage(&mut r, &format!("step:{name}"));- r.step_set(name, *back, why.as_deref());+ r.step_set(name, back_eff, why.as_deref()); if !seq.is_empty() { sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE); } // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor) let mut clip_note = String::new();@@ -6026,10 +6050,10 @@ fn main() { } let suspects: Vec<String> = r.data["captures"].as_array().cloned().unwrap_or_default().iter().filter(|c| c.get("suspect").is_some() && c.get("discarded").is_none()).map(|c| format!("{}-{}: {}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1), c["suspect"].as_str().unwrap_or(""))).collect(); if !suspects.is_empty() && !*accept_suspect {- err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!(" {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);+ note(&format!("{} clip(s) filmed nothing usable (delivering anyway; the video may have gaps):\n{}", suspects.len(), suspects.iter().map(|x| format!(" {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]); } if r.data["clock"]["finishTime"].is_null() {- err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the checked board design first, then the video and your message. Physical qualification is a separate claim.".into()]);+ note("delivering before `finish`: the board's checks have not been gathered into a finish report yet", &["Deliver comes after finish: the checked board design first, then the video and your message. Physical qualification is a separate claim.".into()]); } let vp = Path::new(video); if !vp.is_file() {
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.47",+ "version": "0.2.48", "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.47",+ "version": "0.2.48", "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.",
skills/adom-aiflow/SKILL.md+9−7@@ -8,6 +8,8 @@ description: >- This tool is the place-route-pour-analyze skill made executable: the step order is a file, the checks report what is wrong, the hints arrive as the answer to the command you just ran, and the ledger records what you did. Follow the commands and the skill follows itself. +**AI Flow is a suggestion engine. It never refuses.** Every check reports what it found (DRC errors stay prominent) and suggests a next action; every command completes; you and the human decide. `step` suggests the order and notes a step you skip; `finish` is a report card (what passed, what is still open, what it suggests), not a gate.+ **AI Flow is a helper, not a wall.** It gives you ready-made tools (the router, pours, DRC and analysis gates, linters) so you don't write them from scratch, hints for the next step, and the Adom habits: public wiki pages for new parts that every Adom user then shares, and a project page with the 3D, schematic and board viewers and the datasheets. You are still the engineer. When a check refuses a board that is right, because AI Flow has not seen a design like it, don't stop and don't fake a pass: improve AI Flow (see "Contribute to AI Flow" at the end) and finish the board with your build. Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kicad` for the offline DRC gate, and the KiCad Bridge on a desktop for the live stages). `adom-aiflow --version`.@@ -22,7 +24,7 @@ Run `adom-aiflow --run <run> finish-guard --json` before ending a turn. Exit 2 / A failed engineering gate, an inaccurate heuristic, missing automation, a source bug, a slow solve, or a fix that needs implementation is work to do. Inspect evidence, repair the design/tool, test it, synchronize the dashboard and continue. A pending owner review is not a global blocker while isolated experiments, a safe alternative, or other requested work remains. Do not classify your own unfinished implementation as an external dependency. Never lower acceptance thresholds, fabricate receipts or mark work passed to satisfy this guard. -`done` now refuses ordinary closure until the sequence, finish and delivery are complete. It is no longer a way to close an intermediate progress update. The guard checks recorded gates and delivery artifacts; the AI must also verify the user's full scope, including firmware, molecule, wiki, dashboard and edited video when requested. Track additional deliverables in the project's completion contract; optional `run.json` `continuation.requiredDeliverables` rows use `name`, `complete` and an existing `evidence` file. Evidence existence is not independent validation of its contents.+`done` closes a session at any time and notes when the finish line has not been reached yet. The guard checks recorded gates and delivery artifacts; the AI must also verify the user's full scope, including firmware, molecule, wiki, dashboard and edited video when requested. Track additional deliverables in the project's completion contract; optional `run.json` `continuation.requiredDeliverables` rows use `name`, `complete` and an existing `evidence` file. Evidence existence is not independent validation of its contents. Only the user's explicit pause/cancellation, or an unavoidable external dependency after exhausting useful authorized work, permits an incomplete handoff. Use `finish-guard --stop-evidence <json>` and `done --stop-evidence <json> --message ...`. See `docs/continuation.md` for the evidence schemas. Preserve the exact user instruction when pausing. Stop evidence never completes a gate and must not be reused after a resume or an external-state change. @@ -46,7 +48,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.-8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`.+8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` reports whether the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`. ### High-speed boards: your own router, inside the flow @@ -72,17 +74,17 @@ If the binary should do this step itself, file it (see Give it back). A run carries its lane's required steps in order (`flows/board.json` `sequence`; Fusion: intake, rules, design, test-plan, sourcing, library, schematic, simulation, placement, routing, thermal-vias, pours,-outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` refuses while any+outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` notes (and logs) any required step before x is not done, and a step is done only when its own command ran during its latest visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` for the test plan, `land moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `silkscreen-audit --record` (Fusion: `fusion silkscreen`; Altium: `altium silkscreen`), `export` (Fusion: `fusion export`; Altium: `altium export`), `fusion|altium library` and `fusion|altium schematic` ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it draws an arc on the timeline, and every later step becomes a redo that must be done again in order.-`finish` refuses until the whole chain is in order, or until you accept what it lists on purpose.+`finish` lists what is still open in the chain as a report card, with suggestions; it never refuses. **When a check is wrong for this board, deviate on purpose instead of stopping.** AI Flow has not seen-every kind of board. When a step does not apply, or `finish` refuses something that is right for this+every kind of board. When a step does not apply, or `finish` lists something as open that is right for this design, record it: `adom-aiflow deviate "<item>" --why "<why this board is right anyway>"`. The item is the one the refusal names (`gate`, `heatsink vias`, `analyze-thermal`, `step test-plan`, ...); the refusal's hint lists them. A deviation is bound to the saved board and spec (any change to either@@ -332,7 +334,7 @@ The first `step` (or `capture start`) asks the test box once which recorder its ## Design gates (schematic, molecule, fab rules, 3D models, copper pads) -Deterministic checks that refuse, each an OK/ERROR with hints:+Deterministic checks that report, each an OK or FINDINGS with suggestions (never a refusal): - **`netlist check [--sch design.kicad_sch] [--board B] [--net exported.net]`**: exports the schematic's netlist with kicad-cli (`sch export netlist --format kicadsexpr`, through `ADOM_AIFLOW_KICAD_CLI`; `adom-aiflow-kicad-cli-remote` does it on a desktop's KiCad 10 and carries the sub-sheets, lib tables and `${KIPRJMOD}` symbol libraries) and compares it pin by pin with the board's pads: missing and extra references, and every pin whose net differs (`/VIN` equals `VIN`; unlabelled `Net-(...)` nets compare by their pins). In a run it is recorded; `finish` re-checks the stored netlist against the board it finishes on and asks again when the schematic changed. - **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Corner-pin class (0.2.17, issue #87): an error when the pins are medium but the board needs large ones (seats in the base scaffold, over 100 g with its parts, a motor, actuator, heatsink over 20 g, cabled connector or hard-pressed button, longest side over 100 mm, or a pin net over 3.5 A in `spec.loads`); a warning when the mass is unknown or a part looks like an undeclared mechanical load; large pins with no reason are fine. The rule, its data and the spec fields are in skill aiflow-molecule. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}, "seat": "base|lrgmed|board", "massG": 42, "mechanicalLoads": [{"ref": "M1", "kind": "motor"}], "pinCurrentA": 1.2}` (mm from MP1, y up; all optional); `--bom <csv>` (default: the run's BOM) gives a `mass_g` column.@@ -348,7 +350,7 @@ The judgement is in these skills, which this tool executes: kicad-place-route-lo - **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board. - **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate.-- **Courtyards and bodies are errors.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.+- **Courtyards and bodies are reported prominently.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout. - **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule. - **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then. - **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`.
tests/board-kind-workflow.py+3−3@@ -24,7 +24,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-board-kind-') as td: r = call(run, 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test', '--kind', 'pattern'); assert r.returncode == 0, r.stderr routed = p / 'routed.kicad_pcb'; routed.write_text(HEAD + pin('MP1', 5, 'GND') + pin('MP2', 3, 'GND') + pin('J1', 15, 'SIG') + pin('J2', 17, 'SIG') + ')\n') attach(run, routed, spec)- r = call(run, 'step', 'rules'); assert r.returncode != 0 and '2 nets' in r.stdout + r.stderr, (r.stdout, r.stderr)+ r = call(run, 'step', 'rules'); assert r.returncode == 0 and '2 nets' in r.stdout + r.stderr, (r.stdout, r.stderr) # #86: a carrier plate, 4 machine pins on a GND pour and 8 one-pad unconnected-(SDn-Pad1) seats nets = ''.join(f' (net {i + 1} "unconnected-(SD{i}-Pad1)")' for i in range(1, 9)) seat = lambda ref, x, n, name: f' (footprint "MC" (layer "F.Cu") (at {x} 12) (property "Reference" "{ref}") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net {n} "{name}")))'@@ -36,12 +36,12 @@ with tempfile.TemporaryDirectory(prefix='aiflow-board-kind-') as td: r = call(run, 'step', 'rules'); assert r.returncode == 0, (r.stdout, r.stderr) carrier.write_text(HEAD.replace('(net 2 "SIG")', nets) + pins + seats + seat('SD9', 18, 4, 'unconnected-(SD3-Pad1)') + zone + ')\n') r = call(run, 'step', 'rules'); out = r.stdout + r.stderr- assert r.returncode != 0 and '2 nets' in out and 'unconnected-(SD3-Pad1)' in out, out+ assert r.returncode == 0 and '2 nets' in out and 'unconnected-(SD3-Pad1)' in out, out disc = p / 'disc.kicad_pcb'; disc.write_text(HEAD + ' (gr_circle (center 10 10) (end 14 10) (layer "F.Cu") (width 0.5)))\n') attach(run, disc, spec) r = call(run, 'step', 'rules'); assert r.returncode == 0, (r.stdout, r.stderr) assert data(run)['sequence'] == ['intake', 'rules', 'models', 'outline', 'silkscreen', '3d', 'finish', 'export'], data(run)['sequence']- r = call(run, 'finish'); out = r.stdout + r.stderr; assert r.returncode != 0+ r = call(run, 'finish'); out = r.stdout + r.stderr; assert r.returncode == 0 and 'open item' in out, out assert 'native DRC' in out and 'land route' not in out and 'analyze-current' not in out and 'measure (' not in out, out # a run stuck at `rules` on the electrical sequence switches once requirements say boardKind pattern stuck = p / 'stuck'; assert call(stuck, 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test').returncode == 0
tests/continuation-workflow.py+4−5@@ -13,15 +13,14 @@ with tempfile.TemporaryDirectory(prefix='aiflow-continuation-') as td: assert before=={f.name:f.read_bytes() for f in run.iterdir() if f.is_file()},'guard mutated run files' data=json.loads((run/'run.json').read_text());data['sequence']=['current'];data['currentStep']='current';data['steps']={'current':{'visits':[{'at':'2026-10-10T00:00:00Z'}]}};data['outcomeAt']={'analyze-current':'2026-10-10T00:01:00Z'};data['outcomes']={'analyze-current':{'pass':False}};(run/'run.json').write_text(json.dumps(data)) r=call('finish-guard','--json');assert 'current' in json.loads(r.stdout)['remaining'],'failed analysis was counted as proof'- r=call('done','--message','Made good progress');assert r.returncode==2,(r.stdout,r.stderr)- events=[json.loads(l) for l in (run/'run.jsonl').read_text().splitlines()]- assert not any(e.get('event')=='done' or e.get('kind')=='done' for e in events)+ # never refused (John, 2026-11-04): done closes the session and notes that the finish line is not reached+ r=call('done','--message','Made good progress');assert r.returncode==0 and 'before the finish line' in r.stderr,(r.stdout,r.stderr) assert call('finish-guard','--json','--stop-evidence',str(p/'missing.json')).returncode==2 pause=p/'pause.json';pause.write_text(json.dumps(dict(kind='user-pause',reason='User requested pause',userInstruction='pause until I resume',instructionAt='2026-10-10T00:01:00Z'))) r=call('finish-guard','--json','--stop-evidence',str(pause));assert r.returncode==0 assert not json.loads(r.stdout)['engineeringComplete'] assert call('done','--message','Paused by user','--stop-evidence',str(pause)).returncode==0 assert call('prompt','--text','Resume').returncode==0- assert call('done','--message','Stop again').returncode==2,'old pause reused after resume'+ r=call('done','--message','Stop again');assert r.returncode==0 and 'before the finish line' in r.stderr,'an old pause is not reused after resume: done still notes the open finish line' assert call('finish-guard','--json').returncode==2- print('PASS: incomplete closure refused, guard read-only, missing evidence fails closed, explicit pause recorded without passing gates, resume requires fresh decision')+ print('PASS: done closes with a note (never refused), guard read-only, missing evidence fails closed, explicit pause recorded without passing gates, resume requires fresh decision')
tests/deviation-workflow.py+22−22@@ -32,41 +32,40 @@ with tempfile.TemporaryDirectory(prefix="aiflow-deviation-") as td: r = call("start", "--board", "board.kicad_pcb", "--spec", "spec.json", "--engine", "test") assert r.returncode == 0, r.stdout + r.stderr - # deviate before any refusal: nothing to name- assert "finish" in call("deviate", "gate", "--why", WHY).stdout+ # never refused (John, 2026-11-04): deviate records even before anything was reported, with a note+ r = call("deviate", "gate", "--why", WHY)+ assert r.returncode == 0 and "finish" in r.stderr, r.stdout + r.stderr+ assert "removed" in call("deviate", "gate", "--clear").stdout - # a step past undone steps refuses, names them, and offers the deliberate skip+ # a step past undone steps proceeds, names them, and logs the skip r = call("step", "routing")- assert r.returncode == 2 and "deviate \"step " in r.stdout, r.stdout+ assert r.returncode == 0 and "earlier steps not done yet" in r.stderr, r.stdout + r.stderr skipped = items() assert skipped and all(i.startswith("step ") for i in skipped), skipped- for i in skipped:- assert call("deviate", i, "--why", WHY).returncode == 0- assert call("step", "routing").returncode == 0, "a deliberately skipped step still blocked"+ log = (p / "run" / "run.jsonl").read_text()+ assert '"steps-skipped"' in log - # finish refuses with its items and the deviate hint+ # finish completes as a report card: the open items, never a refusal r = call("finish")- assert r.returncode == 2 and "adom-aiflow deviate" in r.stdout, r.stdout+ assert r.returncode == 0 and "open item(s)" in r.stdout and "Open items" in r.stderr, r.stdout + r.stderr open_items = items() assert open_items, "finish recorded no items"- # a reason is required, and only named items can be deviated- assert call("deviate", open_items[0], "--why", "skip").returncode == 2- r = call("deviate", "not-an-item", "--why", WHY)- assert r.returncode == 2 and open_items[0] in r.stdout, r.stdout- # one item accepted: finish still refuses the others, and no longer names that one- assert call("deviate", open_items[0], "--why", WHY).returncode == 0- r = call("finish")- refused = r.stdout.split("\n")[0]- assert r.returncode == 2 and not any(seg.strip().lower().startswith(open_items[0]) for seg in refused.removeprefix("ERROR: not finished:").split(";")), refused+ summary = json.loads((p / "run" / "run.json").read_text())["summary"]+ assert summary["open"], summary+ # a short reason and an unknown item are recorded too, with notes+ r = call("deviate", "not-an-item", "--why", "skip")+ assert r.returncode == 0 and "not an item" in r.stderr and "reason" in r.stderr, r.stderr+ assert "removed" in call("deviate", "not-an-item", "--clear").stdout+ # deviations move items from open to accepted-on-purpose for i in items(): assert call("deviate", i, "--why", WHY).returncode == 0, i r = call("finish") assert r.returncode == 0 and "recorded deviation(s)" in r.stdout and "except" in r.stdout, r.stdout + r.stderr summary = json.loads((p / "run" / "run.json").read_text())["summary"]- assert summary["deviations"] and all(d["why"] == WHY for d in summary["deviations"])+ assert summary["deviations"] and all(d["why"] == WHY for d in summary["deviations"]) and not summary["open"], summary assert "deviation(s) on this board and spec" in call("deviate", "--list").stdout - # the board changes: every deviation expires and finish refuses again+ # the board changes: every deviation expires and the items are open again data = json.loads((p / "run" / "run.json").read_text()) current = data["boards"][-1] if data.get("boards") else data["board"] cp = pathlib.Path(current if isinstance(current, str) else current["path"])@@ -74,8 +73,9 @@ with tempfile.TemporaryDirectory(prefix="aiflow-deviation-") as td: cp.write_text(cp.read_text().replace("(at 20 10)", "(at 21 10)")) r = call("deviate", "--list") assert "0 deviation(s)" in r.stdout and "expired" in r.stdout, r.stdout- assert call("finish").returncode == 2, "a deviation outlived the board it was recorded on"+ r = call("finish")+ assert r.returncode == 0 and json.loads((p / "run" / "run.json").read_text())["summary"]["open"], "an expired deviation still hid an item" # clear removes one assert "removed" in call("deviate", open_items[0], "--clear").stdout- print("PASS: step and finish name their items, deviate needs a named item and a reason, finish completes listing every deviation, a changed board expires them, clear removes one")+ print("PASS: never refused: step proceeds and logs skips, finish is a report card with open items, deviate records with notes and moves items to accepted, a changed board expires them, clear removes one")
tests/review-workflow.py+5−5@@ -37,7 +37,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-review-') as td: r = call(run, 'step', 'rules'); assert r.returncode == 0, (r.stdout, r.stderr) seq = data(run)['sequence']; assert seq.index('mechanical-review') == seq.index('finish') - 1 and 'wiring-review' not in seq, seq r = call(run, 'finish'); out = r.stdout + r.stderr- assert r.returncode != 0 and 'mechanical review not recorded' in out, out+ assert r.returncode == 0 and 'mechanical review not recorded' in out, out # tool not installed: a clear refusal that says it is unreleased and names the repo mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1', 'tool': '2.5 mm hex'}]}))@@ -52,7 +52,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-review-') as td: # a failing review records nothing mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1'}]})) r = call(run, 'review', 'mechanical', env=with_tool); out = r.stdout + r.stderr- assert r.returncode != 0 and 'did not pass' in out and 'Specify the actual tool' in out, out+ assert r.returncode == 0 and 'FINDINGS' in out and 'did not pass' in out and 'Specify the actual tool' in out, out assert 'mechanical-review' not in data(run)['outcomes'] # a passing review, during its step, bound to the review file, spec and board mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1', 'tool': '2.5 mm hex'}]}))@@ -62,7 +62,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-review-') as td: assert o['pass'] and o['schemaVersion'] == 1 and o['boardSha256'] and o['inputSha256'] and o['specSha256'] and o['refs'] == ['H1'], o assert [f['ref'] for f in o['serviceFeatures']] == ['H1'] and o['tool']['mode'] == 'path', o r = call(run, 'finish'); out = r.stdout + r.stderr- assert r.returncode != 0 and 'mechanical review' not in out and 'native DRC' in out, out+ assert r.returncode == 0 and 'mechanical review' not in out and 'native DRC' in out, out # the review file edited afterwards: stale mech.write_text(json.dumps({'schemaVersion': 1, 'fasteners': [{'id': 'H1-screw', 'ref': 'H1', 'tool': '3 mm hex'}]})) r = call(run, 'finish'); assert 'review file changed' in r.stdout + r.stderr, r.stdout + r.stderr@@ -72,7 +72,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-review-') as td: moved = p / 'moved.kicad_pcb'; moved.write_text(HEAD + hole(16) + ')\n') d = data(run); d['boards'].append(str(moved)); (run / 'run.json').write_text(json.dumps(d)) r = call(run, 'finish'); out = r.stdout + r.stderr- assert r.returncode != 0 and 'mechanical review is of another board' in out, out+ assert r.returncode == 0 and 'mechanical review is of another board' in out, out # not declared: no review step, finish says nothing about reviews plain = p / 'plain'; empty = p / 'empty.json'; empty.write_text('{}')@@ -80,7 +80,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-review-') as td: attach(plain, board, empty) assert call(plain, 'step', 'rules').returncode == 0 assert data(plain)['sequence'] == ['intake', 'rules', 'models', 'outline', 'silkscreen', '3d', 'finish', 'export'], data(plain)['sequence']- r = call(plain, 'finish'); assert 'review' not in (r.stdout + r.stderr).lower(), r.stdout + r.stderr+ r = call(plain, 'finish'); open_items = data(plain)['summary']['open']; assert not any('review' in x.lower() for x in open_items), open_items # an undeclared review can still be run (advisory), it says finish does not need it r = call(plain, 'review', 'mechanical', '--input', str(mech), env=with_tool); assert r.returncode == 0 and 'Not declared' in r.stdout + r.stderr, r.stdout + r.stderr assert data(plain)['sequence'] == ['intake', 'rules', 'models', 'outline', 'silkscreen', '3d', 'finish', 'export']
tests/rules-layers-workflow.py+3−3@@ -24,9 +24,9 @@ with tempfile.TemporaryDirectory(prefix='aiflow-rules-layers-') as td: r=call('rules','check','--profile',prof,'--board',str(board),inrun=False);assert r.returncode==0,(prof,r.stdout,r.stderr) # 3. install on a 2-layer board over a 6L pack: --force replaces it and says so, keeping a .bak two=p/'b2.kicad_pcb';two.write_text('(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (31 "B.Cu" signal)))\n');two.with_suffix('.kicad_dru').write_text(PACK6)- r=call('rules','install','--profile','jlcpcb','--board',str(two),inrun=False);assert r.returncode!=0 and 'jlcpcb-6L' in (r.stdout+r.stderr)+ r=call('rules','install','--profile','jlcpcb','--board',str(two),inrun=False);assert r.returncode==0 and 'jlcpcb-6L' in (r.stdout+r.stderr) and 'replaced' in (r.stdout+r.stderr),(r.stdout,r.stderr) r=call('rules','install','--profile','jlcpcb','--board',str(two),'--force',inrun=False);out=r.stdout+r.stderr- assert r.returncode==0 and 'jlcpcb-2L' in out and 'replaced the jlcpcb-6L pack' in out,out+ assert r.returncode==0 and 'jlcpcb-2L' in out,out assert two.with_suffix('.kicad_dru.bak').read_text()==PACK6 r=call('rules','check','--profile','jlcpcb','--board',str(two),inrun=False);assert r.returncode==0,(r.stdout,r.stderr) # 4. a run whose spec names no fab.rules falls back to run.json fabRules@@ -37,7 +37,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-rules-layers-') as td: r=call('rules','check');assert r.returncode==0,(r.stdout,r.stderr) o=json.loads(state.read_text())['outcomes']['rules'];assert o['pass'] and o['profile']=='jlcpcb',o dru.write_text('(version 1)\n# adom-aiflow rules profile: jlcpcb-2L\n(rule "x")\n')- r=call('rules','check');assert r.returncode!=0 and '--layers 6' in (r.stdout+r.stderr),(r.stdout,r.stderr)+ r=call('rules','check');assert r.returncode==0 and 'FINDINGS' in r.stdout and '--layers 6' in (r.stdout+r.stderr),(r.stdout,r.stderr) # the spec disagreeing with fabRules warns spec.write_text(json.dumps({'fab':{'rules':'jlcpcb-2L'}})) r=call('rules','check');assert 'fabRules' in r.stderr,(r.stdout,r.stderr)
tests/rules-workflow.py+2−2@@ -20,7 +20,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-rules-cli-') as td: other=p/'other.kicad_pcb';other.write_text(board.read_text());other.with_suffix('.kicad_dru').write_text(body) assert call('rules','check','--board',str(other)).returncode==0 assert outcome()==o,'unrelated board changed this run proof'- r=call('rules','check','--profile','jlcpcb');assert r.returncode!=0+ r=call('rules','check','--profile','jlcpcb');assert r.returncode==0 and 'FINDINGS' in r.stdout,(r.stdout,r.stderr) assert outcome()==o,'profile override changed selected-profile proof' alias=p/'same-board.kicad_pcb';alias.symlink_to(board);alias.with_suffix('.kicad_dru').write_text(body) assert call('rules','check','--board',str(alias)).returncode==0@@ -28,7 +28,7 @@ with tempfile.TemporaryDirectory(prefix='aiflow-rules-cli-') as td: alias.with_suffix('.kicad_dru').unlink();alias.with_suffix('.kicad_dru').symlink_to(rules) assert call('rules','check','--board',str(alias)).returncode==0 assert outcome()['pass']- rules.unlink();r=call('rules','check');assert r.returncode!=0+ rules.unlink();r=call('rules','check');assert r.returncode==0 and 'FINDINGS' in r.stdout,(r.stdout,r.stderr) assert outcome()['pass'] is False,'failed recheck left a stale passing outcome' g=json.loads(call('finish-guard','--json').stdout);assert any('rules' in x for x in g['remaining']) and not g['mayStop'] assert not rules.exists(),'check rewrote rules'
tests/sourcing-suggestions.py+1−1@@ -125,5 +125,5 @@ with tempfile.TemporaryDirectory(prefix='aiflow-sourcing-') as td: # a real problem still fails: no MPN bad = p / 'bad.csv'; bad.write_text('ref,mpn,source,stock_checked_2026-10-27\nU1,,DigiKey,10\n') r = call(run, 'sourcing', 'check', '--bom', str(bad), '--qty', '1')- assert r.returncode != 0 and 'U1: no MPN' in r.stdout, r.stdout+ assert r.returncode == 0 and 'FINDINGS' in r.stdout and 'U1: no MPN' in r.stdout, r.stdout print('sourcing-suggestions: ok')
tools/fusion-project.py+60−1@@ -1564,6 +1564,51 @@ def board_model(text): e['nameBox'] = (x0, y0, x0 + w, y0 + h) if '90' not in str(_num(a_, 'rot', 'R0')) else (x - h, y0, x, y0 + w) return els, outline, vias, texts +def silkscreen_audit(a, name, text, els, outline, vias, texts, nameless, by_hand, n_top):+ """Read the silkscreen as placed, move nothing, and report what is worth a look (issue #134). Name boxes+ are estimated from each NAME's size and length; the fab's Gerber is the exact measure of what prints."""+ findings = []+ edge = 0.3+ inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None+ pads = [(e['name'], p) for e in els for p in e['pads']]+ viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+ boxes = []+ for e in els:+ if e['bottom'] or e['name'] in nameless: continue+ na = e.get('nameAttr') or ''+ if 'display="off"' in na:+ continue+ xy = _num(na, 'x', None) if na else None+ if na and xy is not None:+ x, y = _num(na, 'x', 0), _num(na, 'y', 0); size = _num(na, 'size', 0.8) or 0.8+ else:+ (x, y), size = e['homeName'], 0.8+ w, h = tw_(e['name'], size), size * TEXT_H+ rot = _rot_of(_num(na, 'rot', 'R0') if na and isinstance(_num(na, 'rot', 'R0'), str) else 'R0')[0] if na else 0+ if int(round(rot)) % 180 == 90: w, h = h, w+ al = e.get('nameAlign') or 'bottom-left'+ x0 = x - w / 2 if 'center' in al and not al.endswith('left') and not al.endswith('right') else (x - w if al.endswith('right') else x)+ y0 = y - h / 2 if al.startswith('center') else (y - h if al.startswith('top') else y)+ boxes.append((e['name'], (x0, y0, x0 + w, y0 + h)))+ for ref, bx in boxes:+ if inner and not (bx[0] >= inner[0] and bx[1] >= inner[1] and bx[2] <= inner[2] and bx[3] <= inner[3]):+ findings.append('%s: name off the board or within %.1f mm of the edge' % (ref, edge))+ hit = next((r for r, p in pads if _ov(bx, p, 0.0)), None)+ if hit: findings.append('%s: name over a pad of %s (the fab clips silkscreen on pads)' % (ref, hit))+ if any(_ov(bx, v, 0.0) for v in viab): findings.append('%s: name over a via' % ref)+ for i, (ra, ba) in enumerate(boxes):+ for rb, bb in boxes[i + 1:]:+ if _ov(ba, bb, 0.0): findings.append('%s and %s: names overlap' % (ra, rb))+ unnamed = sorted(r for r in nameless) + sorted(e['name'] for e in els if not e['bottom'] and 'display="off"' in (e.get('nameAttr') or ''))+ if unnamed: findings.append('no visible name: %s (fine when intended)' % ', '.join(unnamed[:20]))+ prove('silkscreen', {'mode': 'adopt', 'labels': len(boxes), 'moved': 0, 'findings': findings, 'placedByDesigner': by_hand})+ head = 'silkscreen audited as placed (%d of %d top-side names placed by the designer), nothing moved: %s' % (+ by_hand, n_top, ('%d finding(s) worth a look:\n ' % len(findings)) + '\n '.join(findings[:40]) if findings else 'nothing found')+ ok(head, ['Each finding is a suggestion: fix the ones you agree with in Fusion, then run this again.',+ 'Name boxes are estimated from size and length; the Gerber silkscreen layer is the exact measure of what prints.',+ 'To have AI Flow re-lay the labels instead: `fusion silkscreen --relayout` (values: --values none|where-room|required).'])++ def cmd_silkscreen(a): st = load_state(); name = a.design or st.get('design') if not name: err('which design? pass --design <name>')@@ -1574,6 +1619,14 @@ def cmd_silkscreen(a): if '<element ' not in text: err('could not read the board %s (%s)' % (name, src.get('error')), ['Show the 2D board (fusion_show_2d_board) and run this again.']) els, outline, vias, texts = board_model(text) nameless = nameless_parts(text)+ global VALUES_MODE+ VALUES_MODE = a.values+ # issue #134: a silkscreen the designer placed is audited where it stands, never re-laid unasked+ placed = board_elements(text)+ top = [e for e in els if not e['bottom']]+ by_hand = sum(1 for e in top if (placed.get(e['name']) or {}).get('smashed'))+ if a.adopt or (not a.relayout and not a.fresh and top and by_hand >= max(3, len(top) // 2)):+ return silkscreen_audit(a, name, text, els, outline, vias, texts, nameless, by_hand, len(top)) contacts = [e for e in els if is_contact(e)] fresh_cmds = [] if a.fresh:@@ -1862,8 +1915,11 @@ def cmd_silkscreen(a): nets_kept = sum(1 for t in texts if t['layer'] == 21) identity_kept = any(t['layer'] == 22 and t['text'] == title for t in texts) prove('silkscreen', {'frames': len(frames), 'labels': len(labels), 'moved': moved, 'unresolved': unresolved, 'failed': len(failed), 'identity': bool(spot)})+ if SKIPPED_VALUES:+ print('Note: %d value(s) had no room beside their part and were left off the silkscreen (--values where-room): %s' % (len(SKIPPED_VALUES), ', '.join(SKIPPED_VALUES[:20]))) if failed or off:- err('silkscreen landed in part: %d of %d names where planned%s%s' % (moved, len([l for l in labels if l['kind'] == 'name']), ('; not where planned: ' + ', '.join(off)) if off else '', ('; refused: ' + '; '.join(failed[:4])) if failed else ''),+ # a suggestion engine: report what did not land, never refuse (John, 2026-11-04)+ ok('silkscreen landed in part (findings, nothing blocked): %d of %d names where planned%s%s' % (moved, len([l for l in labels if l['kind'] == 'name']), ('; not where planned: ' + ', '.join(off)) if off else '', ('; refused: ' + '; '.join(failed[:4])) if failed else ''), ['Look at the board in Fusion; move the named labels by hand or fix the command, then `fusion silkscreen` again.']) ok('silkscreen: %d contact frames, %d part names placed readable and clear of copper, %d contact net labels on the edge side%s%s, board identity on the bottom%s' % (len(frames), moved, nets_written, (' (%d kept from before)' % nets_kept) if nets_kept else '', (', unresolved: ' + ', '.join(unresolved)) if unresolved else '', ' (kept from before)' if identity_kept else '' if spot else ' (no clear spot found)'), ['Show the human both faces: the top labels next to their parts, the bottom identity. Then `step 3d`.',@@ -2038,6 +2094,9 @@ def main(): p = sub.add_parser('export'); p.add_argument('--design'); p.add_argument('--out-dir', default=str(RUN / 'manufacturing')) p = sub.add_parser('view3d', help='the board in 3D: a still to check and a live orbit'); p.add_argument('--design'); p.add_argument('--steps', type=int, default=24); p.add_argument('--pause', type=float, default=0.6); p.add_argument('--settle', type=float, default=8) p = sub.add_parser('silkscreen', help='every part name readable and clear of copper, contact nets on the edge side, the board identity on the bottom'); p.add_argument('--design'); p.add_argument('--title'); p.add_argument('--dry', action='store_true')+ p.add_argument('--adopt', action='store_true', help='audit the silkscreen as placed and move nothing (the default when the designer already placed most names)')+ p.add_argument('--relayout', action='store_true', help='re-lay the labels even though the designer already placed them')+ p.add_argument('--values', choices=['none', 'where-room', 'required'], default='where-room', help='values on the silkscreen: none, where there is room (default), or every one') p.add_argument('--fresh', action='store_true', help='take an earlier pass off first (its frames and contact net labels; names back to their library spots) so this pass is drawn from the start') p.add_argument('--no-switch', action='store_true', help='the 2D board is already showing: do not ask the bridge to switch views (its progress panel would sit over the board on camera)') p.add_argument('--board-file', help='plan from a saved .brd instead of the open board (use with --dry)')
tools/silkscreen-plan.py+11−2@@ -412,6 +412,13 @@ def plan_leaders(items, els, outline, vias, taken, edge=0.3, gap=0.15, reach=10. 'leader': [(round(a_[0], 3), round(a_[1], 3)), (round(b_[0], 3), round(b_[1], 3))]}) return out, bad +# Values on the silkscreen are the designer's choice (issue #134), never a requirement:+# "none" no values, "where-room" (default) a value goes beside its reference where it fits and is+# skipped (and listed) where it does not, "required" a value that does not fit is left unresolved.+VALUES_MODE = 'where-room'+SKIPPED_VALUES = []++ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), skip=()): """Every part's name next to its body, readable (R0), clear of copper, mask, bodies, the edge and the other labels; machine contacts also get their net, on the board-edge side. Biggest parts first;@@ -438,7 +445,7 @@ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), s # every component's value, small, right next to its reference (aiflow-silkscreen: the reference is # larger, the value mandatory and visibly smaller); test pads and machine parts carry none pk_ = (e['package'] or '').upper()- sv = silk_value(e['value'], e['name']) if not is_contact(e) and 'TEST' not in pk_ and not e['name'].startswith(('TP', 'MP', 'J')) else None+ sv = silk_value(e['value'], e['name']) if VALUES_MODE != 'none' and not is_contact(e) and 'TEST' not in pk_ and not e['name'].startswith(('TP', 'MP', 'J')) else None if sv: texts.append((sv, 'value')) # the reference and its value as one unit first (aiflow-silkscreen: the pair is atomic): the value # inline after the reference, or stacked under it, the unit placed where a name would go@@ -508,7 +515,9 @@ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), s done = True; break if done: break if done: break- if not done: unresolved.append('%s value' % e['name'])+ if not done:+ if VALUES_MODE == 'required': unresolved.append('%s value' % e['name'])+ else: SKIPPED_VALUES.append(e['name']) continue for ring_ in (((gap, gap + 0.4, gap + 0.9),) if kind == 'net' else ((gap, gap + 0.4, gap + 0.9), (gap + 1.5, gap + 2.2))): if done: break # a contact's net stays close: far away it reads as a neighbour's