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.49
35 files changed
+2837−100
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+19−2bin/adom-aiflowcrates/adom-aiflow/src/checks.rs+45−26crates/adom-aiflow/src/continuation.rs+17−1crates/adom-aiflow/src/gate_eval.rs+5−1crates/adom-aiflow/src/handson.rs+520crates/adom-aiflow/src/main.rs+332−17crates/adom-aiflow/src/molecule_pins.rs+329crates/adom-aiflow/src/route_quality.rs+378crates/adom-aiflow/src/route_variants.rs+2−2crates/adom-aiflow/src/screen.rs+114crates/adom-aiflow/src/sequence.rs+2crates/adom-aiflow/src/thermalvias.rs+105−7crates/adom-aiflow/src/timeline_server.rs+82crates/aiflow-bridge/src/lib.rs+64−2crates/aiflow-router/src/simplify.rs+102−4docs/hands-on.md+64flows/board.json+1−1package.json+15−3page.json+15−3skills/adom-aiflow/SKILL.md+19−2skills/aiflow-hands-on/SKILL.md+107skills/aiflow-intake/SKILL.md+4skills/aiflow-molecule/SKILL.md+7−6skills/aiflow-project-page/SKILL.md+4−2skills/aiflow-schematic-to-board/SKILL.md+2−1tests/handson-workflow.py+130tests/project-page-check.py+6tests/project-page-library.py+21tools/project-page-check.py+8−2tools/project-page.py+23−6tools/timeline/README.md+27tools/timeline/index.html+257−1Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.48"+version = "0.2.49" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.48"+version = "0.2.49" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.48"+version = "0.2.49" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.48"+version = "0.2.49" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.48"+version = "0.2.49" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.48"+version = "0.2.49" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.48"+version = "0.2.49" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.48"+version = "0.2.49" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.48"+version = "0.2.49" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.48"+version = "0.2.49" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.48"+version = "0.2.49" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+19−2@@ -1,7 +1,7 @@ --- name: adom-aiflow description: >-- The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+ The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: hands on, hands-on aiflow, hands-on AI Flow, check in with me, my flow, full AI flow, flow profile, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. --- # adom-aiflow: the loop, measured@@ -149,6 +149,20 @@ required step grouped by phase, a teal check on each done step, the current one go", and a purple arc for every return with its reason, ending at the finish line (Gerbers, BOM and CPL exported). Every `step` refreshes it within a second. Open it at intake and leave it up. +## Hands-on AI Flow: the human chooses who does each step++When the human asks for it ("use hands-on AI Flow", "check in with me after placement", "I'll route it myself", "use my flow"), run `handson plan` right after `intake` or `start`, before any design work. The timeline shows every step with four choices:+1. **auto**: the AI takes care of it.+2. **checkin**: the AI does it, then `handson ask` and waits for the human's OK. The next step is refused until they approve.+3. **human**: the human does it. The AI waits for "done", then runs the step's own check on their work.+4. **custom**: their written instructions, printed at every `step`.++The timeline also has their notification choice and their screen choice (hands-off by default: AI Flow never raises or maximises windows on their desktop). They press Start there, or you set it from their words (`handson set placement=checkin routing=human`, `handson start --save-as "<name>"`).++The choice is a flow profile in `~/.adom/aiflow/profiles/`. The first one confirmed becomes their default, and every new run follows it. Switch with `handson use <slug>` (`full-ai` is built in); list with `handson profiles`.++While a plan, check-in or human step is open, `finish-guard` answers `waiting-for-human`, a legitimate pause. Proofs and finish checks never change. Never approve for the human. A run that is not hands-on shows a "Want a say in each step?" card on the timeline. Skill: `aiflow-hands-on`; reference: docs/hands-on.md.+ ## A board in Autodesk Fusion When the human asks for a board in Fusion ("a 12 V to 5 V buck converter using the TPS54202, in Fusion"),@@ -158,7 +172,7 @@ windowed (about 90 % of the screen), never maximized, so the human sees both and catches Fusion's 3D view. Inside Hydrogen, the VS Code tab (the chat) always stays in the left pane with its file explorer closed (`adom-vscode sidebar close`), and every webview (the timeline, the parts pages, the SPICE dashboard, the fields app) opens in the right pane; never widen or collapse a pane. `step`-brings the window each step works in to the front: Hydrogen for intake, design, the test plan, sourcing,+brings the window each step works in to the front when the screen is `allowed` (a test box; see "Screen" below): Hydrogen for intake, design, the test plan, sourcing, the library, simulation, current, thermal, fields and export, Fusion for everything drawn on the design. 1. **Intake and the timeline.** `intake`, `timeline open`. Offer the Claude usage widget in Hydrogen's@@ -273,6 +287,8 @@ The first `step` (or `capture start`) asks the test box once which recorder its `capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers. +**Screen: hands-off on the human's own machine (issues #132, #135).** `screen: hands-off | ask | allowed` says whether AI Flow may bring windows to the front on the run's desktop. When the target is the machine the human is using (a Hydrogen local workspace's own PC, or the primary machine `adom-bridge targets` names) it is `hands-off` by default; a test box is `allowed`. The run records it in run.json (`screenMode`, with where it came from) and prints it once. In hands-off (and `ask`), `step` raises nothing, `capture open` opens the board without focus or maximise, and if Windows still puts a newly launched KiCad in front it sends it to the bottom and gives the human their window back (the answer says what it did); `tour 3d` does not run, because the viewer only paints in front: suggest the human runs it, or takes the screen off hands-off for it. Clips keep recording by window capture, which needs no foreground; only a minimised window films nothing. The human changes it any time: `capture screen allowed` ("you can take my screen now"), `capture screen hands-off`, `capture screen` to read it; a declared test box can say `"capture": {"screen": "allowed"}` in the spec; `AIFLOW_SCREEN=allowed|ask|hands-off` wins over both.+ ## The spec `docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Vias of the pad's net already at the pad (any size, board vias or the footprint's own through-hole via pads) count toward `count`, as in `thermal-vias plan`, and no planned via comes within the hole-to-hole minimum or the via-to-via clearance of a via or hole already there. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable.@@ -308,6 +324,7 @@ AI Flow's binary is the instrument, the gates and the hands; the judgement for e | step | skill | |---|---| | a brief or a video to reproduce, before any board exists | `aiflow-intake` |+| the human wants a say in each step (hands-on, check-ins, their own steps, flow profiles, screen and notification preferences) | `aiflow-hands-on` | | choosing parts, the fab profile (3rd party fab by default, JLCPCB on request), symbols, footprints and 3D models | `aiflow-sourcing` | | datasheet equations, stocked values, margins | `aiflow-circuit-design` | | loop and transient simulation, vendor PSpice models | `aiflow-simulate` |
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/checks.rs+45−26@@ -282,6 +282,13 @@ pub fn molecule_check_bom(b: &Board, spec: &Value, before: Option<&Board>, bom: } } if contacts.is_empty() { f.warn("no MachineContact footprints: the molecule has no wired contacts".into(), "Contacts take the signals to the Control Panel; add MachineContactMedium footprints on the grid (skill aiflow-molecule)."); }+ // issue #138: the names the wiki's model registry reads (adom.molecule-pins/2). A finding with+ // the rename to make, never an error: the board is fine copper; only the names keep the registry+ // from counting its contacts.+ let census = crate::molecule_pins::census(b);+ let named = crate::molecule_pins::findings(&census);+ if named.is_empty() { f.ok(crate::molecule_pins::summary(&census)); }+ for (finding, suggestion) in named { f.warn(finding, &suggestion); } // edge margin: every pin and contact centre at least edgeMarginMm inside the outline if b.outline.len() >= 3 { for r in pins.keys().chain(contacts.iter()) {@@ -967,7 +974,7 @@ mod tests { (footprint "Molecule:MachinePinMediumShort" (layer "F.Cu") (at 124 100) (property "Reference" "MP2") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.2) (layers "*.Cu") (net "GND"))) (footprint "Molecule:MachinePinMediumShort" (layer "F.Cu") (at 100 84) (property "Reference" "MP3") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.2) (layers "*.Cu") (net "GND"))) (footprint "Molecule:MachinePinMediumShort" (layer "F.Cu") (at 124 84) (property "Reference" "MP4") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.2) (layers "*.Cu") (net "GND")))- (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 100 88) (property "Reference" "J1") (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net "VIN")))+ (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 100 88) (property "Reference" "MC1") (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net "VIN"))) (footprint "Resistor_SMD:R_0402_1005Metric" (layer "F.Cu") (at 110 90) (property "Reference" "R1") (pad "1" smd roundrect (at -0.5 0) (size 0.5 0.6) (layers "F.Cu") (net "VIN")) (pad "2" smd roundrect (at 0.5 0) (size 0.5 0.6) (layers "F.Cu") (net "Net-(R1-Pad2)"))@@ -979,6 +986,18 @@ mod tests { fn board(text: &str) -> Board { Board::from_text("t.kicad_pcb", text.to_string()).unwrap() } + #[test]+ fn a_contact_not_named_mc_n_is_a_finding_with_the_rename_never_an_error() {+ // issue #138: J1 on MachineContactMedium passed; the registry counts only MC<n>+ let named_j = BOARD.replace("\"Reference\" \"MC1\"", "\"Reference\" \"J1\"");+ let f = molecule_check(&board(&named_j), &json!({"molecule": {}}), None);+ assert!(f.errors.is_empty(), "{:?}", f.lines);+ assert!(f.warnings.iter().any(|w| w.contains("not named MC<n>") && w.contains("J1")), "{:?}", f.lines);+ assert!(f.hints.iter().any(|h| h.starts_with("Rename them MC1") && h.contains("no wire attach point")), "{:?}", f.hints);+ let f = molecule_check(&board(BOARD), &json!({"molecule": {}}), None);+ assert!(f.lines.iter().any(|l| l.starts_with("OK") && l.contains("1 machine contacts (MC<n>)")), "{:?}", f.lines);+ }+ #[test] fn molecule_large_pin_grid_rejects_medium_span() { let text = BOARD.replace("MachinePinMediumShort", "MachinePinLargeShort")@@ -1050,11 +1069,11 @@ mod tests { #[test] fn pin_class_current_on_a_pin_net_needs_large() {- // J1 (a machine contact) is on VIN+ // MC1 (a machine contact) is on VIN let mut spec = calm(json!({})); spec["loads"] = json!({"/VIN": {"amps": 4}}); let f = molecule_check(&board(BOARD), &spec, None);- assert!(pin_errors(&f).iter().any(|e| e.contains("net VIN (J1) carries 4 A")), "{:?}", f.lines);+ assert!(pin_errors(&f).iter().any(|e| e.contains("net VIN (MC1) carries 4 A")), "{:?}", f.lines); // a heavy load on a net no pin or contact carries does not count spec["loads"] = json!({"Net-(R1-Pad2)": {"amps": 10}}); assert!(molecule_check(&board(BOARD), &spec, None).errors.is_empty());@@ -1134,30 +1153,30 @@ mod tests { .replace("124", "132").replace(" 84", " 68").replace("(start 98 82)", "(start 96 64)").replace("(end 126 102)", "(end 136 104)") } - /// A MachineContactLarge J2 on VMOT, 6 mm right of J1 (on the 2 mm grid from MP1).+ /// A MachineContactLarge MC2 on VMOT, 6 mm right of MC1 (on the 2 mm grid from MP1). fn with_large_contact(text: &str) -> String {- text.replacen(" (footprint \"Resistor_SMD", " (footprint \"Molecule:MachineContactLarge\" (layer \"F.Cu\") (at 106 88) (property \"Reference\" \"J2\") (pad \"1\" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers \"*.Cu\") (net \"VMOT\")))\n (footprint \"Resistor_SMD", 1)+ text.replacen(" (footprint \"Resistor_SMD", " (footprint \"Molecule:MachineContactLarge\" (layer \"F.Cu\") (at 106 88) (property \"Reference\" \"MC2\") (pad \"1\" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers \"*.Cu\") (net \"VMOT\")))\n (footprint \"Resistor_SMD", 1) } #[test] fn pin_class_large_contact_needs_large_corners() { let f = molecule_check(&board(&with_large_contact(BOARD)), &calm(json!({})), None); let e = pin_errors(&f);- assert!(e.len() == 1 && e[0].contains("J2 is a large contact"), "{:?}", f.lines);+ assert!(e.len() == 1 && e[0].contains("MC2 is a large contact"), "{:?}", f.lines); assert!(f.hints.iter().any(|h| h.contains("largeContactMediumPins")), "{:?}", f.hints); assert_eq!(f.pin_class["required"], "large");- assert_eq!(f.pin_class["largeContacts"], json!(["J2"]));+ assert_eq!(f.pin_class["largeContacts"], json!(["MC2"])); // the contact itself passes the contact checks (4.4 mm pad, 2.62 mm drill, on the grid)- assert!(f.lines.iter().any(|l| l.starts_with("OK") && l.contains("J2 MachineContactLarge")), "{:?}", f.lines);+ assert!(f.lines.iter().any(|l| l.starts_with("OK") && l.contains("MC2 MachineContactLarge")), "{:?}", f.lines); // large corners: as needed let f = molecule_check(&board(&with_large_contact(&large_board())), &calm(json!({"seat": "base"})), None); assert!(f.errors.is_empty(), "{:?}", f.lines);- assert!(f.lines.iter().any(|l| l.contains("large corner pins, as needed") && l.contains("J2")), "{:?}", f.lines);+ assert!(f.lines.iter().any(|l| l.contains("large corner pins, as needed") && l.contains("MC2")), "{:?}", f.lines); } #[test] fn pin_class_large_contact_override_keeps_medium() {- let why = "light 28 x 20 mm board in a LrgMed scaffold; J2 is only the 14 AWG motor feed";+ let why = "light 28 x 20 mm board in a LrgMed scaffold; MC2 is only the 14 AWG motor feed"; let mut spec = calm(json!({"largeContactMediumPins": why})); spec["loads"] = json!({"VMOT": {"amps": 12}}); let f = molecule_check(&board(&with_large_contact(BOARD)), &spec, None);@@ -1169,7 +1188,7 @@ mod tests { // the override covers the large contact only, never another reason let f = molecule_check(&board(&with_large_contact(BOARD)), &calm(json!({"largeContactMediumPins": why, "massG": 150})), None); let e = pin_errors(&f);- assert!(e.len() == 1 && e[0].contains("150 g") && !e[0].contains("J2"), "{:?}", f.lines);+ assert!(e.len() == 1 && e[0].contains("150 g") && !e[0].contains("MC2"), "{:?}", f.lines); // it has to say why let f = molecule_check(&board(&with_large_contact(BOARD)), &calm(json!({"largeContactMediumPins": true})), None); assert!(f.warnings.iter().any(|w| w.contains("must be a sentence")), "{:?}", f.lines);@@ -1188,11 +1207,11 @@ mod tests { assert!(f.errors.is_empty() && f.warnings.is_empty(), "{:?}", f.lines); spec["loads"] = json!({"VMOT": {"amps": 25}}); let f = molecule_check(&board(&text), &spec, None);- assert!(f.warnings.iter().any(|w| w.contains("net VMOT (J2) carries 25 A") && w.contains("20.8 A")), "{:?}", f.lines);+ assert!(f.warnings.iter().any(|w| w.contains("net VMOT (MC2) carries 25 A") && w.contains("20.8 A")), "{:?}", f.lines); // a medium contact on the same heavy net still counts against the medium rating let shared = with_large_contact(BOARD).replace("(net \"VIN\")))", "(net \"VMOT\")))"); let f = molecule_check(&board(&shared), &calm(json!({"largeContactMediumPins": "test"})).tap_loads(json!({"VMOT": {"amps": 12}})), None);- assert!(pin_errors(&f).iter().any(|e| e.contains("net VMOT (J1, J2) carries 12 A")), "{:?}", f.lines);+ assert!(pin_errors(&f).iter().any(|e| e.contains("net VMOT (MC1, MC2) carries 12 A")), "{:?}", f.lines); } #[test]@@ -1214,9 +1233,9 @@ mod tests { fn netlist(r2_pin2: &str, extra: &str) -> String { format!(r#"(export (version "E")- (components (comp (ref "MP1")) (comp (ref "MP2")) (comp (ref "MP3")) (comp (ref "MP4")) (comp (ref "J1")) (comp (ref "R1") (footprint "Resistor_SMD:R_0402_1005Metric")) (comp (ref "R2")) {extra})+ (components (comp (ref "MP1")) (comp (ref "MP2")) (comp (ref "MP3")) (comp (ref "MP4")) (comp (ref "MC1")) (comp (ref "R1") (footprint "Resistor_SMD:R_0402_1005Metric")) (comp (ref "R2")) {extra}) (nets- (net (code "1") (name "/VIN") (node (ref "J1") (pin "1")) (node (ref "R1") (pin "1")))+ (net (code "1") (name "/VIN") (node (ref "MC1") (pin "1")) (node (ref "R1") (pin "1"))) (net (code "2") (name "Net-(R1-Pad2)") (node (ref "R1") (pin "2")) (node (ref "R2") (pin "1"))) (net (code "3") (name "GND") (node (ref "MP1") (pin "1")) (node (ref "MP2") (pin "1")) (node (ref "MP3") (pin "1")) (node (ref "MP4") (pin "1")) (node (ref "R2") (pin "{r2_pin2}")))))"#) }@@ -1241,10 +1260,10 @@ mod tests { #[test] fn molecule_conforms_and_catches_off_grid_contact() {- let spec = json!({"molecule": {"gridMm": 2, "edgeMarginMm": 2, "fixed": {"J1": [0, 12]}}});+ let spec = json!({"molecule": {"gridMm": 2, "edgeMarginMm": 2, "fixed": {"MC1": [0, 12]}}}); let f = molecule_check(&board(BOARD), &spec, None); assert!(f.errors.is_empty(), "{:?}", f.lines);- let off = BOARD.replace("(at 100 88) (property \"Reference\" \"J1\")", "(at 100.5 88) (property \"Reference\" \"J1\")");+ let off = BOARD.replace("(at 100 88) (property \"Reference\" \"MC1\")", "(at 100.5 88) (property \"Reference\" \"MC1\")"); let f = molecule_check(&board(&off), &spec, None); assert!(f.errors.iter().any(|e| e.contains("off the 2 mm grid")), "{:?}", f.lines); assert!(f.errors.iter().any(|e| e.contains("spec.molecule.fixed")), "{:?}", f.lines);@@ -1271,10 +1290,10 @@ mod tests { assert!(geom::point_in_poly(100.0, 100.0, &b.outline), "MP1 is inside the outer outline"); let f = molecule_check(&b, &spec, None); assert!(f.errors.is_empty(), "{:?}", f.lines);- // the margin applies to a cutout edge too: a 1 mm hole 1.5 mm from J1+ // the margin applies to a cutout edge too: a 1 mm hole 1.5 mm from MC1 let near = outer.replace(lines, &format!("{lines}\n (gr_circle (center 102.5 88) (end 103.5 88) (layer \"Edge.Cuts\"))")); let f = molecule_check(&board(&near), &spec, None);- assert!(f.errors.iter().any(|e| e.contains("J1") && e.contains("cutout edge")), "{:?}", f.lines);+ assert!(f.errors.iter().any(|e| e.contains("MC1") && e.contains("cutout edge")), "{:?}", f.lines); } #[test]@@ -1288,22 +1307,22 @@ mod tests { .replace("162 102", "166 102").replace("98 30", "98 26"); let f = molecule_check(&board(&fixed), &spec, None); assert!(f.errors.is_empty(), "{:?}", f.lines);- // Contact J1 remains on the separate 2 mm grid, not the scaffold grid.+ // Contact MC1 remains on the separate 2 mm grid, not the scaffold grid. assert!(fixed.contains("(at 100 88)")); } #[test] fn molecule_moved_fixed_ref_against_start_board() {- let spec = json!({"molecule": {}, "fixedRefs": ["J1"]});- let moved = BOARD.replace("(at 100 88) (property \"Reference\" \"J1\")", "(at 100 90) (property \"Reference\" \"J1\")");+ let spec = json!({"molecule": {}, "fixedRefs": ["MC1"]});+ let moved = BOARD.replace("(at 100 88) (property \"Reference\" \"MC1\")", "(at 100 90) (property \"Reference\" \"MC1\")"); let f = molecule_check(&board(&moved), &spec, Some(&board(BOARD))); assert!(f.errors.iter().any(|e| e.contains("moved since the run started")), "{:?}", f.lines); } #[test] fn explicit_interface_revision_retains_all_other_locks() {- let spec = json!({"molecule": {"fixed": {"J1": [0, 10]}}, "fixedRefs": ["J1"]});- let moved = BOARD.replace("(at 100 88) (property \"Reference\" \"J1\")", "(at 100 90) (property \"Reference\" \"J1\")");+ let spec = json!({"molecule": {"fixed": {"MC1": [0, 10]}}, "fixedRefs": ["MC1"]});+ let moved = BOARD.replace("(at 100 88) (property \"Reference\" \"MC1\")", "(at 100 90) (property \"Reference\" \"MC1\")"); let f = molecule_check(&board(&moved), &spec, Some(&board(BOARD))); assert!(f.errors.is_empty(), "{:?}", f.lines); // The revised contract is enforced, not merely a movement waiver.@@ -1312,9 +1331,9 @@ mod tests { let rotated = moved.replace("(at 100 90)", "(at 100 90 90)"); let f = molecule_check(&board(&rotated), &spec, Some(&board(BOARD))); assert!(f.errors.iter().any(|e| e.contains("moved since the run started")));- let explicit_only = json!({"molecule": {"fixed": {"J1": [0, 10]}}});+ let explicit_only = json!({"molecule": {"fixed": {"MC1": [0, 10]}}}); assert!(molecule_check(&board(&rotated), &explicit_only, Some(&board(BOARD))).errors.iter().any(|e| e.contains("moved since the run started")));- let bad = json!({"molecule": {"fixed": {"J1": [0]}}, "fixedRefs": ["J1"]});+ let bad = json!({"molecule": {"fixed": {"MC1": [0]}}, "fixedRefs": ["MC1"]}); assert!(!molecule_check(&board(&moved), &bad, Some(&board(BOARD))).errors.is_empty()); let barely_wrong = moved.replace("(at 100 90)", "(at 100.007 90)"); assert!(molecule_check(&board(&barely_wrong), &spec, Some(&board(BOARD))).errors.iter().any(|e| e.contains("spec.molecule.fixed")));
crates/adom-aiflow/src/continuation.rs+17−1@@ -155,6 +155,12 @@ pub fn assess(data: &Value, pending: &[String], stop: Option<Result<Value, Strin remaining.len() ) };+ // hands-on (handson.rs): an open check-in, a human step or an unconfirmed plan is the human's turn+ let human = if complete { None } else { crate::handson::waiting(data) };+ if let Some(w) = &human {+ decision = "waiting-for-human";+ reason = format!("Waiting for {} (hands-on flow); the run resumes on their answer. {} requirement(s) remain.", w["what"].as_str().unwrap_or("the human"), remaining.len());+ } let mut stop_record = Value::Null; if let Some(evidence) = stop { match evidence.and_then(|v| stop_reason(&v).map(|kind| (kind, v))) {@@ -174,7 +180,8 @@ pub fn assess(data: &Value, pending: &[String], stop: Option<Result<Value, Strin } json!({"schemaVersion":1,"decision":decision,"mayStop":decision!="continue","engineeringComplete":complete, "remaining":remaining,"currentStep":data["currentStep"],"reason":reason,"stopEvidence":stop_record,- "nextAction":if complete {"Verify requested scope and hand off the delivery.".into()} else {format!("Continue the earliest unmet requirement ({}). Repair tools or design as needed; preserve all acceptance gates. Report progress in commentary, then execute the next action.",remaining.first().map(String::as_str).unwrap_or("requested scope"))},+ "waitingForHuman":human,+ "nextAction":if complete {"Verify requested scope and hand off the delivery.".into()} else if let Some(w) = &human {format!("Tell the human what you need ({}), then `handson wait`; resume when they answer.", w["what"].as_str().unwrap_or(""))} else {format!("Continue the earliest unmet requirement ({}). Repair tools or design as needed; preserve all acceptance gates. Report progress in commentary, then execute the next action.",remaining.first().map(String::as_str).unwrap_or("requested scope"))}, "runtimeEnforcement":"CLI guard only. An AI host must invoke this before ending a turn; no watchdog or automatic restart is installed."}) } @@ -214,6 +221,15 @@ mod tests { assert_eq!(r["engineeringComplete"], false); } #[test]+ fn an_open_check_in_is_the_humans_turn() {+ let d = json!({"currentStep":"placement","steps":{"placement":{"visits":[{"n":1,"at":"2026-11-03T10:05:00Z"}]}},+ "handsOn":{"state":"active","setAt":"2026-11-03T10:00:00Z","steps":{"placement":{"mode":"checkin"}},"asks":[{"step":"placement","visit":1,"at":"2026-11-03T10:06:00Z","summary":"look"}],"verdicts":[]}});+ let r = assess(&d, &["placement".into()], None);+ assert_eq!(r["decision"], "waiting-for-human");+ assert_eq!(r["mayStop"], true);+ assert_eq!(r["engineeringComplete"], false);+ }+ #[test] fn finish_without_delivery_still_continues() { let d = json!({"clock":{"finishTime":"2026-10-10T01:00:00Z"}}); assert_eq!(assess(&d, &[], None)["mayStop"], false);
crates/adom-aiflow/src/gate_eval.rs+5−1@@ -18,6 +18,8 @@ pub struct GateEval { pub dec: Option<decoupling_check::Review>, pub mech: Option<mechanical::Report>, pub stairs: Vec<String>,+ /// issues #127/#128: route geometry and signal quality, a report with suggestions (never blocking)+ pub quality: (Value, Vec<String>), } /// Apply `plan_v` (already normalised) to the run's board `b`, write the routed board to `routed`@@ -54,5 +56,7 @@ pub fn evaluate(r: &Run, b: &Board, plan_v: &Value, routed: &Path, base: &Value, let stairs = rb().map(|rb| aiflow_router::simplify::board_staircases(&rb)).unwrap_or_default(); s["mechanical"] = mech.as_ref().map(|m| m.to_json()).unwrap_or(json!("not checked: Fusion board")); s["staircases"] = json!(stairs);- Ok(GateEval { s, pu, pu_issues, sw_issues, dec, mech, stairs })+ let quality = rb().map(|rb| crate::route_quality::of_board(&rb, &spec_g)).unwrap_or_else(|e| (json!({"error": e}), Vec::new()));+ s["routeQuality"] = crate::route_quality::outcome(&quality.0, "route-quality.json");+ Ok(GateEval { s, pu, pu_issues, sw_issues, dec, mech, stairs, quality }) }
crates/adom-aiflow/src/handson.rsadded+520@@ -0,0 +1,520 @@+//! Hands-on AI Flow (Noah, 2026-11-03: "a hands on mode of AI Flow that allows the user to have more+//! involvement with the process"). Before any design work the human chooses, for every step of the+//! run, how much the AI does on its own:+//! auto the AI takes care of it with as little human input as possible (the flow as it always was)+//! checkin the AI does the step, shows it and waits for the human's OK before the next step (AI Flow never refuses: going on without it is logged and listed at finish)+//! human the human does the step in their own tools; the AI hands it over, waits for "done", then+//! runs the step's own check on their work (a step's proof never changes)+//! custom the human's own instructions for the step, printed every time the AI enters it+//! The choices are a flow profile (`~/.adom/aiflow/profiles/<slug>.json`): saved the first time, the+//! default for the next board, and switchable ("noah flow" for real boards, "full-ai" for simple+//! ones). A profile also says how the AI may reach the human: `notify` (hydrogen, toast, both, none)+//! and `screen` (hands-off: never raise or maximise a window on the human's desktop; allowed).+//! Answers come from the chat (`handson approve|changes|done --quote`) or from the timeline's+//! buttons (POST /handson writes `<run>/timeline/handson-inbox.json`, which every command ingests).+//! This changes who does a step and when the run may move on; it never waives a proof or a finish check.+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++pub const MODES: [&str; 4] = ["auto", "checkin", "human", "custom"];+pub const NOTIFY: [&str; 4] = ["hydrogen", "toast", "both", "none"];+pub const SCREEN: [&str; 2] = ["hands-off", "allowed"];+pub const INBOX: &str = "handson-inbox.json";+const MAX_TEXT: usize = 4000;++pub fn mode_label(mode: &str) -> &'static str {+ match mode { "checkin" => "AI does it, then checks in with you", "human" => "you do it", "custom" => "your custom instructions", _ => "AI takes care of it" }+}++/// Where the human's flow profiles live: `AIFLOW_PREFS_DIR`, else `~/.adom/aiflow`.+pub fn home() -> PathBuf {+ std::env::var("AIFLOW_PREFS_DIR").map(PathBuf::from)+ .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())).join(".adom").join("aiflow"))+}++/// "Noah flow" -> "noah-flow".+pub fn slug(name: &str) -> String {+ let mut s = String::new();+ for c in name.trim().to_lowercase().chars() {+ if c.is_ascii_alphanumeric() { s.push(c) } else if !s.ends_with('-') { s.push('-') }+ }+ s.trim_matches('-').chars().take(48).collect()+}++/// The one built-in profile: every step to the AI, and the human's screen left alone.+fn builtin(s: &str) -> Option<Value> {+ (s == "full-ai").then(|| json!({"name": "Full AI flow", "slug": "full-ai", "builtin": true, "fallback": "auto", "steps": {}, "notify": "hydrogen", "screen": "hands-off"}))+}++fn read(p: &Path) -> Option<Value> { serde_json::from_str(&std::fs::read_to_string(p).ok()?).ok() }++fn write_atomic(p: &Path, v: &Value) -> Result<(), String> {+ if let Some(d) = p.parent() { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }+ let tmp = p.with_extension("json.tmp");+ std::fs::write(&tmp, serde_json::to_string_pretty(v).unwrap()).and_then(|_| std::fs::rename(&tmp, p)).map_err(|e| e.to_string())+}++pub fn load_profile(name: &str) -> Option<Value> {+ let s = slug(name);+ builtin(&s).or_else(|| read(&home().join("profiles").join(format!("{s}.json"))))+}++pub fn default_slug() -> Option<String> {+ read(&home().join("preferences.json")).and_then(|v| v["defaultProfile"].as_str().map(str::to_string))+}++pub fn default_profile() -> Option<Value> { default_slug().and_then(|s| load_profile(&s)) }++pub fn set_default(name: &str) -> Result<(), String> {+ let p = home().join("preferences.json");+ let mut v = read(&p).unwrap_or(json!({}));+ v["defaultProfile"] = json!(slug(name));+ write_atomic(&p, &v)+}++/// The saved profiles (and the built-in full-ai), the default one marked.+pub fn list_profiles() -> Vec<Value> {+ let def = default_slug();+ let mut out: Vec<Value> = std::fs::read_dir(home().join("profiles")).into_iter().flatten().flatten()+ .filter(|e| e.path().extension().is_some_and(|x| x == "json"))+ .filter_map(|e| read(&e.path())).filter(|v| v["slug"].is_string()).collect();+ out.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));+ out.push(builtin("full-ai").unwrap());+ for p in &mut out { p["default"] = json!(def.as_deref() == p["slug"].as_str()); }+ out+}++/// Save the run's plan as a profile under `name`; the first profile a human saves becomes their default.+pub fn save_profile(name: &str, plan: &Value, at: &str) -> Result<String, String> {+ let s = slug(name);+ if s.is_empty() { return Err("a profile needs a name with letters or digits".into()); }+ if builtin(&s).is_some() { return Err(format!("{s} is built in; save under another name")); }+ let p = home().join("profiles").join(format!("{s}.json"));+ let created = read(&p).and_then(|v| v["created"].as_str().map(str::to_string)).unwrap_or_else(|| at.to_string());+ let first = default_slug().is_none();+ write_atomic(&p, &json!({"name": name.trim(), "slug": s, "fallback": plan["fallback"].as_str().unwrap_or("auto"), "steps": plan["steps"],+ "notify": plan["notify"].as_str().unwrap_or("hydrogen"), "screen": plan["screen"].as_str().unwrap_or("hands-off"), "created": created, "updated": at}))?;+ if first { set_default(&s)?; }+ Ok(s)+}++pub fn delete_profile(name: &str) -> Result<(), String> {+ let s = slug(name);+ std::fs::remove_file(home().join("profiles").join(format!("{s}.json"))).map_err(|e| format!("{s}: {e}"))?;+ if default_slug().as_deref() == Some(s.as_str()) {+ let p = home().join("preferences.json");+ let mut v = read(&p).unwrap_or(json!({}));+ v.as_object_mut().map(|o| o.remove("defaultProfile"));+ write_atomic(&p, &v)?;+ }+ Ok(())+}++/// One step's choice, checked: a known mode, and instructions for `custom` (optional for the others).+fn step_choice(step: &str, v: &Value) -> Result<Value, String> {+ let mode = v["mode"].as_str().unwrap_or("auto");+ if !MODES.contains(&mode) { return Err(format!("{step}: mode {mode:?} is not one of {}", MODES.join(", "))); }+ let text = v["instructions"].as_str().map(str::trim).unwrap_or("");+ if text.len() > MAX_TEXT { return Err(format!("{step}: instructions over {MAX_TEXT} characters")); }+ if mode == "custom" && text.is_empty() { return Err(format!("{step}: custom needs the instructions to follow")); }+ Ok(if text.is_empty() { json!({"mode": mode}) } else { json!({"mode": mode, "instructions": text}) })+}++/// A plan as the dashboard or a profile writes it, checked and normalised:+/// {"steps": {name: {mode, instructions}}, "fallback", "notify", "screen"}.+pub fn normalise(plan: &Value) -> Result<Value, String> {+ let mut steps = serde_json::Map::new();+ for (k, v) in plan["steps"].as_object().into_iter().flatten() {+ if k.is_empty() || k.len() > 64 { return Err("a step name is empty or too long".into()); }+ steps.insert(k.clone(), step_choice(k, v)?);+ }+ let pick = |key: &str, set: &[&str], dflt: &str| -> Result<String, String> {+ let v = plan[key].as_str().unwrap_or(dflt);+ if set.contains(&v) { Ok(v.to_string()) } else { Err(format!("{key} {v:?} is not one of {}", set.join(", "))) }+ };+ let fallback = pick("fallback", &["auto", "checkin"], "auto")?;+ Ok(json!({"steps": steps, "fallback": fallback, "notify": pick("notify", &NOTIFY, "hydrogen")?, "screen": pick("screen", &SCREEN, "hands-off")?}))+}++fn hands(data: &Value) -> &Value { &data["handsOn"] }++pub fn active(data: &Value) -> bool { hands(data)["state"] == "active" }+pub fn planning(data: &Value) -> bool { hands(data)["state"] == "planning" }++pub fn mode_of<'a>(data: &'a Value, step: &str) -> &'a str {+ if !active(data) && !planning(data) { return "auto"; }+ let h = hands(data);+ h["steps"][step]["mode"].as_str().or(h["fallback"].as_str()).unwrap_or("auto")+}++pub fn instructions_of<'a>(data: &'a Value, step: &str) -> Option<&'a str> {+ hands(data)["steps"][step]["instructions"].as_str().filter(|s| !s.is_empty())+}++/// The hands-on profile's screen choice (issue #132), when this run has a hands-on plan; every profile+/// defaults to hands-off. main.rs `screen_mode` (screen.rs) folds it into the one screen rule.+pub fn screen(data: &Value) -> Option<&str> { (active(data) || planning(data)).then(|| hands(data)["screen"].as_str()).flatten() }++/// Put a plan on the run. `state` is planning (shown to the human, not yet confirmed) or active.+pub fn apply(data: &mut Value, plan: &Value, state: &str, profile: Option<(&str, &str)>, source: &str, at: &str) {+ let keep = hands(data).clone();+ let mut h = json!({"state": state, "source": source, "steps": plan["steps"], "fallback": plan["fallback"], "notify": plan["notify"], "screen": plan["screen"],+ "verdicts": keep["verdicts"].as_array().cloned().unwrap_or_default(), "asks": keep["asks"].as_array().cloned().unwrap_or_default(),+ "inboxSeq": keep["inboxSeq"].as_u64().unwrap_or(0), "history": keep["history"].as_array().cloned().unwrap_or_default()});+ if let Some((s, n)) = profile { h["profile"] = json!(s); h["profileName"] = json!(n); }+ // a plan confirmed now governs the visits from now; history before it is not re-reviewed+ h["setAt"] = if state == "active" { json!(at) } else { keep["setAt"].clone() };+ let entry = json!({"at": at, "state": state, "source": source, "profile": h["profile"]});+ h["history"].as_array_mut().unwrap().push(entry);+ data["handsOn"] = h;+}++pub fn plan_of(data: &Value) -> Value {+ let h = hands(data);+ json!({"steps": h["steps"], "fallback": h["fallback"], "notify": h["notify"], "screen": h["screen"]})+}++/// The profile as a plan, ready for `apply`.+pub fn profile_plan(p: &Value) -> Value {+ normalise(&json!({"steps": p["steps"], "fallback": p["fallback"], "notify": p["notify"], "screen": p["screen"]})).unwrap_or_else(|_| json!({"steps": {}, "fallback": "auto", "notify": "hydrogen", "screen": "hands-off"}))+}++/// A new run takes the human's default profile, when they saved one.+pub fn apply_default(data: &mut Value, at: &str) -> Option<String> {+ // headless automation (every integration test) never inherits the developer's own default flow;+ // a test that wants profiles names its own folder with AIFLOW_PREFS_DIR+ if std::env::var("AIFLOW_DASHBOARD").as_deref() == Ok("off") && std::env::var("AIFLOW_PREFS_DIR").is_err() { return None; }+ let p = default_profile()?;+ let (s, n) = (p["slug"].as_str().unwrap_or("").to_string(), p["name"].as_str().unwrap_or("").to_string());+ apply(data, &profile_plan(&p), "active", Some((&s, &n)), "default profile", at);+ Some(format!("Hands-on: the human's default flow \"{n}\" applies to this run ({}). Tell them in one line. `handson plan` lets them change it for this board on the timeline; `handson use <profile>` switches (`handson profiles` lists them, full-ai hands every step to you).", summary(data)))+}++/// "placement: checkin, routing: human, 2 custom" style one-liner.+pub fn summary(data: &Value) -> String {+ let h = hands(data);+ let mut parts: Vec<String> = h["steps"].as_object().into_iter().flatten()+ .filter(|(_, v)| v["mode"] != "auto").map(|(k, v)| format!("{k} {}", v["mode"].as_str().unwrap_or(""))).collect();+ if parts.is_empty() { parts.push(format!("every step {}", h["fallback"].as_str().unwrap_or("auto"))); }+ else { parts.push(format!("the rest {}", h["fallback"].as_str().unwrap_or("auto"))); }+ format!("{}; notify {}, screen {}", parts.join(", "), h["notify"].as_str().unwrap_or("hydrogen"), h["screen"].as_str().unwrap_or("hands-off"))+}++fn latest_visit(data: &Value, step: &str) -> Option<(u64, String)> {+ let v = data["steps"][step]["visits"].as_array()?.last()?;+ Some((v["n"].as_u64().unwrap_or(1), v["at"].as_str().unwrap_or("").to_string()))+}++fn latest<'a>(list: &'a Value, step: &str, visit: u64) -> Option<&'a Value> {+ list.as_array()?.iter().rev().find(|e| e["step"] == step && e["visit"].as_u64() == Some(visit))+}++/// The human's latest word on a step's visit: approved, changes or done.+pub fn verdict<'a>(data: &'a Value, step: &str, visit: u64) -> Option<&'a Value> { latest(&hands(data)["verdicts"], step, visit) }++/// What the run is waiting on from the human, if anything: the plan, a check-in or a human step.+pub fn waiting(data: &Value) -> Option<Value> {+ if planning(data) { return Some(json!({"kind": "plan", "what": "the human to choose how hands-on each step is"})); }+ if !active(data) { return None; }+ let set = hands(data)["setAt"].as_str().unwrap_or("");+ let cur = data["currentStep"].as_str().unwrap_or("");+ if let Some((n, at)) = latest_visit(data, cur).filter(|(_, at)| at.as_str() >= set) {+ if mode_of(data, cur) == "human" && !verdict(data, cur, n).is_some_and(|v| v["verdict"] == "done" || v["verdict"] == "approved") {+ return Some(json!({"kind": "human", "step": cur, "visit": n, "since": at, "what": format!("the human to do {cur} and say done")}));+ }+ }+ // an open check-in: the latest ask on a step's latest visit with no verdict after it+ for a in hands(data)["asks"].as_array().into_iter().flatten().rev() {+ let (Some(s), Some(n)) = (a["step"].as_str(), a["visit"].as_u64()) else { continue };+ if latest_visit(data, s).map(|(v, _)| v) != Some(n) { continue; }+ let answered = verdict(data, s, n).is_some_and(|v| v["at"].as_str() >= a["at"].as_str());+ if !answered { return Some(json!({"kind": "checkin", "step": s, "visit": n, "since": a["at"], "summary": a["summary"], "what": format!("the human to review {s}")})); }+ }+ None+}++/// What stops a forward move to `target` on the human's side. Only visits that started after the+/// plan was confirmed count; a step's own proof is still `sequence::blockers`' business.+pub fn blockers(data: &Value, seq: &[String], target: &str) -> Vec<String> {+ if planning(data) && target != "intake" {+ return vec!["hands-on plan (the human has not confirmed how hands-on each step is: they choose on the timeline, or say it in chat for `handson set` + `handson start`)".into()];+ }+ if !active(data) { return vec![]; }+ let Some(pos) = seq.iter().position(|s| s == target) else { return vec![] };+ let set = hands(data)["setAt"].as_str().unwrap_or("").to_string();+ let mut out = Vec::new();+ for s in &seq[..pos] {+ let Some((n, at)) = latest_visit(data, s) else { continue };+ if at < set { continue; }+ let mode = mode_of(data, s);+ let ask = latest(&hands(data)["asks"], s, n);+ let asked = ask.is_some();+ // a verdict older than the latest ask was about the work before it (changes made, asked again)+ let v = verdict(data, s, n).filter(|v| ask.map_or(true, |a| v["at"].as_str() >= a["at"].as_str()));+ let said = |v: Option<&Value>| v.and_then(|v| v["quote"].as_str()).filter(|q| !q.is_empty()).map(|q| format!(": \"{}\"", aiflow_run::clip_text(q, 160))).unwrap_or_default();+ match (mode, v.and_then(|v| v["verdict"].as_str())) {+ (_, Some("changes")) => out.push(format!("{s} (hands-on: the human asked for changes{}; make them, then `handson ask {s}` again)", said(v))),+ ("human", Some("done" | "approved")) => {}+ ("human", _) => out.push(format!("{s} (hands-on: the human's step; they have not said they are done)")),+ (_, Some("approved")) => {}+ ("checkin", None) if !asked => out.push(format!("{s} (hands-on check-in: show the human what you did, then `handson ask {s} --summary \"...\"` and wait for their OK)")),+ (_, None) if asked || mode == "checkin" => out.push(format!("{s} (hands-on: waiting for the human's review)")),+ _ => {}+ }+ }+ out+}++/// What finish still needs from the human: every check-in and human step on the way to finish.+pub fn finish_missing(data: &Value, seq: &[String]) -> Vec<String> {+ let end = seq.iter().position(|s| s == "finish").map(|p| seq[p].clone()).unwrap_or_else(|| "\u{0}end".into());+ let mut s2: Vec<String> = seq.to_vec();+ if !s2.contains(&end) { s2.push(end.clone()); }+ blockers(data, &s2, &end).into_iter().filter(|b| !b.starts_with("hands-on plan")).collect()+}++/// How the human's own work becomes the step's proof: the step's own check, run on what they made.+pub fn human_proof(step: &str, flow: &Value) -> String {+ match step {+ "placement" => "save it, `adopt-board --board <their saved board> --evidence <a note: placed by the human>`, then `place verify`: their placement becomes the placement proof".into(),+ "routing" => "`adopt-board` their routed board, then `route` (it plans only what their copper leaves open; nothing when it is complete), `gate --plan <run>/plan-route.json` and `land route --plan <run>/plan-route.json`: an empty plan lands nothing and records the routing proof. Finish any open connections only if they ask you to".into(),+ "pours" => "`adopt-board`, then `verify-pours --adopt --why \"poured by the human\"`".into(),+ "schematic" => "`schematic check --sch <their schematic>`".into(),+ "silkscreen" => "`silkscreen-audit --input <audit of their silkscreen> --out <result> --record`".into(),+ "simulation" => "`evidence add --kind simulation --file <their result>`".into(),+ _ => {+ let keys: Vec<&str> = flow["proofs"][step].as_array().into_iter().flatten().filter_map(Value::as_str).collect();+ if keys.is_empty() { "nothing to record: the step has no proof".into() } else { format!("run this step's own check on their work so it records {}", keys.join(" or ")) }+ }+ }+}++/// The hints `step <name>` prints for the step's mode.+pub fn step_hints(data: &Value, step: &str, flow: &Value) -> Vec<String> {+ if !active(data) { return vec![]; }+ let mut out = Vec::new();+ let does = flow["short"][step].as_str().unwrap_or("");+ match mode_of(data, step) {+ "checkin" => out.push(format!("HANDS-ON CHECK-IN: do {step}, then show the human what you did (the board or the file, a screenshot, the numbers that matter) and run `handson ask {step} --summary \"<what to look at>\"`. The next step is refused until they approve (timeline button or chat, then `handson approve {step} --quote \"<their words>\"`). Wait with `handson wait`; never approve for them.")),+ "human" => out.push(format!("HANDS-ON HUMAN STEP: the human does {step} ({does}) themselves. Do NOT do its work. Tell them what it needs and where (the editor they use), then wait (`handson wait`). When they say done (timeline button, or chat: `handson done {step} --quote \"<their words>\"`), {}. If the check fails, show them what failed and ask: fix it, let you fix it, or deviate.", human_proof(step, flow))),+ "custom" => {}+ _ => out.push(format!("Hands-on: {step} is yours to do without asking (flow \"{}\").", hands(data)["profileName"].as_str().unwrap_or("this run's plan"))),+ }+ if let Some(t) = instructions_of(data, step) { out.push(format!("THE HUMAN'S INSTRUCTIONS FOR {}: \"{t}\". Follow them; when they ask to see the result, `handson ask {step}` makes it a check-in.", step.to_uppercase())); }+ out+}++fn text_ok(s: &str) -> Result<&str, String> {+ let s = s.trim();+ if s.len() > MAX_TEXT { Err(format!("over {MAX_TEXT} characters")) } else { Ok(s) }+}++/// Record a check-in: the AI asks the human to look at a step's latest visit.+pub fn ask(data: &mut Value, step: &str, summary: &str, at: &str) -> Result<u64, String> {+ if !active(data) { return Err("no hands-on plan on this run (`handson plan` or `handson use <profile>` first)".into()); }+ let (n, _) = latest_visit(data, step).ok_or_else(|| format!("{step} has not been visited; `step {step}` and do it first"))?;+ let summary = text_ok(summary)?;+ data["handsOn"]["asks"].as_array_mut().unwrap().push(json!({"step": step, "visit": n, "summary": summary, "at": at}));+ Ok(n)+}++/// Record the human's word on a step: approved, changes or done. `by` is chat or dashboard.+pub fn record_verdict(data: &mut Value, step: &str, verdict: &str, quote: &str, by: &str, at: &str) -> Result<u64, String> {+ if !["approved", "changes", "done"].contains(&verdict) { return Err(format!("verdict {verdict:?}")); }+ if !active(data) { return Err("no hands-on plan on this run".into()); }+ let (n, _) = latest_visit(data, step).ok_or_else(|| format!("{step} has not been visited"))?;+ let quote = text_ok(quote)?;+ if by == "chat" && quote.is_empty() { return Err("--quote the human's own words: an approval is theirs to give, never inferred".into()); }+ if verdict == "changes" && quote.is_empty() { return Err("changes need the human's words: what to change".into()); }+ data["handsOn"]["verdicts"].as_array_mut().unwrap().push(json!({"step": step, "visit": n, "verdict": verdict, "quote": quote, "by": by, "at": at}));+ Ok(n)+}++/// Apply the timeline's queued answers (POST /handson) to the run; returns one line per event.+/// Profiles named in a plan are saved here, under the human's own name for them.+pub fn ingest(data: &mut Value, timeline_dir: &Path) -> Vec<String> {+ let Some(inbox) = read(&timeline_dir.join(INBOX)) else { return vec![] };+ let seen = hands(data)["inboxSeq"].as_u64().unwrap_or(0);+ let mut out = Vec::new();+ let mut top = seen;+ for e in inbox["events"].as_array().into_iter().flatten() {+ let seq = e["seq"].as_u64().unwrap_or(0);+ if seq <= seen { continue; }+ top = top.max(seq);+ let at = e["at"].as_str().unwrap_or("").to_string();+ let note = e["note"].as_str().unwrap_or("");+ let line = match e["action"].as_str().unwrap_or("") {+ "plan" => match normalise(&e["plan"]) {+ Ok(plan) => {+ let name = e["plan"]["saveAs"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);+ let mut msg = String::new();+ let saved = match &name { Some(n) => match save_profile(n, &plan, &at) { Ok(s) => { msg = format!(", saved as profile \"{n}\" ({s})"); Some((s, n.clone())) } Err(x) => { msg = format!(", NOT saved as a profile: {x}"); None } }, None => None };+ if e["plan"]["makeDefault"] == true { if let Some((s, _)) = &saved { if set_default(s).is_ok() { msg.push_str(", now their default"); } } }+ let prof = saved.clone().or_else(|| hands(data)["profile"].as_str().map(|s| (s.to_string(), hands(data)["profileName"].as_str().unwrap_or(s).to_string())));+ apply(data, &plan, "active", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "timeline", &at);+ format!("HUMAN (timeline): confirmed the hands-on plan{msg}: {}", summary(data))+ }+ Err(x) => format!("HUMAN (timeline): sent a plan that was refused ({x}); ask them in chat"),+ },+ v @ ("approved" | "changes" | "done") => {+ let step = e["step"].as_str().unwrap_or("");+ match record_verdict(data, step, v, note, "dashboard", &at) {+ Ok(n) => format!("HUMAN (timeline): {v} {step} (visit {n}){}", if note.is_empty() { String::new() } else { format!(": \"{note}\"") }),+ Err(x) => format!("HUMAN (timeline): {v} on {step} not recorded ({x})"),+ }+ }+ other => format!("HUMAN (timeline): unknown action {other:?} ignored"),+ };+ out.push(line);+ }+ if top > seen {+ if !data["handsOn"].is_object() { data["handsOn"] = json!({"state": "off"}); }+ data["handsOn"]["inboxSeq"] = json!(top);+ }+ out+}++/// The dashboard's view: the plan, every profile for the picker, and what the run waits on.+pub fn timeline_block(data: &Value) -> Value {+ let h = hands(data);+ let profiles: Vec<Value> = list_profiles().into_iter().map(|p| json!({"slug": p["slug"], "name": p["name"], "default": p["default"], "builtin": p["builtin"].as_bool().unwrap_or(false),+ "steps": p["steps"], "fallback": p["fallback"], "notify": p["notify"], "screen": p["screen"]})).collect();+ let mut steps = serde_json::Map::new();+ if let Some(o) = data["steps"].as_object() {+ for (name, _) in o {+ if let Some((n, _)) = latest_visit(data, name) {+ if let Some(v) = verdict(data, name, n) { steps.insert(name.clone(), json!({"visit": n, "verdict": v["verdict"], "quote": v["quote"], "by": v["by"], "at": v["at"]})); }+ }+ }+ }+ json!({"state": h["state"].as_str().unwrap_or("off"), "profile": h["profile"], "profileName": h["profileName"], "source": h["source"],+ "plan": {"steps": h["steps"], "fallback": h["fallback"].as_str().unwrap_or("auto"), "notify": h["notify"].as_str().unwrap_or("hydrogen"), "screen": h["screen"].as_str().unwrap_or("hands-off")},+ "verdicts": steps, "waiting": waiting(data), "profiles": profiles,+ "modes": MODES.iter().map(|m| json!({"mode": m, "label": mode_label(m)})).collect::<Vec<_>>()})+}++/// Tell the human the run needs them, the way their profile says. Never raises a window.+pub fn notify(data: &Value, ai_thread: &str, title: &str, body: &str) -> String {+ if std::env::var("AIFLOW_NOTIFY").map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "0" | "false")).unwrap_or(false) { return "notification skipped (AIFLOW_NOTIFY=off)".into(); }+ let how = hands(data)["notify"].as_str().unwrap_or("hydrogen");+ let mut sent = Vec::new();+ let run = |prog: &str, args: Vec<String>| -> bool {+ let Ok(mut c) = std::process::Command::new(prog).args(&args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() else { return false };+ let t = std::time::Instant::now();+ while t.elapsed() < std::time::Duration::from_secs(8) { if let Ok(Some(s)) = c.try_wait() { return s.success(); } std::thread::sleep(std::time::Duration::from_millis(100)); }+ true // still running: the notification is on its way+ };+ if matches!(how, "hydrogen" | "both") && run("adom-cli", vec!["notify".into(), "--title".into(), title.into(), "--tag".into(), "aiflow-handson".into(), body.into()]) { sent.push("Hydrogen"); }+ if matches!(how, "toast" | "both") {+ let mut a = vec!["--ai-thread".to_string(), ai_thread.to_string()];+ if let Some(t) = data["target"].as_str().filter(|t| !t.is_empty()) { a.extend(["--target".to_string(), t.to_string()]); }+ a.extend(["notify_user".to_string(), json!({"title": title, "body": body, "scenario": "reminder"}).to_string()]);+ if run("adom-bridge", a) { sent.push("a desktop toast"); }+ }+ if how == "none" { "notifications are off in this flow: tell the human in chat".into() }+ else if sent.is_empty() { format!("could not notify the human ({how}): tell them in chat") }+ else { format!("the human was notified ({})", sent.join(" and ")) }+}++#[cfg(test)]+mod tests {+ use super::*;+ fn seq() -> Vec<String> { ["intake", "rules", "placement", "routing", "pours", "finish"].iter().map(|s| s.to_string()).collect() }+ fn run_with(plan: Value) -> Value {+ let mut d = json!({"steps": {}, "currentStep": "intake"});+ apply(&mut d, &normalise(&plan).unwrap(), "active", Some(("t", "Test flow")), "test", "2026-11-03T10:00:00Z");+ d+ }+ fn visit(d: &mut Value, step: &str, at: &str) {+ let st = d["steps"].as_object_mut().unwrap().entry(step.to_string()).or_insert(json!({"visits": []}));+ let n = st["visits"].as_array().unwrap().len() as u64 + 1;+ st["visits"].as_array_mut().unwrap().push(json!({"n": n, "at": at}));+ d["currentStep"] = json!(step);+ }+ #[test]+ fn slug_and_validation() {+ assert_eq!(slug(" Noah flow! "), "noah-flow");+ assert!(normalise(&json!({"steps": {"placement": {"mode": "custom"}}})).is_err());+ assert!(normalise(&json!({"steps": {"placement": {"mode": "maybe"}}})).is_err());+ assert!(normalise(&json!({"steps": {}, "screen": "grab"})).is_err());+ let p = normalise(&json!({"steps": {"routing": {"mode": "human"}}})).unwrap();+ assert_eq!(p["screen"], "hands-off");+ assert_eq!(p["notify"], "hydrogen");+ }+ #[test]+ fn a_checkin_step_blocks_until_the_human_approves_its_latest_visit() {+ let mut d = run_with(json!({"steps": {"placement": {"mode": "checkin"}}}));+ visit(&mut d, "placement", "2026-11-03T10:05:00Z");+ let b = blockers(&d, &seq(), "routing");+ assert!(b[0].contains("handson ask placement"), "{b:?}");+ ask(&mut d, "placement", "look at the gate loops", "2026-11-03T10:06:00Z").unwrap();+ assert_eq!(waiting(&d).unwrap()["kind"], "checkin");+ assert!(blockers(&d, &seq(), "routing")[0].contains("waiting"));+ assert!(record_verdict(&mut d, "placement", "approved", "", "chat", "2026-11-03T10:07:00Z").is_err(), "chat approvals quote the human");+ record_verdict(&mut d, "placement", "changes", "move U2 next to U1", "dashboard", "2026-11-03T10:07:00Z").unwrap();+ assert!(blockers(&d, &seq(), "routing")[0].contains("move U2"));+ ask(&mut d, "placement", "U2 moved", "2026-11-03T10:08:00Z").unwrap();+ assert!(blockers(&d, &seq(), "routing")[0].contains("waiting"), "a new ask supersedes the old changes");+ record_verdict(&mut d, "placement", "approved", "good", "chat", "2026-11-03T10:09:00Z").unwrap();+ assert!(blockers(&d, &seq(), "routing").is_empty());+ assert!(waiting(&d).is_none());+ // a return to placement is a new visit: it needs its own approval+ visit(&mut d, "routing", "2026-11-03T10:10:00Z");+ visit(&mut d, "placement", "2026-11-03T10:20:00Z");+ assert!(!blockers(&d, &seq(), "routing").is_empty());+ }+ #[test]+ fn a_human_step_waits_for_done_and_history_before_the_plan_is_not_reviewed() {+ let mut d = json!({"steps": {}, "currentStep": "intake"});+ visit(&mut d, "placement", "2026-11-03T09:00:00Z");+ apply(&mut d, &normalise(&json!({"steps": {"placement": {"mode": "checkin"}, "routing": {"mode": "human"}}})).unwrap(), "active", None, "test", "2026-11-03T10:00:00Z");+ assert!(blockers(&d, &seq(), "routing").is_empty(), "placement was visited before the plan");+ visit(&mut d, "routing", "2026-11-03T10:30:00Z");+ assert_eq!(waiting(&d).unwrap()["kind"], "human");+ assert!(blockers(&d, &seq(), "pours")[0].contains("not said they are done"));+ record_verdict(&mut d, "routing", "done", "routed it", "chat", "2026-11-03T11:00:00Z").unwrap();+ assert!(blockers(&d, &seq(), "pours").is_empty());+ assert!(finish_missing(&d, &seq()).is_empty());+ }+ #[test]+ fn planning_blocks_design_work_and_auto_never_blocks() {+ let mut d = json!({"steps": {}, "currentStep": "intake"});+ apply(&mut d, &normalise(&json!({"steps": {}})).unwrap(), "planning", None, "test", "2026-11-03T10:00:00Z");+ assert!(!blockers(&d, &seq(), "rules").is_empty());+ assert!(blockers(&d, &seq(), "intake").is_empty());+ assert_eq!(waiting(&d).unwrap()["kind"], "plan");+ let mut d = run_with(json!({"steps": {"placement": {"mode": "custom", "instructions": "keep U1 near J1"}}}));+ visit(&mut d, "placement", "2026-11-03T10:05:00Z");+ assert!(blockers(&d, &seq(), "routing").is_empty());+ assert!(step_hints(&d, "placement", &json!({})).iter().any(|h| h.contains("keep U1 near J1")));+ assert_eq!(screen(&d), Some("hands-off"));+ assert_eq!(screen(&json!({})), None, "runs without hands-on take the machine default (screen.rs)");+ }+ #[test]+ fn profiles_and_the_timeline_inbox() {+ let tmp = std::env::temp_dir().join(format!("aiflow-handson-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&tmp);+ std::env::set_var("AIFLOW_PREFS_DIR", tmp.join("prefs"));+ let tl = tmp.join("timeline");+ std::fs::create_dir_all(&tl).unwrap();+ let mut d = json!({"steps": {}, "currentStep": "intake"});+ apply(&mut d, &normalise(&json!({"steps": {}})).unwrap(), "planning", None, "test", "2026-11-03T10:00:00Z");+ write_atomic(&tl.join(INBOX), &json!({"events": [{"seq": 1, "at": "2026-11-03T10:01:00Z", "action": "plan",+ "plan": {"steps": {"routing": {"mode": "human"}}, "notify": "none", "screen": "hands-off", "saveAs": "Noah flow"}}]})).unwrap();+ let lines = ingest(&mut d, &tl);+ assert!(lines[0].contains("saved as profile"), "{lines:?}");+ assert!(active(&d));+ assert_eq!(mode_of(&d, "routing"), "human");+ assert_eq!(default_slug().as_deref(), Some("noah-flow"), "the first saved profile is the default");+ assert!(ingest(&mut d, &tl).is_empty(), "an event is applied once");+ let mut fresh = json!({"steps": {}});+ assert!(apply_default(&mut fresh, "2026-11-03T12:00:00Z").unwrap().contains("Noah flow"));+ assert_eq!(mode_of(&fresh, "routing"), "human");+ assert_eq!(list_profiles().len(), 2);+ assert!(save_profile("full ai", &plan_of(&fresh), "x").is_err());+ delete_profile("noah-flow").unwrap();+ assert!(default_slug().is_none());+ std::env::remove_var("AIFLOW_PREFS_DIR");+ let _ = std::fs::remove_dir_all(&tmp);+ }+}
crates/adom-aiflow/src/main.rs+332−17@@ -37,6 +37,7 @@ mod project_lib; const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html"); mod library_review; mod checks;+mod molecule_pins; mod preboard; mod mechanical; mod fills;@@ -52,11 +53,14 @@ mod boardkind; mod pagedir; mod zone_ownership; mod gate_eval;+mod route_quality; mod route_variants; mod impedance_cmd; mod reviews; mod deviations; mod prefs;+mod handson;+mod screen; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -267,6 +271,8 @@ enum Cmd { SpecAmend { #[arg(long)] why: String }, /// Accept one item `finish` (or a `step`) refused, on purpose, when the check rather than the board is wrong: `deviate "<item>" --why "..."` (the item as the refusal names it, e.g. "gate", "heatsink vias", "step routing"). Recorded, bound to the saved board and spec, and listed in the finish summary; `--list` shows them, `--clear` removes one. Deviate { item: Option<String>, #[arg(long)] why: Option<String>, #[arg(long)] list: bool, #[arg(long)] clear: bool },+ /// Hands-on AI Flow (docs/hands-on.md): the human chooses, step by step, whether the AI takes care of it (auto), does it and checks in (checkin), leaves it to them (human) or follows their instructions (custom), saved as flow profiles. `plan [--profile P]` shows the choice on the timeline before any design work; `set STEP=MODE ... [--note STEP=TEXT]`, `start [--save-as NAME] [--default]` confirm it from chat; `use <profile>` switches (full-ai is built in); `ask STEP --summary`, `approve|changes|done STEP --quote`, `wait` carry the human's part; `status`, `profiles [--profile P --default] [--delete P]`, `off`.+ Handson { #[arg(value_parser = ["plan", "set", "start", "use", "ask", "approve", "changes", "done", "wait", "status", "profiles", "off"])] action: String, args: Vec<String>, #[arg(long)] profile: Option<String>, #[arg(long)] save_as: Option<String>, #[arg(long)] default: bool, #[arg(long)] quote: Option<String>, #[arg(long)] summary: Option<String>, #[arg(long)] note: Vec<String>, #[arg(long)] notify: Option<String>, #[arg(long)] screen: Option<String>, #[arg(long, default_value_t = 540)] timeout: u64, #[arg(long)] delete: Option<String> }, /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back. Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> }, /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.@@ -305,7 +311,7 @@ enum Cmd { #[arg(long)] on: Option<String> }, /// Print the append-only ledger (run.jsonl): every turn with its thinking and tool seconds, every step visit and return, every stage and outcome, in order. Ledger { #[arg(long)] json: bool },- /// open, start, mark <label>, stop+ /// open, start, mark <label>, stop, reject <step>-<visit>, screen [hands-off|ask|allowed] (may AI Flow bring windows to the front on the run's desktop; no mode prints the current one) Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String> }, /// The comparison page: a sub-README for this run and another (`compose --with` first), the side-by-side video first, then the numbers from both ledgers, the AIs' drawings side by side and the links to both run pages; --push puts it on the page under docs/comparisons/<a>-vs-<b>/ Compare { #[arg(long)] with: PathBuf, #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool },@@ -818,6 +824,28 @@ fn container_host() -> Option<String> { std::fs::read_to_string("/proc/sys/kernel/hostname").ok().map(|h| h.trim().to_string()).filter(|h| !h.is_empty()) } +/// The run's screen mode (screen.rs, issues #132/#135), recorded in run.json `screenMode` and said+/// once, when it is first set or changes. Whether the target is the human's own machine is kept per+/// target, so a step does not ask `adom-bridge targets` every time.+fn screen_mode(r: &mut Run) -> screen::Mode {+ let target = r.data["target"].as_str().unwrap_or("").to_string();+ let prev = r.data["screenMode"].clone();+ let own = prev["ownMachine"].as_bool().filter(|_| prev["target"].as_str() == Some(target.as_str())).unwrap_or_else(|| {+ let local = std::env::var("ADOM_DESKTOP_MODE").ok().as_deref() == Some("local") || std::env::var("HOME").map(|h| Path::new(&h).join(".adom/hd-control-url").is_file()).unwrap_or(false);+ let host = local.then(|| std::fs::read_to_string("/proc/sys/kernel/hostname").ok()).flatten().map(|h| h.trim().to_string());+ let targets = std::process::Command::new("adom-bridge").arg("targets").output().ok().and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok());+ screen::own_machine(&target, local, host.as_deref(), targets.as_ref())+ });+ let spec = r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok()).unwrap_or(Value::Null);+ let env = std::env::var("AIFLOW_SCREEN").ok();+ let (mode, source, notes) = screen::decide(env.as_deref(), r.data["screen"].as_str(), handson::screen(&r.data), spec["capture"]["screen"].as_str(), &target, own);+ for n in ¬es { eprintln!("WARNING: {n}"); }+ let rec = json!({"mode": mode.as_str(), "source": source, "ownMachine": own, "target": target});+ if prev["mode"] != rec["mode"] || prev["source"] != rec["source"] { eprintln!("{}", screen::line(mode, &source)); }+ if prev != rec { r.log("screen-mode", rec.clone()); r.data["screenMode"] = rec; }+ mode+}+ /// The board's own copper as plan-shaped entries (one per net and track width, one per via), so the /// analyses see a board whose copper is already on it (an adopted, hand-reworked or natively routed /// board) and not only what a routing plan adds.@@ -2119,6 +2147,23 @@ fn latest_shot(dir: &Path, step: &str) -> Option<String> { of_step.or(shots.first()).map(|(_, p)| p.display().to_string()) } +/// Issue #137: a molecule spec's board is published by adom/molecule-publish, a separate package,+/// onto a page of kind molecule whose model, footprint and symbol the wiki's model registry builds.+/// The hint for export, finish and deliver until the run records the publish (`stage end+/// molecule-publish --result <page>`); None for a plain board or once it is recorded.+fn molecule_publish_hint(r: &Run) -> Option<String> {+ if spec_soft(r).get("molecule").is_none() || molecule_published(&r.data) { return None; }+ Some("NEXT (molecule): publish it with molecule-publish, a separate package (`adom-wiki pkg install adom/molecule-publish`; skill molecule-publish). Its bundle is the STEP, the board, every schematic sheet, `<slug>-fab.zip` and `export.json` (both made with molecule-publish's scripts/fab_bundle.py create from the same saved board as the Gerbers `adom-aiflow export` wrote); check names first with its scripts/pin_names.py. The page is kind molecule: never push a GLB or footprint/symbol JSON and never bind component.parts.model_3d there; the wiki's model registry builds the model, footprint and symbol, and the publish is proven by `adom-wiki model status` showing the model built by the pipeline with metadataComplete true. Then record it: `adom-aiflow stage end molecule-publish --result <owner/slug>` (skill aiflow-molecule, section 5).".into())+}++/// Issue #137: what a molecule's project page is for (kind molecule) and what never goes on it.+const MOLECULE_PAGE_HINT: &str = "This page was created as kind molecule (the spec has \"molecule\"): molecule-publish puts the sources here after export (STEP, board, schematic, <slug>-fab.zip, export.json) and the wiki's model registry builds its GLB, footprint and symbol. Do not push a GLB or bind component.parts.model_3d on it; molecule-publish refuses them (REGISTRY_MODEL_FILE_REFUSED).";++/// The run recorded the molecule publish (`stage end molecule-publish`).+fn molecule_published(data: &Value) -> bool {+ data["stages"]["molecule-publish"]["runs"].as_array().is_some_and(|a| a.iter().any(|x| !x["end"].is_null()))+}+ /// The give-back hint: what the AI learned belongs to every Adom user. fn give_back_hint() -> String { "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()@@ -2767,9 +2812,13 @@ fn main() { let mut report = continuation_report(&r, stop_evidence.as_deref()); let warning = overdue_warning(&r); if let (Some(w), Some(o)) = (&warning, report.as_object_mut()) { o.insert("warning".into(), json!(w)); }+ // issue #137: an open item worth a look, never a requirement (the decision is unchanged)+ let suggestions: Vec<String> = molecule_publish_hint(&r).map(|h| format!("molecule not published yet: {}", h.trim_start_matches("NEXT (molecule): "))).into_iter().collect();+ if let Some(o) = report.as_object_mut() { o.insert("suggestions".into(), json!(suggestions)); } if *as_json { println!("{}", serde_json::to_string_pretty(&report).unwrap()); } else { println!("{}: {}", report["decision"].as_str().unwrap_or("continue"), report["reason"].as_str().unwrap_or("")); println!("Next: {}", report["nextAction"].as_str().unwrap_or("review remaining scope"));+ for s in &suggestions { println!("Suggestion: {s}"); } if let Some(w) = &warning { println!("WARNING: {w}"); } } std::process::exit(if report["mayStop"] == true { 0 } else { 2 }); }@@ -2997,6 +3046,7 @@ fn main() { let mut hints: Vec<String> = report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}")).collect(); hints.extend(report["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str()).map(str::to_string)); hints.push("Tell the human where every file is. Bottom-side CPL rotations follow kicad-cli's convention and were counted, not compared; check them against the fab's preview.".into());+ if let Some(h) = molecule_publish_hint(&r) { hints.push(h); } let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r))); let checked = verified::run_items(&r, facts.as_ref()); ok(&format!("manufacturing files in {}, checked against the board: Gerbers ({} copper layers of {}, pours present as regions on {} poured layer(s)), drill ({} hits = vias and drilled pads), pick and place ({} parts), BOM ({} lines)\n{}", out_dir.display(), report["gerbers"]["copperLayers"], report["gerbers"]["boardCopperLayers"], report["fillsGerbers"]["layersWithPours"].as_object().map(|o| o.len()).unwrap_or(0), report["drill"]["hits"], report["cpl"]["refs"], report["bom"]["lines"], verified::message(&checked)), &hints);@@ -3073,6 +3123,7 @@ fn main() { r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone())); if let Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); } if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }+ if r.data["target"].is_string() { screen_mode(&mut r); } let idx = r.turn_begin(&command_line()); record_usage(&mut r, "start"); r.save().unwrap();@@ -3098,6 +3149,8 @@ fn main() { r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone())); if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); } if let Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }+ let hands_line = handson::apply_default(&mut r.data, &now());+ if target.is_some() { screen_mode(&mut r); } let idx = r.turn_begin(&command_line()); record_usage(&mut r, "start"); r.save().unwrap();@@ -3113,6 +3166,7 @@ fn main() { start_hints.extend(converter_warning(&r.data, &spec_v, None, Board::load(©.display().to_string()).ok().as_ref())); if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); } start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);+ start_hints.extend(hands_line); ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints); print_next(&cli); }@@ -3135,6 +3189,8 @@ fn main() { if let Some(e) = eda { r.data["eda"] = json!(e); } // a passive copper-pattern board: the pattern sequence (boardkind.rs) if let Some(k) = kind { r.data["kind"] = json!(k); }+ let hands_line = handson::apply_default(&mut r.data, &now()).unwrap_or_else(|| "Hands-on AI Flow: when the human asks for it (\"hands-on\", \"check in with me after placement\", \"I'll route it myself\"), run `handson plan` now, before any design work: they choose per step on the timeline, and the choice is saved as their flow profile for the next board (skill aiflow-hands-on).".into());+ if target.is_some() { screen_mode(&mut r); } let idx = r.turn_begin(&command_line()); record_usage(&mut r, "start"); r.save().unwrap();@@ -3147,6 +3203,7 @@ fn main() { "When the board exists: `start --board B.kicad_pcb --spec spec.json --engine <you> [--target <box> --remote-board <path>]` on this same --run attaches it; the clock stays at the prompt.".into(), if r.data["page"].is_null() { "Name the run's page once (`clips --page <owner/slug>` or `report --page ...`) so every clip lands on docs/clips.md as it stops.".into() } else { String::new() }, "Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it.".into(),+ hands_line, ]); print_next(&cli); }@@ -3390,6 +3447,8 @@ fn main() { 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()); let lane = sequence::lane(&dir, &r.data); let cfg = json!({"run": dir.display().to_string(), "eda": lane, "page": page, "pageDir": page_dir, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0",+ // issue #137: a molecule's page is kind molecule; molecule-publish puts its sources there+ "molecule": spec.get("molecule").is_some(), "libRef": r.data["cloudLibrary"].as_str().or(r.data["outcomes"]["project-page"]["cloudLibrary"].as_str())}); let cfgp = dir.join("project-page").join("config.json"); let _ = std::fs::create_dir_all(cfgp.parent().unwrap());@@ -3419,12 +3478,14 @@ fn main() { let from_lib: Vec<&str> = v["projectLibParts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect(); ok(&format!("{}: README revision 1 with {} parts and the component table (Altium Designer: the library step builds the SchLib and PcbLib from these same wiki pages{})", page_said, v["parts"], if from_lib.is_empty() { String::new() } else { format!(" and, for {}, from the board's project library", from_lib.join(", ")) }), &[if from_lib.is_empty() { String::new() } else { format!("from the board's project library (no public page yet): {}", from_lib.join(", ")) },+ if v["kind"] == "molecule" { MOLECULE_PAGE_HINT.into() } else { String::new() }, "Next: `step library`, then `adom-aiflow altium library`: one SchLib and one PcbLib from the parts' wiki pages (project-library parts from their own KiCad files, through the same converters). Every step you leave is pushed to this page as steps/<nn>-<step>/README.md.".into()]); return; } let c = &v["counts"]; let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect(); if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+ if v["kind"] == "molecule" { hints.push(MOLECULE_PAGE_HINT.into()); } ok(&format!("{}: README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", page_said, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")), &[hints, if *no_push { vec![format!("Local build only: the README, PCM zip and repository files are in {}. The repository URL above works only after `project-page` runs without --no-push.", dir.join("project-page").display())] } else { vec![] }, vec![format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]].concat()); }@@ -3464,8 +3525,12 @@ fn main() { let symbols = schematic_symbols::check(Path::new(sch), &spec_soft(&r)).unwrap_or_else(|e| json!({"ok": false, "errors": [format!("could not read the hierarchy: {e}")], "warnings": []})); schematic_symbols::record(&mut r, sch, &symbols); let pass = erc_errors.is_empty() && missing.is_empty() && foreign.is_empty() && symbols["ok"] == true;+ // issue #138: name machine pins and contacts the way the wiki's model registry counts them+ // while it is one edit in the schematic; a hint, never a refusal+ let pin_names = molecule_pins::names_from_footprints(&on_sheet.iter().map(|(rf, (_, fp))| (rf.clone(), fp.clone())).collect::<Vec<_>>());+ let pin_hints: Vec<String> = pin_names.iter().map(|(finding, suggestion)| format!("Molecule names: {finding}. {suggestion}")).collect(); let canon = std::fs::canonicalize(sch).map(|p| p.display().to_string()).unwrap_or(sch.clone());- r.outcome("schematic", json!({"pass": pass, "schematic": canon, "sha256": sha256_file(sch), "ercErrors": erc_errors, "symbols": on_sheet.len(), "missingRefs": missing, "notFromProjectLibrary": foreign, "notInBom": extra, "library": lib}));+ r.outcome("schematic", json!({"pass": pass, "schematic": canon, "sha256": sha256_file(sch), "ercErrors": erc_errors, "symbols": on_sheet.len(), "missingRefs": missing, "notFromProjectLibrary": foreign, "notInBom": extra, "library": lib, "moleculeNames": pin_names.iter().map(|(a, b)| json!({"finding": a, "suggestion": b})).collect::<Vec<_>>()})); if pass { r.data["schematic"] = json!(canon); } r.save().unwrap(); if !pass {@@ -3477,10 +3542,11 @@ 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));+ hints.extend(pin_hints.iter().cloned()); 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()]);+ &[pin_hints, vec![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()]].concat()); } Cmd::Components { etch } => { thread(&cli);@@ -3778,6 +3844,12 @@ fn main() { let mut route_o = json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}); if !variants_rec.is_null() { route_o["variants"] = variants_rec; } if !report["impedance"].is_null() { route_o["impedance"] = report["impedance"].clone(); }+ // issues #127/#128: route geometry and signal quality of the plan on this board, suggestions only+ let quality_lines = match route_quality::of_plan(&board_of(&r), &plan_doc, &spec) {+ Some((qv, lines)) => { let qp = dir.join("plan-route-quality.json"); write_json(&qp, &qv); route_o["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string()); lines }+ None => Vec::new(),+ };+ for l in &quality_lines { eprintln!("{l}"); } r.outcome("route", route_o); r.save().unwrap(); let policy_hint = if regions.is_empty() { String::new() } else if preexisting.is_empty() {@@ -3900,7 +3972,12 @@ fn main() { // the checks themselves are gate_eval::evaluate, shared with `route --variants` (issue #55) let base = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&e, &[])); let routed = dir.join("board-routed.kicad_pcb");- let gate_eval::GateEval { mut s, pu, pu_issues, sw_issues, dec, mech, stairs, .. } = gate_eval::evaluate(&r, &b, &plan_v, &routed, &base, true).unwrap_or_else(|e| err(&e, &[]));+ let gate_eval::GateEval { mut s, pu, pu_issues, sw_issues, dec, mech, stairs, quality, .. } = gate_eval::evaluate(&r, &b, &plan_v, &routed, &base, true).unwrap_or_else(|e| err(&e, &[]));+ // issues #127/#128: route geometry and signal quality, reported with suggestions, never a refusal+ let qp = dir.join("route-quality.json");+ write_json(&qp, &quality.0);+ s["routeQuality"] = route_quality::outcome(&quality.0, &qp.display().to_string());+ for l in &quality.1 { eprintln!("{l}"); } decoupling_check::record(&mut r, &dir, &dec); if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); } if !plan_notes.is_empty() { s["planNormalised"] = json!(plan_notes); }@@ -3908,7 +3985,7 @@ fn main() { let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0)); let npu = pu_issues.len() as u64; r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);- r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "silkAdvisories": s["silkAdvisories"], "silkBlocking": s["silkBlocking"], "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));+ r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routeQuality": s["routeQuality"], "silkAdvisories": s["silkAdvisories"], "silkBlocking": s["silkBlocking"], "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes})); r.save().unwrap(); if !sw_issues.is_empty() { r.outcome("switch-node", json!({"issues": sw_issues}));@@ -4378,6 +4455,11 @@ fn main() { if placement["mode"]=="verified-existing" && copper_carry::placement_bound(&r.data,&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err() { missing.push("place verify (saved board or spec changed; verify again)".into()); }+ {+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&r.dir, &r.data)));+ missing.extend(handson::finish_missing(&r.data, &seq));+ } let gate = o.get("gate").cloned().unwrap_or(Value::Null); if !pattern && (gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0)) { missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());@@ -4612,6 +4694,7 @@ fn main() { let (_, gaps, _) = testplan::check_board(&list, &board_now()); if !gaps.is_empty() { missing.push(format!("test plan: no probe landing on {} (`testplan check` says why)", gaps.join(", "))); } }+ let (qv, quality_lines) = route_quality::of_board(&board_now(), &spec_soft(&r)); // every required step before the finish line, done in order and proven let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap(); // issue #60: declared mechanical and wiring reviews, bound to this board, spec and review file@@ -4669,20 +4752,41 @@ fn main() { r.data["summary"]["notApplicable"] = json!(not_applicable); r.data["summary"]["deviations"] = json!(deviated); r.data["summary"]["open"] = json!(open_items);+ // issues #127/#128: route geometry and signal quality of the saved board, report-card lines that never block+ let qp = dir.join("finish-route-quality.json");+ write_json(&qp, &qv);+ r.data["summary"]["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string()); 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"], 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()]);+ 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 ")) } + &if quality_lines.is_empty() { String::new() } else { format!("\n{}", quality_lines.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(), molecule_publish_hint(&r).unwrap_or_default(), give_back_hint()]); } Cmd::Capture { action, label, reason } => { let mut r = load_run(&cli);+ if action == "screen" {+ // the human's word, mid-run: "you can take my screen now" -> allowed, until they say otherwise+ let m = match label.as_deref() {+ None => None,+ Some(l) => match screen::Mode::parse(l) {+ Some(m) => Some(m.as_str()),+ None => { note(&format!("capture screen {l}: not a screen mode; the modes are hands-off, ask, allowed. The mode is unchanged."), &[]); None }+ },+ };+ if let Some(m) = m { r.data["screen"] = json!(m); }+ let mode = screen_mode(&mut r);+ r.save().unwrap();+ let src = r.data["screenMode"]["source"].as_str().unwrap_or("").to_string();+ ok(&screen::line(mode, &src), &[if m.is_some() && std::env::var("AIFLOW_SCREEN").is_ok() { "AIFLOW_SCREEN is set in this environment and wins over run.json.".into() } else { String::new() }]);+ return;+ } let br = bridge_of(&r, &cli); let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string(); let engine = r.data["engine"].as_str().unwrap_or("").to_string(); match action.as_str() { "open" => {- let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));- r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));+ let raise = screen_mode(&mut r).may_raise();+ let (hwnd, shown) = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box")), raise).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));+ r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd, "window": shown})); r.save().unwrap(); let (revision, reply) = br.revision_with_reply(&rb); write_json(&dir.join("capture-open-ipc.json"), &reply);@@ -4692,7 +4796,7 @@ fn main() { let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum(); let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) }; let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };- ok(&format!("board open, maximised, zoomed to fit (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]);+ ok(&format!("board open, {shown} (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]); } "start" => { let why = reason.clone().unwrap_or_else(|| format!("aiflow run {engine}"));@@ -4756,7 +4860,7 @@ fn main() { r.save().unwrap(); ok(&format!("clip {tag} rejected: it stays on the page as evidence and out of the video"), &["Re-take the step if the walkthrough matters: `step <name> --back --why ...`.".into()]); }- other => err(&format!("capture {other}: use open, start, mark, stop or reject"), &[]),+ other => err(&format!("capture {other}: use open, start, mark, stop, reject or screen"), &[]), } } Cmd::Status => {@@ -4993,10 +5097,12 @@ fn main() { r.save().unwrap_or_else(|e| err(&e, &[])); } let fp_hint = if fpd.is_empty() { String::new() } else { format!("Footprints differ (not blocking, but check they are the parts you meant): {}", fpd.join("; ")) };+ // issue #138: the machine pin and contact names the wiki's model registry counts, from the schematic's references+ let names_hint = molecule_pins::names_from_footprints(&sn.refs.iter().map(|(a, b)| (a.clone(), b.clone())).collect::<Vec<_>>()).into_iter().map(|(finding, suggestion)| format!("Molecule names: {finding}. {suggestion}")).collect::<Vec<_>>().join(" "); if !passed {- err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint]);+ err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint, names_hint]); }- ok(&lines.join("\n"), &["This is electrical parity between schematic and board only: not ERC, DRC, courtyards, pours or fabrication readiness; never tell the human the board can go to the fab on this result.".into(), if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]);+ ok(&lines.join("\n"), &[names_hint, "This is electrical parity between schematic and board only: not ERC, DRC, courtyards, pours or fabrication readiness; never tell the human the board can go to the fab on this result.".into(), if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]); } Cmd::Molecule { action: _, board, spec, bom } => { let mut run = Run::open(&dir);@@ -5040,11 +5146,16 @@ fn main() { let freshness = thermalvias::pour_freshness(&spec, &b, pours_landed); for f in freshness.iter().filter(|f| f.starts_with("WARNING")) { eprintln!("{f}"); } let mut lines: Vec<String> = parts.iter().map(|p| format!(" {:<5} {:<5} target {:>2} existing {:>2} planned {:>2}{}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note.as_ref().map(|n| format!(" ({n})")).unwrap_or_default())).collect();+ // issue #136: say which copper each answer rests on, the board's own zones by name+ for p in parts.iter().filter(|p| !p.copper.is_empty()) { lines.push(format!(" {} copper read: {}", p.reference, p.copper.join("; "))); }+ let board_zones: Vec<String> = thermalvias::board_regions(&spec, &b).iter().filter_map(|z| z["label"].as_str().map(str::to_string)).fold(Vec::new(), |mut v, l| { if !v.contains(&l) { v.push(l); } v });+ if board_zones.is_empty() { lines.push(" board zones: none filled on the saved board; only spec.pours was read as copper".into()); }+ else { lines.push(format!(" board zones read as copper ({} filled on the saved board): {}", board_zones.len(), board_zones.join("; "))); } lines.extend(freshness.iter().map(|f| format!(" {f}")));- r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note})).collect::<Vec<_>>(), "pourFreshness": freshness}));+ r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note, "copper": p.copper})).collect::<Vec<_>>(), "pourFreshness": freshness, "boardZones": board_zones})); r.save().unwrap(); if spec["hot"].as_object().map(|o| o.is_empty()).unwrap_or(true) { err("spec.hot names no hot parts", &["Add every part that dissipates real power: \"hot\": {\"U1\": {\"watts\": 0.25, \"tabNet\": \"GND\"}} (watts from the datasheet's efficiency or Rds(on), the inductor's DCR times I squared).".into()]); }- ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into()]);+ ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into(), "Copper here is spec.pours plus the saved board's filled zones (the human's zones count before the pours step adopts them); after the human redraws or refills a zone, save the board (adopt-board) and plan again.".into()]); } else { let pp = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).map(str::to_string).unwrap_or_else(|| err("no heatsink via plan; run `thermal-vias plan` first", &[])); // issue #90: an acknowledged part's planned vias stay off the board@@ -5508,6 +5619,17 @@ fn main() { let editor = if what == "fields" { 0 } else { br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())))).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) }; match what.as_str() { "3d" => {+ // the viewer paints its GL canvas only in front (below): on the human's own machine+ // (screen hands-off or ask) it is their call, so nothing is opened, raised or filmed+ let mode = screen_mode(&mut r);+ if !mode.may_raise() {+ r.log("tour-3d-skipped", json!({"step": r.data["currentStep"], "screen": mode.as_str(), "why": "the 3D viewer paints only in front"}));+ r.save().unwrap();+ ok(&format!("3D walkthrough not run: screen is {} on {}, and the 3D viewer only paints in front, so filming it would take the human's screen for about a minute", mode.as_str(), br.target), &[+ "Suggest to the human: run it themselves (KiCad: View > 3D Viewer, then watch it here), or let AI Flow take the screen for it: `capture screen allowed` (or AIFLOW_SCREEN=allowed for one command), then `tour 3d` again, then `capture screen hands-off`.".into(),+ "Nothing is missing: finish does not need the 3D walkthrough. Next: step nets, or step capture / finish.".into()]);+ return;+ } r.stage_start("tour-3d", Some("binary"), None); // the viewer, its own window, is this step's camera: swap the running clip onto it let op = br.call("kicad_open_3d_viewer", &json!({"editor": "pcb", "pcbHwnd": editor}));@@ -5802,11 +5924,169 @@ fn main() { } println!("{}", step_table(&r)); }+ Cmd::Handson { action, args, profile, save_as, default, quote, summary, note, notify, screen, timeout, delete } => {+ let at = now();+ // the profiles live with the human, not the run: listing, the default and deleting need no run+ if action == "profiles" {+ if let Some(d) = delete { handson::delete_profile(d).unwrap_or_else(|e| err(&e, &[])); }+ if *default { let p = profile.clone().or_else(|| args.first().cloned()).unwrap_or_else(|| err("--default needs the profile: `handson profiles --profile <name> --default`", &[]));+ if handson::load_profile(&p).is_none() { err(&format!("no profile {p:?}"), &["`handson profiles` lists them.".into()]); }+ handson::set_default(&p).unwrap_or_else(|e| err(&e, &[])); }+ let list = handson::list_profiles();+ let lines: Vec<String> = list.iter().map(|p| {+ let st: Vec<String> = p["steps"].as_object().into_iter().flatten().filter(|(_, v)| v["mode"] != "auto").map(|(k, v)| format!("{k} {}", v["mode"].as_str().unwrap_or(""))).collect();+ format!("{}{} ({}): {}; the rest {}; notify {}, screen {}", if p["default"] == true { "* " } else { " " }, p["name"].as_str().unwrap_or(""), p["slug"].as_str().unwrap_or(""),+ if st.is_empty() { "every step auto".into() } else { st.join(", ") }, p["fallback"].as_str().unwrap_or("auto"), p["notify"].as_str().unwrap_or("hydrogen"), p["screen"].as_str().unwrap_or("hands-off"))+ }).collect();+ ok(&format!("{} flow profile(s) in {} (* = the default for a new run):\n{}", list.len(), handson::home().join("profiles").display(), lines.join("\n")), &[+ "Switch this run: `handson use <slug>`. Make one the default: `handson profiles --profile <slug> --default`. A new one: `handson plan`, then the human saves it from the timeline (or `handson start --save-as \"<name>\"`).".into()]);+ return;+ }+ let read_only = matches!(action.as_str(), "status" | "wait");+ let t = if read_only { cli.ai_thread.clone().unwrap_or_default() } else { thread(&cli) };+ let mut r = load_run(&cli);+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&dir, &r.data)));+ let tl = dir.join("timeline");+ let mut lines = handson::ingest(&mut r.data, &tl);+ let step_arg = |what: &str| -> String {+ let s = args.first().cloned().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err(&format!("{what} needs the step"), &[]));+ if r.data["steps"][&s].is_null() { err(&format!("{s} has not been visited in this run"), &[format!("`step {s}` first; {what} answers for a step's latest visit.")]); }+ s+ };+ let profile_of = |name: &str| handson::load_profile(name).unwrap_or_else(|| err(&format!("no flow profile {name:?}"), &["`handson profiles` lists them (full-ai is built in).".into()]));+ match action.as_str() {+ "plan" => {+ // start from the profile named, the run's own plan, the human's default, or every step auto+ let (plan, prof) = if let Some(p) = profile { let v = profile_of(p); (handson::profile_plan(&v), Some((v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string()))) }+ else if r.data["handsOn"]["steps"].is_object() { (handson::plan_of(&r.data), r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()))) }+ else if let Some(v) = handson::default_profile() { (handson::profile_plan(&v), Some((v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string()))) }+ else { (handson::normalise(&json!({})).unwrap(), None) };+ handson::apply(&mut r.data, &plan, "planning", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "plan", &at);+ r.log("handson-plan", json!({"state": "planning", "profile": prof.as_ref().map(|p| p.0.clone())}));+ r.save().unwrap();+ let note = handson::notify(&r.data, &t, "AI Flow: choose your flow", "Pick, for each step, whether the AI does it, checks in with you, leaves it to you, or follows your instructions.");+ ok(&format!("hands-on plan open on the timeline, waiting for the human (starting from {}): {}", prof.as_ref().map(|p| format!("profile \"{}\"", p.1)).unwrap_or_else(|| "every step auto".into()), handson::summary(&r.data)), &[+ format!("Tell the human: the AI Flow timeline shows every step with four choices (1 AI takes care of it, 2 AI checks in with you after it, 3 you do it, 4 custom instructions), plus how to notify them and whether you may bring windows to the front. They press Start there and can save it as a named flow (the first one becomes their default). {note}."),+ "Then `handson wait` (it returns when they press Start). If they answer in chat instead: `handson set placement=checkin routing=human --note schematic=\"...\"`, then `handson start --save-as \"<their name for it>\"`.".into(),+ "Wait for them to confirm before design work: `step` notes every step started while the plan is still open.".into()]);+ }+ "set" => {+ let mut plan = handson::plan_of(&r.data);+ if !plan["steps"].is_object() { plan["steps"] = json!({}); }+ for a in args { let (k, v) = a.split_once('=').unwrap_or_else(|| err(&format!("{a:?}: write STEP=MODE (modes: {})", handson::MODES.join(", ")), &[]));+ let keep = plan["steps"][k]["instructions"].clone();+ plan["steps"][k] = json!({"mode": v}); if !keep.is_null() { plan["steps"][k]["instructions"] = keep; } }+ for n in note { let (k, v) = n.split_once('=').unwrap_or_else(|| err(&format!("{n:?}: write --note STEP=\"text\""), &[]));+ if plan["steps"][k].is_null() { plan["steps"][k] = json!({"mode": "custom"}); }+ plan["steps"][k]["instructions"] = json!(v); }+ if let Some(n) = notify { plan["notify"] = json!(n); }+ // the human's latest word on the screen: the profile's choice replaces an earlier `capture screen`+ if let Some(sc) = screen { plan["screen"] = json!(sc); if let Some(o) = r.data.as_object_mut() { o.remove("screen"); } }+ let plan = handson::normalise(&plan).unwrap_or_else(|e| err(&e, &[]));+ let state = if handson::active(&r.data) { "active" } else { "planning" };+ let prof = r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()));+ handson::apply(&mut r.data, &plan, state, prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "chat", &at);+ r.save().unwrap();+ ok(&format!("hands-on plan ({state}): {}", handson::summary(&r.data)), &[if state == "planning" { "When the human is happy: `handson start` (`--save-as \"<name>\"` to keep it as a flow profile).".into() } else { "Changed for this run only; `handson start --save-as \"<name>\"` keeps it as a profile.".into() }]);+ }+ "start" => {+ if !r.data["handsOn"]["steps"].is_object() { err("no hands-on plan on this run", &["`handson plan` first.".into()]); }+ let plan = handson::plan_of(&r.data);+ let first = handson::default_slug().is_none();+ // the first flow a human confirms is kept as their preference, so the next board starts from it+ let name = save_as.clone().or_else(|| first.then(|| "My flow".to_string()));+ let mut prof = r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()));+ let mut saved = String::new();+ if let Some(n) = &name { let s = handson::save_profile(n, &plan, &at).unwrap_or_else(|e| err(&e, &[])); saved = format!("; saved as flow profile \"{n}\" ({s}){}", if first { ", their default from now on" } else { "" }); prof = Some((s, n.clone())); }+ if *default { if let Some((s, _)) = &prof { handson::set_default(s).unwrap_or_else(|e| err(&e, &[])); saved.push_str("; now their default"); } }+ handson::apply(&mut r.data, &plan, "active", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "chat", &at);+ r.log("handson-plan", json!({"state": "active", "profile": prof.as_ref().map(|p| p.0.clone())}));+ r.save().unwrap();+ ok(&format!("hands-on plan confirmed{saved}: {}", handson::summary(&r.data)), &["Every `step` now prints its mode: check-in steps end with `handson ask`, human steps are handed over and waited for, custom steps print the human's instructions.".into()]);+ }+ "use" => {+ let name = args.first().cloned().or(profile.clone()).unwrap_or_else(|| err("use needs a profile: `handson use full-ai`", &["`handson profiles` lists them.".into()]));+ let v = profile_of(&name);+ let (s, n) = (v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string());+ handson::apply(&mut r.data, &handson::profile_plan(&v), "active", Some((&s, &n)), "use", &at);+ r.log("handson-plan", json!({"state": "active", "profile": s}));+ r.save().unwrap();+ ok(&format!("this run now follows \"{n}\": {}", handson::summary(&r.data)), &["Visits from now on follow it; work already done is not re-reviewed.".into()]);+ }+ "ask" => {+ let s = step_arg("ask");+ let sum = summary.clone().unwrap_or_else(|| err("ask needs --summary: what the human should look at, and where", &[]));+ let n = handson::ask(&mut r.data, &s, &sum, &at).unwrap_or_else(|e| err(&e, &[]));+ r.log("handson-ask", json!({"step": s, "visit": n, "summary": sum}));+ r.save().unwrap();+ let note = handson::notify(&r.data, &t, &format!("Review {s}"), &format!("AI Flow finished {s} and is waiting for your OK: {}", aiflow_run::clip_text(&sum, 180)));+ ok(&format!("check-in on {s} (visit {n}) is open; {note}"), &["Now wait: `handson wait` returns when they press Approve or Request changes on the timeline. If they answer in chat: `handson approve <step> --quote \"<their words>\"` or `handson changes <step> --quote \"...\"`.".into(),+ "While you wait, work that does not depend on this step is fine; the next step is refused until they approve.".into()]);+ }+ v @ ("approve" | "changes" | "done") => {+ let s = step_arg(v);+ let verdict = match v { "approve" => "approved", other => other };+ let q = quote.clone().unwrap_or_default();+ let n = handson::record_verdict(&mut r.data, &s, verdict, &q, "chat", &at).unwrap_or_else(|e| err(&e, &[]));+ r.log("handson-verdict", json!({"step": s, "visit": n, "verdict": verdict, "quote": q, "by": "chat"}));+ r.save().unwrap();+ let next = match verdict { "changes" => format!("Make the changes they asked for, then `handson ask {s} --summary \"<what changed>\"` again."), "done" => format!("Now record {s}'s proof from their work: {}.", handson::human_proof(&s, &flow)), _ => "Go on to the next step.".into() };+ ok(&format!("{verdict} recorded for {s} (visit {n}), in the human's words"), &[next]);+ }+ "wait" => {+ let before = handson::waiting(&r.data);+ let Some(w) = before.clone() else {+ for l in &lines { println!("{l}"); }+ if !lines.is_empty() { r.save().unwrap(); refresh_dashboard(&dir); ok("the human had already answered on the timeline", &["Nothing else is waiting on them: go on (the step hints say what each mode needs).".into()]); }+ else { ok("nothing to wait for: no plan, check-in or human step is open", &[]); }+ return;+ };+ let started = std::time::Instant::now();+ if !lines.is_empty() { r.save().unwrap(); refresh_dashboard(&dir); }+ while lines.is_empty() && started.elapsed().as_secs() < *timeout {+ std::thread::sleep(std::time::Duration::from_secs(1));+ let Some(mut r2) = Run::open(&dir) else { break };+ let got = handson::ingest(&mut r2.data, &tl);+ // an answer recorded from chat by another command also ends the wait+ if !got.is_empty() || handson::waiting(&r2.data) != before { if !got.is_empty() { r2.save().unwrap(); refresh_dashboard(&dir); } lines = got; r = r2; if lines.is_empty() { lines.push("answered from chat".into()); } }+ }+ let secs = started.elapsed().as_secs();+ r.log("handson-wait", json!({"for": w, "humanWaitSeconds": secs, "answered": !lines.is_empty()}));+ if lines.is_empty() {+ ok(&format!("still waiting after {secs} s for {}", w["what"].as_str().unwrap_or("the human")), &["Run `handson wait` again, or stop and tell the human what you need: finish-guard counts an open check-in as a pause, not an unfinished run.".into()]);+ } else {+ for l in &lines { println!("{l}"); }+ ok(&format!("the human answered after {secs} s"), &[handson::waiting(&r.data).map(|w| format!("Still open: {}.", w["what"].as_str().unwrap_or(""))).unwrap_or_else(|| "Nothing else is waiting on them: go on (the step hints say what each mode needs).".into())]);+ }+ return;+ }+ "status" => {+ if !lines.is_empty() { r.save().unwrap(); }+ for l in &lines { println!("{l}"); }+ let h = &r.data["handsOn"];+ let st: Vec<String> = seq.iter().map(|s| { let m = handson::mode_of(&r.data, s); format!(" {s}: {m}{}", handson::instructions_of(&r.data, s).map(|t| format!(" (\"{}\")", aiflow_run::clip_text(t, 80))).unwrap_or_default()) }).collect();+ ok(&format!("hands-on {} (flow \"{}\"); notify {}, screen {}\n{}\nwaiting: {}", h["state"].as_str().unwrap_or("off"), h["profileName"].as_str().unwrap_or("-"), h["notify"].as_str().unwrap_or("-"), h["screen"].as_str().unwrap_or("-"), st.join("\n"),+ handson::waiting(&r.data).map(|w| w["what"].as_str().unwrap_or("").to_string()).unwrap_or_else(|| "nothing".into())), &[]);+ return;+ }+ "off" => {+ if r.data["handsOn"].is_object() { r.data["handsOn"]["state"] = json!("off"); r.data["handsOn"]["history"].as_array_mut().map(|a| a.push(json!({"at": at, "state": "off"}))); }+ r.log("handson-plan", json!({"state": "off"}));+ r.save().unwrap();+ ok("hands-on is off for this run: every step is the AI's again (the screen and notification choices revert to the defaults)", &["Their saved profiles are kept; `handson use <profile>` turns it back on.".into()]);+ }+ _ => unreachable!(),+ }+ for l in &lines { println!("{l}"); }+ refresh_dashboard(&dir);+ } 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);+ for l in handson::ingest(&mut r.data, &dir.join("timeline")) { println!("{l}"); } 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()); for s in preboard::PREBOARD_STEPS.iter().chain(reviews::STEPS.iter()) { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } } let known: Vec<&str> = known.iter().map(String::as_str).collect();@@ -5872,6 +6152,21 @@ fn main() { 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; } } }+ // hands-on (handson.rs): the human's plan, check-ins and their own steps; a return is never held+ let earlier = matches!((sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))), (Some(p), Some(c)) if p < c);+ if !*back && !earlier {+ let b = handson::blockers(&r.data, &seq, name);+ if !b.is_empty() {+ r.save().ok();+ let wait = handson::waiting(&r.data).map(|w| format!("The run is waiting for {}: `handson wait` (or the human answers in chat). Do other work that does not depend on it meanwhile, or stop and tell them; finish-guard counts this as a pause.", w["what"].as_str().unwrap_or("the human"))).unwrap_or_default();+ // never refused (John, 2026-11-04): the human asked to be waited for, so say so plainly and log it+ // when the AI goes on anyway; finish lists the missing OK as open and finish-guard reads it as a pause+ r.log("handson-proceeded-without-ok", json!({"step": name, "waitingFor": b}));+ r.save().ok();+ note(&format!("the human asked to approve before {name} (hands-on flow \"{}\") and has not yet:\n {}\nWait for them unless they told you to go on; going on now is logged and listed at finish", r.data["handsOn"]["profileName"].as_str().unwrap_or("this run's plan"), b.join("\n ")), &[wait,+ "To change it, the human changes the plan (`handson plan`, or `handson set <step>=auto` when they say so in chat).".into()]);+ }+ } 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));@@ -5943,7 +6238,11 @@ fn main() { .map(|b| aiflow_bridge::eda::for_board(&aiflow_bridge::eda::BoardRef::parse(&format!("{b}:")))); let cam = pre_board.unwrap_or(eda); let tx = |v: &str, a: &Value| br.raw_call(v, a);- if let Some(h) = cam.hydrogen_for_step(name) {+ // issue #132: on the human's own machine (screen hands-off or ask) nothing comes to the front;+ // the clip records the window where it is+ let raise = screen_mode(&mut r).may_raise();+ if !raise { r.log("screen-hands-off", json!({"step": name})); }+ if !raise { /* the human's screen is hands-off: film the windows where they are */ } else if let Some(h) = cam.hydrogen_for_step(name) { let w = if h { br.hydrogen_hwnd() } else { cam.main_window(&tx) }; br.bring_front(w, if h { "Hydrogen" } else { cam.name() }); } else if cam.id() == "kicad" && r.data["sequence"].as_array().map_or(false, |s| s.iter().any(|x| x == "project-page")) {@@ -6027,10 +6326,14 @@ fn main() { } let skill_lines = skill_hints::step_hints(&flow_full, name, &skill_home, skill_pkg.as_deref()); let board_hints = board_step_hints(&r, name);+ let mut hands_hints = handson::step_hints(&r.data, name, &flow_full);+ if handson::active(&r.data) && handson::mode_of(&r.data, name) == "human" {+ hands_hints.push(handson::notify(&r.data, &thread(&cli), &format!("Your turn: {name}"), &format!("AI Flow handed {name} to you. Tell the AI when you are done (timeline button or chat).")));+ } let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default(); let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) }; let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };- ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], skill_lines, board_hints, vec![back_hint, report_note, clips_note]].concat());+ ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], hands_hints, skill_lines, board_hints, vec![back_hint, report_note, clips_note]].concat()); print_next(&cli); } Cmd::ProjectPageCheck { project, live_json } => {@@ -6091,7 +6394,7 @@ fn main() { report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; "))); } let verified_text = verified::message(&checked);- ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r), clipwatch::no_recorder_note(&r.data).map(|n| format!("\n{n}")).unwrap_or_default()), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);+ ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r), clipwatch::no_recorder_note(&r.data).map(|n| format!("\n{n}")).unwrap_or_default()), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, molecule_publish_hint(&r).unwrap_or_default(), give_back_hint()]); } } }@@ -6100,6 +6403,18 @@ fn measurement_usable(value: Option<&Value>) -> bool { value.map(|v| v["failed"] != true && v["filledAreaByLayerMm2"].as_object().map(|a| !a.is_empty() && a.values().all(|n| n.as_f64().map(|x| x.is_finite() && x >= 0.0).unwrap_or(false))).unwrap_or(false)).unwrap_or(false) } +#[cfg(test)]+mod molecule_publish_tests {+ #[test]+ fn the_publish_counts_once_the_stage_has_ended() {+ // issue #137: `stage end molecule-publish` records it; a started stage is not a publish+ assert!(!super::molecule_published(&serde_json::json!({})));+ assert!(!super::molecule_published(&serde_json::json!({"stages": {"molecule-publish": {"runs": [{"start": "t", "end": null}]}}})));+ assert!(super::molecule_published(&serde_json::json!({"stages": {"molecule-publish": {"runs": [{"start": "t", "end": "u", "result": "adom/x"}]}}})));+ assert!(super::MOLECULE_PAGE_HINT.contains("kind molecule") && super::MOLECULE_PAGE_HINT.contains("model_3d"));+ }+}+ #[cfg(test)] mod mpn_mark_tests { use super::*;
crates/adom-aiflow/src/molecule_pins.rsadded+329@@ -0,0 +1,329 @@+//! Issue #138: machine pins and contacts by reference designator, as the wiki's model registry reads+//! them (rule adom.molecule-pins/2: molecule-publish's scripts/pins.json and pin_names.py). The+//! registry counts a machine contact only when it is a one-pad part NAMED MC<n> on a 0.78 or 2.62 mm+//! hole, and a machine pin only when it is named MP1 to MP4 (MS<n> for any extra one). A contact named+//! J3 on a MachineContactMedium footprint passes every other check here and still gets no wire attach+//! point in Hydrogen. This is the KiCad half of that census, ported; it reports findings with the+//! rename to make, and never refuses: AI Flow suggests, the registry decides.++use aiflow_board::sx::{Item, Node};+use aiflow_board::Board;++pub const RULE: &str = "adom.molecule-pins/2";+/// pins.json drillBucketsMm and drillToleranceMm.+const BUCKETS: [(&str, f64); 4] = [("mediumContact", 0.78), ("mediumPin", 1.20), ("largeContact", 2.62), ("largePin", 3.45)];+const DRILL_TOL: f64 = 0.001;+/// pins.json furniturePackages: glob, case ignored, against the footprint name after its last ':'+/// and the file name of the 3D model.+const FURNITURE: [&str; 9] = ["MACH_PIN_*", "CONTACT_MED", "CONTACT_LRG", "PIN_MED*", "ADOM_*_PIN*", "ADOM_*_CONTACT*", "MpinContact*", "MachinePin*", "MachineContact*"];+/// pins.json libraryModelLengths: model files whose name carries no length word.+const MODEL_LENGTHS: [(&str, &str); 1] = [("largemachinepin v3.step", "standard")];+/// Why a renamed contact matters, said once with every suggestion.+pub const WHY: &str = "the wiki's model registry (rule adom.molecule-pins/2, run by molecule-publish) counts only one-pad parts named MC<n> as machine contacts and MP1 to MP4 as machine pins, so a part named otherwise gets no wire attach point when the molecule is imported into Hydrogen";++/// One through-hole part the census classifies, as pin_names.py writes its rows.+#[derive(Debug, Clone, PartialEq)]+pub struct Row {+ pub designator: String,+ pub package: String,+ /// "machine-pin", "machine-contact" or "group"+ pub class: &'static str,+ /// "ok", "extra" (MS<n>), "warned", "refused" or "group"+ pub result: &'static str,+ /// not-named, lowercase, outside-mp1-mp4, pin-name, no-length, hole-off-table, duplicate+ pub reason: Option<&'static str>,+ pub hole: Option<f64>,+}++fn digits(s: &str) -> bool { !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) }+/// `^<prefix>[1-9][0-9]*$`, case kept.+fn numbered(des: &str, prefix: &str) -> bool { des.strip_prefix(prefix).is_some_and(|n| digits(n) && !n.starts_with('0')) }+/// designators.machinePin: MP1 to MP4.+pub fn machine_pin_name(des: &str) -> bool { matches!(des, "MP1" | "MP2" | "MP3" | "MP4") }+/// designators.machineContact: MC<n>.+pub fn machine_contact_name(des: &str) -> bool { numbered(des, "MC") }+/// designators.nearMiss: MP<n> or MC<n> in any case.+fn near_miss(des: &str) -> bool { des.len() > 2 && des.is_char_boundary(2) && matches!(des[..2].to_ascii_uppercase().as_str(), "MP" | "MC") && digits(&des[2..]) }++fn glob(pattern: &str, text: &str) -> bool {+ let (p, t): (Vec<char>, Vec<char>) = (pattern.to_lowercase().chars().collect(), text.to_lowercase().chars().collect());+ fn go(p: &[char], t: &[char]) -> bool {+ match p.split_first() {+ None => t.is_empty(),+ Some(('*', rest)) => (0..=t.len()).any(|k| go(rest, &t[k..])),+ Some((c, rest)) => t.first() == Some(c) && go(rest, &t[1..]),+ }+ }+ go(&p, &t)+}++fn file_name(path: &str) -> String { path.replace('\\', "/").rsplit('/').next().unwrap_or("").to_string() }++fn bucket(drill: Option<f64>) -> Option<&'static str> {+ let d = drill?;+ BUCKETS.iter().find(|(_, v)| (d - v).abs() < DRILL_TOL).map(|(k, _)| *k)+}++fn atoms(n: &Node) -> impl Iterator<Item = &str> { n.items.iter().filter_map(|it| if let Item::Atom { text, .. } = it { Some(text.as_str()) } else { None }) }++/// A footprint as the census reads it: designator, package (after the last ':'), 3D model, and its+/// pads as (drill, oval).+struct Part { designator: String, package: String, model: String, hint: String, pads: Vec<(Option<f64>, bool)> }++fn parts(b: &Board) -> Vec<Part> {+ let mut out = Vec::new();+ for fp in b.root.find_all("footprint") {+ let name = fp.atom(1).unwrap_or("").to_string();+ let mut reference = fp.find_all("property").find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).map(str::to_string);+ if reference.is_none() { reference = fp.find_all("fp_text").find(|t| t.atom(1) == Some("reference")).and_then(|t| t.atom(2)).map(str::to_string); }+ let package = name.rsplit(':').next().unwrap_or("").to_string();+ let model = fp.find("model").and_then(|m| m.value()).unwrap_or("").to_string();+ let pads = fp.find_all("pad").filter(|p| atoms(p).count() >= 3).map(|p| {+ let dr = p.find("drill");+ let oval = dr.and_then(|d| d.atom(1)) == Some("oval");+ (dr.and_then(|d| d.number(1)), oval)+ }).collect();+ out.push(Part { designator: reference.filter(|r| !r.is_empty()).unwrap_or_else(|| package.clone()), hint: format!("{name} {model}").trim().to_lowercase(), package, model, pads });+ }+ out+}++/// The census of the board's machine pins and contacts (pin_names.py `census`, KiCad boards, test+/// points left out): one row per part with a pin or contact hole, or named or packaged as one.+pub fn census(b: &Board) -> Vec<Row> {+ let mut rows = Vec::new();+ for part in parts(b) {+ let des = part.designator.as_str();+ let length = ["short", "sht", "standard", "std"].iter().any(|w| part.hint.contains(w))+ || MODEL_LENGTHS.iter().any(|(m, _)| file_name(&part.model).to_lowercase() == *m);+ let furniture = near_miss(des) || [part.package.clone(), file_name(&part.model)].iter().any(|n| !n.is_empty() && FURNITURE.iter().any(|g| glob(g, n)));+ let (mut pins, mut contacts, mut dropped, mut off) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+ for &(drill, oval) in &part.pads {+ match if oval { None } else { bucket(drill) } {+ Some(k) if k.ends_with("Pin") => if length { pins.push(drill) } else { dropped.push(drill) },+ Some(_) => contacts.push(drill),+ None if drill.is_some() || oval => off.push(if oval { None } else { drill }),+ None => {}+ }+ }+ let lower = near_miss(des) && !(des.starts_with("MP") || des.starts_with("MC"));+ let mp_numbered = des.strip_prefix("MP").is_some_and(digits);+ let extra = numbered(des, "MS");+ let single = part.pads.len() == 1;+ let row = |class, result, reason, hole| Row { designator: des.to_string(), package: part.package.clone(), class, result, reason, hole };+ let r = if !pins.is_empty() {+ if machine_pin_name(des) { row("machine-pin", "ok", None, pins[0]) }+ else if extra { row("machine-pin", "extra", None, pins[0]) }+ else if lower { row("machine-pin", "refused", Some("lowercase"), pins[0]) }+ else if mp_numbered { row("machine-pin", "refused", Some("outside-mp1-mp4"), pins[0]) }+ else { row("machine-pin", "warned", Some("not-named"), pins[0]) }+ } else if !contacts.is_empty() && !single && !furniture {+ row("group", "group", None, contacts[0])+ } else if !contacts.is_empty() {+ if machine_contact_name(des) { row("machine-contact", "ok", None, contacts[0]) }+ else if lower { row("machine-contact", "refused", Some("lowercase"), contacts[0]) }+ else if mp_numbered { row("machine-contact", "refused", Some("pin-name"), contacts[0]) }+ else { row("machine-contact", "warned", Some("not-named"), contacts[0]) }+ } else if !dropped.is_empty() && (furniture || mp_numbered || lower || extra) {+ row("machine-pin", "warned", Some("no-length"), dropped[0])+ } else if !dropped.is_empty() && single {+ row("machine-pin", "warned", Some("not-named"), dropped[0])+ } else if furniture && !off.is_empty() && off[0].is_some() {+ // named or packaged as a pin or contact on a round hole of no listed size+ let pinlike = !(des.to_ascii_uppercase().starts_with("MC") || format!("{} {}", part.package, part.model).to_lowercase().contains("contact"));+ let named = near_miss(des) || (extra && pinlike);+ row(if pinlike { "machine-pin" } else { "machine-contact" }, if named { "refused" } else { "warned" }, Some("hole-off-table"), off[0])+ } else { continue };+ rows.push(r);+ }+ // designators the build keeps are unique+ let kept: Vec<String> = rows.iter().filter(|r| matches!(r.result, "ok" | "extra")).map(|r| r.designator.clone()).collect();+ for r in rows.iter_mut().filter(|r| matches!(r.result, "ok" | "extra")) {+ if kept.iter().filter(|d| **d == r.designator).count() > 1 { r.result = "refused"; r.reason = Some("duplicate"); }+ }+ rows+}++fn list(rows: &[&Row], with_package: bool) -> String {+ let mut names: Vec<String> = rows.iter().map(|r| if with_package { format!("{} ({}{})", r.designator, r.package, r.hole.map(|h| format!(", {h:.2} mm hole")).unwrap_or_default()) } else { r.designator.clone() }).collect();+ // natural order: J2 before J10+ names.sort_by_key(|n| { let d: String = n.chars().take_while(|c| !c.is_ascii_digit()).collect(); let k: u32 = n.chars().skip(d.chars().count()).take_while(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0); (d.to_lowercase(), k) });+ if names.len() > 8 { format!("{} and {} more", names[..8].join(", "), names.len() - 8) } else { names.join(", ") }+}++/// The next unused MC<n> names, for the suggestion.+fn free_names(rows: &[Row], prefix: &str, n: usize) -> Vec<String> {+ let used: Vec<&str> = rows.iter().map(|r| r.designator.as_str()).collect();+ (1..).map(|k| format!("{prefix}{k}")).filter(|d| !used.contains(&d.as_str())).take(n).collect()+}++/// The census's findings, each with its suggestion: (finding, suggestion). Empty when the registry+/// would read every machine pin and contact the way the board means them.+pub fn findings(rows: &[Row]) -> Vec<(String, String)> {+ let mut out = Vec::new();+ let by = |class: &str, result: &str, reason: Option<&str>| rows.iter().filter(|r| (class.is_empty() || r.class == class) && r.result == result && (reason.is_none() || r.reason == reason)).collect::<Vec<_>>();+ let unnamed = by("machine-contact", "warned", Some("not-named"));+ if !unnamed.is_empty() {+ let names = free_names(rows, "MC", unnamed.len());+ out.push((format!("{} machine-contact part(s) not named MC<n>, so the registry will not count them as machine contacts: {}", unnamed.len(), list(&unnamed, true)),+ format!("Rename them {} (one-pad part, MC<n>, on a 0.78 or 2.62 mm hole) in the schematic and the board, best before layout: {WHY}. After layout a rename also changes silkscreen labels, pinout tables and the copper fingerprint.", names.join(", "))));+ }+ let pin_named = by("machine-contact", "refused", Some("pin-name"));+ if !pin_named.is_empty() {+ out.push((format!("{} part(s) on machine-contact holes are named as machine pins: {}", pin_named.len(), list(&pin_named, true)),+ format!("Rename them {} in the schematic and the board: machine contacts are MC<n>; {WHY}.", free_names(rows, "MC", pin_named.len()).join(", "))));+ }+ let outside = by("machine-pin", "refused", Some("outside-mp1-mp4"));+ if !outside.is_empty() {+ out.push((format!("{} machine pin(s) numbered outside MP1 to MP4: {}", outside.len(), list(&outside, false)),+ format!("MP1 to MP4 are the four outermost corner pins; name any other machine pin MS1, MS2, ... in the schematic and the board: {WHY}.")));+ }+ let case: Vec<&Row> = rows.iter().filter(|r| r.reason == Some("lowercase")).collect();+ if !case.is_empty() {+ out.push((format!("{} designator(s) not in capitals: {}", case.len(), list(&case, false)),+ format!("Write them {} in the schematic and the board: the registry matches MP<n> and MC<n> in capitals only; {WHY}.", { let mut up: Vec<String> = case.iter().map(|r| r.designator.to_ascii_uppercase()).collect(); up.sort(); up.join(", ") })));+ }+ let loose = by("machine-pin", "warned", None);+ if !loose.is_empty() {+ out.push((format!("{} part(s) fit the machine-pin standard only in part and are not machine pins to the registry: {}", loose.len(), list(&loose, true)),+ format!("A machine pin is named MP1 to MP4 (MS<n> beyond the four corners) on a MachinePin footprint whose name or model says its length (Short or Standard), on a 1.20 or 3.45 mm hole; {WHY}.")));+ }+ let groups = by("group", "group", None);+ if !groups.is_empty() {+ out.push((format!("{} multi-pad part(s) have contact-sized holes and are not machine contacts: {}", groups.len(), list(&groups, true)),+ "A jumper or socket connects to the molecule through MC<n> contacts; if these holes are meant as contacts, make each a one-pad MachineContact part named MC<n>.".into()));+ }+ let off = by("", "refused", Some("hole-off-table")).into_iter().chain(by("", "warned", Some("hole-off-table"))).collect::<Vec<_>>();+ if !off.is_empty() {+ out.push((format!("{} machine pin(s) or contact(s) drilled off the standard sizes: {}", off.len(), list(&off, true)),+ "Machine pins are drilled 1.20 mm (medium) or 3.45 mm (large), machine contacts 0.78 mm or 2.62 mm, within 0.001 mm; use the Adom library footprints (lib/Molecule.pretty), whose drills match.".into()));+ }+ let dups = by("", "refused", Some("duplicate"));+ if !dups.is_empty() {+ out.push((format!("repeated machine pin or contact designators: {}", list(&dups, false)), "Give each machine pin and contact its own designator (annotate the schematic again) so the registry can tell them apart.".into()));+ }+ out+}++/// One line for the OK side: what the registry will read.+pub fn summary(rows: &[Row]) -> String {+ let n = |class: &str, result: &str| rows.iter().filter(|r| r.class == class && r.result == result).count();+ format!("registry pin rule {RULE}: {} machine pins (MP1 to MP4), {} machine contacts (MC<n>){}", n("machine-pin", "ok"), n("machine-contact", "ok"),+ match n("machine-pin", "extra") { 0 => String::new(), k => format!(", {k} extra machine pins (MS<n>)") })+}++/// Before the board exists (the schematic step): the same names judged from each reference's+/// footprint name alone, `(reference, footprint lib id)`. Returns (finding, suggestion) as `findings`.+pub fn names_from_footprints(parts: &[(String, String)]) -> Vec<(String, String)> {+ let kind = |fp: &str| { let n = fp.rsplit(':').next().unwrap_or(fp).to_ascii_lowercase(); if n.starts_with("machinecontact") || n.starts_with("contact_") { Some("contact") } else if n.starts_with("machinepin") || n.starts_with("mach_pin") { Some("pin") } else { None } };+ let mut rows = Vec::new();+ for (des, fp) in parts {+ let Some(k) = kind(fp) else { continue };+ let lower = near_miss(des) && !(des.starts_with("MP") || des.starts_with("MC"));+ let mp_numbered = des.strip_prefix("MP").is_some_and(digits);+ let row = |class, result, reason| Row { designator: des.clone(), package: fp.rsplit(':').next().unwrap_or(fp).to_string(), class, result, reason, hole: None };+ rows.push(match k {+ "contact" if machine_contact_name(des) => row("machine-contact", "ok", None),+ "contact" if lower => row("machine-contact", "refused", Some("lowercase")),+ "contact" if mp_numbered => row("machine-contact", "refused", Some("pin-name")),+ "contact" => row("machine-contact", "warned", Some("not-named")),+ _ if machine_pin_name(des) => row("machine-pin", "ok", None),+ _ if numbered(des, "MS") => row("machine-pin", "extra", None),+ _ if lower => row("machine-pin", "refused", Some("lowercase")),+ _ if mp_numbered => row("machine-pin", "refused", Some("outside-mp1-mp4")),+ _ => row("machine-pin", "warned", Some("not-named")),+ });+ }+ findings(&rows)+}++#[cfg(test)]+mod tests {+ use super::*;++ fn fp(lib: &str, des: &str, drill: f64, pads: usize, model: &str) -> String {+ let pads: String = (1..=pads).map(|k| format!(r#"(pad "{k}" thru_hole circle (at {} 0) (size 1.3 1.3) (drill {drill}) (layers "*.Cu"))"#, k as f64 * 2.0)).collect();+ let model = if model.is_empty() { String::new() } else { format!(r#"(model "{model}")"#) };+ format!(r#"(footprint "{lib}" (layer "F.Cu") (at 0 0) (property "Reference" "{des}") {pads} {model})"#)+ }+ fn board(fps: &[String]) -> Board { Board::from_text("t.kicad_pcb", format!("(kicad_pcb (layers (0 \"F.Cu\" signal)) (gr_rect (start -50 -50) (end 50 50) (layer \"Edge.Cuts\")) {})", fps.join("\n"))).unwrap() }++ #[test]+ fn contacts_count_only_when_named_mc_n() {+ // the AM32 ESC: contacts J1 and J3 on the library's MachineContact footprints+ let b = board(&[+ fp("Molecule:MachinePinLargeShort", "MP1", 3.45, 1, ""), fp("Molecule:MachinePinLargeShort", "MP2", 3.45, 1, ""),+ fp("Molecule:MachineContactMedium", "J1", 0.78, 1, ""), fp("Molecule:MachineContactLarge", "J3", 2.62, 1, ""),+ fp("Molecule:MachineContactMedium", "MC1", 0.78, 1, ""), fp("Molecule:MachineContactMedium", "MC2", 0.78, 1, ""),+ ]);+ let rows = census(&b);+ let res = |d: &str| rows.iter().find(|r| r.designator == d).map(|r| (r.class, r.result, r.reason)).unwrap();+ assert_eq!(res("MP1"), ("machine-pin", "ok", None));+ assert_eq!(res("MC1"), ("machine-contact", "ok", None));+ assert_eq!(res("J1"), ("machine-contact", "warned", Some("not-named")));+ assert_eq!(res("J3"), ("machine-contact", "warned", Some("not-named")));+ let f = findings(&rows);+ assert_eq!(f.len(), 1, "{f:?}");+ assert!(f[0].0.contains("2 machine-contact part(s) not named MC<n>") && f[0].0.contains("J1 (MachineContactMedium, 0.78 mm hole)") && f[0].0.contains("J3"), "{f:?}");+ // the suggestion names free MC numbers and why+ assert!(f[0].1.starts_with("Rename them MC3, MC4") && f[0].1.contains("no wire attach point"), "{f:?}");+ assert!(summary(&rows).contains("2 machine pins (MP1 to MP4), 2 machine contacts"));+ }++ #[test]+ fn pins_outside_mp1_to_mp4_case_and_pin_names_on_contacts() {+ let b = board(&[+ fp("Molecule:MachinePinMediumShort", "MP1", 1.2, 1, ""), fp("Molecule:MachinePinMediumShort", "MP5", 1.2, 1, ""),+ fp("Molecule:MachinePinMediumShort", "MS1", 1.2, 1, ""), fp("Molecule:MachinePinMediumShort", "mp2", 1.2, 1, ""),+ fp("Molecule:MachineContactMedium", "mc1", 0.78, 1, ""), fp("Molecule:MachineContactMedium", "MP7", 0.78, 1, ""),+ // a pin footprint whose name says no length, and a model that does (libraryModelLengths)+ fp("Molecule:MachinePinLarge", "MP3", 3.45, 1, ""), fp("Molecule:MachinePinLarge", "MP4", 3.45, 1, "${KIPRJMOD}/3d/LargeMachinePin v3.step"),+ // a two-pad part on contact holes, and an old 0.81 mm contact drill+ fp("Conn:Header_1x2", "J9", 0.78, 2, ""), fp("Molecule:MachineContactMedium", "MC9", 0.81, 1, ""),+ // a plain part on a pin-sized hole, not named or packaged as a pin: left alone+ fp("Conn:Header_1x2", "J10", 1.2, 2, ""),+ ]);+ let rows = census(&b);+ let res = |d: &str| rows.iter().find(|r| r.designator == d).map(|r| (r.result, r.reason));+ assert_eq!(res("MP5"), Some(("refused", Some("outside-mp1-mp4"))));+ assert_eq!(res("MS1"), Some(("extra", None)));+ assert_eq!(res("mp2"), Some(("refused", Some("lowercase"))));+ assert_eq!(res("mc1"), Some(("refused", Some("lowercase"))));+ assert_eq!(res("MP7"), Some(("refused", Some("pin-name"))));+ assert_eq!(res("MP3"), Some(("warned", Some("no-length"))));+ assert_eq!(res("MP4"), Some(("ok", None)));+ assert_eq!(res("J9"), Some(("group", None)));+ assert_eq!(res("MC9"), Some(("refused", Some("hole-off-table"))));+ assert_eq!(res("J10"), None);+ let text: Vec<String> = findings(&rows).into_iter().map(|(a, b)| format!("{a} => {b}")).collect();+ let has = |s: &str| text.iter().any(|t| t.contains(s));+ assert!(has("numbered outside MP1 to MP4: MP5") && has("MS1, MS2"), "{text:?}");+ assert!(has("not in capitals: mc1, mp2") && has("Write them MC1, MP2"), "{text:?}");+ assert!(has("named as machine pins: MP7"), "{text:?}");+ assert!(has("only in part") && has("MP3"), "{text:?}");+ assert!(has("multi-pad part(s)") && has("J9"), "{text:?}");+ assert!(has("drilled off the standard sizes: MC9"), "{text:?}");+ }++ #[test]+ fn duplicates_and_the_name_rules() {+ let b = board(&[fp("Molecule:MachineContactMedium", "MC1", 0.78, 1, ""), fp("Molecule:MachineContactMedium", "MC1", 0.78, 1, "")]);+ assert!(census(&b).iter().all(|r| r.reason == Some("duplicate")));+ assert!(machine_contact_name("MC12") && !machine_contact_name("MC0") && !machine_contact_name("MC") && !machine_contact_name("mc1") && !machine_contact_name("J1"));+ assert!(machine_pin_name("MP4") && !machine_pin_name("MP5") && !machine_pin_name("MP0"));+ assert!(near_miss("mc3") && near_miss("MP12") && !near_miss("MS1") && !near_miss("MCU1"));+ assert!(glob("MachineContact*", "MACHINECONTACTMEDIUM") && glob("ADOM_*_PIN*", "adom_large_pin_v2") && !glob("CONTACT_MED", "CONTACT_MEDIUM"));+ }++ #[test]+ fn the_schematic_step_judges_names_from_footprints() {+ let parts: Vec<(String, String)> = [("MP1", "Molecule:MachinePinMediumShort"), ("MP2", "Molecule:MachinePinMediumShort"), ("J1", "Molecule:MachineContactMedium"),+ ("MC1", "Molecule:MachineContactMedium"), ("R1", "Resistor_SMD:R_0402"), ("MP5", "Molecule:MachinePinMediumShort"), ("mc2", "PCM_lib:MachineContactLarge")]+ .iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();+ let f = names_from_footprints(&parts);+ let text = format!("{f:?}");+ assert!(text.contains("not named MC<n>") && text.contains("J1") && !text.contains("R1"), "{text}");+ assert!(text.contains("outside MP1 to MP4: MP5") && text.contains("not in capitals: mc2"), "{text}");+ assert!(names_from_footprints(&parts[..2]).is_empty());+ }+}
crates/adom-aiflow/src/route_quality.rsadded+378@@ -0,0 +1,378 @@+//! Route geometry and signal quality (issues #127, #128): a report, never a refusal. Read from the+//! straight track segments of a board (the routed board `gate` writes, the board a `route` plan+//! makes, the saved board at `finish`), with the metric definitions of the issue's route_fig.py:+//! - off-45: a segment whose angle is more than 1 degree off a multiple of 45;+//! - right / acute corner: two segments of one net on one layer meeting at a point where exactly+//! two segment ends meet, turning by 90 (+-2) degrees / by more than 92 degrees;+//! - wiggle: a segment no longer than JOG_MM whose two end turns go opposite ways (a sideways step);+//! - per signal net (not ground, supply, battery, phase or a spec power net): vias, routed length,+//! and detour = routed length / minimum spanning tree of the net's pads.+//! Worst nets first; a net far over its pad tree or with several vias gets a placement suggestion.+use std::collections::{BTreeMap, BTreeSet, HashMap};++use aiflow_board::{Board, Pt};+use serde_json::{json, Value};++pub const OFF_DEG: f64 = 1.0;+pub const RIGHT_TOL_DEG: f64 = 2.0;+pub const JOG_MM: f64 = 1.0;+/// a signal net is worth a placement suggestion at this detour (with DETOUR_MIN_MM extra copper) or this many vias+pub const DETOUR_HINT: f64 = 1.5;+pub const DETOUR_MIN_MM: f64 = 3.0;+pub const VIAS_HINT: usize = 2;+const AT_MAX: usize = 12;++#[derive(Debug, Clone)]+pub struct Seg { pub net: String, pub layer: usize, pub a: Pt, pub b: Pt, pub width: f64 }++pub fn layer_name(l: usize) -> String { match l { 0 => "F.Cu".into(), 1 => "B.Cu".into(), k => format!("In{}.Cu", k - 1) } }++fn dist(a: Pt, b: Pt) -> f64 { (b.0 - a.0).hypot(b.1 - a.1) }+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// Degrees off the nearest multiple of 45.+pub fn off45(a: Pt, b: Pt) -> f64 {+ let ang = (b.1 - a.1).atan2(b.0 - a.0).to_degrees().rem_euclid(180.0);+ let m = ang % 45.0;+ m.min(45.0 - m)+}++/// The signed turn in degrees from direction u to direction v.+fn turn(u: Pt, v: Pt) -> f64 { (u.0 * v.1 - u.1 * v.0).atan2(u.0 * v.0 + u.1 * v.1).to_degrees() }++/// Ground, supply, battery and phase nets by name (route_fig.py's NONSIG list, widened to V*, +*, nV, VCC/VDD).+pub fn power_like(name: &str) -> bool {+ let n = name.trim_start_matches('/').to_ascii_uppercase();+ let n = n.rsplit('/').next().unwrap_or(&n).to_string();+ let digit_v = { let mut c = n.chars(); c.next().is_some_and(|d| d.is_ascii_digit()) && n.contains('V') && n.chars().all(|c| c.is_ascii_digit() || c == 'V' || c == '.' || c == '_') };+ n.contains("GND") || n.contains("BAT") || n.starts_with("PH") || n.starts_with('V') || n.starts_with('+') || n.starts_with('-') || n.contains("VDD") || n.contains("VCC") || n.contains("VSS") || n.contains("PWR") || digit_v+}++/// Nets the spec treats as power: loads, wideNets, planes, pours, switchNodes.+pub fn spec_power(spec: &Value) -> BTreeSet<String> {+ let mut s = BTreeSet::new();+ for k in ["loads", "planes"] { for n in spec[k].as_object().into_iter().flatten().map(|(n, _)| n) { s.insert(n.clone()); } }+ for k in ["wideNets", "switchNodes"] {+ if let Some(o) = spec[k].as_object() { s.extend(o.keys().cloned()); }+ for n in spec[k].as_array().into_iter().flatten().filter_map(|v| v.as_str()) { s.insert(n.to_string()); }+ }+ for p in spec["pours"].as_array().into_iter().flatten() { if let Some(n) = p["net"].as_str() { s.insert(n.to_string()); } }+ s+}++pub fn mst(pts: &[Pt]) -> (f64, Vec<(usize, usize, f64)>) {+ if pts.len() < 2 { return (0.0, Vec::new()); }+ let mut best: Vec<(f64, usize)> = pts.iter().map(|p| (dist(pts[0], *p), 0)).collect();+ let mut inside = vec![false; pts.len()];+ inside[0] = true;+ let (mut tot, mut edges) = (0.0, Vec::new());+ for _ in 1..pts.len() {+ let Some(j) = (0..pts.len()).filter(|&i| !inside[i]).min_by(|&a, &b| best[a].0.total_cmp(&best[b].0)) else { break };+ inside[j] = true;+ tot += best[j].0;+ edges.push((best[j].1, j, best[j].0));+ for i in 0..pts.len() { if !inside[i] { let d = dist(pts[j], pts[i]); if d < best[i].0 { best[i] = (d, j); } } }+ }+ (tot, edges)+}++/// A pad for the detour: its key ("U1.21"), its part and where it is.+#[derive(Debug, Clone)]+pub struct PadAt { pub key: String, pub reference: String, pub at: Pt }++#[derive(Debug, Default, Clone)]+pub struct NetQ {+ pub net: String, pub signal: bool, pub length: f64, pub segments: usize, pub vias: usize,+ pub off: Vec<(Pt, Pt, usize, f64)>, pub right: Vec<(Pt, usize)>, pub acute: Vec<(Pt, usize, f64)>, pub wiggles: Vec<(Pt, usize)>,+ pub pad_tree: Option<f64>, pub longest_hop: Option<(String, String, f64)>,+}++impl NetQ {+ pub fn detour(&self) -> Option<f64> { self.pad_tree.filter(|_| self.length > 0.0).map(|m| self.length / m.max(0.1)) }+ pub fn off_len(&self) -> f64 { self.off.iter().map(|o| dist(o.0, o.1)).sum() }+ /// worst first: vias and detour weigh on signal nets, geometry on every net+ pub fn score(&self) -> f64 {+ let sig = if self.signal { 2.0 * self.vias as f64 + 10.0 * (self.detour().unwrap_or(1.0) - 1.0).max(0.0) } else { 0.0 };+ sig + self.off.len() as f64 + self.right.len() as f64 + 2.0 * self.acute.len() as f64 + self.wiggles.len() as f64+ }+ pub fn wants_placement(&self) -> bool {+ self.signal && (self.vias >= VIAS_HINT || (self.detour().unwrap_or(0.0) >= DETOUR_HINT && self.length - self.pad_tree.unwrap_or(self.length) >= DETOUR_MIN_MM))+ }+}++/// The geometry and signal numbers of a set of segments, vias (net, at) and pads per net.+pub fn analyze(segs: &[Seg], vias: &[(String, Pt)], pads: &BTreeMap<String, Vec<PadAt>>, part_pads: &HashMap<String, usize>, signal: &dyn Fn(&str) -> bool) -> Vec<NetQ> {+ let mut q: BTreeMap<String, NetQ> = BTreeMap::new();+ let key = |net: &str, l: usize, p: Pt| (net.to_string(), l, (p.0 * 1000.0).round() as i64, (p.1 * 1000.0).round() as i64);+ let mut ends: HashMap<(String, usize, i64, i64), Vec<usize>> = HashMap::new();+ for (i, s) in segs.iter().enumerate() {+ let n = q.entry(s.net.clone()).or_insert_with(|| NetQ { net: s.net.clone(), signal: signal(&s.net), ..Default::default() });+ let l = dist(s.a, s.b);+ n.length += l;+ n.segments += 1;+ if l < 1e-4 { continue; }+ let dev = off45(s.a, s.b);+ if dev > OFF_DEG { n.off.push((s.a, s.b, s.layer, dev)); }+ ends.entry(key(&s.net, s.layer, s.a)).or_default().push(i);+ ends.entry(key(&s.net, s.layer, s.b)).or_default().push(i);+ }+ let near = |a: Pt, b: Pt| dist(a, b) < 1e-3;+ let other = |s: &Seg, p: Pt| if near(s.a, p) { s.b } else { s.a };+ // the turn at p, walking into p along segment i and out along the other segment there+ let turn_at = |i: usize, p: Pt| -> Option<f64> {+ let s = &segs[i];+ let lst = ends.get(&key(&s.net, s.layer, p))?;+ if lst.len() != 2 { return None; }+ let j = if lst[0] == i { lst[1] } else { lst[0] };+ let (q0, q1) = (other(s, p), other(&segs[j], p));+ Some(turn((p.0 - q0.0, p.1 - q0.1), (q1.0 - p.0, q1.1 - p.1)))+ };+ for (k, lst) in &ends {+ if lst.len() != 2 { continue; }+ let p = (k.2 as f64 / 1000.0, k.3 as f64 / 1000.0);+ let s = &segs[lst[0]];+ let at = if near(s.a, p) { s.a } else { s.b };+ let Some(t) = turn_at(lst[0], at) else { continue };+ let n = q.get_mut(&k.0).unwrap();+ if (t.abs() - 90.0).abs() < RIGHT_TOL_DEG { n.right.push((at, k.1)); } else if t.abs() > 90.0 + RIGHT_TOL_DEG { n.acute.push((at, k.1, 180.0 - t.abs())); }+ }+ for (i, s) in segs.iter().enumerate() {+ let l = dist(s.a, s.b);+ if !(1e-4..=JOG_MM).contains(&l) { continue; }+ let (Some(ta), Some(tb)) = (turn_at(i, s.a), turn_at(i, s.b)) else { continue };+ if ta.abs() < 1.0 || tb.abs() < 1.0 { continue; }+ // turn_at(a) walks this segment backwards: its forward turn is -ta+ if (-ta > 0.0) != (tb > 0.0) { q.get_mut(&s.net).unwrap().wiggles.push((((s.a.0 + s.b.0) / 2.0, (s.a.1 + s.b.1) / 2.0), s.layer)); }+ }+ for (net, _) in vias { if let Some(n) = q.get_mut(net) { n.vias += 1; } else { q.insert(net.clone(), NetQ { net: net.clone(), signal: signal(net), vias: 1, ..Default::default() }); } }+ for n in q.values_mut() {+ let Some(ps) = pads.get(&n.net).filter(|p| p.len() >= 2) else { continue };+ let pts: Vec<Pt> = ps.iter().map(|p| p.at).collect();+ let (tot, edges) = mst(&pts);+ n.pad_tree = Some(tot);+ // the longest hop between two parts: the smaller part (fewer pads) is the one to move+ n.longest_hop = edges.iter().filter(|e| ps[e.0].reference != ps[e.1].reference).max_by(|a, b| a.2.total_cmp(&b.2)).map(|e| {+ let (a, b) = (&ps[e.0], &ps[e.1]);+ let (na, nb) = (part_pads.get(&a.reference).copied().unwrap_or(0), part_pads.get(&b.reference).copied().unwrap_or(0));+ if nb <= na { (a.key.clone(), b.key.clone(), e.2) } else { (b.key.clone(), a.key.clone(), e.2) }+ });+ }+ let mut out: Vec<NetQ> = q.into_values().filter(|n| n.segments > 0 || n.vias > 0).collect();+ out.sort_by(|a, b| b.score().total_cmp(&a.score()).then(a.net.cmp(&b.net)));+ out+}++/// The straight track segments of a board on every copper layer (arcs are deliberate curves, not counted).+pub fn board_segments(b: &Board) -> Vec<Seg> {+ let p2 = |n: Option<&aiflow_board::Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) };+ b.root.nodes().filter(|n| n.tag == "segment").filter_map(|n| {+ let layer = n.find("layer").and_then(|l| l.value()).and_then(aiflow_board::copper_layer)?;+ Some(Seg { net: b.item_net(n), layer, a: p2(n.find("start"))?, b: p2(n.find("end"))?, width: n.find("width").and_then(|w| w.number(1)).unwrap_or(0.0) })+ }).collect()+}++/// The suggestion that names placement as the lever for a signal net that routed long or through vias.+pub fn placement_line(n: &NetQ) -> Option<String> {+ if !n.wants_placement() { return None; }+ let (a, b, d) = n.longest_hop.clone()?;+ let mover = b.split('.').next().unwrap_or(&b).to_string();+ let anchor = a.split('.').next().unwrap_or(&a).to_string();+ let vias = if n.vias > 0 { format!(", {} via{}", n.vias, if n.vias == 1 { "" } else { "s" }) } else { String::new() };+ let tree = n.pad_tree.unwrap_or(0.0);+ Some(if (tree - d).abs() < 1e-6 {+ format!("{}: {:.1} mm{vias} between {a} and {b} that are {d:.1} mm apart: move {mover} toward {anchor} or rotate it (place pack --wish)", n.net, n.length)+ } else {+ format!("{}: {:.1} mm{vias} for a pad tree of {tree:.1} mm ({:.1}x); the longest hop is {a} to {b}, {d:.1} mm apart: move {mover} toward {anchor} or rotate it (place pack --wish)", n.net, n.length, n.detour().unwrap_or(0.0))+ })+}++fn pt(p: Pt, l: usize) -> Value { json!([r3(p.0), r3(p.1), layer_name(l)]) }++/// The whole report as JSON, plus the compact lines `route`, `gate` and `finish` print.+pub fn report_of(nets: &[NetQ]) -> (Value, Vec<String>) {+ let sum = |f: &dyn Fn(&NetQ) -> f64| nets.iter().map(f).sum::<f64>();+ let segs = nets.iter().map(|n| n.segments).sum::<usize>();+ let (off, right, acute, wig) = (nets.iter().map(|n| n.off.len()).sum::<usize>(), nets.iter().map(|n| n.right.len()).sum::<usize>(), nets.iter().map(|n| n.acute.len()).sum::<usize>(), nets.iter().map(|n| n.wiggles.len()).sum::<usize>());+ let sig: Vec<&NetQ> = nets.iter().filter(|n| n.signal).collect();+ let mut det: Vec<f64> = sig.iter().filter_map(|n| n.detour()).collect();+ det.sort_by(|a, b| a.total_cmp(b));+ let median = det.get(det.len() / 2).copied();+ let sig_vias = sig.iter().map(|n| n.vias).sum::<usize>();+ let sig_len = sig.iter().map(|n| n.length).sum::<f64>();+ let suggestions: Vec<String> = nets.iter().filter_map(placement_line).take(8).collect();+ let per: Vec<Value> = nets.iter().map(|n| json!({+ "net": n.net, "signal": n.signal, "lengthMm": r2(n.length), "segments": n.segments, "vias": n.vias,+ "padTreeMm": n.pad_tree.map(r2), "detour": n.detour().map(r2),+ "longestHop": n.longest_hop.as_ref().map(|h| json!({"from": h.0, "to": h.1, "mm": r2(h.2)})),+ "offAngle": {"count": n.off.len(), "lengthMm": r2(n.off_len()), "at": n.off.iter().take(AT_MAX).map(|o| json!({"from": pt(o.0, o.2), "to": pt(o.1, o.2), "offDeg": r2(o.3)})).collect::<Vec<_>>()},+ "rightAngles": {"count": n.right.len(), "at": n.right.iter().take(AT_MAX).map(|r| pt(r.0, r.1)).collect::<Vec<_>>()},+ "acute": {"count": n.acute.len(), "at": n.acute.iter().take(AT_MAX).map(|r| json!({"at": pt(r.0, r.1), "angleDeg": r2(r.2)})).collect::<Vec<_>>()},+ "wiggles": {"count": n.wiggles.len(), "at": n.wiggles.iter().take(AT_MAX).map(|r| pt(r.0, r.1)).collect::<Vec<_>>()},+ "suggestion": placement_line(n),+ })).collect();+ let v = json!({+ "segments": segs, "lengthMm": r2(sum(&|n| n.length)),+ "offAngle": {"count": off, "lengthMm": r2(sum(&|n| n.off_len()))}, "rightAngles": right, "acute": acute, "wiggles": wig,+ "signal": {"nets": sig.len(), "netsWithVias": sig.iter().filter(|n| n.vias > 0).count(), "vias": sig_vias, "lengthMm": r2(sig_len), "medianDetour": median.map(r2)},+ "nets": per, "suggestions": suggestions, "blocking": false,+ "rule": format!("off-45 = more than {OFF_DEG} degree off a multiple of 45; right corner = two segments of one net on one layer turning 90 +-{RIGHT_TOL_DEG} degrees, acute = turning more; wiggle = a segment <= {JOG_MM} mm with opposite turns at its ends; detour = routed length / minimum spanning tree of the net's pads, on signal nets (not ground, supply, battery, phase or spec power nets); arcs not counted. A report with suggestions, never a gate"),+ });+ let mut lines = vec![format!("route quality (suggestions, not blocking): {segs} segments {:.0} mm; {off} off-45 ({:.1} mm), {right} right-angle corners, {acute} acute, {wig} wiggles; signal nets: {sig_vias} vias on {} of {}, {:.0} mm, median detour {}",+ sum(&|n| n.length), sum(&|n| n.off_len()), sig.iter().filter(|n| n.vias > 0).count(), sig.len(), sig_len, median.map(|m| format!("{m:.2}")).unwrap_or("-".into()))];+ let worst: Vec<String> = nets.iter().filter(|n| n.score() > 0.0).take(5).map(|n| {+ let mut parts = Vec::new();+ if n.signal { parts.push(format!("{:.1} mm", n.length)); if n.vias > 0 { parts.push(format!("{} via(s)", n.vias)); } if let Some(d) = n.detour() { parts.push(format!("detour {d:.2}")); } }+ if !n.off.is_empty() { let o = &n.off[0]; parts.push(format!("{} off-45 (first at {:.2}, {:.2} {})", n.off.len(), o.0 .0, o.0 .1, layer_name(o.2))); }+ if !n.right.is_empty() { let r = &n.right[0]; parts.push(format!("{} right angle(s) (first at {:.2}, {:.2})", n.right.len(), r.0 .0, r.0 .1)); }+ if !n.acute.is_empty() { parts.push(format!("{} acute", n.acute.len())); }+ if !n.wiggles.is_empty() { let w = &n.wiggles[0]; parts.push(format!("{} wiggle(s) (first at {:.2}, {:.2})", n.wiggles.len(), w.0 .0, w.0 .1)); }+ format!("{}: {}", n.net, parts.join(", "))+ }).collect();+ if !worst.is_empty() { lines.push(format!(" worst nets: {}", worst.join("; "))); }+ for s in &suggestions { lines.push(format!(" suggestion: {s}")); }+ if off + right + acute + wig > 0 { lines.push(" suggestion: draw copper at multiples of 45 degrees, chamfer right angles into two 45s and straighten wiggles (the router's simplify pass does this for its own plans; a hand-written plan or adopted copper needs it by hand). Every finding has a coordinate in routeQuality.".into()); }+ (v, lines)+}++/// The report for a board's copper, with the spec's power nets kept out of the signal numbers.+pub fn of_board(b: &Board, spec: &Value) -> (Value, Vec<String>) {+ let segs = board_segments(b);+ let vias: Vec<(String, Pt)> = b.vias().into_iter().map(|v| (v.net, (v.x, v.y))).collect();+ let mut pads: BTreeMap<String, Vec<PadAt>> = BTreeMap::new();+ let mut part_pads: HashMap<String, usize> = HashMap::new();+ for p in &b.pads {+ *part_pads.entry(p.reference.clone()).or_default() += 1;+ if !p.net.is_empty() { pads.entry(p.net.clone()).or_default().push(PadAt { key: p.key.clone(), reference: p.reference.clone(), at: (p.x, p.y) }); }+ }+ let power = spec_power(spec);+ let signal = |n: &str| !n.is_empty() && !power.contains(n) && !power_like(n) && !n.to_ascii_lowercase().starts_with("unconnected-");+ report_of(&analyze(&segs, &vias, &pads, &part_pads, &signal))+}++/// The report for a plan applied to a board (what `route` planned), or None when it does not apply.+pub fn of_plan(b: &Board, plan: &Value, spec: &Value) -> Option<(Value, Vec<String>)> {+ let (text, _) = aiflow_copper::apply(b, plan, false).ok()?;+ let rb = Board::from_text(&b.path, text).ok()?;+ Some(of_board(&rb, spec))+}++/// A compact record for run outcomes (the full report sits in its own file).+pub fn outcome(v: &Value, file: &str) -> Value {+ json!({"report": file, "offAngle": v["offAngle"]["count"], "rightAngles": v["rightAngles"], "acute": v["acute"], "wiggles": v["wiggles"], "signalVias": v["signal"]["vias"], "signalNetsWithVias": v["signal"]["netsWithVias"], "signalNets": v["signal"]["nets"], "signalLengthMm": v["signal"]["lengthMm"], "medianDetour": v["signal"]["medianDetour"], "suggestions": v["suggestions"], "blocking": false})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn s(net: &str, a: Pt, b: Pt) -> Seg { Seg { net: net.into(), layer: 0, a, b, width: 0.25 } }+ fn pads(net: &str, ps: &[(&str, Pt)]) -> BTreeMap<String, Vec<PadAt>> {+ let mut m = BTreeMap::new();+ m.insert(net.to_string(), ps.iter().map(|(k, at)| PadAt { key: k.to_string(), reference: k.split('.').next().unwrap().to_string(), at: *at }).collect());+ m+ }+ fn run(segs: &[Seg], vias: &[(String, Pt)], p: &BTreeMap<String, Vec<PadAt>>) -> Vec<NetQ> {+ let parts: HashMap<String, usize> = [("U1".to_string(), 32), ("U2".to_string(), 8)].into_iter().collect();+ analyze(segs, vias, p, &parts, &|n| !power_like(n))+ }++ #[test]+ fn off_angle_segments_are_found_and_45s_are_not() {+ let q = run(&[s("A", (0.0, 0.0), (1.0, 0.0)), s("A", (1.0, 0.0), (2.0, 1.0)), s("A", (2.0, 1.0), (4.0, 2.0))], &[], &BTreeMap::new());+ assert_eq!(q[0].off.len(), 1, "{q:?}");+ assert!((q[0].off[0].3 - 18.43).abs() < 0.1);+ assert!((off45((0.0, 0.0), (1.0, 1.02)) - 0.57).abs() < 0.05, "under a degree is on the grid");+ }++ #[test]+ fn right_and_acute_corners_but_not_45_turns_or_tees() {+ let q = run(&[s("A", (0.0, 0.0), (2.0, 0.0)), s("A", (2.0, 0.0), (2.0, 2.0)), s("A", (2.0, 2.0), (3.0, 3.0)), s("A", (3.0, 3.0), (2.0, 4.0))], &[], &BTreeMap::new());+ assert_eq!(q[0].right.len(), 2, "{q:?}"); // (2,0) and (3,3): 45 then 90+ assert!(q[0].right.iter().any(|r| dist(r.0, (2.0, 0.0)) < 1e-9));+ assert!(q[0].acute.is_empty());+ let q = run(&[s("A", (0.0, 0.0), (2.0, 0.0)), s("A", (2.0, 0.0), (0.5, 1.5))], &[], &BTreeMap::new());+ assert_eq!(q[0].acute.len(), 1, "{q:?}");+ // a tee (three ends at a point) is not a corner; other layers and nets do not join+ let mut t = vec![s("A", (0.0, 0.0), (2.0, 0.0)), s("A", (2.0, 0.0), (4.0, 0.0)), s("A", (2.0, 0.0), (2.0, 2.0)), s("B", (5.0, 0.0), (6.0, 0.0))];+ t.push(Seg { layer: 1, ..s("B", (6.0, 0.0), (6.0, 1.0)) });+ assert!(run(&t, &[], &BTreeMap::new()).iter().all(|n| n.right.is_empty()));+ }++ #[test]+ fn a_short_sideways_step_is_a_wiggle_a_chamfered_corner_is_not() {+ let q = run(&[s("A", (0.0, 0.0), (3.0, 0.0)), s("A", (3.0, 0.0), (3.0, 0.4)), s("A", (3.0, 0.4), (6.0, 0.4))], &[], &BTreeMap::new());+ assert_eq!(q[0].wiggles.len(), 1, "{q:?}");+ assert!(dist(q[0].wiggles[0].0, (3.0, 0.2)) < 1e-9);+ assert_eq!(q[0].right.len(), 2);+ let q = run(&[s("A", (0.0, 0.0), (3.0, 0.0)), s("A", (3.0, 0.0), (3.5, 0.5)), s("A", (3.5, 0.5), (3.5, 4.0))], &[], &BTreeMap::new());+ assert!(q[0].wiggles.is_empty() && q[0].right.is_empty() && q[0].off.is_empty(), "{q:?}");+ // a long step is a lane change, not a wiggle+ let q = run(&[s("A", (0.0, 0.0), (3.0, 0.0)), s("A", (3.0, 0.0), (3.0, 2.0)), s("A", (3.0, 2.0), (6.0, 2.0))], &[], &BTreeMap::new());+ assert!(q[0].wiggles.is_empty());+ }++ #[test]+ fn detour_vias_and_the_placement_suggestion() {+ // INHA: pads 14 mm apart, routed 25 mm through 2 vias+ let segs = [s("INHA", (0.0, 0.0), (0.0, 5.5)), s("INHA", (0.0, 5.5), (14.0, 5.5)), s("INHA", (14.0, 5.5), (14.0, 0.0))];+ let vias = vec![("INHA".to_string(), (0.0, 5.5)), ("INHA".to_string(), (14.0, 5.5)), ("GND".to_string(), (9.0, 9.0))];+ let mut p = pads("INHA", &[("U1.21", (0.0, 0.0)), ("U2.22", (14.0, 0.0))]);+ p.extend(pads("GND", &[("U1.1", (1.0, 1.0)), ("U2.1", (30.0, 1.0))]));+ let q = run(&segs, &vias, &p);+ let n = q.iter().find(|n| n.net == "INHA").unwrap();+ assert_eq!(n.vias, 2);+ assert!((n.detour().unwrap() - 25.0 / 14.0).abs() < 1e-9);+ let line = placement_line(n).unwrap();+ assert!(line.starts_with("INHA: 25.0 mm, 2 vias between U1.21 and U2.22 that are 14.0 mm apart: move U2 toward U1"), "{line}");+ let g = q.iter().find(|n| n.net == "GND").unwrap();+ assert!(!g.signal && placement_line(g).is_none());+ assert_eq!(q[0].net, "INHA", "worst first");+ let (v, lines) = report_of(&q);+ assert_eq!(v["signal"]["vias"], 2);+ assert_eq!(v["signal"]["netsWithVias"], 1);+ assert_eq!(v["blocking"], false);+ assert!(lines[0].contains("2 vias on 1 of 1"), "{lines:?}");+ assert!(lines.iter().any(|l| l.contains("place pack --wish")));+ // a short direct route says nothing+ let q = run(&[s("X", (0.0, 0.0), (5.0, 0.0))], &[], &pads("X", &[("U1.2", (0.0, 0.0)), ("U2.3", (5.0, 0.0))]));+ assert!(placement_line(&q[0]).is_none());+ }++ /// AIFLOW_RQ_BOARD=<.kicad_pcb> [AIFLOW_RQ_SPEC=<spec.json>] cargo test -- --ignored real_board --nocapture+ #[test]+ #[ignore]+ fn real_board() {+ let Ok(p) = std::env::var("AIFLOW_RQ_BOARD") else { return };+ let spec = std::env::var("AIFLOW_RQ_SPEC").ok().and_then(|s| std::fs::read_to_string(s).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ let (v, lines) = of_board(&Board::load(&p).unwrap(), &spec);+ println!("{}\n{}", lines.join("\n"), serde_json::to_string_pretty(&json!({"totals": {"segments": v["segments"], "lengthMm": v["lengthMm"], "offAngle": v["offAngle"], "rightAngles": v["rightAngles"], "acute": v["acute"], "wiggles": v["wiggles"], "signal": v["signal"]}, "worst": v["nets"].as_array().unwrap().iter().take(6).collect::<Vec<_>>()})).unwrap());+ }++ /// The router on a real unrouted board: quality of the raw plan and of the simplified one.+ /// AIFLOW_RQ_ROUTE=<.kicad_pcb> [AIFLOW_RQ_SPEC=<spec.json>] cargo test -- --ignored real_route --nocapture+ #[test]+ #[ignore]+ fn real_route() {+ let Ok(p) = std::env::var("AIFLOW_RQ_ROUTE") else { return };+ let spec: Value = std::env::var("AIFLOW_RQ_SPEC").ok().and_then(|s| std::fs::read_to_string(s).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ let b = Board::load(&p).unwrap();+ let mut r = aiflow_router::Router::new(Board::load(&p).unwrap(), aiflow_router::Rules::from_spec(&spec), false);+ let (mut plan, _, failed) = aiflow_router::drive::route_board(&mut r, 6, 8, 400);+ let raw = plan.clone();+ let simp = aiflow_router::simplify::simplify_plan(&mut r, &mut plan);+ for (name, pl) in [("raw", raw), ("simplified", plan)] {+ let (_, lines) = of_plan(&b, &json!({"nets": pl}), &spec).unwrap();+ println!("{name} (unrouted {failed}): {}", lines[0]);+ }+ println!("simplify: {}", json!({"staircases": simp["staircasesReplaced"], "chamfered": simp["cornersChamfered"], "wiggles": simp["wigglesStepped"], "kept": simp["smoothKept"]}));+ }++ #[test]+ fn power_nets_by_name_and_spec() {+ for n in ["GND", "/PGND", "VBAT", "V5", "+3V3", "3V3", "12V", "PHA", "PHASE_B", "GVDD", "VCC_IO"] { assert!(power_like(n), "{n}"); }+ for n in ["INHA", "BEMF_A", "SWCLK", "SDA", "/Sheet/LED1", "EN"] { assert!(!power_like(n), "{n}"); }+ let s = spec_power(&json!({"loads": {"OUT": {}}, "wideNets": ["SW"], "planes": {"AGND": "B.Cu"}, "pours": [{"net": "MOTOR"}]}));+ assert!(["OUT", "SW", "AGND", "MOTOR"].iter().all(|n| s.contains(*n)));+ }+}
crates/adom-aiflow/src/route_variants.rs+2−2@@ -27,7 +27,7 @@ pub struct Routed { /// The issue #39 simplify line, from the report's "simplify" block. pub fn simp_line(simp: &Value) -> String {- format!("simplified: {} staircase(s) replaced, {} kept (no clear 45 degree path), {} collinear point(s) merged; vertices {} -> {} (per net in plan-route-report.json \"simplify\")", simp["staircasesReplaced"], simp["staircasesKept"], simp["collinearMerged"], simp["verticesBefore"], simp["verticesAfter"])+ format!("simplified: {} staircase(s) replaced, {} kept (no clear 45 degree path), {} collinear point(s) merged, {} right-angle corner(s) chamfered, {} wiggle(s) made one 45 degree step; vertices {} -> {} (per net in plan-route-report.json \"simplify\")", simp["staircasesReplaced"], simp["staircasesKept"], simp["collinearMerged"], simp["cornersChamfered"].as_u64().unwrap_or(0), simp["wigglesStepped"].as_u64().unwrap_or(0), simp["verticesBefore"], simp["verticesAfter"]) } /// One route of `b` with `levers` (rules already carry the spec): the router, the 45 degree@@ -106,7 +106,7 @@ fn gate_summary(g: &gate_eval::GateEval) -> Value { let mech = g.mech.as_ref().map(|m| m.errors().len() as u64).unwrap_or(0); let dec = g.dec.as_ref().map(|d| d.errors).unwrap_or(0); let issues = n("newErrors") + n("unconnected") + g.pu_issues.len() as u64 + g.sw_issues.len() as u64 + mech + dec;- json!({"newErrors": n("newErrors"), "unconnected": n("unconnected"), "inheritedErrors": n("inheritedErrors"), "planeUnderIssues": g.pu_issues.len(), "switchNodeIssues": g.sw_issues.len(), "mechanicalErrors": mech, "decouplingErrors": dec, "staircases": g.stairs.len(), "issues": issues, "newErrorTypes": g.s["newErrorTypes"]})+ json!({"newErrors": n("newErrors"), "unconnected": n("unconnected"), "inheritedErrors": n("inheritedErrors"), "planeUnderIssues": g.pu_issues.len(), "switchNodeIssues": g.sw_issues.len(), "mechanicalErrors": mech, "decouplingErrors": dec, "staircases": g.stairs.len(), "routeQuality": g.s["routeQuality"], "issues": issues, "newErrorTypes": g.s["newErrorTypes"]}) } /// The compact table `route --variants` prints and the run page shows.
crates/adom-aiflow/src/screen.rsadded+114@@ -0,0 +1,114 @@+//! Whether AI Flow may bring windows to the front on the run's desktop (issues #132, #135).+//! `screen: hands-off | ask | allowed`, the names of PR #59's flow profiles. On the machine the human+//! is using it defaults to hands-off: no raise, no maximise, the board opened without focus; clips+//! keep recording by window capture, which needs no foreground. A declared test box keeps `allowed`.+//! Precedence: AIFLOW_SCREEN, then run.json `screen` (`capture screen <mode>`), then the hands-on+//! flow profile's screen (PR #59, `handson set --screen`), then the spec's `capture.screen`, then the+//! default from the machine. A value it does not know is skipped and said.+use serde_json::Value;++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Mode { HandsOff, Ask, Allowed }++impl Mode {+ pub fn parse(s: &str) -> Option<Mode> {+ match s.trim().to_ascii_lowercase().replace(['_', ' '], "-").as_str() {+ "hands-off" | "handsoff" | "off" | "never" => Some(Mode::HandsOff),+ "ask" => Some(Mode::Ask),+ "allowed" | "allow" | "on" => Some(Mode::Allowed),+ _ => None,+ }+ }+ pub fn as_str(self) -> &'static str { match self { Mode::HandsOff => "hands-off", Mode::Ask => "ask", Mode::Allowed => "allowed" } }+ /// Only `allowed` raises or maximises on its own; `ask` means the AI asks the human first.+ pub fn may_raise(self) -> bool { self == Mode::Allowed }+}++/// Is the run's target the machine the human is using? The primary machine (`adom-bridge targets`:+/// `primaryMachine`, or the primary entry's name or hostname), or under a Hydrogen local workspace+/// the PC this container runs on. A local workspace that cannot name its primary counts as the+/// human's own: when in doubt, the screen is theirs.+pub fn own_machine(target: &str, local_workspace: bool, host: Option<&str>, targets: Option<&Value>) -> bool {+ let t = target.trim();+ if t.is_empty() { return local_workspace; }+ let is = |n: Option<&str>| n.is_some_and(|n| !n.is_empty() && n.eq_ignore_ascii_case(t));+ if local_workspace && (is(host) || matches!(t.to_ascii_lowercase().as_str(), "local" | "localhost")) { return true; }+ let Some(v) = targets else { return local_workspace };+ let primary = v["primaryMachine"].as_str().filter(|s| !s.is_empty());+ let entry = v["targets"].as_array().and_then(|a| a.iter().find(|e| e["primary"].as_bool() == Some(true) || (primary.is_some() && e["name"].as_str() == primary)));+ if primary.is_none() && entry.is_none() { return local_workspace; }+ is(primary) || entry.is_some_and(|e| is(e["name"].as_str()) || is(e["hostname"].as_str()))+}++/// The effective mode, where it came from, and notes for values that were not understood.+pub fn decide(env: Option<&str>, run: Option<&str>, hands: Option<&str>, spec: Option<&str>, target: &str, own: bool) -> (Mode, String, Vec<String>) {+ let mut notes = Vec::new();+ for (v, src) in [(env, "AIFLOW_SCREEN"), (run, "run.json screen (`capture screen <mode>`)"), (hands, "the hands-on flow profile"), (spec, "the spec's capture.screen")] {+ let Some(v) = v.filter(|v| !v.trim().is_empty()) else { continue };+ match Mode::parse(v) {+ Some(m) => return (m, src.into(), notes),+ None => notes.push(format!("{src} {v:?} is not a screen mode (hands-off, ask, allowed); skipped")),+ }+ }+ if own { (Mode::HandsOff, format!("default: {target} is the machine you are using"), notes) }+ else { (Mode::Allowed, format!("default: {target} is a test box, not the machine you are using"), notes) }+}++/// The one line the AI reads when the mode is first set or changes.+pub fn line(mode: Mode, source: &str) -> String {+ let what = match mode {+ Mode::HandsOff => "AI Flow never brings a window to the front or maximises one on this desktop; clips record by window capture",+ Mode::Ask => "AI Flow raises nothing by itself; ask the human before anything that needs the screen (tour 3d)",+ Mode::Allowed => "AI Flow may bring KiCad and Hydrogen to the front and maximise the editor for the clips",+ };+ format!("Screen: {} ({source}). {what}. Change it with `capture screen hands-off|ask|allowed` or AIFLOW_SCREEN.", mode.as_str())+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ #[test]+ fn parses_modes() {+ assert_eq!(Mode::parse("hands-off"), Some(Mode::HandsOff));+ assert_eq!(Mode::parse(" Hands_Off "), Some(Mode::HandsOff));+ assert_eq!(Mode::parse("ASK"), Some(Mode::Ask));+ assert_eq!(Mode::parse("allowed"), Some(Mode::Allowed));+ assert_eq!(Mode::parse("sometimes"), None);+ assert!(Mode::Allowed.may_raise() && !Mode::Ask.may_raise() && !Mode::HandsOff.may_raise());+ }++ #[test]+ fn own_machine_from_targets_and_workspace() {+ let t = json!({"primaryMachine": "AdomLapper", "targets": [{"name": "crr", "hostname": "ConfRoomROG", "primary": false}, {"name": "AdomLapper", "hostname": "ADOMLAPPER-PC", "primary": true}]});+ assert!(own_machine("adomlapper", false, None, Some(&t)));+ assert!(own_machine("ADOMLAPPER-PC", false, None, Some(&t)));+ assert!(!own_machine("crr", false, None, Some(&t)));+ assert!(!own_machine("crr", true, Some("AdomLapper"), Some(&t)));+ // a local workspace targeting its own PC, even when `targets` could not be read+ assert!(own_machine("AdomLapper", true, Some("AdomLapper"), None));+ assert!(own_machine("somebox", true, None, None));+ assert!(!own_machine("somebox", false, None, None));+ assert!(own_machine("localhost", true, None, Some(&t)));+ }++ #[test]+ fn precedence_and_defaults() {+ assert_eq!(decide(None, None, None, None, "AdomLapper", true).0, Mode::HandsOff);+ assert_eq!(decide(None, None, None, None, "crr", false).0, Mode::Allowed);+ // a declared test box opts in from the spec or the run; the human's machine too, when they say so+ assert_eq!(decide(None, None, None, Some("allowed"), "AdomLapper", true).0, Mode::Allowed);+ assert_eq!(decide(None, Some("allowed"), None, Some("hands-off"), "AdomLapper", true).0, Mode::Allowed);+ let (m, src, _) = decide(Some("hands-off"), Some("allowed"), None, Some("allowed"), "crr", false);+ assert_eq!((m, src.as_str()), (Mode::HandsOff, "AIFLOW_SCREEN"));+ assert_eq!(decide(None, Some("ask"), None, None, "crr", false).0, Mode::Ask);+ assert_eq!(decide(None, None, Some("hands-off"), None, "crr", false).0, Mode::HandsOff, "a hands-on profile's screen wins over the machine default");+ assert_eq!(decide(None, Some("allowed"), Some("hands-off"), None, "crr", false).0, Mode::Allowed, "capture screen is the human's latest word");+ // an unknown value is skipped and said, never an error+ let (m, _, notes) = decide(Some("yes please"), None, None, None, "AdomLapper", true);+ assert_eq!(m, Mode::HandsOff);+ assert_eq!(notes.len(), 1);+ assert!(line(Mode::HandsOff, "x").starts_with("Screen: hands-off (x)."));+ }+}
crates/adom-aiflow/src/sequence.rs+2@@ -284,6 +284,7 @@ pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes: "status": s.status.as_str(), "visits": s.visits, "minutes": minutes.get(&s.name).and_then(|m| m["wallSeconds"].as_f64()).map(|x| (x / 6.0).round() / 10.0).unwrap_or(0.0), "unproven": s.unproven,+ "mode": crate::handson::mode_of(data, &s.name), })).collect(); // the loops: every return, from the step the run was in to the step it went back to let mut events: Vec<(String, String, bool, Option<String>)> = Vec::new();@@ -325,6 +326,7 @@ pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes: "position": idx, "total": st.len(), "done": steps.iter().filter(|s| s["status"] == "done").count(), "steps": steps, "loops": loops,+ "handsOn": crate::handson::timeline_block(data), }) }
crates/adom-aiflow/src/thermalvias.rs+105−7@@ -53,7 +53,62 @@ fn local_pours(pours: &[Value], net: &str, c: (f64, f64), radius: f64, outline: })) } -pub struct Part { pub reference: String, pub net: String, pub target: usize, pub existing: usize, pub planned: Vec<(f64, f64)>, pub note: Option<String> }+pub struct Part { pub reference: String, pub net: String, pub target: usize, pub existing: usize, pub planned: Vec<(f64, f64)>, pub note: Option<String>, pub copper: Vec<String> }++/// Issue #136: the saved board's filled zones as copper regions, the same shape as spec.pours (one+/// region per filled island and layer), so zones the human drew in the EDA tool count as copper+/// before this run's pours step adopts them. Each carries `label`: the zone's name, net and layer,+/// and whose it is: a zone on a net and layer spec.pours plans is the run's pour as filled; any other+/// is the board's own (drawn by the human or another tool). Keepouts and unfilled zones give nothing:+/// only copper the board file holds as filled counts, as of its last save.+pub fn board_regions(spec: &Value, b: &Board) -> Vec<Value> {+ let planned = |net: &str, layer: &str| spec["pours"].as_array().into_iter().flatten().any(|p| p["net"].as_str() == Some(net)+ && p["layers"].as_array().is_some_and(|ls| ls.iter().any(|l| l.as_str() == Some(layer))));+ let mut out = Vec::new();+ for z in b.root.find_all("zone") {+ if z.find("keepout").is_some() { continue; }+ let net = b.item_net(z);+ if net.is_empty() { continue; }+ let name = z.find("name").and_then(|n| n.value()).filter(|n| !n.is_empty()).map(str::to_string);+ for f in z.find_all("filled_polygon") {+ let Some(layer) = f.find("layer").and_then(|l| l.value()).filter(|l| *l == "F.Cu" || *l == "B.Cu") else { continue };+ let poly: Vec<Value> = f.find("pts").map(|p| p.find_all("xy").filter_map(|xy| Some(json!([xy.number(1)?, xy.number(2)?]))).collect()).unwrap_or_default();+ if poly.len() < 3 { continue; }+ let whose = if planned(&net, layer) { "the run's pour (spec.pours), as filled on the saved board" } else { "the board's own zone, not in spec.pours (drawn by the human or another tool)" };+ let label = format!("{} ({net} {layer}): {whose}", name.clone().map(|n| format!("zone \"{n}\"")).unwrap_or_else(|| "unnamed zone".into()));+ out.push(json!({"net": net, "layers": [layer], "polygon": poly, "name": name, "source": "board", "label": label}));+ }+ }+ out+}++/// The copper the planner and the count read: spec.pours (the run's plan, filled or not), then the+/// saved board's filled zones (`board_regions`).+fn copper_regions(spec: &Value, b: &Board) -> Vec<Value> {+ let mut pours = spec["pours"].as_array().cloned().unwrap_or_default();+ pours.extend(board_regions(spec, b));+ pours+}++/// The regions of `net` on an outer layer that reach within `reach` mm of any of `pads`, by label+/// (spec.pours by name), one each: which copper the answer for a part rests on.+fn regions_near(pours: &[Value], net: &str, pads: &[(f64, f64)], reach: f64) -> Vec<String> {+ let mut out: Vec<String> = Vec::new();+ for p in pours.iter().filter(|p| p["net"].as_str() == Some(net)) {+ let near = match p["polygon"].as_array().and_then(|a| a.iter().map(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect::<Option<Vec<_>>>()) {+ Some(poly) if poly.len() >= 3 => pads.iter().any(|&c| point_in(&poly, c) || (0..poly.len()).any(|i| seg_dist(c, poly[i], poly[(i + 1) % poly.len()]) < reach)),+ Some(_) => false,+ None => true, // a spec pour without a polygon fills the outline+ };+ if !near { continue; }+ let label = p["label"].as_str().map(str::to_string).unwrap_or_else(|| {+ let layers: Vec<&str> = p["layers"].as_array().into_iter().flatten().filter_map(|l| l.as_str()).collect();+ format!("{} ({net} {}): spec.pours", p["name"].as_str().map(str::to_string).unwrap_or_else(|| format!("{net} pour")), layers.join("+"))+ });+ if !out.contains(&label) { out.push(label); }+ }+ out+} /// Plan the vias. `poured_both` says whether a net has copper on both outer layers (a via only moves /// heat where the net has copper on the other side).@@ -73,7 +128,8 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a let silk_gap = spec["silkClearance"].as_f64().unwrap_or(0.1); let edge = spec["edgeClearance"].as_f64().unwrap_or(0.3); let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0);- let pours = spec["pours"].as_array().cloned().unwrap_or_default();+ // issue #136: the board's filled zones count too, so a human's zones are seen before the pours step+ let pours = copper_regions(spec, b); let poured_both = |net: &str| -> bool { let mut f = false; let mut bk = false; for p in pours.iter().filter(|p| p["net"].as_str() == Some(net)) {@@ -94,16 +150,17 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a let watts = h["watts"].as_f64().unwrap_or(0.0); // issue #116: a lead-cooled part has no tab to put heatsink vias beside if aiflow_analyze::lead_cooling::declared(&reference, &h).unwrap_or(false) {- parts.push(Part { reference: reference.clone(), net: aiflow_analyze::lead_cooling::LEADS.into(), target: 0, existing: 0, planned: vec![], note: Some(format!("{reference} is lead-cooled (no exposed tab): no heatsink vias; the Fields solve judges it")) });+ parts.push(Part { reference: reference.clone(), net: aiflow_analyze::lead_cooling::LEADS.into(), target: 0, existing: 0, planned: vec![], note: Some(format!("{reference} is lead-cooled (no exposed tab): no heatsink vias; the Fields solve judges it")), copper: vec![] }); continue; } let net = h["tabNet"].as_str().unwrap_or("GND").to_string(); let tgt = target(watts, h["maxRiseC"].as_f64().unwrap_or(max_rise)); let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();- let mut part = Part { reference: reference.clone(), net: net.clone(), target: tgt, existing: 0, planned: vec![], note: None };+ let centres: Vec<(f64, f64)> = pads.iter().map(|p| (p.x, p.y)).collect();+ let mut part = Part { reference: reference.clone(), net: net.clone(), target: tgt, existing: 0, planned: vec![], note: None, copper: regions_near(&pours, &net, ¢res, 3.0) }; if sw_nets.contains(&net) { part.note = Some(format!("{net} is a switch node: no heatsink vias on it")); parts.push(part); continue; } if pads.is_empty() { part.note = Some(format!("{reference} has no pad on {net}")); parts.push(part); continue; }- if !poured_both(&net) { part.note = Some(format!("{net} has no pour on both layers: a via would carry the heat nowhere (pour {net} on B.Cu under {reference}, or ack)")); parts.push(part); continue; }+ if !poured_both(&net) { part.note = Some(format!("{net} has no pour on both layers, in spec.pours or among the board's filled zones: a via would carry the heat nowhere (pour {net} on B.Cu under {reference}, or ack; a zone drawn but not filled counts once it is filled and saved)")); parts.push(part); continue; } // vias of the net already within 3 mm of the part's heat pads let near = |x: f64, y: f64| pads.iter().any(|p| (p.x - x).hypot(p.y - y) < 3.0); part.existing = vias.iter().filter(|v| v.net == net && near(v.x, v.y)@@ -154,7 +211,7 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a } if part.existing + part.planned.len() < tgt { let silk_note = if under_silk > 0 { format!("; {under_silk} spot(s) left out under silkscreen text or graphics (silkClearance {silk_gap} mm): move the label to free them") } else { String::new() };- part.note = Some(format!("room for {} of {} vias within 3 mm of {reference} and inside both planned {net} copper regions: review local copper and cooling; native refill/connectivity still required{silk_note}", part.existing + part.planned.len(), tgt));+ part.note = Some(format!("room for {} of {} vias within 3 mm of {reference} and inside {net} copper on both layers (spec.pours or the board's filled zones): review local copper and cooling; native refill/connectivity still required{silk_note}", part.existing + part.planned.len(), tgt)); } parts.push(part); }@@ -261,7 +318,8 @@ pub fn short_live(spec: &Value, b: &Board, live: &aiflow_bridge::landing::LiveCo fn short_with(spec: &Value, b: &Board, vias: &[(String, f64, f64, f64)], live_pads: &dyn Fn(&str, &str) -> Option<Vec<(f64, f64)>>, acked: &[String]) -> Vec<(String, usize, usize)> { let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0);- let pours = spec["pours"].as_array().cloned().unwrap_or_default();+ // the same copper the planner read (issue #136: the board's filled zones too)+ let pours = copper_regions(spec, b); let mut out = Vec::new(); for (reference, h) in spec["hot"].as_object().cloned().unwrap_or_default() { if acked.contains(&reference) || aiflow_analyze::lead_cooling::declared(&reference, &h).unwrap_or(false) { continue; }@@ -432,6 +490,46 @@ mod tests { assert_eq!(parts.len(), 1); } #[test]+ fn the_humans_filled_zones_count_as_copper_and_are_named() {+ // issue #136: spec.pours is empty (pours: human); the board holds the human's GND zones on both+ // layers, filled. Before, every hot part read "no pour on both layers"+ let zone = |layer: &str, name: &str, filled: bool| format!(r#"(zone (net 1) (net_name "GND") (layer "{layer}") (name "{name}") (polygon (pts (xy 5 5) (xy 15 5) (xy 15 15) (xy 5 15))){})"#,+ if filled { format!(r#" (filled_polygon (layer "{layer}") (pts (xy 5 5) (xy 15 5) (xy 15 15) (xy 5 15)))"#) } else { String::new() });+ let board = |zones: &str| aiflow_board::Board::from_text("fixture", format!(r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))+ (footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND")))+ {zones})"#)).unwrap();+ let spec = serde_json::json!({"hot": {"U1": {"watts": 0.5, "tabNet": "GND"}}});+ // no zones: the old answer, which now says where it looked+ let (none, _) = super::plan(&spec, &board(""), &[]);+ assert!(none[0].planned.is_empty() && none[0].note.as_deref().unwrap().contains("among the board's filled zones"), "{:?}", none[0].note);+ // zones drawn but never filled are not copper yet+ let (unfilled, _) = super::plan(&spec, &board(&(zone("F.Cu", "top gnd", false) + &zone("B.Cu", "bottom gnd", false))), &[]);+ assert!(unfilled[0].planned.is_empty());+ // filled on both layers: vias are planned, and the part names the zones it rests on as the board's own+ let human = board(&(zone("F.Cu", "top gnd", true) + &zone("B.Cu", "bottom gnd", true)));+ let regions = super::board_regions(&spec, &human);+ assert_eq!(regions.len(), 2);+ assert!(regions.iter().all(|r| r["source"] == "board" && r["label"].as_str().unwrap().contains("not in spec.pours")), "{regions:?}");+ let (parts, plan) = super::plan(&spec, &human, &[]);+ assert!(!parts[0].planned.is_empty(), "{:?}", parts[0].note);+ assert!(parts[0].copper.iter().any(|c| c.starts_with("zone \"bottom gnd\" (GND B.Cu)")), "{:?}", parts[0].copper);+ // the count after landing reads the same copper+ let mut landed = human.text[..human.text.len() - 1].to_string();+ for e in plan["nets"].as_array().unwrap() { let (x, y) = (e["paths"][0][0][0].as_f64().unwrap(), e["paths"][0][0][1].as_f64().unwrap()); landed.push_str(&format!("(via (at {x} {y})(size 0.6)(drill 0.3)(layers \"F.Cu\" \"B.Cu\")(net 1))")); }+ landed.push(')');+ let saved = aiflow_board::Board::from_text("landed", landed).unwrap();+ let short = super::short(&spec, &saved, &[]);+ assert!(short.is_empty() || short[0].1 == parts[0].planned.len(), "{short:?}");+ // a filled zone on a net and layer spec.pours plans is labelled the run's pour+ let mut planned = spec.clone();+ planned["pours"] = serde_json::json!([{"net": "GND", "layers": ["B.Cu"], "name": "GND bottom"}]);+ let labels: Vec<String> = super::board_regions(&planned, &human).iter().map(|r| r["label"].as_str().unwrap().to_string()).collect();+ assert!(labels.iter().any(|l| l.contains("B.Cu): the run's pour")) && labels.iter().any(|l| l.contains("F.Cu): the board's own")), "{labels:?}");+ // only one layer filled: still no copper on both layers+ let (one, _) = super::plan(&spec, &board(&zone("F.Cu", "top gnd", true)), &[]);+ assert!(one[0].planned.is_empty());+ }+ #[test] fn targets_scale_with_power() { assert_eq!(super::target(0.25, 10.0), 6); assert_eq!(super::target(0.05, 10.0), 6);
crates/adom-aiflow/src/timeline_server.rs+82@@ -144,6 +144,37 @@ fn presentation(body: &Value) -> Result<Value, &'static str> { } Ok(body["presentation"].clone()) }+/// The human's answer from the timeline (hands-on AI Flow): a plan, or approved / changes / done on a+/// step. It is queued in handson-inbox.json for the CLI to apply under the run's own lock; nothing+/// here touches run.json, so the page can never move a gate itself.+fn handson_event(body: &Value) -> Result<Value, &'static str> {+ let action = body["action"].as_str().ok_or("action_required")?;+ if !["plan", "approved", "changes", "done"].contains(&action) {+ return Err("unknown_action");+ }+ let note = body["note"].as_str().unwrap_or("").trim();+ if note.len() > 4000 {+ return Err("note_too_long");+ }+ let mut event = json!({"action": action, "note": note});+ if action == "plan" {+ let plan = &body["plan"];+ crate::handson::normalise(plan).map_err(|_| "plan_invalid")?;+ let save_as = plan["saveAs"].as_str().unwrap_or("").trim();+ if save_as.len() > 64 {+ return Err("profile_name_too_long");+ }+ event["plan"] = json!({"steps": plan["steps"], "fallback": plan["fallback"], "notify": plan["notify"], "screen": plan["screen"],+ "saveAs": save_as, "makeDefault": plan["makeDefault"] == true});+ } else {+ let step = body["step"].as_str().filter(|s| !s.is_empty() && s.len() <= 64).ok_or("step_required")?;+ if action == "changes" && note.is_empty() {+ return Err("changes_need_a_note");+ }+ event["step"] = json!(step);+ }+ Ok(event)+} fn handle(mut stream: TcpStream, dir: &Path, live: &Live) { let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));@@ -285,6 +316,46 @@ fn handle(mut stream: TcpStream, dir: &Path, live: &Live) { } return; }+ if method == "POST" && path == "/handson" {+ // a custom header makes a cross-site form or fetch need a preflight this server never grants+ let header = |name: &str| head.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim().eq_ignore_ascii_case(name)).map(|(_, v)| v.trim().to_string()));+ if header("x-aiflow-handson").as_deref() != Some("1") || !header("content-type").is_some_and(|v| v.eq_ignore_ascii_case("application/json")) {+ response(&mut stream, 415, json!({"ok":false,"error":"json_with_x_aiflow_handson_header_required"}));+ return;+ }+ let Some(length) = header("content-length").and_then(|v| v.parse::<usize>().ok()).filter(|n| *n <= 65536) else {+ response(&mut stream, 413, json!({"ok":false,"error":"bounded_content_length_required"}));+ return;+ };+ while request.len() < header_end + length {+ match stream.read(&mut chunk) {+ Ok(0) | Err(_) => return,+ Ok(n) => request.extend_from_slice(&chunk[..n]),+ }+ }+ let body = serde_json::from_slice::<Value>(&request[header_end..header_end + length]).unwrap_or(Value::Null);+ match handson_event(&body) {+ Ok(mut event) => {+ let Ok(_guard) = live.mutation.lock() else { return };+ let path = dir.join(crate::handson::INBOX);+ let mut inbox = read_json(&path).filter(|v| v["events"].is_array()).unwrap_or(json!({"events": []}));+ let seq = inbox["events"].as_array().unwrap().iter().filter_map(|e| e["seq"].as_u64()).max().unwrap_or(0) + 1;+ event["seq"] = json!(seq);+ event["at"] = json!(aiflow_run::now());+ let events = inbox["events"].as_array_mut().unwrap();+ events.push(event);+ let drop = events.len().saturating_sub(200);+ events.drain(..drop);+ let tmp = dir.join("handson-inbox.tmp");+ match fs::write(&tmp, inbox.to_string()).and_then(|_| fs::rename(tmp, path)) {+ Ok(()) => response(&mut stream, 200, json!({"ok":true,"seq":seq})),+ Err(e) => response(&mut stream, 500, json!({"ok":false,"error":e.to_string()})),+ }+ }+ Err(code) => response(&mut stream, 400, json!({"ok":false,"errorCode":code})),+ }+ return;+ } if method != "GET" { response(&mut stream, 405, json!({"ok":false})); return;@@ -362,4 +433,15 @@ mod tests { } assert!(presentation(&json!({"aiThread":"load","presentation":{"caption":"Thermal remains open","image":"review.png"}})).is_ok()); }+ #[test]+ fn handson_answers_are_checked_before_they_are_queued() {+ assert!(handson_event(&json!({"action":"approve-everything"})).is_err());+ assert!(handson_event(&json!({"action":"approved"})).is_err(), "a verdict names its step");+ assert!(handson_event(&json!({"action":"changes","step":"placement"})).is_err(), "changes say what to change");+ assert!(handson_event(&json!({"action":"plan","plan":{"steps":{"routing":{"mode":"custom"}}}})).is_err(), "custom needs instructions");+ let e = handson_event(&json!({"action":"plan","plan":{"steps":{"routing":{"mode":"human"}},"saveAs":"Noah flow","makeDefault":true,"extra":1}})).unwrap();+ assert_eq!(e["plan"]["saveAs"], "Noah flow");+ assert!(e["plan"].get("extra").is_none());+ assert_eq!(handson_event(&json!({"action":"approved","step":"placement","note":"good"})).unwrap()["step"], "placement");+ } }
crates/aiflow-bridge/src/lib.rs+64−2@@ -179,6 +179,13 @@ pub const WINDOW_MAX_MS: u64 = 3_600_000; /// Seconds between `desktop_record_status` polls while a stopped take finalises. pub const FINALISE_POLL_SECS: u64 = 10; +/// Issue #135: did a KiCad window (the editor, its project manager or splash) take the foreground+/// from the human? Not when KiCad was already in front before the launch: then the human chose it.+pub fn took_foreground(editor: i64, before: Option<&Value>, fg: &Value) -> bool {+ let kicad = |w: &Value| w["hwnd"].as_i64() == Some(editor) || w["title"].as_str().is_some_and(|t| t.contains("PCB Editor") || t.starts_with("KiCad") || t.to_ascii_lowercase().starts_with("pcbnew"));+ kicad(fg) && !before.is_some_and(|b| kicad(b))+}+ fn window_max_bitrate() -> u64 { std::env::var("AIFLOW_CLIP_MAX_BITRATE").ok().and_then(|v| v.trim().parse().ok()).filter(|v| *v >= 1_000_000).unwrap_or(WINDOW_MAX_BITRATE) }@@ -370,7 +377,24 @@ impl Bridge { } // -- capture recipe -------------------------------------------------------------------- pub fn open_board(&self, remote_board: &str, reason: &str) -> Result<i64, Value> {+ /// Open the run's board in KiCad; returns the editor's hwnd and what was done to the window.+ /// `raise` (screen `allowed`, a test box): in front, maximised. Otherwise (issues #132, #135) the+ /// board opens without focus or maximise, and a KiCad window Windows put in front anyway (a newly+ /// launched process gets the foreground) goes to the bottom, the human's window back in front.+ pub fn open_board(&self, remote_board: &str, reason: &str, raise: bool) -> Result<(i64, String), Value> {+ if !raise {+ let before = self.foreground();+ let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": false}));+ let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };+ let mut done: Vec<String> = self.keep_behind(hwnd, before.as_ref()).into_iter().collect();+ std::thread::sleep(std::time::Duration::from_secs(40));+ // KiCad can come forward again when the board finishes loading+ done.extend(self.keep_behind(hwnd, before.as_ref()));+ self.call("kicad_send_key", &json!({"hwnd": hwnd, "menu": "Zoom to Fit"}));+ std::thread::sleep(std::time::Duration::from_secs(3));+ let what = if done.is_empty() { "the human's window stayed in front".to_string() } else { done.join("; ") };+ return Ok((hwnd, format!("opened in the background, not maximised (screen hands-off), zoomed to fit; {what}")));+ } let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": true, "foregroundReason": reason})); let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) }; self.call("desktop_ui_window", &json!({"hwnd": hwnd, "action": "maximize", "reason": "aiflow capture: fill the monitor"}));@@ -379,7 +403,34 @@ impl Bridge { std::thread::sleep(std::time::Duration::from_secs(2)); self.call("kicad_send_key", &json!({"hwnd": hwnd, "key": "ctrl+home"})); std::thread::sleep(std::time::Duration::from_secs(3));- Ok(hwnd)+ Ok((hwnd, "maximised, zoomed to fit".into()))+ }++ /// The OS foreground window: {hwnd, pid, title}, None when there is none or it cannot be read.+ pub fn foreground(&self) -> Option<Value> {+ let r = self.raw_call("desktop_get_foreground", &json!({}));+ let d = r.get("data").cloned().unwrap_or(r);+ d["hwnd"].as_i64().filter(|h| *h != 0).map(|_| d)+ }++ /// Issue #135: when a KiCad window took the foreground that `before` held (Windows gives a newly+ /// launched process's first window the foreground), send it to the bottom without activating+ /// anything, and when it is still in front put the human's previous window back. Says what it did.+ pub fn keep_behind(&self, editor: i64, before: Option<&Value>) -> Option<String> {+ let fg = self.foreground()?;+ let h = fg["hwnd"].as_i64()?;+ if !took_foreground(editor, before, &fg) { return None; }+ let title = fg["title"].as_str().unwrap_or("").to_string();+ let r = self.raw_call("desktop_send_to_bottom", &json!({"hwnd": h, "force": true, "reason": "aiflow screen hands-off: KiCad was launched in front of the human's window"}));+ let mut did = format!("KiCad came up in front (hwnd {h} {title:?}); sent it to the bottom{}", if r["sentToBack"].as_bool() == Some(false) || r["data"]["sentToBack"].as_bool() == Some(false) { " (the bridge said it did not move)" } else { "" });+ let prev = before.and_then(|b| b["hwnd"].as_i64());+ if let (Some(p), Some(now)) = (prev, self.foreground().and_then(|f| f["hwnd"].as_i64())) {+ if now == h || now == editor {+ let back = self.raw_call("desktop_bring_to_front", &json!({"hwnd": p, "reason": "aiflow screen hands-off: give the human back the window they had before KiCad launched"}));+ did.push_str(&format!(", and put the previous window (hwnd {p}) back in front{}", if back["success"].as_bool() == Some(false) { " (that failed)" } else { "" }));+ }+ }+ Some(did) } /// Record ONE window (Windows Graphics Capture, background-capturable): the take survives@@ -700,6 +751,17 @@ mod tests { mod pull_tests { use super::*; + #[test]+ fn a_launched_kicad_in_front_is_sent_back_only_when_it_took_the_human_s_window() {+ let human = json!({"hwnd": 11, "title": "board.kicad_pcb - Adom Hydrogen"});+ assert!(took_foreground(42, Some(&human), &json!({"hwnd": 42, "title": "esc — PCB Editor"})));+ assert!(took_foreground(42, Some(&human), &json!({"hwnd": 77, "title": "KiCad 10.0"})));+ assert!(took_foreground(42, None, &json!({"hwnd": 42, "title": ""})));+ assert!(!took_foreground(42, Some(&human), &human));+ // the human had KiCad in front already: their choice, left alone+ assert!(!took_foreground(42, Some(&json!({"hwnd": 42, "title": "esc — PCB Editor"})), &json!({"hwnd": 42, "title": "esc — PCB Editor"})));+ }+ fn scratch(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("aiflow-pull-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d);
crates/aiflow-router/src/simplify.rs+102−4@@ -116,6 +116,59 @@ pub fn simplify_chain(pts: &[Pt], free: &[bool], width: f64, clear: &mut dyn FnM (p, f, c) } +/// A chamfer leg is at most this many track widths (and half of either leg it cuts).+pub const CHAMFER_FACTOR: f64 = 3.0;+/// A wiggle is a sideways step no longer than this (issue #127's route_fig.py definition).+pub const JOG_MM: f64 = 1.0;++fn unit(a: Pt, b: Pt) -> Pt { let l = len(a, b).max(1e-12); ((b.0 - a.0) / l, (b.1 - a.1) / l) }+fn turn_deg(a: Pt, b: Pt, c: Pt) -> f64 { let (u, v) = (unit(a, b), unit(b, c)); (u.0 * v.1 - u.1 * v.0).atan2(u.0 * v.0 + u.1 * v.1).to_degrees() }+fn on45(a: Pt, b: Pt) -> bool { let ang = (b.1 - a.1).atan2(b.0 - a.0).to_degrees().rem_euclid(45.0); ang.min(45.0 - ang) < 0.5 }+fn right(t: f64) -> bool { (t.abs() - 90.0).abs() < 0.5 }++#[derive(Debug, Default, Clone, Copy)]+pub struct Smooth { pub chamfered: usize, pub jogs: usize, pub kept: usize }++/// Issue #127: on a chain whose legs already run at multiples of 45 degrees, a short sideways step+/// (two opposite right angles around a segment of at most JOG_MM) becomes one 45 degree step, and+/// every other right-angle corner at a free point is cut into two 45 degree turns, each only where+/// `clear` passes the new diagonal (the shortened legs lie on the old copper). Fixed points stay.+pub fn smooth_chain(pts: &[Pt], free: &[bool], width: f64, clear: &mut dyn FnMut(Pt, Pt) -> bool) -> (Vec<Pt>, Vec<bool>, Smooth) {+ let (mut p, mut f) = (pts.to_vec(), free.to_vec());+ let mut c = Smooth::default();+ let mut i = 1;+ while i + 2 < p.len() {+ let d = len(p[i], p[i + 1]);+ let (t1, t2) = (turn_deg(p[i - 1], p[i], p[i + 1]), turn_deg(p[i], p[i + 1], p[i + 2]));+ if f[i] && f[i + 1] && d > EPS && d <= JOG_MM + EPS && right(t1) && right(t2) && t1.signum() != t2.signum() && on45(p[i - 1], p[i]) && on45(p[i + 1], p[i + 2]) {+ let (ui, uo) = (unit(p[i - 1], p[i]), unit(p[i + 1], p[i + 2]));+ let back = (p[i].0 - ui.0 * d, p[i].1 - ui.1 * d);+ let fwd = (p[i + 1].0 + uo.0 * d, p[i + 1].1 + uo.1 * d);+ if len(p[i - 1], p[i]) > d + EPS && clear(back, p[i + 1]) { p[i] = back; c.jogs += 1; i += 2; continue; }+ if len(p[i + 1], p[i + 2]) > d + EPS && clear(p[i], fwd) { p[i + 1] = fwd; c.jogs += 1; i += 2; continue; }+ c.kept += 1;+ }+ i += 1;+ }+ let mut i = 1;+ while i + 1 < p.len() {+ let (a, b, n) = (p[i - 1], p[i], p[i + 1]);+ if !(f[i] && right(turn_deg(a, b, n)) && on45(a, b) && on45(b, n)) { i += 1; continue; }+ let cmax = (CHAMFER_FACTOR * width).min(len(a, b) / 2.0).min(len(b, n) / 2.0);+ let (ui, uo) = (unit(a, b), unit(b, n));+ let done = [cmax, cmax / 2.0].into_iter().filter(|&k| k >= width / 2.0 - EPS).find_map(|k| {+ let (q1, q2) = ((b.0 - ui.0 * k, b.1 - ui.1 * k), (b.0 + uo.0 * k, b.1 + uo.1 * k));+ clear(q1, q2).then_some((q1, q2))+ });+ match done {+ Some((q1, q2)) => { p.splice(i..=i, [q1, q2]); f.splice(i..=i, [true, true]); c.chamfered += 1; i += 2; }+ None => { c.kept += 1; i += 1; }+ }+ }+ merge_collinear(&mut p, &mut f);+ (p, f, c)+}+ impl Router { /// The router's own obstacle check for a straight copper segment of `width` on layer `l`: /// every other net's pad, stub, route, via and fixed copper, a foreign planeUnder region, and@@ -169,8 +222,11 @@ fn round(v: f64) -> f64 { (v * 1e6).round() / 1e6 } pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value { let mut per_net: BTreeMap<String, (usize, usize)> = BTreeMap::new(); let mut total = Counts::default();+ let mut smooth = Smooth::default(); for e in plan.iter_mut() { let net = e["net"].as_str().unwrap_or("").to_string();+ // a coupled impedance pair keeps its shape: both halves would have to move together+ let pair = router.rules.impedance.contains_key(&net); let width = e["width"].as_f64().unwrap_or(0.25); let entry_layer = e["layer"].as_str().map(str::to_string); let key = if e["paths"].is_array() { "paths" } else if e["points"].is_array() { "points" } else { continue };@@ -203,10 +259,12 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value { let free: Vec<bool> = (i..=k).map(|q| q != i && q != k).collect(); if let (Some(l), true) = (l, k - i >= 2) { let mut accepted: Vec<(Pt, Pt)> = Vec::new();- let (np, _, c) = {+ let (np, c) = { let r = &mut *router; let net_s = net.clone();- simplify_chain(&pts, &free, width, &mut |a, b| { let ok = r.segment_clear(&net_s, l, a, b, width); if ok { accepted.push((a, b)); } ok })+ let mut chk = |a: Pt, b: Pt| { let ok = r.segment_clear(&net_s, l, a, b, width); if ok { accepted.push((a, b)); } ok };+ let (np, nf, c) = simplify_chain(&pts, &free, width, &mut chk);+ if pair { (np, c) } else { let (sp, _, sc) = smooth_chain(&np, &nf, width, &mut chk); smooth.chamfered += sc.chamfered; smooth.jogs += sc.jogs; smooth.kept += sc.kept; (sp, c) } }; for (a, b) in accepted { router.stamp_segment(&net, l, a, b, width); } total.merged += c.merged; total.replaced += c.replaced; total.kept += c.kept;@@ -228,8 +286,8 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value { } let nets: serde_json::Map<String, Value> = per_net.iter().map(|(n, (b, a))| (n.clone(), json!({"before": b, "after": a}))).collect(); let (b, a): (usize, usize) = per_net.values().fold((0, 0), |s, v| (s.0 + v.0, s.1 + v.1));- json!({"verticesBefore": b, "verticesAfter": a, "staircasesReplaced": total.replaced, "staircasesKept": total.kept, "collinearMerged": total.merged, "nets": nets,- "rule": format!("staircase = {STAIR_MIN_STEPS}+ alternating 0/90 degree segments each <= {STAIR_FACTOR} x track width; replaced by a 45 degree segment plus at most one orthogonal leg only where the router's obstacle check keeps clearance")})+ json!({"verticesBefore": b, "verticesAfter": a, "staircasesReplaced": total.replaced, "staircasesKept": total.kept, "collinearMerged": total.merged, "cornersChamfered": smooth.chamfered, "wigglesStepped": smooth.jogs, "smoothKept": smooth.kept, "nets": nets,+ "rule": format!("staircase = {STAIR_MIN_STEPS}+ alternating 0/90 degree segments each <= {STAIR_FACTOR} x track width; replaced by a 45 degree segment plus at most one orthogonal leg only where the router's obstacle check keeps clearance. Then (not on impedance pairs) a sideways step <= {JOG_MM} mm between two opposite right angles becomes one 45 degree step, and a right-angle corner at a free point is cut into two 45 degree turns (leg <= {CHAMFER_FACTOR} x width and half of either leg), each only where the same check keeps clearance")}) } /// Staircases left in a board's copper: track chains per net and layer joined end to end through@@ -325,6 +383,46 @@ mod tests { assert!(p.len() == 3 && p.iter().zip(want).all(|(a, b)| len(*a, b) < 1e-9), "{p:?}"); } + fn mid_free(n: usize) -> Vec<bool> { (0..n).map(|i| i != 0 && i != n - 1).collect() }+ fn close(p: &[Pt], q: &[Pt]) -> bool { p.len() == q.len() && p.iter().zip(q).all(|(a, b)| len(*a, *b) < 1e-9) }++ #[test]+ fn a_right_angle_is_chamfered_into_two_45s_where_it_clears() {+ let pts = vec![(0.0, 0.0), (3.0, 0.0), (3.0, 3.0)];+ let (p, _, c) = smooth_chain(&pts, &mid_free(3), 0.25, &mut |_, _| true);+ assert_eq!(c.chamfered, 1);+ assert!(close(&p, &[(0.0, 0.0), (2.25, 0.0), (3.0, 0.75), (3.0, 3.0)]), "{p:?}");+ assert!(right(turn_deg(pts[0], pts[1], pts[2])) && (turn_deg(p[0], p[1], p[2]).abs() - 45.0).abs() < 1e-6);+ // the full chamfer blocked, the half one clears+ let (p, _, c) = smooth_chain(&pts, &mid_free(3), 0.25, &mut |a, _| len(a, (2.25, 0.0)) > 1e-9);+ assert_eq!(c.chamfered, 1);+ assert!(close(&p, &[(0.0, 0.0), (2.625, 0.0), (3.0, 0.375), (3.0, 3.0)]), "{p:?}");+ // nothing clears: kept; a fixed corner (a pad) is never moved; a 45 degree bend is left alone+ let (p, _, c) = smooth_chain(&pts, &mid_free(3), 0.25, &mut |_, _| false);+ assert_eq!((c.chamfered, c.kept), (0, 1));+ assert_eq!(p, pts);+ assert_eq!(smooth_chain(&pts, &[false, false, false], 0.25, &mut |_, _| true).0, pts);+ let bend = vec![(0.0, 0.0), (3.0, 0.0), (5.0, 2.0)];+ assert_eq!(smooth_chain(&bend, &mid_free(3), 0.25, &mut |_, _| true).2.chamfered, 0);+ }++ #[test]+ fn a_short_sideways_step_becomes_one_45_degree_step() {+ let pts = vec![(0.0, 0.0), (3.0, 0.0), (3.0, 0.4), (6.0, 0.4)];+ let (p, _, c) = smooth_chain(&pts, &mid_free(4), 0.25, &mut |_, _| true);+ assert_eq!((c.jogs, c.chamfered), (1, 0));+ assert!(close(&p, &[(0.0, 0.0), (2.6, 0.0), (3.0, 0.4), (6.0, 0.4)]), "{p:?}");+ // the step back is blocked: the step forward+ let (p, _, c) = smooth_chain(&pts, &mid_free(4), 0.25, &mut |a, _| len(a, (2.6, 0.0)) > 1e-9);+ assert_eq!(c.jogs, 1);+ assert!(close(&p, &[(0.0, 0.0), (3.0, 0.0), (3.4, 0.4), (6.0, 0.4)]), "{p:?}");+ // a long step is a lane change: its two corners are chamfered instead+ let lane = vec![(0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (6.0, 2.0)];+ let (p, _, c) = smooth_chain(&lane, &mid_free(4), 0.25, &mut |_, _| true);+ assert_eq!((c.jogs, c.chamfered), (0, 2));+ assert!(p.windows(3).all(|w| !right(turn_deg(w[0], w[1], w[2]))), "{p:?}");+ }+ #[test] fn a_staircase_is_kept_when_no_replacement_clears() { let pts = stair(4, 0.3, (0.0, 0.0));
docs/hands-on.mdadded+64@@ -0,0 +1,64 @@+# Hands-on AI Flow++Requested by Noah (2026-11-03) after building the same AM32 ESC twice: once steering every step by hand, once with AI Flow alone. Hands-on mode lets the human decide, before any design work, how much the AI does at each step. The decision is kept as a named preference ("flow profile") for the next boards. The AI's skill for it is `skills/aiflow-hands-on/SKILL.md`; the code is `crates/adom-aiflow/src/handson.rs`.++## The four modes++| Mode | The AI | The gate |+|---|---|---|+| `auto` | does the step | none added |+| `checkin` | does the step, then `handson ask <step> --summary` | `step <later>` notes the missing OK and `finish` lists it as open until the human approves that step's latest visit (never refused; wait for them) |+| `human` | hands the step over, waits, then runs the step's own check on the human's work | `step <later>` notes it and `finish` lists it as open until the human says done for the latest visit (never refused; wait for them) |+| `custom` | follows the human's instructions, printed at every `step` | none added (a `handson ask` makes it a check-in) |++**Proofs never change.** A human step still needs its proof: the AI runs the step's own check on the human's work (`place verify` after an `adopt-board`, an empty route plan through `gate` and `land route`, `verify-pours --adopt`, `schematic check`, ...). `handson done` prints the recipe for the step. Deviations are untouched. The human's answers gate *when* the run may move on, never *whether* a check passed.++Only visits that start after a plan is confirmed are gated. Switching flow mid-run does not ask the human to re-review work already done. A return to a step is a new visit and needs its own approval.++## Commands++```+handson plan [--profile P] open the plan on the timeline (state planning: wait for the human to confirm; `step` notes any step started before)+handson set STEP=MODE ... [--note STEP=TEXT] [--notify N] [--screen S] edit it from the human's chat words+handson start [--save-as NAME] [--default] confirm (state active); the first confirmed plan is saved as "My flow" and made the default+handson use P apply a profile now (full-ai is built in)+handson ask STEP --summary TEXT open a check-in on the step's latest visit; notifies the human+handson approve|changes|done STEP --quote record the human's answer given in chat, in their words (an approval needs a quote)+handson wait [--timeout 540] block until the human answers on the timeline (or another command records it)+handson status the plan, per step, and what the run waits on+handson profiles [--profile P --default] [--delete P] list, set the default, delete (needs no run)+handson off every step back to the AI for this run+```++## Where things live++- **run.json `handsOn`:** `state` (planning, active, off), `profile`, `profileName`, `source`, `setAt`, `steps` ({step: {mode, instructions}}), `fallback`, `notify`, `screen`, `asks[]`, `verdicts[]` (step, visit, verdict approved|changes|done, quote, by chat|dashboard, at), `inboxSeq`, `history[]`. Ledger events: `handson-plan`, `handson-ask`, `handson-verdict`, `handson-wait` (with `humanWaitSeconds`, so measured AI time can leave the wait out), `screen-hands-off`.+- **Profiles:** `~/.adom/aiflow/profiles/<slug>.json` ({name, slug, fallback, steps, notify, screen, created, updated}) and `~/.adom/aiflow/preferences.json` ({defaultProfile}). `AIFLOW_PREFS_DIR` overrides the folder (tests). A headless run (`AIFLOW_DASHBOARD=off`) never inherits the default profile unless `AIFLOW_PREFS_DIR` is set, so a developer's own flow cannot leak into the integration tests. A profile stores modes by step name, so it applies to every lane; steps a lane does not have are ignored. `intake` and `start` apply the default profile to a new run.+- **Timeline:** `timeline.json` carries `handsOn` (state, plan, profiles, waiting, the latest verdict per step) and a `mode` per step.+ - The page shows the planner (four choices per step, notify, screen, save as, use for my next boards).+ - It shows a "Ready for your review" or "Your turn" card with Approve, Request changes and I'm done.+ - It shows a summary bar with Change, mode chips on the step rows, and a "Want a say in each step?" card when the run is not hands-on.+- **Answers from the page:** `POST handson` with `X-Aiflow-Handson: 1` and JSON:+ - `{action: plan, plan: {steps, notify, screen, saveAs, makeDefault}}`+ - or `{action: approved|changes|done, step, note}`++ The server checks the shape and queues the event in `<run>/timeline/handson-inbox.json`; it never touches run.json. Every `step`, `handson` command and `finish` ingests the queue under the run's own lock and prints `HUMAN (timeline): ...` lines for the AI. The custom header means a cross-site page cannot post without a preflight the server never grants. Like the rest of the dashboard, anyone who can reach the server's port can post; that is a process control, not an engineering gate.++## Screen and notifications (issue #132)++- **`screen: hands-off`** (the default for every profile, including the built-in full-ai):+ - `step` skips its per-step `desktop_bring_to_front` calls.+ - `capture open` opens the board without focus and without maximising.+ - `tour 3d` stops and asks, since KiCad's 3D viewer paints only in front: the human allows it with `handson set --screen allowed`.+ - Window capture still records the clips of covered windows. Runs with no hands-on record keep the old behaviour.+- **`notify`:** `hydrogen` (`adom-cli notify`), `toast` (`adom-bridge notify_user`, to the run's target or the relay's own box), `both` or `none`. `AIFLOW_NOTIFY=off` turns it off for tests and headless automation. A notification never raises a window.++## finish-guard++While a plan, a check-in or a human step is open, `finish-guard` answers `waiting-for-human` with `mayStop: true` and `waitingForHuman` naming it. The engineering state is unchanged: `engineeringComplete` stays false until finish and delivery.++## Not done yet++- A profile is local to one machine. Syncing it through the wiki (a private page per user) would make it follow the human across machines.+- Toast buttons (approve from the notification) are not wired; the toast points at the timeline and the chat.+- No native acceptance on a live board yet: unit tests and a CLI workflow test cover the gates, the inbox and the profiles. The dashboard was checked in headless Chromium.
flows/board.json+1−1@@ -647,7 +647,7 @@ }, "skills": { "_": "the companion skills to read for each step (the Companion skills table in SKILL.md); `step <name>` names each SKILL.md by absolute path",- "intake": ["aiflow-intake"],+ "intake": ["aiflow-intake", "aiflow-hands-on"], "rules": ["aiflow-sourcing", "aiflow-molecule"], "design": ["aiflow-circuit-design"], "test-plan": ["aiflow-test-plan"],
package.json+15−3@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.48",+ "version": "0.2.49", "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.",@@ -45,11 +45,19 @@ "route the board", "run.jsonl", "thermal analysis",- "Help me contribute an improvement to adom-aiflow through a pull request"+ "Help me contribute an improvement to adom-aiflow through a pull request",+ "hands on ai flow",+ "hands-on aiflow",+ "use hands on ai flow",+ "check in with me after each step",+ "let me do the routing myself",+ "ai flow profile",+ "my flow preference" ], "dependencies": { "adom/adom-lbr": "*",- "adom/adom-fields": "*"+ "adom/adom-fields": "*",+ "adom/molecule-publish": "*" }, "scripts": { "install": "./install.sh",@@ -69,6 +77,10 @@ "label": "Run the flow on a board", "prompt": "aiflow the ESC: place, route, pour, analyze and deliver the video" },+ {+ "label": "Hands-on: you choose each step",+ "prompt": "use hands on AI Flow for this board: check in with me after placement, I'll do the routing myself"+ }, { "label": "Route to 100 percent", "prompt": "route this board to 100 percent and refuse to finish with anything unconnected"
page.json+15−3@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.48",+ "version": "0.2.49", "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.",@@ -45,11 +45,19 @@ "route the board", "run.jsonl", "thermal analysis",- "Help me contribute an improvement to adom-aiflow through a pull request"+ "Help me contribute an improvement to adom-aiflow through a pull request",+ "hands on ai flow",+ "hands-on aiflow",+ "use hands on ai flow",+ "check in with me after each step",+ "let me do the routing myself",+ "ai flow profile",+ "my flow preference" ], "dependencies": { "adom/adom-lbr": "*",- "adom/adom-fields": "*"+ "adom/adom-fields": "*",+ "adom/molecule-publish": "*" }, "scripts": { "install": "./install.sh",@@ -69,6 +77,10 @@ "label": "Run the flow on a board", "prompt": "aiflow the ESC: place, route, pour, analyze and deliver the video" },+ {+ "label": "Hands-on: you choose each step",+ "prompt": "use hands on AI Flow for this board: check in with me after placement, I'll do the routing myself"+ }, { "label": "Route to 100 percent", "prompt": "route this board to 100 percent and refuse to finish with anything unconnected"
skills/adom-aiflow/SKILL.md+19−2@@ -1,7 +1,7 @@ --- name: adom-aiflow description: >-- The board loop as one command for KiCad boards on the Adom KiCad Bridge, Autodesk Fusion boards on the Fusion bridge and Altium Designer boards on the Altium bridge (one project library with real 3D and part numbers, the schematic drawn live, the board, current and heat refined until it passes, the Gerbers and BOM): start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: board in Altium, Altium Designer, build it in Altium, Altium schematic, Altium PCB, Altium project, PcbDoc, board in Fusion, build it in Fusion, Fusion electronics, Fusion library for my project, Fusion schematic, Fusion PCB, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+ The board loop as one command for KiCad boards on the Adom KiCad Bridge, Autodesk Fusion boards on the Fusion bridge and Altium Designer boards on the Altium bridge (one project library with real 3D and part numbers, the schematic drawn live, the board, current and heat refined until it passes, the Gerbers and BOM): start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: hands on, hands-on aiflow, hands-on AI Flow, check in with me, my flow, full AI flow, flow profile, board in Altium, Altium Designer, build it in Altium, Altium schematic, Altium PCB, Altium project, PcbDoc, board in Fusion, build it in Fusion, Fusion electronics, Fusion library for my project, Fusion schematic, Fusion PCB, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. --- # adom-aiflow: the loop, measured@@ -138,6 +138,20 @@ required step grouped by phase, a teal check on each done step, the current one go", and a purple arc for every return with its reason, ending at the finish line (Gerbers, BOM and CPL exported). Every `step` refreshes it within a second. Open it at intake and leave it up. +## Hands-on AI Flow: the human chooses who does each step++When the human asks for it ("use hands-on AI Flow", "check in with me after placement", "I'll route it myself", "use my flow"), run `handson plan` right after `intake` or `start`, before any design work. The timeline shows every step with four choices:+1. **auto**: the AI takes care of it.+2. **checkin**: the AI does it, then `handson ask` and waits for the human's OK. The next step is refused until they approve.+3. **human**: the human does it. The AI waits for "done", then runs the step's own check on their work.+4. **custom**: their written instructions, printed at every `step`.++The timeline also has their notification choice and their screen choice (hands-off by default: AI Flow never raises or maximises windows on their desktop). They press Start there, or you set it from their words (`handson set placement=checkin routing=human`, `handson start --save-as "<name>"`).++The choice is a flow profile in `~/.adom/aiflow/profiles/`. The first one confirmed becomes their default, and every new run follows it. Switch with `handson use <slug>` (`full-ai` is built in); list with `handson profiles`.++While a plan, check-in or human step is open, `finish-guard` answers `waiting-for-human`, a legitimate pause. Proofs and finish checks never change. Never approve for the human. A run that is not hands-on shows a "Want a say in each step?" card on the timeline. Skill: `aiflow-hands-on`; reference: docs/hands-on.md.+ ## A board in Autodesk Fusion When the human asks for a board in Fusion ("a 12 V to 5 V buck converter using the TPS54202, in Fusion"),@@ -147,7 +161,7 @@ windowed (about 90 % of the screen), never maximized, so the human sees both and catches Fusion's 3D view. Inside Hydrogen, the VS Code tab (the chat) always stays in the left pane with its file explorer closed (`adom-vscode sidebar close`), and every webview (the timeline, the parts pages, the SPICE dashboard, the fields app) opens in the right pane; never widen or collapse a pane. `step`-brings the window each step works in to the front: Hydrogen for intake, design, the test plan, sourcing,+brings the window each step works in to the front when the screen is `allowed` (a test box; see "Screen" below): Hydrogen for intake, design, the test plan, sourcing, the library, simulation, current, thermal, fields and export, Fusion for everything drawn on the design. 1. **Intake and the timeline.** `intake`, `timeline open`. Offer the Claude usage widget in Hydrogen's@@ -328,6 +342,8 @@ The first `step` (or `capture start`) asks the test box once which recorder its `capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers. +**Screen: hands-off on the human's own machine (issues #132, #135).** `screen: hands-off | ask | allowed` says whether AI Flow may bring windows to the front on the run's desktop. When the target is the machine the human is using (a Hydrogen local workspace's own PC, or the primary machine `adom-bridge targets` names) it is `hands-off` by default; a test box is `allowed`. The run records it in run.json (`screenMode`, with where it came from) and prints it once. In hands-off (and `ask`), `step` raises nothing, `capture open` opens the board without focus or maximise, and if Windows still puts a newly launched KiCad in front it sends it to the bottom and gives the human their window back (the answer says what it did); `tour 3d` does not run, because the viewer only paints in front: suggest the human runs it, or takes the screen off hands-off for it. Clips keep recording by window capture, which needs no foreground; only a minimised window films nothing. The human changes it any time: `capture screen allowed` ("you can take my screen now"), `capture screen hands-off`, `capture screen` to read it; a declared test box can say `"capture": {"screen": "allowed"}` in the spec; `AIFLOW_SCREEN=allowed|ask|hands-off` wins over both.+ ## The spec `docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Vias of the pad's net already at the pad (any size, board vias or the footprint's own through-hole via pads) count toward `count`, as in `thermal-vias plan`, and no planned via comes within the hole-to-hole minimum or the via-to-via clearance of a via or hole already there. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable.@@ -363,6 +379,7 @@ AI Flow's binary is the instrument, the gates and the hands; the judgement for e | step | skill | |---|---| | a brief or a video to reproduce, before any board exists | `aiflow-intake` |+| the human wants a say in each step (hands-on, check-ins, their own steps, flow profiles, screen and notification preferences) | `aiflow-hands-on` | | choosing parts, the fab profile (3rd party fab by default, JLCPCB on request), symbols, footprints and 3D models | `aiflow-sourcing` | | datasheet equations, stocked values, margins | `aiflow-circuit-design` | | loop and transient simulation, vendor PSpice models | `aiflow-simulate` |
skills/aiflow-hands-on/SKILL.mdadded+107@@ -0,0 +1,107 @@+---+name: aiflow-hands-on+description: >-+ Hands-on AI Flow: the human chooses, for every step of an adom-aiflow run, how much the AI does on its own (1 the AI takes care of it, 2 the AI does it and checks in for their OK, 3 the human does it, 4 their own custom instructions) on the AI Flow timeline before any design work, and the choice is saved as a named flow profile ("my flow", "Full AI flow") that the next board starts from. Covers the check-in loop (`handson ask`, `handson wait`, approve or request changes), handing a step to the human and turning their work into the step's proof, switching profiles, and the screen and notification preferences (never raise windows on the human's desktop unless they allow it). Use when the human says "hands on", "hands-on AI Flow", "check in with me after placement", "let me do the routing myself", "use my flow", "use full AI flow", or names one of their flow profiles. Trigger words: hands on, hands-on aiflow, hands-on AI Flow, use hands on AI Flow, check in with me, ask me before routing, I'll place it myself, I'll route it myself, my flow, noah flow, full AI flow, flow profile, flow preference, switch flow, AI Flow timeline options, approve placement, request changes, don't take over my screen, notify me.+---++Parent skill: **adom-aiflow**++# aiflow-hands-on: the human decides who does each step++AI Flow normally takes a board from the prompt to the finish line on its own. Hands-on mode lets the human take part where they want to, step by step. It comes from Noah's comparison of two builds of the same ESC: the board he steered by hand was better routed and placed, and the fully automatic run took over his screen. Nothing about the engineering gates changes. A step's proof is still required, `finish` still refuses an unfinished board, and nothing the human chooses waives a check.++## The four choices, per step++| # | Mode | What you do | What the binary enforces |+|---|---|---|---|+| 1 | `auto` | Do the step as AI Flow always has, asking only what is truly their decision. | Nothing new. |+| 2 | `checkin` | Do the step, then show the human the result and run `handson ask <step> --summary "..."`. | Wait for their OK before the next step (AI Flow notes it if you go on, and finish lists it as open). A return to the step is a new visit and needs a new OK. |+| 3 | `human` | Do NOT do the step's work. Tell them what it needs, wait for "done", then run the step's own check on their work. | Wait for their "done" before the next step (AI Flow notes it if you go on, and finish lists it as open). The step's own check still runs on their work. |+| 4 | `custom` | Follow their written instructions for the step. They are printed every time you enter it. | Nothing new, unless you `handson ask` (then it is a check-in). |++## Start: before any design work++1. Open the run as usual (`intake ...` or `start ...`, skill aiflow-intake). If the human has a default flow profile, the run already follows it; the `intake` answer says so in a hint. Tell them in one line which flow applies.+2. When they asked for hands-on (or want to change the plan for this board): `adom-aiflow --run <run> --ai-thread "<thread>" handson plan [--profile <slug>]`.+ - The AI Flow timeline (the dashboard in Hydrogen) shows every step of this run with the four choices, plus "notify me" (Hydrogen, desktop toast, both, none) and "my screen" (hands-off or allowed).+ - It also has "save as a flow" and "use it for my next boards". The first flow a human confirms is saved as their preference automatically ("My flow") and becomes their default.+ - While the plan is open, wait for the human to confirm it before design work (`step` notes any step started before).+3. Tell the human it is on the timeline and what the choices mean. Then `handson wait`, which returns when they press Start. Run it in the background or repeat it: it waits up to `--timeout` seconds, 540 by default.+4. If they answer in chat instead, set it from their words:++ ```bash+ adom-aiflow ... handson set placement=checkin routing=human --note schematic="draw it with wires, blocks left to right" --notify toast --screen hands-off+ adom-aiflow ... handson start --save-as "Noah flow" [--default]+ ```++Never pick modes for them beyond what they said. A step they did not mention stays as their profile has it (or `auto`).++## Profiles: their preferences, kept between boards++- Profiles live on the human's machine in `~/.adom/aiflow/profiles/<slug>.json`. The default is in `~/.adom/aiflow/preferences.json`.+- `handson profiles` lists them, with `*` marking the default. `full-ai` is built in: every step auto, screen hands-off.+- `handson use <slug>` switches this run (for example `handson use full-ai` for a quick, unimportant board).+- `handson profiles --profile <slug> --default` sets their default. `handson profiles --delete <slug>` removes one.+- "Use my noah flow", "do this one with full AI": run `handson use <slug>` and say so in one line.+- A profile stores modes by step name, so it carries across KiCad, Fusion and Altium runs. A step a lane does not have is ignored.++## Check-in steps++1. Do the step fully, with its own command and proof, as in auto mode.+2. Show them. Point at the evidence they need to judge it:+ - the board in their editor (refreshed, not raised; see Screen)+ - a screenshot or drawing (`artifact --file ... --caption ...`)+ - the numbers that matter (loop lengths, vias, DRC, temperatures)+3. `handson ask <step> --summary "<what to look at and where>"`. This notifies them their way and puts Approve / Request changes on the timeline.+4. `handson wait`. On **approved**, go on. On **changes**, their words are printed: make the changes, then `handson ask <step>` again.+5. If they answer in chat, record it in their words: `handson approve <step> --quote "<their words>"` or `handson changes <step> --quote "..."`. A chat approval needs a quote.++**Never approve on their behalf**, and never read silence as approval.++## Human steps++When `step <name>` prints HANDS-ON HUMAN STEP:++1. Do not do the step's work, not even "to help".+2. Tell them what the step needs:+ - its goal and inputs (the spec, the constraints, the ranking of signals or heat if you made one)+ - what files to save and where+ - which check you will run afterwards+3. `handson wait`. They press "I'm done" on the timeline, or tell you in chat (`handson done <step> --quote "..."`).+4. Turn their work into the step's proof with the step's own check. `handson done` prints the recipe for the step:++| Step | Recipe |+|---|---|+| placement | `adopt-board --board <their saved board> --evidence <note>`, then `place verify` |+| routing | `adopt-board`, then `route` (plans only what their copper leaves open), `gate --plan <run>/plan-route.json`, `land route --plan <run>/plan-route.json` (an empty plan records the proof) |+| pours | `adopt-board`, then `verify-pours --adopt --why "poured by the human"` |+| schematic | `schematic check --sch <their file>` |+| silkscreen | `silkscreen-audit ... --record` |+| simulation | `evidence add --kind simulation --file <their result>` |++5. If a check fails, show them exactly what failed and let them choose: they fix it, you fix it, or (only when the check is wrong for this board) a deviation they agree to.++## Custom steps++The human's instructions are printed by `step <name>` under "THE HUMAN'S INSTRUCTIONS FOR ...". They take precedence over your defaults for that step, but never over a safety limit or a gate. If they ask to see the result, `handson ask` makes it a check-in. If an instruction conflicts with a check, say so and ask; do not quietly drop either.++## Screen and notifications++The human's computer is theirs.+- **Screen.** With `screen: hands-off`, the default for every profile, AI Flow never brings KiCad or Hydrogen to the front and never maximises windows:+ - `step` skips the per-step raises.+ - `capture open` opens the board without focus.+ - Step clips are still recorded by window capture, which works on covered windows but not minimised ones.+ - `tour 3d` stops and asks, because KiCad's 3D viewer only paints in front. Ask the human; with their yes, run `handson set --screen allowed`, `tour 3d`, then `handson set --screen hands-off` straight after.+ - Do not use other tools to raise windows either (skill foreground-etiquette).+- **Notify.** `notify` is how you reach them when the run needs them (the plan, a check-in, their turn): `hydrogen` (a Hydrogen notification), `toast` (an Adom Bridge desktop toast), `both` or `none`. With `none`, say it in chat only. A notification never raises a window by itself.++## While waiting++- `finish-guard` reports `waiting-for-human` (mayStop true) while a plan, check-in or human step is open. That is a legitimate pause, not an unfinished run.+- Work that does not depend on the open step is fine meanwhile (updating the project page, the clips, the next step's research). Do not start the next step's design work before the gate opens.+- The time spent waiting is logged as `handson-wait` with `humanWaitSeconds`, so measured AI time can leave it out (aiflow-measurement).++## Discoverability++When a run is not hands-on, the timeline shows a "Want a say in each step?" card with the four choices and a "Choose my flow" button. A human who presses it can confirm a plan from the dashboard; your next command prints `HUMAN (timeline): confirmed the hands-on plan ...`. Follow it from then on. When a human seems frustrated by how much the AI decided alone, mention hands-on mode in one line.
skills/aiflow-intake/SKILL.md+4@@ -8,6 +8,10 @@ description: >- Everything before a board used to be unguided and untimed: on the first buck molecule the requirements, part choice, calculations, simulation and schematic took about 91 minutes and landed in the ledger as one lump of "thinking" on the first turn. Intake fixes both halves. It writes one file, `requirements.json`, that every later skill reads (aiflow-sourcing, aiflow-circuit-design, aiflow-simulate, aiflow-schematic-to-board, aiflow-molecule, aiflow-scaffold-probe), and it pins the clock to the prompt. +## 0. Hands-on or not++If the human asked for a hands-on run ("hands-on AI Flow", "check in with me after placement", "I'll route it myself", "use my flow"), open the run as below, then run `handson plan` before anything else: they choose, per step, whether you do it, check in after it, leave it to them, or follow their instructions (skill aiflow-hands-on). If they saved a default flow before, `intake` already applies it and says so in a hint; tell them in one line.+ ## 1. The clock starts at the prompt Write the UTC of the human's message down before anything else. Use the message's own timestamp, not "now" if you are late.
skills/aiflow-molecule/SKILL.md+7−6@@ -1,7 +1,7 @@ --- name: aiflow-molecule description: >-- Make an adom-aiflow board an Adom molecule that fits the scaffold: the 2 mm grid, the origin at the MP1 (front-left) machine pin, the corner-pin class rule (large MachinePinLargeShort when the board seats in the base scaffold, weighs over 100 g, carries a motor, actuator, heatsink over 20 g, cabled connector or hard-pressed button, is over 100 mm long, a pin carries over 3.5 A, or it has a large contact unless spec.molecule.largeContactMediumPins says why medium is right; otherwise MachinePinMediumShort, 1.6 mm pad, 1.2 mm drill) at the corners as MP1 to MP4, MachineContactMedium (1.3 mm pad, 0.78 mm drill) for the signal contacts, MachineContactLarge (4.4 mm pad, 2.62 mm drill, 14 AWG solid wire) for a power net over 3.5 A, footprints from the Adom KiCad Library 1.2.3, the fab-rules profile (3rd party fab default, jlcpcb optional), then STEP export, `step2glb convert --molecule` with its stats gates, real copper pads (`adom-aiflow board-glb copper`: pads and test points are copper, the mask opens and paste shows only where the footprint layers say, with a gate), and molecule-publish. Medium-pin molecules mount on a LrgMed user scaffold (8 mm medium contact grid, offset 4 mm from its front-left large pin, large pins on a 32 mm base grid). Trigger words: aiflow molecule, make it a molecule, fits our scaffold, machine pins, large or medium pins, MachinePinLargeShort, MachinePinMediumShort, MachineContactMedium, MachineContactLarge, large contact, 14 AWG, high-current contact, MP1, molecule grid, molecule outline, fab rules profile, kicad_dru, molecule conformance, molecule export, step2glb molecule, publish molecule, LrgMed scaffold, copper pads, grey pads, test points, solder mask openings, paste layer, board GLB, board-glb.+ Make an adom-aiflow board an Adom molecule that fits the scaffold: the 2 mm grid, the origin at the MP1 (front-left) machine pin, the corner-pin class rule (large MachinePinLargeShort when the board seats in the base scaffold, weighs over 100 g, carries a motor, actuator, heatsink over 20 g, cabled connector or hard-pressed button, is over 100 mm long, a pin carries over 3.5 A, or it has a large contact unless spec.molecule.largeContactMediumPins says why medium is right; otherwise MachinePinMediumShort, 1.6 mm pad, 1.2 mm drill) at the corners as MP1 to MP4, MachineContactMedium (1.3 mm pad, 0.78 mm drill) for the signal contacts (named MC<n>, as the wiki registry counts them), MachineContactLarge (4.4 mm pad, 2.62 mm drill, 14 AWG solid wire) for a power net over 3.5 A, footprints from the Adom KiCad Library 1.2.3, the fab-rules profile (3rd party fab default, jlcpcb optional), then STEP export, `step2glb convert --molecule` with its stats gates, real copper pads (`adom-aiflow board-glb copper`: pads and test points are copper, the mask opens and paste shows only where the footprint layers say, with a gate), and molecule-publish. Medium-pin molecules mount on a LrgMed user scaffold (8 mm medium contact grid, offset 4 mm from its front-left large pin, large pins on a 32 mm base grid). Trigger words: aiflow molecule, make it a molecule, fits our scaffold, machine pins, large or medium pins, MachinePinLargeShort, MachinePinMediumShort, MachineContactMedium, MachineContactLarge, large contact, 14 AWG, high-current contact, MP1, molecule grid, molecule outline, fab rules profile, kicad_dru, molecule conformance, molecule export, step2glb molecule, publish molecule, LrgMed scaffold, copper pads, grey pads, test points, solder mask openings, paste layer, board GLB, board-glb. --- # aiflow-molecule: a board that drops into the scaffold@@ -64,7 +64,7 @@ The medium-pin dimensions and example below apply only when medium pins were sel | Grid | 2 mm for every pin and contact, measured from MP1 | | Origin | the centre of MP1, the front-left (FL) corner pin; aux and grid origin on it; y up in the design frame, y down in KiCad | | Corner pins | MP1 (FL), MP2 (FR), MP3 (BL), MP4 (BR), net GND; the class from the rule above: `MachinePinMediumShort` (1.6 mm pad, 1.2 mm drill) or `MachinePinLargeShort` (5.2 mm pad, 3.45 mm drill; `MachinePinLargeStandard` on LrgMed user-scaffold corners) |-| Contacts | footprint `MachineContactMedium`: 1.3 mm pad, 0.78 mm drill, one per signal the Control Panel wires to; `MachineContactLarge` (4.4 mm pad, 2.62 mm drill, 14 AWG solid wire) for a power net over 3.5 A, which takes large corner pins (rule 6) |+| Contacts | footprint `MachineContactMedium`: 1.3 mm pad, 0.78 mm drill, one per signal the Control Panel wires to; `MachineContactLarge` (4.4 mm pad, 2.62 mm drill, 14 AWG solid wire) for a power net over 3.5 A, which takes large corner pins (rule 6). Name every contact `MC1`, `MC2`, ... in the schematic and the board: the wiki's model registry (rule `adom.molecule-pins/2`, molecule-publish) counts only one-pad parts named `MC<n>` as machine contacts, so a contact named `J3` gets no wire attach point in Hydrogen | | Library | the Adom KiCad Library 1.2.3 (KiCad plugin manager); copy the footprints and their STEP models into the project (`lib/Molecule.pretty`, `3d/`) so the project travels | | Markers | MP1 to MP4 are the anchoring markers the step2glb molecule conversion reads; keep those exact references and the library's 3D models on them | | Outline | pin span plus an edge margin all round (24 x 16 mm span + 2 mm = 28 x 20 mm here), with **rounded corners**: an arc of radius = the edge margin, centred on each corner pin, so the edge stays the same distance from the pin all the way round (2 mm here; the 1.6 mm pin pad then clears the edge by 1.2 mm). Suggest this by default; never 45 degree chamfers, which read as unfinished. |@@ -93,11 +93,12 @@ The DRC gate uses the board's own `.kicad_dru`, so the fab's limits must be in i ## 4. Conformance check before placement -`adom-aiflow molecule check --board <design>.kicad_pcb --spec aiflow/spec.json` (in a run: `molecule check`; `place check`, `gate` and `finish` run it by themselves when the spec has `"molecule": {...}`). Put the interface in the spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "fixed": {"J1": [0, 12], ...}}` (mm from MP1, y up). It checks:+`adom-aiflow molecule check --board <design>.kicad_pcb --spec aiflow/spec.json` (in a run: `molecule check`; `place check`, `gate` and `finish` run it by themselves when the spec has `"molecule": {...}`). Put the interface in the spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "fixed": {"MC1": [0, 12], ...}}` (mm from MP1, y up). It checks: - MP1 to MP4 present, references exact, one MachinePin class (medium or large, with its pad and drill), at the four corners of the span, all on GND. - The corner-pin class: large pins when any large-pin reason applies (seat, mass, mechanical loads, size, pin current; see above), else a warning for an unknown mass or an undeclared part that looks like a mechanical load. - Every contact is `MachineContactMedium` or `MachineContactLarge` with its pad and drill, on the 2 mm grid from MP1. A large contact requires large corner pins unless `largeContactMediumPins` gives the reason.+- The names the registry reads (its rule `adom.molecule-pins/2`, ported): a contact footprint not named `MC<n>`, a machine pin outside `MP1` to `MP4` (name extra ones `MS<n>`), a designator in the wrong case, a pin name on a contact hole, a multi-pad part on contact holes, and an off-table drill come back as findings with the rename to make. `schematic check` and `netlist check` give the same hint from the footprint names, so rename there, before layout: after layout a rename also moves silkscreen labels, pinout tables and the copper fingerprint. - Pin span on 8 mm multiples with medium pins (LrgMed), 32 mm multiples with large pins (base scaffold); outline = span + margin with rounded corners (radius = margin, centred on the corner pins); board origin on MP1. - All interface parts locked and in `fixedRefs`. - Every pin and contact has its STEP model bound (`adom-aiflow models`).@@ -130,7 +131,7 @@ Gate on the printed stats before publishing: The published board GLB is always the `--out` of `board-glb copper`, never the raw `step2glb convert` output. If `board-glb copper` refuses (quantized GLBs from step2glb are read as of 0.1.92), fix the cause and run it again; never publish the raw GLB to get past the gate. -Then hand the bundle (STEP, board, every schematic sheet, `.kicad_pro`, custom 3D models, the GLB and the footprint and symbol JSON) to the molecule-publish skill. Publishing is its own reviewed step: check the payload for anything confidential first, and say "the 3rd party fab" wherever the fab is named.+The GLB above is for your own review and the run's pictures; it never goes on the molecule's page. Publish with molecule-publish 3.x, a separate package (`adom-wiki pkg install adom/molecule-publish`; it is a dependency of adom-aiflow). Its bundle is what the wiki's model registry builds from: the STEP, the board (`.kicad_pcb`), every schematic sheet (with the `.kicad_pro` and any project `.kicad_sym` / `.pretty` and `3d/` models, carried for APM), `<slug>-fab.zip` and `export.json`. Make the last two with its `scripts/fab_bundle.py create <folder> --slug <slug> --fab <a folder with only the Gerber and drill files> --tool kicad --tool-version <v> --exporter adom-aiflow --exporter-version <v> --source step=<slug>.step --source board_kicad_pcb=<design>.kicad_pcb`, from the same saved board as the Gerbers (`adom-aiflow export`), then run its Stage 1 checks (`bundle_check.py`, `pin_names.py check`, `fab_bundle.py check`). Never put a GLB, `*_footprint.json` or `*_symbol.json` in the bundle or bind `component.parts.model_3d`: the registry builds the model, footprint and symbol and refuses a pushed one (`REGISTRY_MODEL_FILE_REFUSED`). The page is kind `molecule` (`project-page` creates it so when the spec has `molecule`). The publish is proven by `adom-wiki model status` showing the model built by the pipeline with `metadataComplete: true`; record it with `adom-aiflow stage end molecule-publish --result <owner/slug>` (`export`, `finish`, `deliver` and `finish-guard` suggest it until then). Publishing is its own reviewed step: check the payload for anything confidential first, and say "the 3rd party fab" wherever the fab is named. ## Pads are real copper (John's rule) @@ -147,12 +148,12 @@ What the step2glb service should do itself is tracked on adom/adom-step2glb issu ## 6. Hand off -A locked interface, a rules profile in the board, and after the build a converted GLB whose stats passed and whose pads passed `board-glb check`. Next: aiflow-scaffold-probe.+A locked interface with its contacts named `MC<n>`, a rules profile in the board, and after the build a converted GLB whose stats passed and whose pads passed `board-glb check`, then the molecule-publish bundle. Next: aiflow-scaffold-probe. ## Worked example: TPS54202 molecule - 28 x 20 mm, MP1 to MP4 at (0,0), (24,0), (0,16), (24,16), all GND.-- Contacts on the short edges: J1 VIN (0,12), J2 GND (0,8), J3 EN (0,4); J4 VOUT (24,12), J5 GND (24,8), J6 VMON (24,4). Input on the left, output on the right, each with its own ground.+- Contacts on the short edges: MC1 VIN (0,12), MC2 GND (0,8), MC3 EN (0,4); MC4 VOUT (24,12), MC5 GND (24,8), MC6 VMON (24,4). Input on the left, output on the right, each with its own ground. - Rules: 3rd party fab 2-layer profile, 1.6 mm FR4, designed for 0.5 oz outer copper. - The Adom library footprints reference their STEP through the plugin manager's path; the project carries copies in `kicad/3d/` so the board renders on any machine.
skills/aiflow-project-page/SKILL.md+4−2@@ -1,6 +1,6 @@ --- name: aiflow-project-page-description: Publish and review a great AIFlow board project wiki page with an explicit assembled-board hero GLB, 5–8 meaningful screenshots, a real project datasheet at the bottom and verified live asset bindings. Use before final delivery or when a chip or blank datasheet appears on a board page.+description: Publish and review a great AIFlow board project wiki page with an explicit assembled-board hero GLB, 5–8 meaningful screenshots, a real project datasheet at the bottom and verified live asset bindings (a molecule page gets no pushed GLB: the wiki registry builds its model). Use before final delivery or when a chip or blank datasheet appears on a board page. user-invocable: true --- # Deliver the project page@@ -11,10 +11,12 @@ user-invocable: true A board in a folder of an existing project page (one page per project): `adom-aiflow project-page --page <owner/slug> --page-dir <folder>` writes the board's README to `<folder>/README.md` (only when the folder has none yet, or with `--replace-readme`) and its PCM repository to `<folder>/pcm/`, with the repository, packages and download URLs under `.../files/<folder>/pcm/`. It never creates a page: the page must exist, `--existing` is implied and `--create` is refused. The library nickname (`PCM_<slug>-<last folder name>`), the PCM identifier and the Cloud Library name carry the folder, so two boards on one page never share a library. The step pages then go to `<folder>/steps/` and the build section to `<folder>/README.md`. +**A molecule is different.** When the spec has `molecule`, `project-page` creates the page with `--kind molecule`, not `board`. Do not push a GLB to it or bind `component.parts.model_3d` (steps 2 and 6 below do not apply to the model): the wiki's model registry builds a molecule page's GLB, footprint and symbol from the sources molecule-publish publishes after `export` (STEP, board, schematic, `<slug>-fab.zip`, `export.json`; skill aiflow-molecule, section 5), and molecule-publish refuses a pushed GLB (`REGISTRY_MODEL_FILE_REFUSED`). `project-page-check` skips the model on a page of kind molecule and suggests dropping a bound one.+ A board project must showcase its board assembly, not one of its component assets. Never rely on recursive repository asset discovery: embedded component GLBs, STEP files and PDFs can silently win. 1. Read the live page and current immutable source HEAD before editing; preserve concurrent work. Confirm which README variant renders. Read wiki-hero-glb and the wiki publishing contract.-2. Explicitly set `component.parts.model_3d` to the reviewed complete board/assembly GLB and `component.parts.datasheet` to the project PDF in page.json. Bind any offered STEP to the real board too, never a component. Inspect the model visually; a nonempty GLB or many meshes do not prove assembly identity. Show connectors, cooling and mounting parts; label candidate revisions, missing details and clearance envelopes. Keep runtime models separate from gentle hero-only state animation.+2. On a board page (not a molecule), explicitly set `component.parts.model_3d` to the reviewed complete board/assembly GLB and `component.parts.datasheet` to the project PDF in page.json. Bind any offered STEP to the real board too, never a component. Inspect the model visually; a nonempty GLB or many meshes do not prove assembly identity. Show connectors, cooling and mounting parts; label candidate revisions, missing details and clearance envelopes. Keep runtime models separate from gentle hero-only state animation. 3. Write a real project-authored engineering datasheet: revision and board identity, operating envelope and derating, modes, connectors/pinout, control protocol, power supplies, protection, mechanical/mounting dimensions, fabrication stack and qualification limits. Clearly separate design limits, simulation and measured specifications. Never repurpose a component-symbol PDF, blank placeholder or unrelated manufacturer datasheet. Inspect every rendered PDF page. Set `sections` to put `readme` before `viewers`, retaining other intended sections, so the datasheet is at the bottom. 4. Put 5–8 distinct real screenshots in the README with descriptive alt text and captions: assembled board, native board/layout, control panel, meaningful analysis, workflow and manufacturing review as available. Inspect each image. Reject blank video posters, duplicate angles, tiny unreadable UI and stale status presented as current. Caption historical revisions and simulated readings. Do not invent test results to fill the gallery.
skills/aiflow-schematic-to-board/SKILL.md+2−1@@ -20,7 +20,7 @@ The KiCad Bridge has no "update PCB from schematic" verb. So do not draw a schem {"ref": "MP1", "sym": "Molecule:MachinePin", "fp": "Molecule:MachinePinMediumShort", "block": "molecule", "fixed": {"x": 0, "y": 0}} ],- "nets": {"VIN": ["U1.3", "C1.1", "C2.1", "J1.1"], "GND": ["U1.1", "MP1.1"]},+ "nets": {"VIN": ["U1.3", "C1.1", "C2.1", "MC1.1"], "GND": ["U1.1", "MP1.1"]}, "molecule": {"pin": "MachinePinMediumShort", "grid_mm": 2, "pin_span_mm": [24, 16], "board_mm": [28, 20], "edge_margin_mm": 2, "origin": "centre of MP1 (FL) pin", "corner_pins_net": "GND"}@@ -30,6 +30,7 @@ The KiCad Bridge has no "update PCB from schematic" verb. So do not draw a schem - `mpn`, `mfr`, `src` come from the BOM (aiflow-sourcing) and become fields. - `block` groups the schematic sheet (input, core, output, feedback, enable, indicator, monitor, molecule, probe). - `fixed` is the molecule interface in mm from the MP1 centre, y up (aiflow-molecule). Those parts are placed and locked; everything else is staged.+- On a molecule, name the references the way the wiki's model registry counts them: machine pins `MP1` to `MP4` (extra ones `MS<n>`) and machine contacts `MC1`, `MC2`, ..., in capitals, never `J<n>`; a contact named otherwise gets no wire attach point in Hydrogen. `schematic check` and `netlist check` hint at any that are off, and renaming here is one edit, where after layout it also changes silkscreen, pinout tables and the copper fingerprint. - Pins are `REF.pad`. Before generating, check that every key exists as a symbol pin number AND a footprint pad number. ## 2. The schematic: gen_schematic.py
tests/handson-workflow.pyadded+130@@ -0,0 +1,130 @@+"""Hands-on AI Flow end to end (docs/hands-on.md): the plan blocks design work until the human+confirms it on the timeline (a real POST to the timeline server), the first confirmed plan is saved+as their default profile, a check-in step holds the run until they approve, a human step until they+say done, finish-guard reads an open check-in as the human's turn, and the next run starts from the+default profile. No CAD, bridge, notifications or dashboard tab."""+import json, os, pathlib, socket, subprocess, sys, tempfile, time, urllib.request++binary = str(pathlib.Path(sys.argv[1]).resolve())+BOARD = """(kicad_pcb (version 20241229) (generator "test") (general (thickness 1.6))+ (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user) (37 "F.SilkS" user))+ (net 0 "") (net 1 "A") (net 2 "B")+ (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts"))+ (footprint "R:R0603" (layer "F.Cu") (at 10 10) (property "Reference" "R1" (at 0 -1.5) (layer "F.SilkS"))+ (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "A"))+ (pad "2" smd rect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "B"))))+"""+WHY = "test fixture: this step's proof is not what this workflow test is about"++with tempfile.TemporaryDirectory(prefix="aiflow-handson-") as td:+ p = pathlib.Path(td)+ env = dict(os.environ, AIFLOW_DASHBOARD="off", AIFLOW_NOTIFY="off", AIFLOW_PREFS_DIR=str(p / "prefs"),+ AIFLOW_NO_PART_SEARCH="1", AIFLOW_NO_PART_LOOKUP="1")+ (p / "board.kicad_pcb").write_text(BOARD)+ (p / "spec.json").write_text("{}")++ def call(*args, run="run"):+ return subprocess.run([binary, "--run", run, "--ai-thread", "handson-test", *args], cwd=p, env=env, capture_output=True, text=True, timeout=120)++ def data(run="run"):+ return json.loads((p / run / "run.json").read_text())++ def step_past(name):+ """`step name`, deviating any proof the fixture cannot give; returns the final answer."""+ for _ in range(12):+ r = call("step", name)+ if r.returncode == 0 or "waits for the human" in r.stdout:+ return r+ items = data().get("lastRefusalItems", [])+ assert items, r.stdout+ for i in items:+ assert call("deviate", i, "--why", WHY).returncode == 0+ raise AssertionError("could not reach " + name)++ r = call("handson", "profiles")+ assert r.returncode == 0 and "Full AI flow" in r.stdout and "1 flow profile" in r.stdout, r.stdout+ r = call("start", "--board", "board.kicad_pcb", "--spec", "spec.json", "--engine", "test")+ assert r.returncode == 0, r.stdout + r.stderr+ assert "handsOn" not in data(), "no default profile yet: the run is not hands-on"+ assert call("step", "intake").returncode == 0+ seq = data()["sequence"]+ a, b, c = seq[1], seq[2], seq[3]++ # the plan is open: the AI waits for the human to confirm it (AI Flow never refuses: a step started now is noted)+ r = call("handson", "plan")+ assert r.returncode == 0 and "waiting for the human" in r.stdout, r.stdout+ r = call("step", a)+ assert r.returncode == 0 and "hands-on plan" in r.stdout + r.stderr, r.stdout + r.stderr+ assert call("finish-guard", "--json").stdout.count('"waiting-for-human"') == 1++ # the human confirms it on the timeline: a real POST to the run's timeline server+ with socket.socket() as s:+ s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]+ server = subprocess.Popen([binary, "--run", "run", "timeline", "serve", "--port", str(port)], cwd=p, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+ try:+ def post(body, header=True):+ req = urllib.request.Request(f"http://127.0.0.1:{port}/handson", data=json.dumps(body).encode(), method="POST",+ headers={"Content-Type": "application/json", **({"X-Aiflow-Handson": "1"} if header else {})})+ try:+ with urllib.request.urlopen(req, timeout=5) as resp: return json.loads(resp.read())+ except urllib.error.HTTPError as e: return json.loads(e.read())+ for _ in range(50):+ try:+ urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1); break+ except Exception: time.sleep(0.1)+ assert post({"action": "approved", "step": a}, header=False)["ok"] is False, "the header is required"+ assert post({"action": "plan", "plan": {"steps": {a: {"mode": "custom"}}}})["ok"] is False, "custom needs instructions"+ plan = {"steps": {a: {"mode": "checkin"}, b: {"mode": "human"}, c: {"mode": "custom", "instructions": "keep R1 near the edge"}},+ "notify": "none", "screen": "hands-off", "saveAs": "Test flow", "makeDefault": True}+ assert post({"action": "plan", "plan": plan})["ok"] is True+ r = call("handson", "wait", "--timeout", "5")+ assert r.returncode == 0 and "HUMAN (timeline): confirmed" in r.stdout and "Test flow" in r.stdout, r.stdout+ h = data()["handsOn"]+ assert h["state"] == "active" and h["steps"][b]["mode"] == "human" and h["screen"] == "hands-off", h+ assert json.loads((p / "prefs" / "preferences.json").read_text())["defaultProfile"] == "test-flow"++ # a check-in step: done, shown, held until the human approves+ r = step_past(a)+ assert r.returncode == 0 and "HANDS-ON CHECK-IN" in r.stdout, r.stdout+ # going on without the OK is never refused: it is noted, logged and listed at finish+ r = call("step", b)+ assert r.returncode == 0 and f"handson ask {a}" in r.stdout + r.stderr, r.stdout + r.stderr+ assert '"handson-proceeded-without-ok"' in (p / "run" / "run.jsonl").read_text()+ assert call("handson", "ask", a, "--summary", "look at R1").returncode == 0+ assert '"waiting-for-human"' in call("finish-guard", "--json").stdout+ r = call("step", b); assert "waiting for the human's review" in r.stdout + r.stderr, r.stdout + r.stderr+ assert call("handson", "approve", a).returncode == 2, "a chat approval quotes the human"+ assert post({"action": "changes", "step": a})["ok"] is False, "changes say what to change"+ assert post({"action": "changes", "step": a, "note": "move R1 left"})["ok"] is True+ r = call("step", b)+ assert r.returncode == 0 and "move R1 left" in r.stdout + r.stderr and "HUMAN (timeline): changes" in r.stdout, r.stdout + r.stderr+ assert call("handson", "ask", a, "--summary", "R1 moved").returncode == 0+ assert post({"action": "approved", "step": a, "note": "good"})["ok"] is True++ # a human step: handed over, held until they say done+ r = step_past(b)+ assert r.returncode == 0 and "HANDS-ON HUMAN STEP" in r.stdout and "Do NOT do its work" in r.stdout, r.stdout+ r = call("step", c)+ assert r.returncode == 0 and "not said they are done" in r.stdout + r.stderr, r.stdout + r.stderr+ r = call("handson", "done", b, "--quote", "routed it myself")+ assert r.returncode == 0 and "proof" in r.stdout, r.stdout+ r = step_past(c)+ assert r.returncode == 0 and "keep R1 near the edge" in r.stdout, r.stdout++ # the timeline carries the plan, the modes and the profiles for the page+ call("timeline", "write")+ tl = json.loads((p / "run" / "timeline" / "timeline.json").read_text())+ assert tl["handsOn"]["state"] == "active" and any(x["slug"] == "test-flow" and x["default"] for x in tl["handsOn"]["profiles"]), tl["handsOn"]+ assert {s["name"]: s["mode"] for s in tl["steps"]}[b] == "human"+ assert "hands-on" in (p / "run" / "timeline" / "index.html").read_text().lower()+ finally:+ server.terminate(); server.wait(timeout=10)++ # the next board starts from the human's default flow; switching is one command+ r = call("start", "--board", "board.kicad_pcb", "--spec", "spec.json", "--engine", "test", run="run2")+ assert r.returncode == 0 and "Test flow" in r.stdout, r.stdout+ assert data("run2")["handsOn"]["steps"][b]["mode"] == "human"+ r = call("handson", "use", "full-ai", run="run2")+ assert r.returncode == 0 and "every step auto" in r.stdout, r.stdout+ assert call("handson", "profiles").stdout.count("* Test flow") == 1+print("ok: hands-on workflow")
tests/project-page-check.py+6@@ -27,4 +27,10 @@ class PageChecks(unittest.TestCase): (self.r/'README.md').write_text('\n'*6);self.assertFalse(self.run_check()['passed']) def test_wrong_live_model_rejected(self): self.run_check();live=self.r/'live.json';live.write_text(json.dumps({'metadata':{'model_3d_path':'chip.glb','datasheet_path':'project.pdf'}}));self.assertFalse(m.check(self.r,live)['passed'])+ def test_molecule_page_needs_no_glb_and_suggests_dropping_one(self):+ # issue #137: the registry builds a molecule page's model; a bound GLB is a suggestion, never an error+ self.p['kind']='molecule';r=self.run_check();self.assertTrue(r['passed'],r);self.assertTrue(r['molecule'])+ self.assertTrue(any('model_3d' in x for x in r['suggestions']),r)+ self.p['component']['parts']={'datasheet':'project.pdf'};r=self.run_check();self.assertTrue(r['passed'],r);self.assertEqual(r['suggestions'],[])+ live=self.r/'live.json';live.write_text(json.dumps({'metadata':{'model_3d_path':'registry.glb','datasheet_path':'project.pdf'}}));self.assertTrue(m.check(self.r,live)['passed']) if __name__=='__main__':unittest.main()
tests/project-page-library.py+21@@ -82,6 +82,8 @@ if a[:2] == ["repo", "push"]: out({"status": "ok", "commit": "abc1234"}, 1 if missing else 0) if a[:2] == ["page", "create"]: st["created"] = True; out({"status": "ok"})+if a[:1] == ["whoami"]:+ out({"status": "ok", "data": {"user": {"username": "adom"}}}) out({"status": "error", "error": "fake adom-wiki: unknown " + " ".join(a)}, 2) ''' @@ -306,6 +308,25 @@ class ProjectPageLibrary(unittest.TestCase): v = self.run_page(push=False, pageDir="../up") self.assertFalse(v["ok"]); self.assertIn("relative to its root", v["error"]) + def test_a_molecule_gets_a_page_of_kind_molecule(self):+ # issue #137: the spec has "molecule": the page is kind molecule (the registry builds its model), never board+ v = self.run_page(push=True, create=True, molecule=True)+ self.assertTrue(v["ok"], v)+ self.assertEqual(v["kind"], "molecule")+ made = [c for c in self.read_state()["calls"] if c[:2] == ["page", "create"]]+ self.assertEqual(len(made), 1)+ self.assertEqual(made[0][made[0].index("--kind") + 1], "molecule")+ self.assertFalse(any(f.endswith(".glb") for p in self.read_state()["pushes"] for f in p["files"]))+ self.assertIn("molecule-publish", (self.run_dir / "project-page" / "README.md").read_text())++ def test_a_plain_board_gets_a_page_of_kind_board(self):+ v = self.run_page(push=True, create=True)+ self.assertTrue(v["ok"], v)+ self.assertEqual(v["kind"], "board")+ made = [c for c in self.read_state()["calls"] if c[:2] == ["page", "create"]][0]+ self.assertEqual(made[made.index("--kind") + 1], "board")+ self.assertNotIn("molecule-publish", (self.run_dir / "project-page" / "README.md").read_text())+ def test_brief_short_description_skips_the_heading(self): v = self.run_page(brief="# Test board\n\nA 12 V to 5 V buck.\n") meta = json.loads(zipfile.ZipFile(v["zip"]).read("metadata.json"))
tools/project-page-check.py+8−2@@ -12,7 +12,13 @@ def check(root, live=None): if not p.is_relative_to(root) or not p.is_file(): raise ValueError('missing/outside project: '+rel) return p page=json.loads((root/'page.json').read_text());parts=page.get('component',{}).get('parts',{})+ # issue #137: a molecule page's model is the registry's, built from the sources molecule-publish pushes+ molecule='molecule' in (page.get('kind'),page.get('component',{}).get('kind'),page.get('hardware_kind'))+ suggestions=[]+ if molecule and parts.get('model_3d'):+ suggestions.append('This is a molecule page: drop component.parts.model_3d ('+str(parts.get('model_3d'))+') and do not push a GLB; the wiki model registry builds the model from the sources molecule-publish publishes (it refuses a pushed GLB: REGISTRY_MODEL_FILE_REFUSED).') for key,ext in [('model_3d','.glb'),('datasheet','.pdf')]:+ if molecule and key=='model_3d':continue val=parts.get(key) if not isinstance(val,str) or not val.endswith(ext):fail('Explicit component.parts.'+key+' required');continue try:@@ -37,11 +43,11 @@ def check(root, live=None): except Exception as e:fail(str(e)) if live: data=json.loads(Path(live).read_text());meta=data.get('metadata',{})- for key in ['model_3d','datasheet']:+ for key in ['datasheet'] if molecule else ['model_3d','datasheet']: if meta.get(key+'_path')!=parts.get(key):fail('Published '+key+' binding differs from page.json') winner=data.get('readme_source_path') if winner and winner!='README.md':fail('Published README is shadowed by '+winner)- return {'passed':not errors,'errors':errors,'screenshots':len(set(shots)), 'visualAcceptance':'pending human/AI inspection of actual rendered assembly, all screenshots and PDF pages; mesh count does not prove board identity'}+ return {'passed':not errors,'errors':errors,'suggestions':suggestions,'molecule':molecule,'screenshots':len(set(shots)), 'visualAcceptance':'pending human/AI inspection of actual rendered assembly, all screenshots and PDF pages; mesh count does not prove board identity'} def main(): ap=argparse.ArgumentParser();ap.add_argument('--project',required=True);ap.add_argument('--live-json');a=ap.parse_args()
tools/project-page.py+23−6@@ -7,7 +7,11 @@ Content Manager (PCM) repository on the page so KiCad installs the whole parts l config: {"run": dir, "page": "owner/slug", "title", "brief", "summary", "parts": [{refs, mpn, manufacturer, value, wiki}], "create": bool, "push": bool, "private": bool, "version": "1.0.0",-"pageDir": "rig/boards/<board>" or null}+"pageDir": "rig/boards/<board>" or null, "molecule": bool}++molecule (issue #137): the spec declares a molecule, so a page this makes is of kind molecule, not board.+Its model, footprint and symbol are built by the wiki's model registry from the sources molecule-publish+puts there after export; nothing here pushes or binds a GLB. pageDir (issue #85): the board lives in a folder of an EXISTING page (one page per project). Its README and PCM repository go to <pageDir>/README.md and <pageDir>/pcm/, the repository URLs carry the folder,@@ -692,6 +696,15 @@ AI Flow builds this board's Altium libraries from {src}: one schematic library ( """ +def molecule_note(cfg):+ """A molecule page's 3D model, footprint and symbol come from the wiki's model registry (issue #137)."""+ if not cfg.get("molecule"):+ return ""+ return (" This board is an Adom molecule: once it is finished its sources (STEP, board, schematic and fabrication files)"+ " are published here with [molecule-publish](https://wiki.adom.inc/adom/molecule-publish), and the wiki builds its"+ " 3D model, footprint and symbol from them.")++ def readme(cfg, lib, pkgid, repo_url): rows, merged = [], {} for p in cfg["parts"]: # one row per MPN, its references together@@ -705,7 +718,7 @@ def readme(cfg, lib, pkgid, repo_url): {cfg.get('summary') or re.sub(r"\A\s*#[^\n]*\n+", "", cfg['brief'])} -**Status: revision 1.** The parts are chosen and every one has its Adom wiki page{" or comes from the board's own project library" if any(p.get("projectLib") and not p.get("wiki") for p in cfg["parts"]) else ""}; the schematic and the board come next, built with [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow){" in Altium Designer" if cfg.get("eda") == "altium" else ""}.+**Status: revision 1.** The parts are chosen and every one has its Adom wiki page{" or comes from the board's own project library" if any(p.get("projectLib") and not p.get("wiki") for p in cfg["parts"]) else ""}; the schematic and the board come next, built with [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow){" in Altium Designer" if cfg.get("eda") == "altium" else ""}.{molecule_note(cfg)} ## Components @@ -750,13 +763,17 @@ def main(): if cfg.get("push") and not exists and not cfg.get("create"): # a dry run (--no-push) builds everything locally raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} does not exist; pass --create to make it"})) + kind = "molecule" if cfg.get("molecule") else "board"+ def make_page(): """The page is made only once there is something to put on it, so a failed library check leaves none.""" nonlocal created if not cfg.get("push") or exists: return- args = ["adom-wiki", "page", "create", slug, "--type", "hardware", "--kind", "board", "--title", cfg["title"], "--brief", cfg["brief"][:200],- "--because", "a new board designed by adom-aiflow: its own project page, one page per board",+ # issue #137: a molecule (the spec has "molecule") gets a page of kind molecule, which the wiki's model+ # registry builds the GLB, footprint and symbol for from the sources molecule-publish puts there+ args = ["adom-wiki", "page", "create", slug, "--type", "hardware", "--kind", kind, "--title", cfg["title"], "--brief", cfg["brief"][:200],+ "--because", f"a new {'molecule' if kind == 'molecule' else 'board'} designed by adom-aiflow: its own project page, one page per board", "--headline", cfg["title"][:60], "--subhead", cfg["brief"][:100], "--source", "altium" if cfg.get("eda") == "altium" else "kicad"] me = (wjson(["adom-wiki", "whoami"]).get("data") or {}).get("user", {}).get("username") or "" if owner != me: args += ["--org", owner]@@ -778,7 +795,7 @@ def main(): if cfg.get("push") and readme_wanted(cfg, folder, created): pushed = push_files(cfg, work, folder, ["README.md"], "Revision 1: the chosen parts and the component table (adom-aiflow, Altium Designer)") page_finished()- print(json.dumps({"ok": True, "eda": "altium", "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": None, "libName": lib,+ print(json.dumps({"ok": True, "eda": "altium", "kind": kind, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": None, "libName": lib, "identifier": None, "repositoryUrl": None, "parts": len(cfg["parts"]), "counts": {}, "libraryCheck": {}, "adds": {"addedPages": 0, "rateLimitWaits": [], "notes": []}, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")], "readme": os.path.join(work, "README.md"), "commit": pushed}))@@ -812,7 +829,7 @@ def main(): if readme_wanted(cfg, folder, created): files.insert(0, "README.md") pushed = push_files(cfg, work, folder, files, "Revision 1: the chosen parts, the component table and the KiCad PCM repository (adom-aiflow)") page_finished()- print(json.dumps({"ok": True, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,+ print(json.dumps({"ok": True, "kind": kind, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib, "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]), "counts": counts, "libraryCheck": check, "adds": adds, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")], "exportReport": payload(report, "report"),
tools/timeline/README.md+27@@ -83,3 +83,30 @@ The optional presentation arrives with timeline SSE state; static exports use po ### Event-driven dashboard state (0.1.90) The server uses one native filesystem watcher per run and a shared state cache. CLI presentation controls notify SSE clients immediately; external timeline/presentation edits trigger native filesystem notifications, including atomic replacements. Idle SSE connections do not poll or reread state files. The 15-second heartbeat only writes a connection comment. Native watcher failures are reported rather than silently falling back to server-side polling. `/health` reports `stateSource`, `stateFileReads`, and `watcherError` for verification. Keep the existing webview open during server upgrades; SSE reconnects automatically.++### Hands-on AI Flow (handson.rs, docs/hands-on.md)++`timeline.json` carries `handsOn`:++```json+{"state": "planning|active|off", "profile": "noah-flow", "profileName": "Noah flow",+ "plan": {"steps": {"placement": {"mode": "checkin"}, "routing": {"mode": "human"}}, "fallback": "auto", "notify": "hydrogen", "screen": "hands-off"},+ "waiting": {"kind": "plan|checkin|human", "step": "placement", "summary": "..."},+ "verdicts": {"placement": {"visit": 1, "verdict": "approved", "quote": "...", "by": "dashboard"}},+ "profiles": [{"slug": "noah-flow", "name": "Noah flow", "default": true, "steps": {}}], "modes": [...]}+```++Each step also has a `mode`.++The page shows:+- **The planner** (state planning, or Change). Four choices per step (1 AI, 2 Check in, 3 Me, 4 Custom with instructions), "start from" a saved profile, notify and screen choices, save as a named flow, and "use it for my next boards".+- **A review or "your turn" card** while a check-in or human step is open.+- **A one-line summary** with Change.+- **A mode chip** on each step row.+- **A "Want a say in each step?" card** when the run is not hands-on.++Answers go to `POST handson` (relative URL) with `Content-Type: application/json` and `X-Aiflow-Handson: 1`. The body is one of:+- `{"action":"plan","plan":{...,"saveAs":"Noah flow","makeDefault":true}}`+- `{"action":"approved"|"changes"|"done","step":"placement","note":"..."}`++The server queues it in `handson-inbox.json`; the CLI applies it. The page never changes run.json or a gate.
tools/timeline/index.html+257−1@@ -451,6 +451,85 @@ body.wide .stage { margin-top: 14px; } .rules-card .rc-note { margin: 10px 0 0; font-size: 12px; color: var(--text-2); } @media (max-width: 800px) { .rules-card .rc-body { grid-template-columns: 1fr; } } +/* ---------- hands-on AI Flow: the human's plan, check-ins and turns (handson.rs) ---------- */+.handson { position: relative; z-index: 2; margin: 18px var(--pad) 0; }+.handson[hidden] { display: none; }+.ho-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px 22px; }+.ho-card.plan { border-top: 2px solid var(--accent); }+.ho-card .eyebrow { font-family: var(--f-display); font-size: 12px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-2); }+.ho-card.turn { border: 1px solid rgba(140, 107, 247, .55); background: linear-gradient(180deg, var(--purple-wash), var(--surface) 70%); }+.ho-card h2 { font-family: var(--f-display); font-size: 22px; font-weight: 700; color: var(--text); margin: 8px 0 4px; line-height: 1.2; }+.ho-card .lead { margin: 0; color: var(--text-body); font-size: 15px; max-width: 760px; }+.ho-card .sub { margin: 6px 0 0; color: var(--text-2); font-size: 13px; }+.ho-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 12px 0 4px; padding: 0; list-style: none; font-size: 13px; color: var(--text-2); }+.ho-legend b { font-family: var(--f-mono); color: var(--accent-bright); font-weight: 600; margin-right: 4px; }+.ho-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 14px 0 6px; }+.ho-top label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 8px; }+.ho-top .all { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--text-2); margin-left: auto; }+.ho select, .ho input[type=text], .ho textarea { font: inherit; font-size: 14px; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; }+.ho select:focus, .ho input:focus, .ho textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; }+.ho textarea { width: 100%; min-height: 58px; resize: vertical; line-height: 1.4; }+.ho select, .ho input[type=text] { max-width: 100%; min-width: 0; }+.ho-foot .field { min-width: 0; max-width: 100%; }+.ho-btn { font: inherit; font-size: 13px; font-weight: 600; border-radius: 8px; padding: 8px 14px; border: 1px solid var(--border); background: var(--elevated); color: var(--text-body); cursor: pointer; }+.ho-btn:hover { border-color: var(--accent); color: var(--text); }+.ho-btn:active { transform: translateY(1px); }+.ho-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }+.ho-btn.primary:hover { background: var(--accent-bright); }+.ho-btn.warn { border-color: rgba(140, 107, 247, .7); color: var(--text); }+.ho-btn.small { padding: 4px 9px; font-size: 12px; font-weight: 500; }+.ho-btn[disabled] { opacity: .5; cursor: default; transform: none; }+.ho-phase { font-family: var(--f-display); font-size: 12px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-2); margin: 14px 0 4px; }+.ho-rows { list-style: none; margin: 0; padding: 0; }+.ho-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 14px; align-items: center; padding: 8px 0; border-top: 1px solid var(--border-soft); }+.ho-row .nm { color: var(--text); font-weight: 600; font-size: 14px; }+.ho-row .nm .n { font-family: var(--f-mono); color: var(--text-2); font-weight: 500; margin-right: 8px; font-size: 12px; }+.ho-row .ds { color: var(--text-2); font-size: 12.5px; margin-top: 1px; }+.ho-row .note { grid-column: 1 / -1; }+.ho-row.done-step .nm { color: var(--text-2); }+.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: var(--bg); }+.seg label { position: relative; cursor: pointer; }+.seg input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }+.seg span { display: block; padding: 6px 11px; font-size: 12.5px; color: var(--text-2); white-space: nowrap; border-left: 1px solid var(--border-soft); }+.seg label:first-child span { border-left: 0; }+.seg span b { font-family: var(--f-mono); font-weight: 600; margin-right: 5px; color: var(--text-3); }+.seg input:checked + span { background: var(--accent-wash); color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }+.seg input:checked + span b { color: var(--accent-bright); }+.seg input[value=human]:checked + span { background: var(--purple-wash); box-shadow: inset 0 -2px 0 var(--purple); }+.seg input[value=human]:checked + span b { color: var(--purple); }+.seg input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: -2px; }+.ho-foot { display: flex; flex-wrap: wrap; gap: 12px 18px; align-items: end; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-soft); }+.ho-foot .field { display: grid; gap: 5px; font-size: 12.5px; color: var(--text-2); }+.ho-foot .check { display: flex; gap: 7px; align-items: center; font-size: 13px; color: var(--text-body); padding-bottom: 8px; }+.ho-foot .go { margin-left: auto; display: flex; gap: 10px; align-items: center; }+.ho-status { font-size: 13px; color: var(--text-2); }+.ho-status.err { color: var(--purple); }+.ho-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 12px; }+.ho-quote { margin: 10px 0 0; padding: 10px 12px; border-left: 3px solid var(--purple); background: var(--bg); border-radius: 0 8px 8px 0; color: var(--text-body); font-size: 14px; white-space: pre-wrap; }+.ho-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 10px 16px; border: 1px solid var(--border-soft); border-radius: 12px; background: var(--surface); font-size: 13px; color: var(--text-2); }+.ho-bar b { color: var(--text); font-weight: 600; }+.ho-bar .chips { display: flex; flex-wrap: wrap; gap: 6px; }+.ho-bar .ho-btn { margin-left: auto; }+.ho-pill { border-radius: 999px; padding: 2px 9px; font-size: 12px; border: 1px solid var(--border); color: var(--text-body); }+.ho-pill.checkin { border-color: var(--accent); color: var(--accent-bright); }+.ho-pill.human { border-color: var(--purple); color: var(--text); }+.ho-discover { margin: 0 var(--pad) 24px; }+.ho-discover .ho-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px 24px; align-items: center; }+.ho-discover h2 { font-size: 18px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; }+.ho-discover ol { margin: 8px 0 0; padding-left: 20px; color: var(--text-body); font-size: 14px; display: grid; gap: 2px; }+.ho-discover .mine { margin-top: 8px; font-size: 13px; color: var(--text-2); }+.mode-chip { margin-left: 8px; border-radius: 999px; padding: 0 7px; font-size: 11px; line-height: 17px; font-family: var(--f-body); font-weight: 600; border: 1px solid var(--border); color: var(--text-2); white-space: nowrap; vertical-align: 1px; }+.mode-chip[hidden] { display: none; }+.mode-chip.checkin { border-color: rgba(0, 184, 177, .6); color: var(--accent-bright); }+.mode-chip.human { border-color: rgba(140, 107, 247, .7); color: var(--text); background: var(--purple-wash); }+@media (max-width: 680px) {+ .ho-row { grid-template-columns: 1fr; }+ .seg span { padding: 6px 8px; }+ .ho-top .all { margin-left: 0; }+ .ho-foot .go { margin-left: 0; }+ .ho-discover .ho-card { grid-template-columns: 1fr; }+}+ </style> </head> <body class="waiting-on">@@ -493,6 +572,8 @@ body.wide .stage { margin-top: 14px; } </div> </section> +<section class="handson" id="handson" hidden aria-label="Hands-on AI Flow" aria-live="polite"></section>+ <div class="section-heading"><h2>From idea to tested board</h2><span>Workflow progress · engineering checks still apply</span></div> <div class="callout-wrap" id="callout-wrap"><div><div class="callout" id="callout" data-tipkey="callout">@@ -547,6 +628,8 @@ body.wide .stage { margin-top: 14px; } </div> </section> +<section class="ho-discover" id="ho-discover" hidden aria-label="Hands-on AI Flow"></section>+ <section class="community" aria-label="Contribute to AI Flow"> <div><h2>Built together. Better for everyone. <button class="prompt-hint" id="contribute-prompt" title="Help me contribute an improvement to adom-aiflow through a pull request." aria-label="Copy contribution prompt">ⓘ</button></h2> <p>AI Flow is MIT open source. Share a fix, a skill, or a lesson from your builds so the whole community benefits.</p></div>@@ -742,6 +825,7 @@ body.wide .stage { margin-top: 14px; } status: STATUSES[s.status] ? s.status : 'todo', visits: Math.max(0, parseInt(s.visits, 10) || 0), minutes: Math.max(0, +s.minutes || 0),+ mode: ['checkin', 'human', 'custom'].includes(s.mode) ? s.mode : 'auto', }); }); if (!steps.length) return null;@@ -791,12 +875,13 @@ body.wide .stage { margin-top: 14px; } li.innerHTML = '<div class="rowbg"></div>' + '<div class="node">' + ICON_CK + ICON_RD + ICON_CU + '</div>' +- '<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span></div><div class="does"></div></div>' ++ '<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span><span class="mode-chip" hidden></span></div><div class="does"></div></div>' + '<div class="meta"></div>'; ol.appendChild(li); rows.set(s.name, { li, node: li.querySelector('.node'), nm: li.querySelector('.nm'), badge: li.querySelector('.badge'), v: li.querySelector('.badge .v'), does: li.querySelector('.does'), meta: li.querySelector('.meta'), status: null,+ mode: li.querySelector('.mode-chip'), }); }); finishLi = document.createElement('li');@@ -898,6 +983,10 @@ body.wide .stage { margin-top: 14px; } r.badge.classList.toggle('off', !showBadge); r.v.textContent = 'x' + s.visits; r.badge.setAttribute('aria-label', 'Visited ' + s.visits + ' times');+ // hands-on: who does the step (auto shows nothing)+ const chip = { checkin: 'check-in', human: 'you', custom: 'custom' }[s.mode] || '';+ r.mode.hidden = !chip;+ if (r.mode.textContent !== chip) { r.mode.textContent = chip; r.mode.className = 'mode-chip ' + s.mode; r.mode.title = { checkin: 'The AI checks in with you after this step', human: 'You do this step', custom: 'Your custom instructions' }[s.mode] || ''; } let meta = ''; if (s.status === 'redo') meta = '<span class="redo-tag">rework</span>'; else if (s.minutes > 0) meta = fmtMin(s.minutes) + ' min';@@ -1348,6 +1437,7 @@ body.wide .stage { margin-top: 14px; } const raw = JSON.stringify(data); const m = normalize(data); if (!m) return;+ try { if (window.aiflowHandsOn) window.aiflowHandsOn(data, m); } catch (_) { /* the hands-on panel is optional */ } lastOkAt = Date.now(); if (raw !== lastRaw) { lastRaw = raw; model = m; everOk = true;@@ -1403,5 +1493,171 @@ body.wide .stage { margin-top: 14px; } poll(); })(); </script>+<script>+// Hands-on AI Flow (handson.rs): the human chooses per step how much the AI does, answers check-ins+// and hands their own steps back. Answers are POSTed to `handson` (relative: the page is served behind+// a proxy path) and queued for the CLI; the page never decides a gate itself.+(() => {+ 'use strict';+ const $ = id => document.getElementById(id);+ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));+ const MODES = [['auto', 'AI', 'The AI takes care of it, with as little input from you as possible'], ['checkin', 'Check in', 'The AI does it, then waits for your OK before the next step'], ['human', 'Me', 'You do this step yourself; the AI waits, then checks your work'], ['custom', 'Custom', 'Your own instructions for the AI on this step']];+ const NOTIFY = [['hydrogen', 'Hydrogen notification'], ['toast', 'Desktop toast'], ['both', 'Both'], ['none', 'Don’t notify me']];+ const SCREEN = [['hands-off', 'Hands off: never bring windows to the front'], ['allowed', 'Allowed: the AI may bring KiCad and Hydrogen to the front']];+ let ho = null, steps = [], draft = null, editing = false, pending = null, planKey = '';+ const box = $('handson'), disc = $('ho-discover');++ function post(body) {+ return fetch('handson', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Aiflow-Handson': '1' }, body: JSON.stringify(body), signal: AbortSignal.timeout(6000) })+ .then(r => r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status })));+ }+ function draftFrom(plan, profile) {+ const st = {};+ steps.forEach(s => {+ const c = (plan.steps || {})[s.name] || {};+ st[s.name] = { mode: c.mode || plan.fallback || 'auto', instructions: c.instructions || '' };+ });+ const mine = (ho.profiles || []).filter(p => !p.builtin);+ const prof = (ho.profiles || []).find(p => p.slug === profile);+ return { steps: st, notify: plan.notify || 'hydrogen', screen: plan.screen || 'hands-off', profile: profile || '',+ saveAs: prof && !prof.builtin ? prof.name : (mine.length ? '' : 'My flow'), makeDefault: prof ? !!prof.default : !mine.length };+ }+ function planOf(d) {+ const st = {};+ Object.entries(d.steps).forEach(([k, v]) => { st[k] = v.mode === 'custom' || v.instructions.trim() ? { mode: v.mode, instructions: v.instructions.trim() } : { mode: v.mode }; });+ return { steps: st, fallback: 'auto', notify: d.notify, screen: d.screen, saveAs: d.saveAs.trim(), makeDefault: d.makeDefault };+ }+ function options(list, cur) { return list.map(([v, l]) => '<option value="' + esc(v) + '"' + (v === cur ? ' selected' : '') + '>' + esc(l) + '</option>').join(''); }++ function planner() {+ const mine = (ho.profiles || []).filter(p => !p.builtin);+ let html = '<div class="ho-card plan ho"><div class="eyebrow">Hands-on AI Flow</div>' ++ '<h2>' + (ho.state === 'planning' ? 'Choose how this board gets built' : 'Change your flow') + '</h2>' ++ '<p class="lead">For every step, pick who does it. The AI starts design work only after you press Start.</p>' ++ '<ul class="ho-legend">' + MODES.map(([, l, d], i) => '<li><b>' + (i + 1) + '</b>' + esc(l) + ': ' + esc(d.charAt(0).toLowerCase() + d.slice(1)) + '</li>').join('') + '</ul>' ++ '<div class="ho-top"><label>Start from <select id="ho-profile"><option value="">This run’s plan</option>' ++ (ho.profiles || []).map(p => '<option value="' + esc(p.slug) + '"' + (p.slug === draft.profile ? ' selected' : '') + '>' + esc(p.name) + (p.default ? ' (your default)' : '') + '</option>').join('') + '</select></label>' ++ '<span class="all">All steps: ' + MODES.slice(0, 3).map(([m, l]) => '<button class="ho-btn small" data-all="' + m + '">' + esc(l) + '</button>').join('') + '</span></div>';+ let phase = null;+ steps.forEach((s, i) => {+ if (s.phase !== phase) { if (phase !== null) html += '</ul>'; phase = s.phase; html += '<div class="ho-phase">' + esc(s.phase || 'Steps') + '</div><ul class="ho-rows">'; }+ const c = draft.steps[s.name];+ html += '<li class="ho-row' + (s.status === 'done' ? ' done-step' : '') + '" data-step="' + esc(s.name) + '"><div><div class="nm"><span class="n">' + String(i + 1).padStart(2, '0') + '</span>' + esc(s.title) + '</div><div class="ds">' + esc(s.does) + '</div></div>' ++ '<div class="seg" role="radiogroup" aria-label="' + esc(s.title) + '">' + MODES.map(([m, l, d], k) => '<label title="' + esc(d) + '"><input type="radio" name="ho-' + esc(s.name) + '" value="' + m + '"' + (c.mode === m ? ' checked' : '') + '><span><b>' + (k + 1) + '</b>' + esc(l) + '</span></label>').join('') + '</div>' ++ (c.mode === 'custom' || c.instructions ? '<div class="note"><textarea data-note="' + esc(s.name) + '" placeholder="Your instructions for the AI on this step (for example: place the MCU next to the gate driver; check in after)">' + esc(c.instructions) + '</textarea></div>' : '') + '</li>';+ });+ if (phase !== null) html += '</ul>';+ html += '<div class="ho-foot">' ++ '<label class="field">Notify me when the AI needs me<select id="ho-notify">' + options(NOTIFY, draft.notify) + '</select></label>' ++ '<label class="field">My screen<select id="ho-screen">' + options(SCREEN, draft.screen) + '</select></label>' ++ '<label class="field">Save as a flow' + (mine.length ? '' : ' (your first: saved as your preference)') + '<input type="text" id="ho-save" maxlength="64" placeholder="e.g. My careful flow" value="' + esc(draft.saveAs) + '"></label>' ++ '<label class="check"><input type="checkbox" id="ho-default"' + (draft.makeDefault ? ' checked' : '') + '> Use it for my next boards</label>' ++ '<div class="go"><span class="ho-status" id="ho-status"></span>' + (editing && ho.state !== 'planning' ? '<button class="ho-btn" id="ho-cancel">Cancel</button>' : '') + '<button class="ho-btn primary" id="ho-start">' + (ho.state === 'active' ? 'Apply' : 'Start') + '</button></div></div></div>';+ box.innerHTML = html;+ box.hidden = false;+ box.querySelectorAll('.seg input').forEach(inp => inp.addEventListener('change', () => {+ const name = inp.name.slice(3); draft.steps[name].mode = inp.value; planner(); }));+ box.querySelectorAll('textarea[data-note]').forEach(t => t.addEventListener('input', () => { draft.steps[t.dataset.note].instructions = t.value; }));+ box.querySelectorAll('[data-all]').forEach(b => b.addEventListener('click', () => { Object.values(draft.steps).forEach(c => { c.mode = b.dataset.all; }); planner(); }));+ $('ho-profile').addEventListener('change', e => {+ const p = (ho.profiles || []).find(x => x.slug === e.target.value);+ draft = p ? draftFrom(p, p.slug) : draftFrom(ho.plan || {}, ho.profile || ''); planner(); });+ $('ho-notify').addEventListener('change', e => { draft.notify = e.target.value; });+ $('ho-screen').addEventListener('change', e => { draft.screen = e.target.value; });+ $('ho-save').addEventListener('input', e => { draft.saveAs = e.target.value; });+ $('ho-default').addEventListener('change', e => { draft.makeDefault = e.target.checked; });+ if ($('ho-cancel')) $('ho-cancel').addEventListener('click', () => { editing = false; draw(); });+ $('ho-start').addEventListener('click', () => {+ const missing = Object.entries(draft.steps).filter(([, c]) => c.mode === 'custom' && !c.instructions.trim()).map(([k]) => k);+ if (missing.length) { status('Write the instructions for ' + missing.join(', ') + ' first.', true); return; }+ if (draft.makeDefault && !draft.saveAs.trim()) { status('Give the flow a name to use it for your next boards.', true); return; }+ $('ho-start').disabled = true; status('Sending…');+ post({ action: 'plan', plan: planOf(draft) }).then(r => {+ if (r.ok) { pending = { action: 'plan', seq: r.seq }; editing = false; status('Sent. The AI picks it up on its next command.'); }+ else { $('ho-start').disabled = false; status('Not sent (' + (r.errorCode || r.error || 'error') + '). Tell the AI in chat instead.', true); }+ }).catch(() => { $('ho-start').disabled = false; status('The dashboard server did not answer. Tell the AI in chat instead.', true); });+ });+ }+ function status(t, bad) { const el = $('ho-status'); if (el) { el.textContent = t; el.classList.toggle('err', !!bad); } }+ function titleOf(name) { const s = steps.find(x => x.name === name); return s ? s.title : name; }++ function turn(w) {+ const human = w.kind === 'human';+ const s = steps.find(x => x.name === w.step) || { title: w.step, does: '' };+ const sent = pending && pending.step === w.step;+ box.innerHTML = '<div class="ho-card turn ho"><div class="eyebrow">Hands-on · ' + esc(ho.profileName || 'your flow') + '</div>' ++ '<h2>' + (human ? 'Your turn: ' : 'Ready for your review: ') + esc(s.title) + '</h2>' ++ '<p class="lead">' + (human ? 'You chose to do this step yourself (' + esc(s.does) + '). Work in your own editor; the AI waits, then checks your work with this step’s own checks.' : 'The AI finished this step and is waiting for your OK before it goes on.') + '</p>' ++ (w.summary ? '<div class="ho-quote">' + esc(w.summary) + '</div>' : '') ++ '<div class="ho-actions" style="display:block"><textarea id="ho-note" placeholder="' + (human ? 'Anything the AI should know about what you did (optional)' : 'Your comments (needed for Request changes)') + '"></textarea></div>' ++ '<div class="ho-actions">' + (human ? '<button class="ho-btn primary" data-v="done">I’m done</button>' : '<button class="ho-btn primary" data-v="approved">Approve</button><button class="ho-btn warn" data-v="changes">Request changes</button>') ++ '<span class="ho-status" id="ho-status">' + (sent ? 'Sent. The AI picks it up on its next command.' : 'Or answer the AI in chat.') + '</span></div></div>';+ box.hidden = false;+ box.querySelectorAll('[data-v]').forEach(b => b.addEventListener('click', () => {+ const note = $('ho-note').value.trim();+ if (b.dataset.v === 'changes' && !note) { status('Say what to change first.', true); $('ho-note').focus(); return; }+ box.querySelectorAll('[data-v]').forEach(x => { x.disabled = true; });+ post({ action: b.dataset.v, step: w.step, note }).then(r => {+ if (r.ok) { pending = { action: b.dataset.v, step: w.step, seq: r.seq }; status('Sent. The AI picks it up on its next command.'); }+ else { box.querySelectorAll('[data-v]').forEach(x => { x.disabled = false; }); status('Not sent (' + (r.errorCode || r.error || 'error') + '). Answer the AI in chat instead.', true); }+ }).catch(() => { box.querySelectorAll('[data-v]').forEach(x => { x.disabled = false; }); status('The dashboard server did not answer. Answer the AI in chat instead.', true); });+ }));+ }++ function bar() {+ const st = (ho.plan && ho.plan.steps) || {};+ const by = m => steps.filter(s => ((st[s.name] || {}).mode || (ho.plan && ho.plan.fallback) || 'auto') === m);+ const chips = [['checkin', 'check-in'], ['human', 'yours'], ['custom', 'custom']].map(([m, l]) => { const n = by(m); return n.length ? '<span class="ho-pill ' + m + '" title="' + esc(n.map(s => s.title).join(', ')) + '">' + n.length + ' ' + l + '</span>' : ''; }).join('');+ box.innerHTML = '<div class="ho-bar ho"><span>Hands-on · <b>' + esc(ho.profileName || 'this run’s plan') + '</b></span><span class="chips">' + (chips || '<span class="ho-pill">every step AI</span>') + '</span>' ++ '<span>screen ' + esc((ho.plan && ho.plan.screen) || 'hands-off') + ' · notify ' + esc((ho.plan && ho.plan.notify) || 'hydrogen') + '</span>' ++ '<button class="ho-btn small" id="ho-change">Change</button></div>';+ box.hidden = false;+ $('ho-change').addEventListener('click', () => { editing = true; draft = draftFrom(ho.plan || {}, ho.profile || ''); draw(); });+ }++ function discover() {+ const mine = (ho && ho.profiles || []).filter(p => !p.builtin);+ disc.innerHTML = '<div class="ho-card ho"><div><h2>Want a say in each step? <button class="prompt-hint" id="ho-prompt" title="Use hands-on AI Flow for this board" aria-label="Copy the hands-on prompt">ⓘ</button></h2>' ++ '<p class="lead">Hands-on AI Flow lets you choose, for every step:</p><ol><li>The AI takes care of it</li><li>The AI checks in with you after it</li><li>You do it yourself</li><li>Your own instructions</li></ol>' ++ '<p class="sub">Your choices are saved as a flow for your next boards, and you can keep several: a careful one for real boards, Full AI for quick ones. The AI also stops bringing windows to the front unless you allow it. Ask the AI: “use hands-on AI Flow”.</p>' ++ (mine.length ? '<p class="mine">Your flows: ' + mine.map(p => esc(p.name) + (p.default ? ' (default)' : '')).join(', ') + ', Full AI flow</p>' : '') + '</div>' ++ '<div><button class="ho-btn primary" id="ho-open">Choose my flow</button></div></div>';+ disc.hidden = false;+ $('ho-open').addEventListener('click', () => { editing = true; draft = draftFrom((mine.find(p => p.default)) || {}, (mine.find(p => p.default) || {}).slug || ''); draw(); box.scrollIntoView({ behavior: 'smooth', block: 'start' }); });+ $('ho-prompt').addEventListener('click', async () => {+ const fb = $('copy-feedback'); const prompt = 'Use hands-on AI Flow for this board';+ try { await navigator.clipboard.writeText(prompt); fb.textContent = 'Prompt copied'; } catch { fb.textContent = prompt; }+ fb.hidden = false; setTimeout(() => { fb.hidden = true; }, 6000);+ });+ }++ function draw() {+ if (!ho) return;+ const w = ho.waiting;+ if (ho.state === 'off' && !editing) { box.hidden = true; box.innerHTML = ''; discover(); return; }+ disc.hidden = true;+ if (ho.state === 'planning' || editing) { if (!draft) draft = draftFrom(ho.plan || {}, ho.profile || ''); planner(); return; }+ if (w && (w.kind === 'checkin' || w.kind === 'human')) { turn(w); return; }+ bar();+ }++ // called by the timeline script with every state it receives+ window.aiflowHandsOn = (data, model) => {+ const next = data && data.handsOn;+ if (!next || !model) return;+ steps = model.steps.map(s => ({ name: s.name, title: s.title, does: s.does, phase: s.phase, status: s.status }));+ const key = JSON.stringify([next.state, next.plan, next.waiting, next.profile, (next.profiles || []).map(p => p.slug + p.default), steps.map(s => s.name)]);+ if (key === planKey) return;+ const wasPlanning = ho && ho.state === 'planning';+ planKey = key; ho = next;+ if (ho.state === 'planning' && !wasPlanning && !editing) draft = null;+ if (ho.state !== 'planning' && wasPlanning) { draft = null; pending = null; }+ if (pending && pending.step && !(ho.waiting && ho.waiting.step === pending.step)) pending = null;+ // keep the human's half-made choices while the CLI writes unrelated updates+ if ((ho.state === 'planning' || editing) && box.contains(document.activeElement)) return;+ draw();+ };+})();+</script> </body> </html>