← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.15"+version = "0.2.16" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.15"+version = "0.2.16" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.15"+version = "0.2.16" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.15"+version = "0.2.16" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.15"+version = "0.2.16" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.15"+version = "0.2.16" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.15"+version = "0.2.16" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.15"+version = "0.2.16" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.15"+version = "0.2.16" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.15"+version = "0.2.16" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.15"+version = "0.2.16" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+15−5
@@ -43,7 +43,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. 2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). `place pack` lays each `spec.chains` entry (a series chain in electrical order, such as a divider) on one line with the shared-net pads facing and says which chains were honoured; when a wish lists several rotations it picks the one whose pads land nearest their net partners. Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's. 1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--sch design.kicad_sch]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. `--sch` records the schematic the board came from, so `finish` requires a passing `netlist check`.-1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--page <owner/slug>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. When the work begins before any board exists, open the run with `intake` instead (next section); `start` on that run attaches the board and keeps its clock.+1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--page <owner/slug> [--page-dir <folder>]]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. When the work begins before any board exists, open the run with `intake` instead (next section); `start` on that run attaches the board and keeps its clock. 2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's. 3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it. 4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.@@ -96,7 +96,15 @@ the board on a test box), and prints each electrical step as not applicable with finish refuse the lane for a board that needs routing: footprint pads on more than one net, on a net no zone carries, tracks or vias on such a net, or a spec with loads, hot parts, signal checks or a converter. Pads with no net or only on the single pour net (machine pins on a GND plate) are allowed; a sourcing-profile still applies to any footprint in the BOM.+profile still applies to any footprint in the BOM. A net with exactly one pad and no tracks, vias or zones+on it is no net for the lane (0.2.16, issue #86): KiCad's `unconnected-(REF-PadN)` nets on floating seats+pass; a second pad on such a net, or copper on it, makes it a net to connect. finish records its live+DRC as the run's `live-drc` outcome (the routing validate's errors and unconnected, the full DRC's errors,+unconnected and manufacturing warnings, the board and its sha256, the time) and, on this lane, refuses+unless the full DRC returned and is clean. The verified summary reads its DRC and connectivity items from+that record only (bound to the current board; no record, not known), and schematic parity, ERC and symbols+as not applicable when the run has no schematic, so a clean pattern board with a passing export reads+cleared.  On the KiCad lane, `silkscreen-audit --input <audit.json> --out <result.json> --record` records the silkscreen proof only when the audit passes and its inventory covers the board: every footprint by its@@ -191,7 +199,7 @@ wrong part at its component page, not just on this board.  The clock starts at the prompt even when there is no board yet. -- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.+- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug> [--page-dir <folder>]]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page. - **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`.@@ -290,9 +298,11 @@ The number is the human's prompt to your "done, here is your video" (`deliver`),  ## The run's own page, as it runs -`report --page <owner/slug> --user-machine <box> --push --refresh` once, early (`--surface webview` instead of a machine when you run inside Adom Hydrogen: the page then lives in a workspace tab named "AI Flow run" and reloads there). From then on every `step` and `deliver` rebuilds the run's sub-README (per-step times, returns and why, decisions, every step visit's 10x clip and its start and end screenshots, where the board stands, what was delivered, what was given back), pushes it to `docs/runs/<id>/` on that page's repo, and reloads it in the human's browser on their machine, so they watch the run progress for hours without asking. The rendered view is `https://wiki.adom.inc/<owner>/<slug>/files/docs/runs/<id>/README.md`; the same path without `files/` is raw markdown.+`report --page <owner/slug> --user-machine <box> --push --refresh` once, early (`--surface webview` instead of a machine when you run inside Adom Hydrogen: the page then lives in a workspace tab named "AI Flow run" and reloads there). From then on every `step` and `deliver` rebuilds the run's sub-README (per-step times, returns and why, decisions, every step visit's 10x clip and its start and end screenshots, where the board stands, what was delivered, what was given back), pushes it to `docs/runs/<id>/` on that page's repo, and reloads it in the human's browser on their machine, so they watch the run progress for hours without asking. The rendered view is `https://wiki.adom.inc/<owner>/<slug>/files/docs/runs/<id>/README.md`; the same path without `files/` is raw markdown. With no page, `report` writes the README locally to `<run>/report/README.md` (its media beside it, referenced relatively) and pushes nothing. -The live clips page is the binary's too: `clips --page <owner/slug>` rebuilds `docs/clips.md` in `<run>/clips-page` (a table of every clip with its step, visit, time, links and suspect state, then one block per clip: the 10x cut as a video, the action cut, the contact sheet, and the note from `<run>/clip-notes.json`) and says what it would push; `--push` pushes it and the changed clip media with `adom-wiki repo push --files`. Once the run has a page (`start --page`, `intake --page`, `report --page` or `clips --page`), every clip stop rebuilds and pushes it by itself, best effort (a failed push is said, never a failed step). It refuses to push a page with an em-dash or a match of the regex in `$AIFLOW_BANNED_RE`. Open the rendered page for the human once and link it from the README (aiflow-live-clips).+**A folder of an existing page (0.2.16, issue #85).** One page per project: put a board's run in its folder with `--page <owner/slug> --page-dir <folder>` on `start`, `intake`, `report` or `clips` (relative, like `rig/boards/rig-carrier`; no leading slash, no `..`; `.` is the page root again). The run stores it, and every push lands under the folder: the run README at `<folder>/docs/runs/<id>/`, the clips page at `<folder>/docs/clips.md` with its media in `<folder>/docs/clips/`; every URL the run writes (`files/<folder>/...`, the video widget's `render/<folder>/...`, the report's media paths) carries it. A new `--page` without `--page-dir` drops the old folder; `clips --page-dir` alone moves only the clips page. `project-page --page <owner/slug> --page-dir <folder>` writes the board's README and PCM repository into that folder of an existing page (it never creates a page; `--existing` is implied, `--create` is refused), and the step pages go to `<folder>/steps/`.++The live clips page is the binary's too: `clips --page <owner/slug>` rebuilds `docs/clips.md` in `<run>/clips-page` (a table of every clip with its step, visit, time, links and suspect state, then one block per clip: the 10x cut as a video, the action cut, the contact sheet, and the note from `<run>/clip-notes.json`) and says what it would push; `--push` pushes it and the changed clip media with `adom-wiki repo push --files`. With `--page-dir <folder>` it is `<folder>/docs/clips.md` instead. Once the run has a page (`start --page`, `intake --page`, `report --page` or `clips --page`), every clip stop rebuilds and pushes it by itself, best effort (a failed push is said, never a failed step). It refuses to push a page with an em-dash or a match of the regex in `$AIFLOW_BANNED_RE`. Open the rendered page for the human once and link it from the README (aiflow-live-clips).  ## Give it back 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/boardkind.rs+37−4
@@ -19,6 +19,11 @@ //! hot parts, signal checks or a converter is refused too. Footprints whose pads carry no net (a //! mounting hole) or only the single pour net (machine pins on a GND plate) are allowed; a profile's //! sourcing check still applies to any of them that go in the BOM.+//!+//! A net with exactly one pad and no tracks, vias or zones on it is no net for the lane (issue #86):+//! KiCad gives every pin that connects to nothing its own `unconnected-(<REF>-Pad<N>)` net, and any+//! other single-pad net has nothing to connect either. Only nets with two or more pads, or with+//! copper on them, count.  use aiflow_board::Board; use serde_json::Value;@@ -34,7 +39,7 @@ pub const PATTERN_NA: &[(&str, &str)] = &[     ("design, sourcing, parts, project-page, library, schematic, simulation, components, libraries", "no parts, BOM or schematic: a passive copper pattern"),     ("test-plan", "no circuit to probe"),     ("placement", "no parts to place"),-    ("routing", "no net needs routing (pads, if any, only on a single pour net)"),+    ("routing", "no net needs routing (pads, if any, only on a single pour net or alone on their own net)"),     ("thermal-vias", "no hot parts"),     ("pours", "the copper is the pattern itself, not a planned pour"),     ("current, thermal", "nothing carries current"),@@ -69,10 +74,14 @@ pub fn fields_applies(spec: &Value, calcs: Option<&Value>) -> Vec<String> { /// Why this board cannot take the pattern lane (None: it can). pub fn pattern_refusal(b: &Board) -> Option<String> {     let zone_nets: BTreeSet<String> = b.zones().into_iter().map(|z| z.net).filter(|n| !n.trim().is_empty()).collect();+    let copper_nets: BTreeSet<String> = b.tracks().into_iter().map(|t| t.net).chain(b.vias().into_iter().map(|v| v.net)).filter(|n| !n.trim().is_empty()).collect();+    // pads per net: a single-pad net with no copper on it is no net (KiCad's unconnected-(REF-PadN))+    let mut pads_on: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();+    for p in b.pads.iter().filter(|p| !p.net.trim().is_empty()) { *pads_on.entry(p.net.clone()).or_insert(0) += 1; }+    let counts = |n: &str| pads_on.get(n).copied().unwrap_or(0) >= 2 || zone_nets.contains(n) || copper_nets.contains(n);     let mut pad_nets: BTreeSet<String> = BTreeSet::new();     let mut refs: BTreeSet<String> = BTreeSet::new();-    for p in b.pads.iter().filter(|p| !p.net.trim().is_empty()) { pad_nets.insert(p.net.clone()); refs.insert(p.reference.clone()); }-    let copper_nets: BTreeSet<String> = b.tracks().into_iter().map(|t| t.net).chain(b.vias().into_iter().map(|v| v.net)).filter(|n| !n.trim().is_empty()).collect();+    for p in b.pads.iter().filter(|p| !p.net.trim().is_empty() && counts(&p.net)) { pad_nets.insert(p.net.clone()); refs.insert(p.reference.clone()); }     let list = |s: &BTreeSet<String>| s.iter().take(8).cloned().collect::<Vec<_>>().join(", ");     if pad_nets.len() > 1 {         return Some(format!("footprint pads are on {} nets ({}): a board with nets to connect needs the electrical lane", pad_nets.len(), list(&pad_nets)));@@ -140,8 +149,10 @@ mod tests {         // a mounting hole without a net: allowed         assert_eq!(pattern_refusal(&board(&pin("H1", 3.0, ""))), None);         // two nets: refused-        let w = pattern_refusal(&board(&format!("{}{}{GND_ZONE}", pin("MP1", 3.0, "GND"), pin("J1", 15.0, "SIG")))).unwrap();+        let w = pattern_refusal(&board(&format!("{}{}{}{GND_ZONE}", pin("MP1", 3.0, "GND"), pin("J1", 15.0, "SIG"), pin("J2", 17.0, "SIG")))).unwrap();         assert!(w.contains("2 nets"), "{w}");+        // one pad alone on SIG with no copper on it (issue #86): no net, allowed+        assert_eq!(pattern_refusal(&board(&format!("{}{}{GND_ZONE}", pin("MP1", 3.0, "GND"), pin("J1", 15.0, "SIG")))), None);         // one net no zone carries: refused         let w = pattern_refusal(&board(&format!("{}{}", pin("MP1", 3.0, "GND"), pin("MP2", 15.0, "GND")))).unwrap();         assert!(w.contains("no zone (pour) carries"), "{w}");@@ -152,6 +163,28 @@ mod tests {         assert!(pattern_spec_refusal(&json!({}), None).is_none());     } +    /// Issue #86: a carrier plate, four machine pins on a GND pour and eight one-pad molecule seats+    /// on KiCad's `unconnected-(SDn-Pad1)` nets. The seats are no nets; a second pad on one is.+    #[test]+    fn single_pad_nets_without_copper_are_no_nets() {+        let mut head = String::from(r#"(kicad_pcb (version 20260101) (net 0 "") (net 1 "GND")"#);+        for i in 1..=8 { head.push_str(&format!(r#" (net {} "unconnected-(SD{i}-Pad1)")"#, i + 1)); }+        head.push_str(r#" (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (gr_rect (start 0 0) (end 40 40) (layer "Edge.Cuts"))"#);+        let fp = |r: &str, x: f64, y: f64, n: usize, name: &str| format!(r#" (footprint "MC" (layer "F.Cu") (at {x} {y}) (property "Reference" "{r}") (pad "1" smd rect (at 0 0) (size 2 2) (layers "F.Cu") (net {n} "{name}")))"#);+        let mut body = String::new();+        for (i, (x, y)) in [(2.0, 2.0), (38.0, 2.0), (2.0, 38.0), (38.0, 38.0)].iter().enumerate() { body.push_str(&fp(&format!("MP{}", i + 1), *x, *y, 1, "GND")); }+        for i in 1..=8 { body.push_str(&fp(&format!("SD{i}"), 4.0 * i as f64, 20.0, i + 1, &format!("unconnected-(SD{i}-Pad1)"))); }+        let zone = r#" (zone (net 1) (net_name "GND") (layer "B.Cu") (uuid "z1") (polygon (pts (xy 1 1) (xy 39 1) (xy 39 39) (xy 1 39))))"#;+        let plate = |extra: &str| Board::from_text("t", format!("{head}{body}{extra}{zone})")).unwrap();+        assert_eq!(pattern_refusal(&plate("")), None);+        // a second pad on unconnected-(SD3-Pad1): a real two-pad net, refused+        let w = pattern_refusal(&plate(&fp("SD9", 30.0, 30.0, 4, "unconnected-(SD3-Pad1)"))).unwrap();+        assert!(w.contains("2 nets") && w.contains("unconnected-(SD3-Pad1)") && !w.contains("SD4"), "{w}");+        // a single pad with a track on its net: copper on it, so it counts+        let w = pattern_refusal(&plate(r#" (segment (start 5 20) (end 9 20) (width 0.25) (layer "F.Cu") (net 3))"#)).unwrap();+        assert!(w.contains("unconnected-(SD2-Pad1)"), "{w}");+    }+     #[test]     fn finish_names_what_did_not_apply() {         let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();
crates/adom-aiflow/src/main.rs+103−54
@@ -44,6 +44,7 @@ mod placement_intent; mod rules_card; mod demo; mod boardkind;+mod pagedir; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -103,7 +104,8 @@ enum Cmd {      /// Start a run: copies the board, stamps the prompt time. On a run made by `intake` it attaches the board and keeps the same clock and ledger.     Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool, /// the run's wiki page (owner/slug): the run README and the clips page go there as the run goes-    #[arg(long)] page: Option<String>, /// the schematic the board was made from: recorded so `finish` requires a passing `netlist check`+    #[arg(long)] page: Option<String>, /// a folder of that page (relative, like rig/boards/rig-carrier; `.` for the page root): every push for the run lands under it, every URL it writes carries it+    #[arg(long)] page_dir: Option<String>, /// the schematic the board was made from: recorded so `finish` requires a passing `netlist check`     #[arg(long)] sch: Option<String> },     /// `netlist check`: export the schematic's netlist with kicad-cli (ADOM_AIFLOW_KICAD_CLI) and compare it pin by pin with the board's pads; OK only when equal. In a run it defaults to the run's schematic and current board and records the outcome `finish` requires.     Netlist { #[arg(value_parser = ["check"])] action: String, #[arg(long)] sch: Option<String>, #[arg(long)] board: Option<String>, /// an already exported netlist (kicadsexpr) instead of exporting one@@ -132,7 +134,8 @@ enum Cmd {     #[arg(long)] brief: Option<String>, /// requirements.json (aiflow-intake): fab.target picks the sourcing profile     #[arg(long)] requirements: Option<String>, #[arg(long)] engine: Option<String>, /// the desktop for pre-board clips (a monitor recording while no editor is open)     #[arg(long)] target: Option<String>, /// the run's wiki page (owner/slug)-    #[arg(long)] page: Option<String>, /// the EDA tool the board is built in (default: the brief's own words, else KiCad); fixes the run's step sequence+    #[arg(long)] page: Option<String>, /// a folder of that page (relative, like rig/boards/rig-carrier; `.` for the page root): every push for the run lands under it, every URL it writes carries it+    #[arg(long)] page_dir: Option<String>, /// the EDA tool the board is built in (default: the brief's own words, else KiCad); fixes the run's step sequence     #[arg(long, value_parser = ["kicad", "fusion", "altium"])] eda: Option<String>, /// `pattern`: a passive copper-pattern KiCad board (no parts, BOM, schematic or electrical load) takes the short pattern sequence; finish reports the electrical steps as not applicable. Refused later for a board with pads on nets that need routing.     #[arg(long, value_parser = ["pattern"])] kind: Option<String> },     /// `sourcing check --bom <csv>`: every electrical board reference in the BOM with an MPN, sources that fit the profile (fab: Mouser or Adom stock, no LCSC/JLCPCB parts; jlcpcb: LCSC numbers), a dated stock check, thin stock warned. finish requires a pass when the spec or requirements name a profile.@@ -142,7 +145,8 @@ enum Cmd {     /// `evidence add --kind calcs|simulation|vendor-model --file <json|md>`: the design record in the run and the ledger, with its sha256; a failing simulation is refused. finish requires the kinds the spec lists in requireEvidence.     Evidence { what: String, #[arg(long, value_parser = ["calcs", "simulation", "vendor-model"])] kind: String, #[arg(long)] file: String, #[arg(long)] note: Option<String> },     /// The live clips page: rebuild docs/clips.md (every clip: step, visit, time, the 10x cut, the action cut, the contact sheet, the suspect state, the note from clip-notes.json) and with --push put it and the clip media on the page. Once a page is set, every clip stop does this by itself.-    Clips { #[arg(long)] page: Option<String>, #[arg(long)] push: bool },+    Clips { #[arg(long)] page: Option<String>, /// a folder of that page (relative, like rig/boards/rig-carrier; `.` for the page root): every push for the run lands under it, every URL it writes carries it (<folder>/docs/clips.md)+    #[arg(long)] page_dir: Option<String>, #[arg(long)] push: bool },     /// Verify and bind the current snapshot to an exact saved native KiCad file.     BindRemoteBoard { #[arg(long)] remote_board: String },     /// Adopt a native-saved board after AI-owned edits, preserving project rules and checking baseline DRC.@@ -154,7 +158,8 @@ enum Cmd {     ProjectPage { #[arg(long)] page: String, /// push every visited step's sub-readme and the README's "How this board was built" section now (each step's is also pushed in the background when the run leaves it)     #[arg(long)] sync: bool, #[arg(long)] create: bool, /// the page already exists and is this same board's own page (an earlier run of it); refused otherwise     #[arg(long)] existing: bool, #[arg(long)] private: bool, #[arg(long)] title: Option<String>, /// what the board is: text, or a path to a file (for example design/brief.md) whose contents are read-    #[arg(long)] brief: Option<String>, #[arg(long)] replace_readme: bool, #[arg(long)] no_push: bool },+    #[arg(long)] brief: Option<String>, #[arg(long)] replace_readme: bool, #[arg(long)] no_push: bool, /// a folder of an EXISTING page (relative, like rig/boards/rig-carrier): the board's README and its PCM repository go there, the repository URLs carry it; implies --existing and never creates a page+    #[arg(long)] page_dir: Option<String> },     /// `pcm check`: the project page's PCM package is installed in the test box's KiCad (kicad_pcm_list), so the schematic can use every part. Install it first with kicad_pcm_add_repository + kicad_pcm_install (the URLs project-page printed).     Pcm { #[arg(value_parser = ["check"])] what: String },     /// `schematic check --sch <file.kicad_sch>`: the from-scratch KiCad schematic, drawn in KiCad: kicad-cli ERC with no errors, every BOM reference on the sheet, every part from the project's PCM library (PCM_<lib>). Records the schematic for the netlist check that follows the board.@@ -223,8 +228,9 @@ enum Cmd {     #[arg(long)] accept_suspect: bool },     /// Check explicit project hero/datasheet bindings, README screenshots and optional published API readback. Still requires rendered visual review.     ProjectPageCheck { #[arg(long)] project: String, #[arg(long)] live_json: Option<String> },-    /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/, --refresh reloads it in the human's browser. Runs by itself at every step change and at deliver once a page is set.-    Report { #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)+    /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/ (<folder>/docs/runs/<id>/ with --page-dir), --refresh reloads it in the human's browser. With no page it is written locally to <run>/report/README.md and nothing is pushed. Runs by itself at every step change and at deliver once a page is set.+    Report { #[arg(long)] page: Option<String>, /// a folder of that page (relative, like rig/boards/rig-carrier; `.` for the page root): every push for the run lands under it, every URL it writes carries it+    #[arg(long)] page_dir: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)     #[arg(long)] surface: Option<String> },     /// Record an issue or PR your AI filed back on adom/adom-aiflow (or any page) during this run; deliver counts them.     Giveback { url: String, #[arg(long)] kind: Option<String>, #[arg(long)] note: Option<String> },@@ -419,29 +425,29 @@ fn requirements_of(r: &Run) -> Value {     r.data["requirements"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null) } -/// The page the clips go to: `clips --page`, else the run's page (`start --page`, `report --page`).-fn clips_page_of(r: &Run) -> Option<String> {-    r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string)-}+/// The page and folder the clips go to: `clips --page [--page-dir]`, else the run's page and folder+/// (`start`, `intake`, `report`).+fn clips_page_of(r: &Run) -> Option<(String, Option<String>)> { pagedir::clips_page(&r.data) } -/// Rebuild docs/clips.md in <run>/clips-page and, with `push`, put it and the changed clip media-/// on the page (`adom-wiki repo push --files`, batches of about 20 MB). Refuses to push a page-/// with an em-dash or a match of $AIFLOW_BANNED_RE. Returns (what happened, the files).-fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String, Vec<String>), String> {+/// Rebuild docs/clips.md (under the page's folder, when there is one) in <run>/clips-page and, with+/// `push`, put it and the changed clip media on the page (`adom-wiki repo push --files`, batches of+/// about 20 MB). Refuses to push a page with an em-dash or a match of $AIFLOW_BANNED_RE. Returns+/// (what happened, the files).+fn publish_clips(r: &Run, dir: &Path, page: &str, page_dir: Option<&str>, push: bool) -> Result<(String, Vec<String>), String> {     let notes: Value = std::fs::read_to_string(dir.join("clip-notes.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);-    let page_ref = r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string);-    let (md, media) = preboard::clips_page(&r.data, dir, &notes, &now(), page_ref.as_deref());+    let (md, media) = preboard::clips_page(&r.data, dir, &notes, &now(), Some(page), page_dir);     let stage = dir.join("clips-page");-    std::fs::create_dir_all(stage.join("docs").join("clips")).map_err(|e| format!("{}: {e}", stage.display()))?;+    let index_doc = pagedir::join(page_dir, "docs/clips.md");+    std::fs::create_dir_all(stage.join(pagedir::join(page_dir, "docs/clips"))).map_err(|e| format!("{}: {e}", stage.display()))?;     // docs/clips.md belongs to the first run that published it (a hidden marker says which). A later     // run on the same page writes docs/clips-<run>.md and adds one link to it in docs/clips.md,     // so a second run never overwrites the first run's clips.     let id = r.data["reportId"].as_str().map(str::to_string).unwrap_or_else(|| report_id(r));     let marker = format!("<!-- aiflow-clips-run: {id} -->");-    let remote = std::process::Command::new("adom-wiki").args(["repo", "show", page, "docs/clips.md"]).output().ok()+    let remote = std::process::Command::new("adom-wiki").args(["repo", "show", page, &index_doc]).output().ok()         .filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.trim_start().starts_with('#') || t.contains("aiflow-clips-run"));     let owner = match &remote { None => true, Some(t) => t.contains(&marker) };-    let doc = if owner { "docs/clips.md".to_string() } else { format!("docs/clips-{id}.md") };+    let doc = if owner { index_doc.clone() } else { pagedir::join(page_dir, &format!("docs/clips-{id}.md")) };     let md = if owner { format!("{marker}\n{md}") } else { format!("{marker}\n{}", md.replacen("# Build clips", &format!("# Build clips: run {id}"), 1)) };     std::fs::write(stage.join(&doc), &md).map_err(|e| e.to_string())?;     let mut index_update: Option<String> = None;@@ -453,7 +459,7 @@ fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String,             let at = lines.iter().position(|l| l.starts_with("# ")).map(|i| i + 1).unwrap_or(0);             lines.insert(at, ""); lines.insert(at + 1, &line);             let updated = lines.join("\n") + "\n";-            std::fs::write(stage.join("docs").join("clips.md"), &updated).map_err(|e| e.to_string())?;+            std::fs::write(stage.join(&index_doc), &updated).map_err(|e| e.to_string())?;             index_update = Some(updated);         }     }@@ -464,7 +470,7 @@ fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String,     }     let clips = md.matches("\n### Clip ").count();     let mut files: Vec<String> = vec![doc.clone()];-    if index_update.is_some() { files.push("docs/clips.md".into()); }+    if index_update.is_some() { files.push(index_doc.clone()); }     files.extend(media.iter().map(|(_, rel)| rel.clone()));     let banned = std::env::var("AIFLOW_BANNED_RE").ok();     let scrubbed = preboard::scrub(&md, banned.as_deref());@@ -480,7 +486,7 @@ fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String,     for f in &files {         let p = stage.join(f);         let sum = library_review::hash(&p).unwrap_or_default();-        if f == &doc || f == "docs/clips.md" || cache.get(f).and_then(|v| v.as_str()) != Some(sum.as_str()) { todo.push((f.clone(), std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0), sum)); }+        if f == &doc || f == &index_doc || cache.get(f).and_then(|v| v.as_str()) != Some(sum.as_str()) { todo.push((f.clone(), std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0), sum)); }     }     let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];     let mut acc = 0u64;@@ -500,7 +506,7 @@ fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String,         }     }     let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());-    r.log("clips-page", json!({"page": page, "clips": clips, "pushed": pushed, "unchanged": files.len() - todo.len(), "failed": failed}));+    r.log("clips-page", json!({"page": page, "pageDir": page_dir, "clips": clips, "pushed": pushed, "unchanged": files.len() - todo.len(), "failed": failed}));     if !failed.is_empty() { return Err(format!("push failed for {}; the next clip stop (or `clips --push`) sends it again", failed.join("; "))); }     Ok((format!("clips page pushed: {clips} clip(s), {pushed} changed file(s) ({} unchanged skipped): {url}", files.len() - todo.len()), files)) }@@ -511,8 +517,8 @@ fn auto_clips(r: &Run, dir: &Path) -> String {     if std::env::var("AIFLOW_AUTO_PUBLISH_CLIPS").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false) {         return "Automatic raw-clip publication disabled; publish reviewed, narrated clips explicitly.".into();     }-    let Some(pg) = clips_page_of(r) else { return String::new() };-    match publish_clips(r, dir, &pg, true) { Ok((s, _)) => s, Err(e) => format!("clips page: {e}") }+    let Some((pg, pd)) = clips_page_of(r) else { return String::new() };+    match publish_clips(r, dir, &pg, pd.as_deref(), true) { Ok((s, _)) => s, Err(e) => format!("clips page: {e}") } }  /// The user's primary machine (`adom-bridge targets` names it).@@ -1714,7 +1720,10 @@ fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) { /// The comparison sub-README: this run (left) against another (right). Returns (rel dir, url, files to push). fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String, Vec<String>) {     let (ida, idb) = (report_id(a), report_id(b));-    let rel = format!("docs/comparisons/{ida}-vs-{idb}");+    // each run's folder on this page (issue #85): the comparison goes in run a's, the links into each run's+    let dir_on = |r: &Run| r.data["pageDir"].as_str().filter(|_| r.data["page"].as_str() == Some(page)).map(str::to_string);+    let (da_dir, db_dir) = (dir_on(a), dir_on(b));+    let rel = pagedir::join(da_dir.as_deref(), &format!("docs/comparisons/{ida}-vs-{idb}"));     let stage = dir.join("report");     let out = stage.join(&rel);     let _ = std::fs::remove_dir_all(&out);@@ -1777,8 +1786,8 @@ fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String     }     // the links     md.push_str("## The two runs, in full\n\n");-    md.push_str(&format!("- [{na}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{ida}/README.md): every step's clip, screenshots, sessions, returns, decisions, plan usage, what it gave back.\n"));-    md.push_str(&format!("- [{nb}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{idb}/README.md): the same for the other engine.\n"));+    md.push_str(&format!("- [{na}: the run page]({}): every step's clip, screenshots, sessions, returns, decisions, plan usage, what it gave back.\n", pagedir::files_url(page, None, &format!("{}/README.md", pagedir::run_rel(da_dir.as_deref(), &ida)))));+    md.push_str(&format!("- [{nb}: the run page]({}): the same for the other engine.\n", pagedir::files_url(page, None, &format!("{}/README.md", pagedir::run_rel(db_dir.as_deref(), &idb)))));     md.push_str(&format!("- [The flow](https://wiki.adom.inc/{page}): Adom's AI Flow, the tool both ran.\n\n"));     md.push_str("*Plan usage on each run page is per account, not per run: every AI thread signed in as that user draws on the same limits.*\n");     let readme = out.join("README.md");@@ -1816,17 +1825,24 @@ fn report_id(r: &Run) -> String {     format!("{board}-{eng}-{date}-{hhmm}") } -/// Build the sub-README and its files under <run>/report/docs/runs/<id>/; returns (staging dir, rel dir, README path).-fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {+/// Build the sub-README and its files under <run>/report/<folder>/docs/runs/<id>/ (the page's folder,+/// issue #85, when the run has one); returns (staging dir, rel dir, README path). With no page the+/// README is written locally to <run>/report/README.md, its media beside it and referenced+/// relatively, and the rel dir is empty.+fn build_report(r: &Run, dir: &Path, page: Option<(&str, Option<&str>)>) -> (PathBuf, String, PathBuf) {     let id = report_id(r);-    let rel = format!("docs/runs/{id}");+    let rel = page.map(|(_, d)| pagedir::run_rel(d, &id)).unwrap_or_default();     let stage = dir.join("report");     let out = stage.join(&rel);     // the staging folder is rebuilt from nothing every time, so an older cut or clip never rides along-    let _ = std::fs::remove_dir_all(&out);+    // (locally only the top-level files: a page's staged folders stay)+    if rel.is_empty() {+        for e in std::fs::read_dir(&out).into_iter().flatten().filter_map(|e| e.ok()) {+            if e.path().is_file() && !e.file_name().to_string_lossy().starts_with('.') { let _ = std::fs::remove_file(e.path()); }+        }+    } else { let _ = std::fs::remove_dir_all(&out); }     let _ = std::fs::create_dir_all(&out);-    let slug = page.split('/').nth(1).unwrap_or(page);-    let blob = format!("/blob/app/{slug}/{rel}");+    let blob = match page { Some((pg, _)) => format!("/blob/app/{}/{rel}", pg.split('/').nth(1).unwrap_or(pg)), None => ".".to_string() };     let led = r.ledger();     let engine = r.data["engine"].as_str().unwrap_or("");     let prompt = r.data["clock"]["promptTime"].as_str().unwrap_or("");@@ -2010,9 +2026,11 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {     (stage, rel, readme) } -/// Push the report to the page's repo and reload it in the human's browser; returns the page URL.+/// Push the report to the page's repo (under the run's page folder, when it has one) and reload it+/// in the human's browser; returns the page URL. fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, thread: &str) -> (String, Vec<String>) {-    let (stage, rel, _readme) = build_report(r, dir, page);+    let page_dir = r.data["pageDir"].as_str().filter(|_| r.data["page"].as_str() == Some(page));+    let (stage, rel, _readme) = build_report(r, dir, Some((page, page_dir)));     // the files viewer renders a sub-README as a page; the bare path serves raw markdown     let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");     let mut notes = Vec::new();@@ -2644,7 +2662,8 @@ fn main() {             if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }             ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);         }-        Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force, sch, page } => {+        Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force, sch, page, page_dir } => {+            if let Some(d) = page_dir { if let Err(e) = pagedir::validate(d) { err(&e, &[]); } }             let t = thread(&cli);             if !Path::new(board).is_file() {                 err(&format!("board not found: {board}"), &[]);@@ -2680,7 +2699,7 @@ fn main() {                 let differs = prompt_time.as_ref().filter(|p| **p != pt).map(|p| format!("--prompt-time {p} ignored: the intake run's clock started at {pt} and stays there. "));                 r.attach_board(&copy.display().to_string(), &specp, engine, target.as_deref(), remote_board.as_deref());                 r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));-                if let Some(p) = page { r.data["page"] = json!(p); }+                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())); }                 let idx = r.turn_begin(&command_line());                 record_usage(&mut r, "start");@@ -2706,7 +2725,7 @@ fn main() {             let mut r = Run::create(&dir, &copy.display().to_string(), &specp, engine, &pt, &t, target.as_deref(), remote_board.as_deref()).unwrap_or_else(|e| err(&e, &[]));             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 Some(p) = page { r.data["page"] = json!(p); }+            if let Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }             let idx = r.turn_begin(&command_line());             record_usage(&mut r, "start");             r.save().unwrap();@@ -2725,19 +2744,20 @@ fn main() {             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);             print_next(&cli);         }-        Cmd::Intake { prompt_time, brief, requirements, engine, target, page, eda, kind } => {+        Cmd::Intake { prompt_time, brief, requirements, engine, target, page, page_dir, eda, kind } => {             let t = thread(&cli);             if aiflow_run::parse(prompt_time).is_none() { err(&format!("--prompt-time {prompt_time} is not a time (RFC 3339 UTC, e.g. 2026-09-29T00:39:00Z)"), &["Use the human message's own timestamp, not the moment you got to it.".into()]); }             if let Some(r) = Run::open(&dir) {                 err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory: intake opens a new run. A follow-up prompt on this run is `prompt --text ...`.".into()]);             }             for f in brief.iter().chain(requirements.iter()) { if !Path::new(f).is_file() { err(&format!("not found: {f}"), &[]); } }+            { let mut probe = json!({}); if let Err(e) = pagedir::set(&mut probe, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); } }             if kind.is_some() && eda.as_deref().is_some_and(|e| e != "kicad") { err("--kind pattern is a KiCad lane", &["Build the pattern board in KiCad (--eda kicad, or leave --eda out).".into()]); }             let canon = |p: &String| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or(p.clone());             // a brief that is already a requirements file (aiflow-intake's shape) is recorded as both             let req = requirements.as_ref().map(canon).or_else(|| brief.as_ref().filter(|b| std::fs::read_to_string(b).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v.get("fab").is_some() || v.get("sourcing").is_some() || v.get("outputs").is_some()).unwrap_or(false)).map(canon));             let mut r = Run::create_intake(&dir, prompt_time, &t, engine.as_deref(), brief.as_ref().map(canon).as_deref(), req.as_deref(), target.as_deref()).unwrap_or_else(|e| err(&e, &[]));-            if let Some(p) = page { r.data["page"] = json!(p); }+            if let Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }             // the EDA tool, named: the step sequence is fixed at the first step after intake, before any             // backend project exists, so a brief that never says "Altium" must not fall into kicad-new             if let Some(e) = eda { r.data["eda"] = json!(e); }@@ -2849,12 +2869,17 @@ fn main() {             }             ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &hints);         }-        Cmd::Clips { page, push } => {+        Cmd::Clips { page, page_dir, push } => {             thread(&cli);             let mut r = load_run(&cli);-            if let Some(p) = page { r.data["clipsPage"] = json!(p); r.save().unwrap(); }-            let pg = clips_page_of(&r).unwrap_or_else(|| err("no page for the clips: `clips --page <owner/slug>` once (the project's wiki page)", &[]));-            match publish_clips(&r, &dir, &pg, *push) {+            if page.is_some() || page_dir.is_some() {+                // --page-dir alone sets the folder of the page the clips already go to+                if page.is_none() && r.data["clipsPage"].is_null() { if let Some(p) = r.data["page"].as_str().map(str::to_string) { r.data["clipsPage"] = json!(p); r.data["clipsPageDir"] = r.data["pageDir"].clone(); } }+                if let Err(e) = pagedir::set(&mut r.data, "clipsPage", "clipsPageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }+                r.save().unwrap();+            }+            let (pg, pd) = clips_page_of(&r).unwrap_or_else(|| err("no page for the clips: `clips --page <owner/slug>` once (the project's wiki page)", &[]));+            match publish_clips(&r, &dir, &pg, pd.as_deref(), *push) {                 Ok((said, files)) => ok(&said, &[if *push { "Look at the rendered page once (a pup tab or a Hydrogen webview on the human's machine): a successful push is not a working page. Link docs/clips.md from the README once (wiki-sub-readme).".to_string() } else { format!("Would push {} file(s): {}", files.len(), files.iter().take(8).cloned().collect::<Vec<_>>().join(", ")) + if files.len() > 8 { ", ..." } else { "" } }, "From now on every clip stop rebuilds and pushes this page by itself; write each clip's note in <run>/clip-notes.json (step, shows, state) after you look at its contact sheet (aiflow-live-clips).".into()]),                 Err(e) => err(&e, &["Fix the page text (clip-notes.json is where notes come from), then `clips --push` again.".into()]),             }@@ -2952,10 +2977,17 @@ fn main() {             }             ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]);         }-        Cmd::ProjectPage { page, sync, create, existing, private, title, brief, replace_readme, no_push } => {+        Cmd::ProjectPage { page, sync, create, existing, private, title, brief, replace_readme, no_push, page_dir } => {             let t = thread(&cli);             let mut r = load_run(&cli);+            // a folder of an existing page (issue #85): implies --existing, never creates a page+            let page_dir_arg = page_dir.clone();+            let page_dir: Option<String> = page_dir.as_deref().map(pagedir::validate).transpose().unwrap_or_else(|e| err(&e, &[])).flatten();+            if page_dir.is_some() && *create { err("--page-dir writes into a folder of an existing page and never creates one: drop --create (or drop --page-dir for a new page)", &[]); }+            let existing = &(*existing || page_dir.is_some());             if *sync {+                if let Err(e) = pagedir::set(&mut r.data, "projectPage", "projectPageDir", Some(page.as_str()), page_dir_arg.as_deref()) { err(&e, &[]); }+                r.save().unwrap();                 let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-steps.py")).arg(&dir).arg(page).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));                 let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(json!({"ok": false, "error": String::from_utf8_lossy(&o.stderr).to_string()}));                 if v["ok"] != true { err(&format!("step pages: {}", v["error"].as_str().unwrap_or("failed")), &[]); }@@ -2973,7 +3005,7 @@ fn main() {             let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::title(&r.data));             let brief = brief.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());             let lane = sequence::lane(&dir, &r.data);-            let cfg = json!({"run": dir.display().to_string(), "eda": lane, "page": page, "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",+            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",                 "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());@@ -2986,6 +3018,7 @@ fn main() {             }             r.outcome("project-page", v.clone());             r.data["projectPage"] = json!(page);+            r.data["projectPageDir"] = json!(page_dir);             if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); }             r.save().unwrap();             if !no_push {@@ -3909,7 +3942,7 @@ fn main() {             let o = r.outcomes().clone();             let mut missing = Vec::new();             // a pattern board's run (boardkind.rs): the electrical steps are not applicable, the native DRC is the live one-            let pattern = boardkind::is_pattern(&r.data) && r.data["sequence"].as_array().is_some_and(|s| !s.iter().any(|x| x == "routing"));+            let pattern = verified::pattern_run(&r.data);             let not_applicable = boardkind::finish_lines(&r.data);             if pattern {                 if let Some(why) = boardkind::pattern_refusal(&board_of(&r)).or_else(|| boardkind::pattern_spec_refusal(&spec_soft(&r), converter::calcs_of_run(&r.data).as_ref())) { missing.push(format!("pattern lane: {why} (start a run on the electrical lane)")); }@@ -4022,8 +4055,9 @@ fn main() {                 if !open.is_empty() { missing.push(format!("3D models right way up (fix or `models --ack {} --why ...`)", open.join(","))); }             }             let mut live = Value::Null;-            if let Some(target) = r.data["target"].as_str() {-                let br = Bridge { ai_thread: t.clone(), target: target.to_string() };+            let mut live_rec: Option<Value> = None;+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let br = Bridge { ai_thread: t.clone(), target: target.clone() };                 live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));                 let eda = aiflow_bridge::eda::active();                 if eda.caps().file_backed_board {@@ -4046,7 +4080,16 @@ fn main() {                     r.log("capability-gap", json!({"step": "finish", "missing": "full_drc", "eda": eda.id()}));                 }                 let drc = if eda.caps().full_drc { br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"})) } else { Value::Null };-                let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).cloned().unwrap_or_default();+                // the live result is the run's DRC evidence (issue #86): the pattern lane's DRC and connectivity+                // items read it, bound to the board it ran on; recorded even when finish refuses+                let rec = verified::live_drc_record(&live, &drc, &target, r.data["remoteBoard"].as_str().unwrap_or(""), &r.current_board(), inherited);+                if pattern && rec["clean"] != true {+                    let f = &rec["fullDrc"];+                    if f["ran"] != true { missing.push(format!("native DRC: the live full DRC returned no result ({}); finish again with the board open on {target}", f["error"].as_str().unwrap_or("no reply"))); }+                    else if f["errors"] != 0 || f["unconnected"] != 0 || f["manufacturingWarnings"] != 0 { missing.push(format!("native DRC: the live full DRC has {} error(s), {} unconnected, {} manufacturing warning(s) {}", f["errors"], f["unconnected"], f["manufacturingWarnings"], f["manufacturingWarningTypes"])); }+                }+                live_rec = Some(rec);+                let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).or(drc["data"]["violations"].as_array()).cloned().unwrap_or_default();                 let silk: Vec<String> = found.iter().filter(|v| v["type"].as_str().map(|t| aiflow_copper::SILK_BLOCKING.contains(&t)).unwrap_or(false))                     .map(|v| format!("{} ({})", v["type"].as_str().unwrap_or(""), v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).collect::<Vec<_>>().join(" / ")).unwrap_or_default())).collect();                 if !silk.is_empty() {@@ -4115,6 +4158,7 @@ fn main() {             let parts_v = o.get("parts").map(|p| p["parts"].clone()).unwrap_or(Value::Null);             let lib_parts = project_lib::report(&parts_v);             for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); }+            if let Some(rec) = live_rec.take() { r.outcome(verified::LIVE_DRC, rec); r.save().ok(); }             if !missing.is_empty() {                 err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);             }@@ -4229,15 +4273,20 @@ fn main() {                 if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { pourverify::mode_label(&o["land-pours"]) } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),                 o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);         }-        Cmd::Report { page, push, refresh, user_machine, surface } => {+        Cmd::Report { page, page_dir, push, refresh, user_machine, surface } => {             let t = thread(&cli);             let mut r = load_run(&cli);-            if let Some(p) = page { r.data["page"] = json!(p); }+            if let Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }             if let Some(m) = user_machine { r.data["userMachine"] = json!(m); }             if let Some(sf) = surface { if !matches!(sf.as_str(), "browser" | "webview") { err("--surface is browser or webview", &[]); } r.data["reportSurface"] = json!(sf); }             r.save().unwrap();-            let pg = r.data["page"].as_str().map(str::to_string).unwrap_or_else(|| err("no page for this run: `report --page <owner/slug>` once (the wiki page the run belongs to)", &[]));             if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }+            // no page (issue #85): the run README is written locally and nothing is pushed+            let Some(pg) = r.data["page"].as_str().map(str::to_string) else {+                let (_, _, readme) = build_report(&r, &dir, None);+                ok(&format!("report written locally: {} (no page for this run: nothing pushed{})", readme.display(), if *push || *refresh { "; --push and --refresh need a page" } else { "" }), &["Its media sit beside it and are referenced relatively. To publish it, name the page once: `report --page <owner/slug> [--page-dir <folder>] --push`.".into()]);+                return;+            };             let (url, notes) = publish_report(&r, &dir, &pg, *push, *refresh, &t);             if notes.iter().any(|n| n.contains("FAILED") || n.starts_with("push failed")) { err(&format!("report built but NOT published: {}", notes.join("; ")), &["Run `report --push` again: only the files that failed go up (the pushed ones are cached).".into()]); }             ok(&format!("report built for {pg}: {url}{}", if notes.is_empty() { String::new() } else { format!("; {}", notes.join("; ")) }), &["From now on every `step` and `deliver` rebuilds, pushes and refreshes it by itself.".into()]);
crates/adom-aiflow/src/pagedir.rsadded+132
@@ -0,0 +1,132 @@+//! Run pages in a folder of an existing page (issue #85). `--page owner/slug --page-dir <folder>` on+//! `start`, `intake`, `report`, `clips` and `project-page` puts everything the run pushes under that+//! folder of the page, the way a project keeps each board in its own sub-readme folder:+//!+//!   <folder>/docs/runs/<id>/README.md   the run README and its media (`report`)+//!   <folder>/docs/clips.md              the clips page, its media in <folder>/docs/clips/ (`clips`)+//!   <folder>/README.md, <folder>/pcm/   the board's README and PCM repository (`project-page`)+//!   <folder>/steps/<nn>-<step>/         the step sub-readmes (`project-page --sync`, step changes)+//!+//! Every URL the run writes carries the folder: `files/<folder>/...`, `render/<folder>/...` and the+//! report's blob paths. Without a folder everything stays at the page root, as before.+//!+//! The folder is relative to the page root: no leading slash, no `..` or `.` parts, no backslash, no+//! empty parts. `--page-dir .` sets it back to the page root.++use serde_json::{json, Value};++/// The folder as stored: trimmed, no trailing slash; None for the page root (`.`).+pub fn validate(dir: &str) -> Result<Option<String>, String> {+    let d = dir.trim();+    if d == "." { return Ok(None); }+    let d = d.trim_end_matches('/');+    let bad = |why: &str| Err(format!("--page-dir {dir:?}: {why} (a folder of the page, relative to its root, like rig/boards/rig-carrier)"));+    if d.is_empty() { return bad("empty"); }+    if d.starts_with('/') { return bad("no leading slash"); }+    if d.contains('\\') { return bad("use / between folder names"); }+    if d.contains(':') { return bad("not a drive or URL"); }+    for part in d.split('/') {+        if part.is_empty() { return bad("empty folder name (//)"); }+        if part == ".." { return bad("no .. parts"); }+        if part == "." { return bad("no . parts"); }+        if part.chars().any(|c| c.is_control()) { return bad("control characters"); }+    }+    Ok(Some(d.to_string()))+}++/// A repo path under the folder: `<dir>/<rel>`, or `rel` at the page root.+pub fn join(dir: Option<&str>, rel: &str) -> String {+    match dir.filter(|d| !d.is_empty()) { Some(d) => format!("{d}/{rel}"), None => rel.to_string() }+}++/// The wiki files viewer URL of a repo path under the folder.+pub fn files_url(page: &str, dir: Option<&str>, rel: &str) -> String {+    format!("https://wiki.adom.inc/{page}/files/{}", join(dir, rel))+}++/// The render path the wiki's widgets take (`/<owner>/<slug>/render/<repo path>`).+pub fn render_path(page: &str, dir: Option<&str>, rel: &str) -> String {+    format!("/{page}/render/{}", join(dir, rel))+}++/// The run README's folder on the page: `<dir>/docs/runs/<id>`.+pub fn run_rel(dir: Option<&str>, id: &str) -> String { join(dir, &format!("docs/runs/{id}")) }++/// The run's page and folder (`page`, `pageDir`).+pub fn run_page(data: &Value) -> Option<(String, Option<String>)> {+    data["page"].as_str().map(|p| (p.to_string(), data["pageDir"].as_str().map(str::to_string)))+}++/// The page and folder the clips go to: `clips --page` (`clipsPage`, `clipsPageDir`), else the run's.+pub fn clips_page(data: &Value) -> Option<(String, Option<String>)> {+    match data["clipsPage"].as_str() {+        Some(p) => Some((p.to_string(), data["clipsPageDir"].as_str().map(str::to_string))),+        None => run_page(data),+    }+}++/// Set a page and its folder in the run (`page_key`, `dir_key`). A new page without --page-dir drops+/// the old folder (it belonged to the old page); the same page keeps it. A folder needs a page.+pub fn set(data: &mut Value, page_key: &str, dir_key: &str, page: Option<&str>, dir: Option<&str>) -> Result<(), String> {+    let dir = dir.map(validate).transpose()?;+    if let Some(p) = page {+        if !p.contains('/') || p.starts_with('/') { return Err(format!("--page {p:?} is <owner>/<slug>")); }+        if data[page_key].as_str() != Some(p) && dir.is_none() { data[dir_key] = Value::Null; }+        data[page_key] = json!(p);+    }+    if let Some(d) = dir {+        if data[page_key].as_str().is_none() { return Err("--page-dir needs a page: --page <owner/slug> --page-dir <folder>".into()); }+        data[dir_key] = d.map(Value::from).unwrap_or(Value::Null);+    }+    Ok(())+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn folders_are_relative_and_plain() {+        assert_eq!(validate("rig/boards/rig-carrier").unwrap().as_deref(), Some("rig/boards/rig-carrier"));+        assert_eq!(validate(" rig/boards/ ").unwrap().as_deref(), Some("rig/boards"));+        assert_eq!(validate(".").unwrap(), None);+        for bad in ["", "/rig", "rig/../x", "..", "./rig", "rig//x", "rig\\x", "C:/x", "https://x"] { assert!(validate(bad).is_err(), "{bad}"); }+    }++    #[test]+    fn paths_and_urls_carry_the_folder() {+        let d = Some("rig/boards/rig-carrier");+        assert_eq!(join(d, "docs/clips.md"), "rig/boards/rig-carrier/docs/clips.md");+        assert_eq!(join(None, "docs/clips.md"), "docs/clips.md");+        assert_eq!(run_rel(d, "carrier-claude-20261021-0100"), "rig/boards/rig-carrier/docs/runs/carrier-claude-20261021-0100");+        assert_eq!(files_url("john/enc", d, "docs/clips.md"), "https://wiki.adom.inc/john/enc/files/rig/boards/rig-carrier/docs/clips.md");+        assert_eq!(render_path("john/enc", d, "docs/clips/a-10x.mp4"), "/john/enc/render/rig/boards/rig-carrier/docs/clips/a-10x.mp4");+        assert_eq!(files_url("john/enc", None, "README.md"), "https://wiki.adom.inc/john/enc/files/README.md");+    }++    #[test]+    fn setting_a_page_and_folder() {+        let mut d = json!({});+        assert!(set(&mut d, "page", "pageDir", None, Some("rig")).is_err(), "a folder needs a page");+        set(&mut d, "page", "pageDir", Some("john/enc"), Some("rig/boards/a")).unwrap();+        assert_eq!(run_page(&d), Some(("john/enc".into(), Some("rig/boards/a".into()))));+        // the same page again keeps the folder; a folder alone changes it; `.` is the root+        set(&mut d, "page", "pageDir", Some("john/enc"), None).unwrap();+        assert_eq!(d["pageDir"], "rig/boards/a");+        set(&mut d, "page", "pageDir", None, Some("rig/boards/b")).unwrap();+        assert_eq!(d["pageDir"], "rig/boards/b");+        set(&mut d, "page", "pageDir", None, Some(".")).unwrap();+        assert!(d["pageDir"].is_null());+        // another page drops the old page's folder+        set(&mut d, "page", "pageDir", None, Some("x")).unwrap();+        set(&mut d, "page", "pageDir", Some("john/other"), None).unwrap();+        assert!(d["pageDir"].is_null());+        assert!(set(&mut d, "page", "pageDir", Some("noslash"), None).is_err());+        assert!(set(&mut d, "page", "pageDir", Some("john/enc"), Some("../up")).is_err());+        // the clips page falls back to the run's page and folder+        let d = json!({"page": "john/enc", "pageDir": "rig"});+        assert_eq!(clips_page(&d), Some(("john/enc".into(), Some("rig".into()))));+        let d = json!({"page": "john/enc", "pageDir": "rig", "clipsPage": "john/clips"});+        assert_eq!(clips_page(&d), Some(("john/clips".into(), None)));+    }+}
crates/adom-aiflow/src/preboard.rs+18−8
@@ -239,8 +239,10 @@ fn when(iso: &str) -> String { /// Media refs (video src, image) are repo-root-relative (docs/clips/x-10x.mp4); plain links are /// relative to docs/ (clips/x-action.mp4), which is how the wiki's files viewer resolves them. /// `page` is the wiki page the clips go to (owner/slug): with it every clip plays in the wiki's-/// `::adom-video` widget; without it, a plain `<video>`.-pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str, page: Option<&str>) -> (String, Vec<(PathBuf, String)>) {+/// `::adom-video` widget; without it, a plain `<video>`. `page_dir` is the page's folder the clips+/// page lives in (issue #85, pagedir.rs): the repo paths and render paths carry it; the plain links+/// stay relative to <folder>/docs/.+pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str, page: Option<&str>, page_dir: Option<&str>) -> (String, Vec<(PathBuf, String)>) {     let t0 = data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse);     let mut caps: Vec<Value> = data["captures"].as_array().cloned().unwrap_or_default().into_iter().filter(|c| c["file"].is_string() && c.get("discarded").map(|d| d.is_null()).unwrap_or(true)).collect();     caps.sort_by(|a, b| a["start"].as_str().unwrap_or("").cmp(b["start"].as_str().unwrap_or("")));@@ -264,14 +266,14 @@ pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str, page:         let mut have = BTreeMap::new();         for suffix in ["-10x.mp4", "-action.mp4", "-sheet.png"] {             let src = dir.join(format!("{stem}{suffix}"));-            if src.is_file() { let rel = format!("docs/clips/{stem}{suffix}"); media.push((src, rel.clone())); have.insert(suffix, rel); }+            if src.is_file() { let rel = crate::pagedir::join(page_dir, &format!("docs/clips/{stem}{suffix}")); media.push((src, rel.clone())); have.insert(suffix, rel); }         }         // the wiki renders <video> with preload="none": without a poster every clip is an empty box until         // clicked, so each 10x cut gets a still (a frame 40 percent in) as its poster         if have.contains_key("-10x.mp4") {             let poster = dir.join(format!("{stem}-poster.jpg"));             if !poster.is_file() { make_poster(&dir.join(format!("{stem}-10x.mp4")), &poster); }-            if poster.is_file() { let rel = format!("docs/clips/{stem}-poster.jpg"); media.push((poster, rel.clone())); have.insert("-poster.jpg", rel); }+            if poster.is_file() { let rel = crate::pagedir::join(page_dir, &format!("docs/clips/{stem}-poster.jpg")); media.push((poster, rel.clone())); have.insert("-poster.jpg", rel); }         }         let mut state: Vec<String> = Vec::new();         if let Some(s) = c["suspect"].as_str() { state.push(format!("suspect: {s}")); }@@ -308,8 +310,8 @@ pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str, page:             }             b.push(String::new());         }-        if let (Some(pg), true) = (page, have.contains_key("-action.mp4")) {-            b.push(format!("::adom-video{{video=\"/{pg}/render/docs/clips/{stem}-action.mp4\" title=\"Clip {n}: {} (action cut)\" muted=\"true\"}}", title.replace('"', "'")));+        if let (Some(pg), Some(rel)) = (page, have.get("-action.mp4")) {+            b.push(format!("::adom-video{{video=\"/{pg}/render/{rel}\" title=\"Clip {n}: {} (action cut)\" muted=\"true\"}}", title.replace('"', "'")));             b.push(String::new());         }         if have.contains_key("-action.mp4") && page.is_none() { b.push(format!("Motion-only action cut: [docs/clips/{stem}-action.mp4](clips/{stem}-action.mp4)")); b.push(String::new()); }@@ -472,8 +474,16 @@ mod tests {             {"file": "./run/window-1-20260929-010000.mp4", "step": "placement", "visit": 1, "start": "2026-09-29T01:00:00Z", "stop": "2026-09-29T01:10:00Z", "suspect": "8 of 8 flat"},             {"file": "./run/window-2.mp4", "step": "3d", "visit": 1, "start": "2026-09-29T01:30:00Z", "discarded": "editor clip"}]});         let notes = json!({"window-1-20260929-010000": {"step": "Placement", "shows": "parts land", "state": "ok"}});-        let (md, media) = clips_page(&data, &dir, &notes, "2026-09-29T03:00:00Z", None);-        let (wmd, _) = clips_page(&data, &dir, &notes, "2026-09-29T03:00:00Z", Some("john/b"));+        let (md, media) = clips_page(&data, &dir, &notes, "2026-09-29T03:00:00Z", None, None);+        let (wmd, _) = clips_page(&data, &dir, &notes, "2026-09-29T03:00:00Z", Some("john/b"), None);+        // issue #85: in a folder of the page, every repo path and render path carries the folder+        let (fmd, fmedia) = clips_page(&data, &dir, &notes, "2026-09-29T03:00:00Z", Some("john/b"), Some("rig/boards/a"));+        assert!(fmd.contains("::adom-video{video=\"/john/b/render/rig/boards/a/docs/clips/window-1-20260929-010000-10x.mp4\""), "{fmd}");+        assert!(fmd.contains("/john/b/render/rig/boards/a/docs/clips/window-1-20260929-010000-action.mp4"), "{fmd}");+        assert!(fmd.contains("![clip 1 contact sheet](rig/boards/a/docs/clips/window-1-20260929-010000-sheet.png)"), "{fmd}");+        assert!(fmd.contains("](clips/window-1-20260929-010000-action.mp4)"), "plain links stay relative to <folder>/docs/: {fmd}");+        assert!(fmedia.iter().all(|(_, rel)| rel.starts_with("rig/boards/a/docs/clips/")), "{fmedia:?}");+        assert!(!fmd.contains("render/docs/"), "{fmd}");         assert!(wmd.contains("::adom-video{video=\"/john/b/render/docs/clips/window-1-20260929-010000-10x.mp4\""), "{wmd}");         assert!(wmd.contains("-action.mp4\" title="), "{wmd}");         assert!(!wmd.contains("<video"), "{wmd}");
crates/adom-aiflow/src/verified.rs+143−7
@@ -6,7 +6,83 @@ use serde_json::{json, Map, Value};  #[derive(Debug, Clone, PartialEq)]-pub enum State { Yes, No(String), Unknown(String) }+pub enum State { Yes, No(String), Unknown(String), NotApplicable(String) }++/// The outcome key of finish's live DRC on the bound board (issue #86): the routing validate and+/// KiCad's full DRC with warnings, recorded with the board it ran on. The pattern lane has no gate,+/// so its DRC and connectivity items come from this record and nothing else.+pub const LIVE_DRC: &str = "live-drc";++fn sha256_file(p: &str) -> String {+    use sha2::{Digest, Sha256};+    std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()+}++/// The record finish stores as `live-drc`: `validate` is the bridge's routing validate reply,+/// `drc` its full DRC reply (Null when the EDA has no full DRC). Counts are taken as given; a reply+/// with no count leaves the field null, which never reads as clean.+pub fn live_drc_record(validate: &Value, drc: &Value, target: &str, remote_board: &str, local_board: &str, inherited: u64) -> Value {+    let d = if drc["data"].is_object() { &drc["data"] } else { drc };+    let ran = !drc.is_null() && drc["success"] != false && (d["violations"].is_array() || d["summary"].is_object());+    let listed: Vec<Value> = d["violations"].as_array().or(d["summary"]["first"].as_array()).cloned().unwrap_or_default();+    let total = d["summary"]["total"].as_u64().unwrap_or(listed.len() as u64);+    let complete = ran && listed.len() as u64 >= total;+    let is_unconnected = |v: &Value| v["type"] == "unconnected_items";+    let unconnected = listed.iter().filter(|v| is_unconnected(v)).count() as u64 + d["unconnected_items"].as_array().map(|a| a.len() as u64).unwrap_or(0);+    let errors = listed.iter().filter(|v| v["severity"] == "error" && !is_unconnected(v)).count() as u64;+    let warnings = listed.iter().filter(|v| v["severity"] == "warning").count() as u64;+    let manuf: Vec<&Value> = listed.iter().filter(|v| v["severity"] == "warning" && aiflow_copper::blocking_violation(v)).collect();+    let full = json!({+        "ran": ran, "complete": complete, "listed": listed.len(), "total": total,+        "errors": d["summary"]["errors"].as_u64().unwrap_or(errors).max(errors), "unconnected": unconnected,+        "warnings": d["summary"]["warnings"].as_u64().unwrap_or(warnings).max(warnings), "manufacturingWarnings": manuf.len(),+        "manufacturingWarningTypes": manuf.iter().filter_map(|v| v["type"].as_str()).collect::<std::collections::BTreeSet<_>>(),+        "error": if ran { Value::Null } else { drc["error"].clone() },+    });+    let clean = validate["errors"].as_u64() == Some(0) && validate["unconnected"].as_u64() == Some(0)+        && ran && complete && full["errors"] == 0 && full["unconnected"] == 0 && full["manufacturingWarnings"] == 0;+    json!({+        "source": "live", "at": aiflow_run::now(), "target": target, "remoteBoard": remote_board,+        "board": local_board, "boardSha256": sha256_file(local_board),+        "errors": validate["errors"], "unconnected": validate["unconnected"], "warnings": validate["warnings"], "inheritedErrors": inherited,+        "fullDrc": full, "clean": clean,+    })+}++/// The run is on the pattern lane: a pattern board whose sequence has no routing step.+pub fn pattern_run(data: &Value) -> bool {+    crate::boardkind::is_pattern(data) && data["sequence"].as_array().is_some_and(|s| !s.iter().any(|x| x == "routing"))+}++/// The pattern lane's connectivity and DRC items, from the recorded live DRC only.+fn live_items(rec: Option<&Value>, data: &Value) -> (State, State) {+    let Some(rec) = rec.filter(|r| r["source"] == "live") else {+        let why = "no live full DRC recorded (finish runs it on the board bound on a test box)";+        return (State::Unknown(why.into()), State::Unknown(why.into()));+    };+    let current = data["boards"].as_array().and_then(|a| a.last()).and_then(|b| b.as_str()).unwrap_or("");+    let sha = rec["boardSha256"].as_str().unwrap_or("");+    if sha.is_empty() || rec["board"].as_str() != Some(current) || sha256_file(current) != sha {+        let why = "the live DRC was recorded on another board than the current one (finish again)";+        return (State::Unknown(why.into()), State::Unknown(why.into()));+    }+    let full = &rec["fullDrc"];+    let unc = match (rec["unconnected"].as_u64(), full["unconnected"].as_u64()) {+        (Some(a), Some(b)) if full["ran"] == true => yes_no(Some(a == 0 && b == 0), "unconnected items remain (live DRC)", ""),+        (Some(a), _) if a > 0 => State::No("unconnected items remain (live DRC)".into()),+        _ => State::Unknown("the live full DRC did not report unconnected items".into()),+    };+    let drc = if full["ran"] != true {+        State::Unknown(format!("the live full DRC did not run ({})", full["error"].as_str().unwrap_or("no result")))+    } else if rec["errors"].as_u64().unwrap_or(1) > 0 || full["errors"].as_u64().unwrap_or(1) > 0 {+        State::No(format!("DRC errors remain (live: {} routing validate, {} full DRC)", rec["errors"], full["errors"]))+    } else if full["manufacturingWarnings"].as_u64().unwrap_or(1) > 0 {+        State::No(format!("manufacturing warnings in the live full DRC ({})", full["manufacturingWarningTypes"]))+    } else if full["complete"] != true {+        State::Unknown("the live full DRC listed only part of its findings".into())+    } else { State::Yes };+    (unc, drc)+}  #[derive(Debug, Clone)] pub struct Item { pub name: &'static str, pub state: State }@@ -25,12 +101,18 @@ pub fn from_run(outcomes: &Map<String, Value>, data: &Value, mech: Option<(bool,         (Some(_), None) => State::Unknown("netlist check not run".into()),         (None, None) => State::Unknown("no schematic in the run".into()),     };-    let connectivity = gate.map(|g| yes_no(g["unconnected"].as_u64().map(|u| u == 0), "unconnected items remain", "not recorded")).unwrap_or(State::Unknown("gate not run".into()));+    let pattern = pattern_run(data);+    // a pattern board with no schematic: parity, ERC and symbols do not apply+    let no_sch = pattern && data["schematic"].as_str().is_none() && netlist.is_none();+    let parity = if no_sch { State::NotApplicable("pattern board, no schematic".into()) } else { parity };+    let (live_conn, live_drc) = live_items(outcomes.get(LIVE_DRC), data);+    let connectivity = if pattern { live_conn } else { gate.map(|g| yes_no(g["unconnected"].as_u64().map(|u| u == 0), "unconnected items remain", "not recorded")).unwrap_or(State::Unknown("gate not run".into())) };     let erc_rec = outcomes.get("erc").map(|e| e["pass"].as_bool().or(e["ok"].as_bool()))         .or_else(|| outcomes.get("schematic").filter(|s| s["ercErrors"].is_array()).map(|s| Some(s["ercErrors"].as_array().map(|a| a.is_empty()).unwrap_or(false))))         .or_else(|| data["evidence"].as_array().and_then(|a| a.iter().rev().find(|e| e["kind"] == "erc")).map(|e| e["pass"].as_bool()));-    let erc = match erc_rec { Some(p) => yes_no(p, "ERC failed", "ERC result states no pass"), None => State::Unknown("no ERC result recorded in the run".into()) };-    let drc = gate.map(|g| yes_no(g["newErrors"].as_u64().map(|n| n == 0), "DRC errors remain", "not recorded")).unwrap_or(State::Unknown("gate not run".into()));+    let erc = match erc_rec { Some(p) => yes_no(p, "ERC failed", "ERC result states no pass"), None if no_sch => State::NotApplicable("pattern board, no schematic".into()), None => State::Unknown("no ERC result recorded in the run".into()) };+    // the pattern lane skips the gate: its DRC is finish's recorded live full DRC, never assumed+    let drc = if pattern { live_drc } else { gate.map(|g| yes_no(g["newErrors"].as_u64().map(|n| n == 0), "DRC errors remain", "not recorded")).unwrap_or(State::Unknown("gate not run".into())) };     let gerbers = outcomes.get("export").map(|e| yes_no(e["fillsGerbers"]["pass"].as_bool(), "a poured layer has no Gerber regions", "export predates this check; run export again")).unwrap_or(State::Unknown("not exported yet (run export)".into()));     vec![         Item { name: "schematic parity", state: parity },@@ -40,6 +122,7 @@ pub fn from_run(outcomes: &Map<String, Value>, data: &Value, mech: Option<(bool,         Item { name: "schematic symbols and pin types", state: match (outcomes.get(crate::schematic_symbols::OUTCOME), data["schematic"].as_str()) {             (Some(c), _) => yes_no(c["ok"].as_bool(), "symbol polarity or pin-type errors (schematic-check.json)", "not recorded"),             (None, Some(_)) => State::Unknown("schematic check not run".into()),+            (None, None) if no_sch => State::NotApplicable("pattern board, no schematic".into()),             (None, None) => State::Unknown("no schematic in the run".into()),         } },         Item { name: "courtyards", state: mech.map(|m| yes_no(Some(m.0), "missing or overlapping courtyards", "")).unwrap_or(State::Unknown("not checked".into())) },@@ -49,7 +132,8 @@ pub fn from_run(outcomes: &Map<String, Value>, data: &Value, mech: Option<(bool,     ] } -pub fn all_passed(items: &[Item]) -> bool { items.iter().all(|i| i.state == State::Yes) }+/// Every item passed; an item that does not apply (a pattern board's schematic items) counts as passed.+pub fn all_passed(items: &[Item]) -> bool { items.iter().all(|i| matches!(i.state, State::Yes | State::NotApplicable(_))) }  /// One line: "verified: connectivity yes, ERC not known (...), ...". pub fn line(items: &[Item]) -> String {@@ -57,6 +141,7 @@ pub fn line(items: &[Item]) -> String {         State::Yes => format!("{} yes", i.name),         State::No(w) => format!("{} NO ({w})", i.name),         State::Unknown(w) => format!("{} not known ({w})", i.name),+        State::NotApplicable(w) => format!("{} not applicable ({w})", i.name),     }).collect();     format!("verified: {}", parts.join(", ")) }@@ -66,7 +151,7 @@ pub fn verdict(items: &[Item]) -> String {     if all_passed(items) {         "Every listed check passed. This is not a fabrication sign-off: a human review of the board, the BOM and the fab files is still advised before ordering.".into()     } else {-        let open: Vec<&str> = items.iter().filter(|i| i.state != State::Yes).map(|i| i.name).collect();+        let open: Vec<&str> = items.iter().filter(|i| !matches!(i.state, State::Yes | State::NotApplicable(_))).map(|i| i.name).collect();         format!("Not cleared for fabrication: {} not verified. Do not tell the human the board is ready to fabricate.", open.join(", "))     } }@@ -74,7 +159,7 @@ pub fn verdict(items: &[Item]) -> String { pub fn to_json(items: &[Item]) -> Value {     let mut m = Map::new();     for i in items {-        m.insert(i.name.to_string(), match &i.state { State::Yes => json!("yes"), State::No(w) => json!({"state": "no", "why": w}), State::Unknown(w) => json!({"state": "not known", "why": w}) });+        m.insert(i.name.to_string(), match &i.state { State::Yes => json!("yes"), State::No(w) => json!({"state": "no", "why": w}), State::Unknown(w) => json!({"state": "not known", "why": w}), State::NotApplicable(w) => json!({"state": "not applicable", "why": w}) });     }     json!({"items": m, "allPassed": all_passed(items), "verdict": verdict(items)}) }@@ -181,6 +266,57 @@ mod tests {         assert!(v.starts_with("Not cleared for fabrication") && v.contains("ERC") && v.contains("body overlap"), "{v}");     } +    /// Issue #86: a pattern board's DRC and connectivity come from finish's recorded live DRC only;+    /// a clean one with a passing export reads cleared, and nothing is assumed without the record.+    #[test]+    fn pattern_lane_reads_the_recorded_live_drc() {+        let dir = std::env::temp_dir().join(format!("verified-live-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let board = dir.join("disc.kicad_pcb"); std::fs::write(&board, "(kicad_pcb)").unwrap();+        let b = board.display().to_string();+        let data = json!({"kind": "pattern", "sequence": ["intake", "rules", "models", "outline", "silkscreen", "3d", "finish", "export"], "boards": [b]});+        let exported = |extra: Option<Value>| { let mut o: Map<String, Value> = serde_json::from_value(json!({"export": {"fillsGerbers": {"pass": true}}})).unwrap(); if let Some(e) = extra { o.insert(LIVE_DRC.into(), e); } o };+        let st = |items: &[Item], n: &str| items.iter().find(|i| i.name == n).unwrap().state.clone();+        // no record: DRC and connectivity not known, never assumed+        let items = from_run(&exported(None), &data, Some((true, true)), Some(true));+        assert!(matches!(st(&items, "DRC"), State::Unknown(ref w) if w.contains("no live full DRC")), "{}", line(&items));+        assert!(verdict(&items).starts_with("Not cleared"));+        assert_eq!(st(&items, "schematic parity"), State::NotApplicable("pattern board, no schematic".into()));+        // a clean live result: errors, unconnected and manufacturing warnings all zero+        let validate = json!({"errors": 0, "unconnected": 0, "warnings": 1});+        let drc = json!({"success": true, "data": {"violations": [{"type": "lib_footprint_mismatch", "severity": "warning"}], "summary": {"total": 1, "errors": 0, "warnings": 1}}});+        let rec = live_drc_record(&validate, &drc, "box", "C:/b/disc.kicad_pcb", &b, 0);+        assert_eq!(rec["source"], "live"); assert!(rec["at"].is_string()); assert_eq!(rec["clean"], true, "{rec}");+        assert_eq!(rec["fullDrc"]["warnings"], 1); assert_eq!(rec["fullDrc"]["manufacturingWarnings"], 0);+        let items = from_run(&exported(Some(rec.clone())), &data, Some((true, true)), Some(true));+        assert!(all_passed(&items), "{}", line(&items));+        assert!(line(&items).contains("DRC yes") && line(&items).contains("ERC not applicable"), "{}", line(&items));+        assert!(verdict(&items).contains("not a fabrication sign-off"));+        // the electrical lane still reads the gate, not the live record+        let elec = json!({"sequence": ["routing"], "boards": [b]});+        assert!(matches!(st(&from_run(&exported(Some(rec.clone())), &elec, Some((true, true)), Some(true)), "DRC"), State::Unknown(ref w) if w == "gate not run"));+        // a manufacturing warning (silkscreen the fab clips) is a no+        let bad = json!({"violations": [{"type": "silk_over_copper", "severity": "warning"}]});+        let rec2 = live_drc_record(&validate, &bad, "box", "r", &b, 0);+        assert_eq!(rec2["clean"], false);+        assert!(matches!(st(&from_run(&exported(Some(rec2)), &data, Some((true, true)), Some(true)), "DRC"), State::No(ref w) if w.contains("silk_over_copper")));+        // unconnected in the full DRC is a connectivity no+        let unc = json!({"violations": [{"type": "unconnected_items", "severity": "error"}]});+        let items = from_run(&exported(Some(live_drc_record(&validate, &unc, "box", "r", &b, 0))), &data, Some((true, true)), Some(true));+        assert!(matches!(st(&items, "connectivity"), State::No(_)));+        // no full DRC reply: not known+        let items = from_run(&exported(Some(live_drc_record(&validate, &json!({"success": false, "error": "busy"}), "box", "r", &b, 0))), &data, Some((true, true)), Some(true));+        assert!(matches!(st(&items, "DRC"), State::Unknown(ref w) if w.contains("busy")), "{}", line(&items));+        // a truncated list with warnings beyond it: not known+        let part = json!({"summary": {"total": 5, "errors": 0, "warnings": 5, "first": [{"type": "lib_footprint_mismatch", "severity": "warning"}]}});+        assert!(matches!(st(&from_run(&exported(Some(live_drc_record(&validate, &part, "box", "r", &b, 0))), &data, Some((true, true)), Some(true)), "DRC"), State::Unknown(_)));+        // the board changed after the record: not known+        std::fs::write(&board, "(kicad_pcb (changed))").unwrap();+        let items = from_run(&exported(Some(rec)), &data, Some((true, true)), Some(true));+        assert!(matches!(st(&items, "DRC"), State::Unknown(ref w) if w.contains("another board")), "{}", line(&items));+        let _ = std::fs::remove_dir_all(&dir);+    }+     #[test]     fn all_passed_still_advises_human_review() {         let o: Map<String, Value> = serde_json::from_value(json!({"gate": {"newErrors": 0, "unconnected": 0}, "netlist": {"ok": true}, "schematic": {"pass": true, "ercErrors": []}, "schematic-check": {"ok": true}, "export": {"fillsGerbers": {"pass": true}}})).unwrap();
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.15",+  "version": "0.2.16",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.15",+  "version": "0.2.16",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+15−5
@@ -33,7 +33,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test- 1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. 2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). `place pack` lays each `spec.chains` entry (a series chain in electrical order, such as a divider) on one line with the shared-net pads facing and says which chains were honoured; when a wish lists several rotations it picks the one whose pads land nearest their net partners. Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's. 1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--sch design.kicad_sch]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. `--sch` records the schematic the board came from, so `finish` requires a passing `netlist check`.-1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--page <owner/slug>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. When the work begins before any board exists, open the run with `intake` instead (next section); `start` on that run attaches the board and keeps its clock.+1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--page <owner/slug> [--page-dir <folder>]]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. When the work begins before any board exists, open the run with `intake` instead (next section); `start` on that run attaches the board and keeps its clock. 2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's. 3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it. 4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.@@ -87,7 +87,15 @@ the board on a test box), and prints each electrical step as not applicable with finish refuse the lane for a board that needs routing: footprint pads on more than one net, on a net no zone carries, tracks or vias on such a net, or a spec with loads, hot parts, signal checks or a converter. Pads with no net or only on the single pour net (machine pins on a GND plate) are allowed; a sourcing-profile still applies to any footprint in the BOM.+profile still applies to any footprint in the BOM. A net with exactly one pad and no tracks, vias or zones+on it is no net for the lane (0.2.16, issue #86): KiCad's `unconnected-(REF-PadN)` nets on floating seats+pass; a second pad on such a net, or copper on it, makes it a net to connect. finish records its live+DRC as the run's `live-drc` outcome (the routing validate's errors and unconnected, the full DRC's errors,+unconnected and manufacturing warnings, the board and its sha256, the time) and, on this lane, refuses+unless the full DRC returned and is clean. The verified summary reads its DRC and connectivity items from+that record only (bound to the current board; no record, not known), and schematic parity, ERC and symbols+as not applicable when the run has no schematic, so a clean pattern board with a passing export reads+cleared.  On the KiCad lane, `silkscreen-audit --input <audit.json> --out <result.json> --record` records the silkscreen proof only when the audit passes and its inventory covers the board: every footprint by its@@ -229,7 +237,7 @@ everything drawn on the design. The lane's steps are the Fusion lane's, with `al  The clock starts at the prompt even when there is no board yet. -- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>] [--eda kicad|fusion|altium]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.+- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug> [--page-dir <folder>]] [--eda kicad|fusion|altium]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page. - **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`.@@ -338,9 +346,11 @@ The number is the human's prompt to your "done, here is your video" (`deliver`),  ## The run's own page, as it runs -`report --page <owner/slug> --user-machine <box> --push --refresh` once, early (`--surface webview` instead of a machine when you run inside Adom Hydrogen: the page then lives in a workspace tab named "AI Flow run" and reloads there). From then on every `step` and `deliver` rebuilds the run's sub-README (per-step times, returns and why, decisions, every step visit's 10x clip and its start and end screenshots, where the board stands, what was delivered, what was given back), pushes it to `docs/runs/<id>/` on that page's repo, and reloads it in the human's browser on their machine, so they watch the run progress for hours without asking. The rendered view is `https://wiki.adom.inc/<owner>/<slug>/files/docs/runs/<id>/README.md`; the same path without `files/` is raw markdown.+`report --page <owner/slug> --user-machine <box> --push --refresh` once, early (`--surface webview` instead of a machine when you run inside Adom Hydrogen: the page then lives in a workspace tab named "AI Flow run" and reloads there). From then on every `step` and `deliver` rebuilds the run's sub-README (per-step times, returns and why, decisions, every step visit's 10x clip and its start and end screenshots, where the board stands, what was delivered, what was given back), pushes it to `docs/runs/<id>/` on that page's repo, and reloads it in the human's browser on their machine, so they watch the run progress for hours without asking. The rendered view is `https://wiki.adom.inc/<owner>/<slug>/files/docs/runs/<id>/README.md`; the same path without `files/` is raw markdown. With no page, `report` writes the README locally to `<run>/report/README.md` (its media beside it, referenced relatively) and pushes nothing. -The live clips page is the binary's too: `clips --page <owner/slug>` rebuilds `docs/clips.md` in `<run>/clips-page` (a table of every clip with its step, visit, time, links and suspect state, then one block per clip: the 10x cut as a video, the action cut, the contact sheet, and the note from `<run>/clip-notes.json`) and says what it would push; `--push` pushes it and the changed clip media with `adom-wiki repo push --files`. Once the run has a page (`start --page`, `intake --page`, `report --page` or `clips --page`), every clip stop rebuilds and pushes it by itself, best effort (a failed push is said, never a failed step). It refuses to push a page with an em-dash or a match of the regex in `$AIFLOW_BANNED_RE`. Open the rendered page for the human once and link it from the README (aiflow-live-clips).+**A folder of an existing page (0.2.16, issue #85).** One page per project: put a board's run in its folder with `--page <owner/slug> --page-dir <folder>` on `start`, `intake`, `report` or `clips` (relative, like `rig/boards/rig-carrier`; no leading slash, no `..`; `.` is the page root again). The run stores it, and every push lands under the folder: the run README at `<folder>/docs/runs/<id>/`, the clips page at `<folder>/docs/clips.md` with its media in `<folder>/docs/clips/`; every URL the run writes (`files/<folder>/...`, the video widget's `render/<folder>/...`, the report's media paths) carries it. A new `--page` without `--page-dir` drops the old folder; `clips --page-dir` alone moves only the clips page. `project-page --page <owner/slug> --page-dir <folder>` writes the board's README and PCM repository into that folder of an existing page (it never creates a page; `--existing` is implied, `--create` is refused), and the step pages go to `<folder>/steps/`.++The live clips page is the binary's too: `clips --page <owner/slug>` rebuilds `docs/clips.md` in `<run>/clips-page` (a table of every clip with its step, visit, time, links and suspect state, then one block per clip: the 10x cut as a video, the action cut, the contact sheet, and the note from `<run>/clip-notes.json`) and says what it would push; `--push` pushes it and the changed clip media with `adom-wiki repo push --files`. With `--page-dir <folder>` it is `<folder>/docs/clips.md` instead. Once the run has a page (`start --page`, `intake --page`, `report --page` or `clips --page`), every clip stop rebuilds and pushes it by itself, best effort (a failed push is said, never a failed step). It refuses to push a page with an em-dash or a match of the regex in `$AIFLOW_BANNED_RE`. Open the rendered page for the human once and link it from the README (aiflow-live-clips).  ## Give it back 
skills/aiflow-live-clips/SKILL.md+1
@@ -16,6 +16,7 @@ This is your job after every step, not at the end. Never batch it. - `clips --page ...` without `--push` is the dry run: it builds the page in `<run>/clips-page` and lists what it would push. - Link `docs/clips.md` from the README's feature-guides table once (wiki-sub-readme); the binary pushes only the clips page and its media, never the README. - Open the rendered page for the human ONCE, in the surface they use (a pup window on their machine, or a Hydrogen webview tab), and tell them the URL in one line: `https://wiki.adom.inc/<owner>/<slug>/files/docs/clips.md`. Do not reopen it at every push; the page reloads.+- A board that lives in a folder of the project's page (one page per project): add `--page-dir <folder>` (for example `rig/boards/rig-carrier`) where you name the page. The clips page is then `<folder>/docs/clips.md`, its media `<folder>/docs/clips/`, and the media paths and the video widget's render paths carry the folder (`<folder>/docs/clips/<stem>-10x.mp4`, `/<owner>/<slug>/render/<folder>/docs/clips/...`); plain links stay relative to `<folder>/docs/`. The URL to give the human is `https://wiki.adom.inc/<owner>/<slug>/files/<folder>/docs/clips.md`.  ## 2. After every step 
skills/aiflow-project-page/SKILL.md+2
@@ -9,6 +9,8 @@ user-invocable: true  `adom-aiflow project-page --page <owner/slug> --create [--brief <text or file>]` makes one Cloud Library per board and keeps its `lib_` id in the run; a rerun reuses it and never makes a second library of the same name (when duplicates already exist it uses the fullest and says which to delete). Rate-limited adds (HTTP 429, `retry_after`) are retried with backoff (`ADOM_AIFLOW_WIKI_ATTEMPTS`, `ADOM_AIFLOW_WIKI_MAX_WAIT`). The step is an ERROR, and nothing is published, when the exported library is short of any BOM part (per part: its symbol is in the library, the symbol's Footprint names a footprint file in it, and that footprint's model file is in it; parts may share a footprint or a model, and a part marked `noModelExpected` needs none; the error names each part by MPN and references) or when the packaged symbol library does not load: the header is written as version 20241209 (KiCad 9 format, read by KiCad 10) and the file is parse-checked, with `kicad-cli sym upgrade` through `ADOM_AIFLOW_KICAD_CLI` when available. `--brief` takes text or a path to a file whose contents are used. Parts from the board's project library (`parts --project-lib`) are packaged beside the wiki parts. +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 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.
tests/board-kind-workflow.py+14−2
@@ -22,9 +22,21 @@ with tempfile.TemporaryDirectory(prefix='aiflow-board-kind-') as td:     assert call(p / 'x', 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test', '--kind', 'pattern', '--eda', 'fusion').returncode != 0, 'pattern is KiCad only'     run = p / 'pattern'     r = call(run, 'intake', '--prompt-time', '2026-10-10T00:00:00Z', '--engine', 'test', '--kind', 'pattern'); assert r.returncode == 0, r.stderr-    routed = p / 'routed.kicad_pcb'; routed.write_text(HEAD + pin('MP1', 5, 'GND') + pin('J1', 15, 'SIG') + ')\n')+    routed = p / 'routed.kicad_pcb'; routed.write_text(HEAD + pin('MP1', 5, 'GND') + pin('MP2', 3, 'GND') + pin('J1', 15, 'SIG') + pin('J2', 17, 'SIG') + ')\n')     attach(run, routed, spec)     r = call(run, 'step', 'rules'); assert r.returncode != 0 and '2 nets' in r.stdout + r.stderr, (r.stdout, r.stderr)+    # #86: a carrier plate, 4 machine pins on a GND pour and 8 one-pad unconnected-(SDn-Pad1) seats+    nets = ''.join(f' (net {i + 1} "unconnected-(SD{i}-Pad1)")' for i in range(1, 9))+    seat = lambda ref, x, n, name: f' (footprint "MC" (layer "F.Cu") (at {x} 12) (property "Reference" "{ref}") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net {n} "{name}")))'+    seats = ''.join(seat(f'SD{i}', 2 + 2 * i, i + 1, f'unconnected-(SD{i}-Pad1)') for i in range(1, 9))+    pins = ''.join(pin(f'MP{i}', x, 'GND') for i, x in enumerate([3, 7, 13, 17], 1))+    zone = ' (zone (net 1) (net_name "GND") (layer "B.Cu") (uuid "z1") (polygon (pts (xy 1 1) (xy 19 1) (xy 19 19) (xy 1 19))))'+    carrier = p / 'carrier.kicad_pcb'; carrier.write_text(HEAD.replace('(net 2 "SIG")', nets) + pins + seats + zone + ')\n')+    attach(run, carrier, spec)+    r = call(run, 'step', 'rules'); assert r.returncode == 0, (r.stdout, r.stderr)+    carrier.write_text(HEAD.replace('(net 2 "SIG")', nets) + pins + seats + seat('SD9', 18, 4, 'unconnected-(SD3-Pad1)') + zone + ')\n')+    r = call(run, 'step', 'rules'); out = r.stdout + r.stderr+    assert r.returncode != 0 and '2 nets' in out and 'unconnected-(SD3-Pad1)' in out, out     disc = p / 'disc.kicad_pcb'; disc.write_text(HEAD + ' (gr_circle (center 10 10) (end 14 10) (layer "F.Cu") (width 0.5)))\n')     attach(run, disc, spec)     r = call(run, 'step', 'rules'); assert r.returncode == 0, (r.stdout, r.stderr)@@ -68,4 +80,4 @@ with tempfile.TemporaryDirectory(prefix='aiflow-board-kind-') as td:     (run / 'run.json').write_text(json.dumps(d))     r = call(run, 'project-page', '--page', 'someone/board', '--no-push'); out = r.stdout + r.stderr     assert r.returncode != 0 and 'part RC0402FR-0733KL is in the parts record but not in the BOM' in out and 'run `parts` again' in out.replace('Run `parts` again', 'run `parts` again'), out-print('PASS: pattern lane chosen and refused for a routed board, finish skips the electrical gates and asks for live DRC; fields n/a recorded and refused at finish once the spec names a load; silkscreen-audit names the bad item; stale parts record named')+print('PASS: pattern lane chosen and refused for a routed board, one-pad unconnected nets are no nets, finish skips the electrical gates and asks for live DRC; fields n/a recorded and refused at finish once the spec names a load; silkscreen-audit names the bad item; stale parts record named')
tests/page-dir-workflow.pyadded+109
@@ -0,0 +1,109 @@+"""Issue #85 through the CLI: `--page owner/slug --page-dir <folder>` puts every push of the run under+that folder (the run README at <folder>/docs/runs/<id>/, the clips page at <folder>/docs/clips.md, its+media in <folder>/docs/clips/), the URLs it writes carry the folder, a bad folder is refused, and+`report` with no page writes the README locally and pushes nothing. adom-wiki is a fake on PATH that+records every `repo push --files` and checks each file exists in the push's working directory.+No CAD, desktop or dashboard.++  python3 tests/page-dir-workflow.py <adom-aiflow binary>+"""+import json, os, pathlib, subprocess, sys, tempfile+binary = str(pathlib.Path(sys.argv[1]).resolve())++FAKE_WIKI = r'''#!/usr/bin/env python3+import json, os, sys+log = os.environ["FAKE_WIKI_LOG"]+a = sys.argv[1:]+rec = {"args": a, "cwd": os.getcwd()}+if a[:2] == ["repo", "push"]:+    i = a.index("--files"); files = a[i + 1:]+    if "-m" in files: files = files[:files.index("-m")]+    rec["files"] = files; rec["missing"] = [f for f in files if not os.path.isfile(os.path.join(os.getcwd(), f))]+    rec["texts"] = {f: open(os.path.join(os.getcwd(), f)).read() for f in files if f.endswith(".md") and f not in rec["missing"]}+open(log, "a").write(json.dumps(rec) + "\n")+if a[:2] == ["repo", "push"]:+    print(json.dumps({"status": "ok", "commit": "abc1234"})); sys.exit(1 if rec["missing"] else 0)+if a[:2] == ["repo", "show"]:+    sys.exit(1)+sys.exit(0)+'''++with tempfile.TemporaryDirectory(prefix='aiflow-page-dir-') as td:+    p = pathlib.Path(td)+    b = p / 'bin'; b.mkdir()+    (b / 'adom-wiki').write_text(FAKE_WIKI); (b / 'adom-wiki').chmod(0o755)+    log = p / 'wiki.jsonl'+    env = dict(os.environ, AIFLOW_DASHBOARD='off', PATH=f"{b}:{os.environ['PATH']}", FAKE_WIKI_LOG=str(log))+    def call(run, *args):+        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'page-dir-test', *args], env=env, capture_output=True, text=True, timeout=60)+    def pushes():+        return [json.loads(l) for l in log.read_text().splitlines() if '"push"' in l] if log.exists() else []+    def data(run): return json.loads((run / 'run.json').read_text())++    # a bad folder is refused before a run is made+    for bad in ['/rig', 'rig/../x', 'rig//x']:+        r = call(p / 'bad', 'intake', '--prompt-time', '2026-10-21T01:00:00Z', '--engine', 'test', '--page', 'john/enc', '--page-dir', bad)+        assert r.returncode != 0 and '--page-dir' in r.stdout + r.stderr, (bad, r.stdout, r.stderr)+        assert not (p / 'bad' / 'run.json').exists()+    r = call(p / 'nopage', 'intake', '--prompt-time', '2026-10-21T01:00:00Z', '--engine', 'test', '--page-dir', 'rig')+    assert r.returncode != 0 and 'needs a page' in r.stdout + r.stderr, (r.stdout, r.stderr)++    # report with no page: written locally, nothing pushed+    local = p / 'local'+    assert call(local, 'intake', '--prompt-time', '2026-10-21T01:00:00Z', '--engine', 'test').returncode == 0+    r = call(local, 'report', '--push'); out = r.stdout + r.stderr+    assert r.returncode == 0, out+    assert (local / 'report' / 'README.md').is_file() and 'written locally' in out and 'nothing pushed' in out, out+    assert '# AI Flow run' in (local / 'report' / 'README.md').read_text()+    assert pushes() == [], pushes()++    # a run in a folder of an existing page+    run = p / 'run'; folder = 'rig/boards/rig-carrier'+    r = call(run, 'intake', '--prompt-time', '2026-10-21T01:00:00Z', '--engine', 'test', '--page', 'john/enc', '--page-dir', folder + '/')+    assert r.returncode == 0, r.stderr+    assert data(run)['page'] == 'john/enc' and data(run)['pageDir'] == folder, data(run)+    r = call(run, 'report', '--push'); out = r.stdout + r.stderr+    assert r.returncode == 0, out+    rid = data(run)['reportId']+    assert f'https://wiki.adom.inc/john/enc/files/{folder}/docs/runs/{rid}/README.md' in out, out+    ps = pushes(); assert ps, 'nothing pushed'+    files = [f for x in ps for f in x['files']]+    assert all(f.startswith(f'{folder}/docs/runs/{rid}/') for f in files) and not any(x['missing'] for x in ps), ps+    assert all(x['args'][2] == 'john/enc' for x in ps)++    # the clips page: <folder>/docs/clips.md, media under <folder>/docs/clips/, render paths with the folder+    stem = 'window-1-20261021-010000'+    for suf in ['.mp4', '-10x.mp4', '-sheet.png', '-poster.jpg']: (run / f'{stem}{suf}').write_bytes(b'x')+    d = data(run); d['captures'] = [{'file': str(run / f'{stem}.mp4'), 'step': 'intake', 'visit': 1, 'start': '2026-10-21T01:00:00Z', 'stop': '2026-10-21T01:05:00Z'}]+    (run / 'run.json').write_text(json.dumps(d))+    log.unlink()+    r = call(run, 'clips', '--push'); out = r.stdout + r.stderr+    assert r.returncode == 0, out+    assert f'https://wiki.adom.inc/john/enc/files/{folder}/docs/clips.md' in out, out+    ps = pushes(); files = [f for x in ps for f in x['files']]+    assert f'{folder}/docs/clips.md' in files and f'{folder}/docs/clips/{stem}-10x.mp4' in files, files+    assert all(f.startswith(f'{folder}/docs/') for f in files) and not any(x['missing'] for x in ps), ps+    md = next(x['texts'][f'{folder}/docs/clips.md'] for x in ps if f'{folder}/docs/clips.md' in x.get('texts', {}))+    assert f'video="/john/enc/render/{folder}/docs/clips/{stem}-10x.mp4"' in md and 'render/docs/' not in md, md+    # the remote clips index is looked up in the folder+    shows = [json.loads(l)['args'] for l in log.read_text().splitlines() if '"show"' in l]+    assert ['repo', 'show', 'john/enc', f'{folder}/docs/clips.md'] in shows, shows++    # clips --page-dir alone moves only the clips page; `.` is the page root+    log.unlink()+    assert call(run, 'clips', '--page-dir', 'rig/clips-only').returncode == 0+    assert data(run)['clipsPage'] == 'john/enc' and data(run)['clipsPageDir'] == 'rig/clips-only' and data(run)['pageDir'] == folder, data(run)+    assert call(run, 'report', '--page-dir', '.').returncode == 0 and data(run).get('pageDir') is None+    # project-page --sync: the step sub-readmes and the README section in the project page's folder+    log.unlink()+    d = data(run); d['sequence'] = ['intake', 'rules']; d.setdefault('steps', {}).setdefault('intake', {})['visits'] = [{'at': '2026-10-21T01:00:00Z'}]+    (run / 'run.json').write_text(json.dumps(d))+    r = call(run, 'project-page', '--sync', '--page', 'john/enc', '--page-dir', folder); out = r.stdout + r.stderr+    assert r.returncode == 0 and data(run)['projectPageDir'] == folder, out+    ps = pushes(); files = [f for x in ps for f in x['files']]+    assert f'{folder}/README.md' in files and f'{folder}/steps/01-intake/README.md' in files, files+    assert all(f.startswith(folder + '/') for f in files) and not any(x['missing'] for x in ps), ps+    step_md = ps[0]['texts'][f'{folder}/steps/01-intake/README.md']+    assert f'video="/john/enc/render/rig/clips-only/docs/clips/{stem}-10x.mp4"' in step_md, step_md+    assert call(run, 'project-page', '--page', 'john/enc', '--page-dir', folder, '--create').returncode != 0+print('PASS: --page-dir refused when not a plain relative folder or without a page; report with no page writes <run>/report/README.md and pushes nothing; report and clips push only under the folder with folder URLs and render paths; project-page --sync writes the step pages and README section in the folder; --create with --page-dir refused')
tests/project-page-library.py+45
@@ -61,6 +61,17 @@ if a[:2] == ["library", "export"]:     with zipfile.ZipFile(dest, "w") as z:         for k, v in files.items(): z.writestr(k, v)     out({"status": "ok", "data": {"report": {"status": "ok", "data": {"included": len(parts), "skipped": [], "parts": [{"ref": "pg_%d" % i, "name": st["pages"][p], "mpn": st["pages"][p]} for i, p in enumerate(parts)]}}}})+if a[:2] == ["repo", "show"]:+    if a[3] in st.get("repo", {}): print(st["repo"][a[3]]); save(); sys.exit(0)+    out({"status": "error", "error": "not found"}, 1)+if a[:2] == ["repo", "push"]:+    files = a[a.index("--files") + 1:a.index("-m")] if "-m" in a[a.index("--files"):] else a[a.index("--files") + 1:]+    missing = [f for f in files if not os.path.isfile(os.path.join(os.getcwd(), f))]+    st.setdefault("pushes", []).append({"page": a[2], "files": files, "missing": missing,+                                        "repository": json.load(open(os.path.join(os.getcwd(), next(f for f in files if f.endswith("repository.json"))))) if any(f.endswith("repository.json") for f in files) else None})+    out({"status": "ok", "commit": "abc1234"}, 1 if missing else 0)+if a[:2] == ["page", "create"]:+    st["created"] = True; out({"status": "ok"}) out({"status": "error", "error": "fake adom-wiki: unknown " + " ".join(a)}, 2) ''' @@ -251,6 +262,40 @@ class ProjectPageLibrary(unittest.TestCase):         self.assertIn("AI Flow builds this board's Altium libraries from the parts' wiki pages above:", readme)         self.assertNotIn("project library", readme) +    def test_page_dir_writes_into_a_folder_of_an_existing_page(self):+        # issue #85: README and PCM repository under the folder, URLs carry it, the page is never created+        s = self.read_state(); s["repo"] = {"page.json": "{}"}; self.write_state(s)+        v = self.run_page(push=True, create=False, pageDir="rig/boards/rig-carrier")+        self.assertTrue(v["ok"], v)+        self.assertFalse(v["created"])+        self.assertEqual(v["pageDir"], "rig/boards/rig-carrier")+        self.assertEqual(v["repositoryUrl"], "https://wiki.adom.inc/api/pages/adom/test-board/files/rig/boards/rig-carrier/pcm/repository.json")+        st = self.read_state()+        self.assertNotIn("created", st)+        push = st["pushes"][-1]+        self.assertEqual(push["missing"], [])+        self.assertTrue(all(f.startswith("rig/boards/rig-carrier/") for f in push["files"]), push["files"])+        self.assertIn("rig/boards/rig-carrier/README.md", push["files"], "the folder had no README: it gets one")+        self.assertTrue(push["repository"]["packages"]["url"].endswith("/files/rig/boards/rig-carrier/pcm/packages.json"), push["repository"])+        pkgs = json.loads((self.run_dir / "project-page" / "pcm" / "packages.json").read_text())+        self.assertIn("/files/rig/boards/rig-carrier/pcm/", pkgs["packages"][0]["versions"][0]["download_url"])+        self.assertEqual(st["libraries"][0]["name"], "adom/test-board/rig/boards/rig-carrier")+        self.assertEqual(v["libName"], "test-board-rig-carrier")+        self.assertIn("rig/boards/rig-carrier/pcm/repository.json", (self.run_dir / "project-page" / "README.md").read_text())+        # the folder's README now exists: never replaced unasked+        s = self.read_state(); s["repo"]["rig/boards/rig-carrier/README.md"] = "# mine"; self.write_state(s)+        self.assertTrue(self.run_page(push=True, create=False, pageDir="rig/boards/rig-carrier")["ok"])+        self.assertNotIn("rig/boards/rig-carrier/README.md", self.read_state()["pushes"][-1]["files"])++    def test_page_dir_never_creates_a_page(self):+        v = self.run_page(push=True, create=False, pageDir="rig/boards/a")+        self.assertFalse(v["ok"]); self.assertIn("never creates one", v["error"])+        v = self.run_page(push=True, create=True, pageDir="rig/boards/a")+        self.assertFalse(v["ok"]); self.assertIn("never creates one", v["error"])+        self.assertNotIn("created", self.read_state())+        v = self.run_page(push=False, pageDir="../up")+        self.assertFalse(v["ok"]); self.assertIn("relative to its root", v["error"])+     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.py+63−23
@@ -6,7 +6,14 @@ Content Manager (PCM) repository on the page so KiCad installs the whole parts l   python3 -c "$(cat project-page.py)" <config.json>  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"}+manufacturer, value, wiki}], "create": bool, "push": bool, "private": bool, "version": "1.0.0",+"pageDir": "rig/boards/<board>" or null}++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,+and the page is never created (an existing page is implied). The library nickname, the PCM identifier and+the Cloud Library name carry the folder too, so two boards on one page never share a library. The+folder's README is pushed when the folder has none yet, or with replaceReadme.  The parts library comes from Adom Cloud Libraries (`adom-wiki library`): one library per board, the parts added by their wiki pages, exported as a KiCad bundle, then repackaged in the PCM layout@@ -67,6 +74,46 @@ def sha(path):     return h.hexdigest()  +def check_dir(d):+    """A page folder: relative, no '..' or '.' parts, no leading slash, no backslash (the binary checks it too)."""+    d = (d or "").strip().rstrip("/")+    if not d or d == ".":+        return None+    parts = d.split("/")+    if d.startswith("/") or "\\" in d or ":" in d or any(p in ("", ".", "..") for p in parts):+        fail(f"pageDir {d!r} must be a folder of the page, relative to its root (like rig/boards/rig-carrier)")+    return d+++def under(d, rel):+    return f"{d}/{rel}" if d else rel+++def push_files(cfg, work, folder, files, message):+    """adom-wiki repo push of files made in `work`, staged under the page folder; returns the commit."""+    cwd = work+    if folder:+        cwd = os.path.join(work, "push-stage"); shutil.rmtree(cwd, ignore_errors=True)+        for f in files:+            dst = os.path.join(cwd, folder, f); os.makedirs(os.path.dirname(dst), exist_ok=True); shutil.copy(os.path.join(work, f), dst)+    p = subprocess.run(["adom-wiki", "repo", "push", cfg["page"], "--files", *[under(folder, f) for f in files], "-m", message],+                       capture_output=True, text=True, cwd=cwd, stdin=subprocess.DEVNULL)+    m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)+    if p.returncode != 0 or not m:+        raise SystemExit(json.dumps({"ok": False, "error": "push failed: " + clip(p.stderr or p.stdout, 600)}))+    return m.group(1)+++def readme_wanted(cfg, folder, created):+    """README rev 1 goes on a page this step made (or when asked); an existing page's README is never+    replaced unasked. In a page folder: when the folder has no README yet."""+    if created or cfg.get("replaceReadme"):+        return True+    if folder:+        return subprocess.run(["adom-wiki", "repo", "show", cfg["page"], under(folder, "README.md")], capture_output=True, stdin=subprocess.DEVNULL).returncode != 0+    return False++ def lib_name(slug):     return re.sub(r"[^A-Za-z0-9_-]", "_", slug) @@ -589,10 +636,14 @@ def main():     cfg = json.load(open(sys.argv[1]))     owner, slug = cfg["page"].split("/", 1)     work = os.path.join(cfg["run"], "project-page"); os.makedirs(work, exist_ok=True)-    page_url = f"https://wiki.adom.inc/{owner}/{slug}"+    folder = check_dir(cfg.get("pageDir"))+    page_url = f"https://wiki.adom.inc/{owner}/{slug}" + (f"/files/{folder}/README.md" if folder else "")     created = False     exists = cfg.get("push") and page_exists(cfg["page"])-    if exists and not cfg.get("existing"):+    if folder and (cfg.get("create") or (cfg.get("push") and not exists)):+        # a folder of a page: the page is the project's and must exist; this never makes one+        raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} " + ("does not exist" if not cfg.get("create") else "is named with --create") + f": --page-dir {folder} writes into a folder of an existing page and never creates one"}))+    if exists and not cfg.get("existing") and not folder:         # a new board gets its own new page: never add one board's library to another board's (or anyone's) page         raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} already exists. A new board gets its own new page: choose a new slug (--create). Pass --existing only when that page is this same board's own page from an earlier run."}))     if cfg.get("push") and not exists:  # a dry run (--no-push) builds everything locally@@ -605,26 +656,21 @@ def main():         if owner != me: args += ["--org", owner]         if cfg.get("private"): args.append("--private")         sh(args); created = True-    lib, pkgid = lib_name(slug), pkg_id(owner, slug)+    lib, pkgid = (lib_name(f"{slug}-{folder.rsplit('/', 1)[-1]}"), pkg_id(owner, f"{slug}.{folder}")) if folder else (lib_name(slug), pkg_id(owner, slug))     version = cfg.get("version") or "1.0.0"     if cfg.get("eda") == "altium":         # Altium: the library step builds the SchLib and PcbLib from the same wiki pages; no KiCad PCM package         open(os.path.join(work, "README.md"), "w").write(readme(cfg, lib, pkgid, None))         pushed = None-        if cfg.get("push") and (created or cfg.get("replaceReadme")):-            p = subprocess.run(["adom-wiki", "repo", "push", cfg["page"], "--files", "README.md",-                                "-m", "Revision 1: the chosen parts and the component table (adom-aiflow, Altium Designer)"], capture_output=True, text=True, cwd=work, stdin=subprocess.DEVNULL)-            m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)-            pushed = m.group(1) if m else None-            if p.returncode != 0 or not pushed:-                raise SystemExit(json.dumps({"ok": False, "error": "push failed: " + clip(p.stderr or p.stdout, 600)}))-        print(json.dumps({"ok": True, "eda": "altium", "page": cfg["page"], "url": page_url, "created": created, "cloudLibrary": None, "libName": lib,+        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)")+        print(json.dumps({"ok": True, "eda": "altium", "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}))         return     if any(p.get("wiki") for p in cfg["parts"]):-        lib_ref, bundle, report, adds = cloud_library(f"{owner}/{slug}", cfg["parts"], work, cfg.get("libRef"))+        lib_ref, bundle, report, adds = cloud_library(f"{owner}/{slug}/{folder}" if folder else f"{owner}/{slug}", cfg["parts"], work, cfg.get("libRef"))     else:   # every part from the project library: no Cloud Library needed         lib_ref, bundle, report, adds = None, None, {}, {"addedPages": 0, "rateLimitWaits": [], "notes": []}     zpath, isize, meta, check = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work, cfg["parts"])@@ -634,7 +680,7 @@ def main():         fail("the KiCad library is incomplete: " + "; ".join(gaps) + (f" (Cloud Library {lib_ref})" if lib_ref else ""),              cloudLibrary=lib_ref, counts=counts, libraryCheck=check)     zname = os.path.basename(zpath)-    files_url = f"{API}/{owner}/{slug}/files"+    files_url = f"{API}/{owner}/{slug}/files" + (f"/{folder}" if folder else "")     v = dict(meta["versions"][0]); v.update({"download_url": f"{files_url}/pcm/{zname}", "download_sha256": sha(zpath), "download_size": os.path.getsize(zpath), "install_size": isize})     pkgs = {"$schema": "https://go.kicad.org/pcm/schemas/v1#/definitions/PackageArray", "packages": [dict(meta, versions=[v])]}     pj = os.path.join(work, "pcm", "packages.json"); json.dump(pkgs, open(pj, "w"), indent=1)@@ -648,15 +694,9 @@ def main():     pushed = None     if cfg.get("push"):         files = [f"pcm/{zname}", "pcm/packages.json", "pcm/repository.json"]-        # README rev 1 goes on a page this step made (or when asked); an existing page's README is never replaced unasked-        if created or cfg.get("replaceReadme"): files.insert(0, "README.md")-        p = subprocess.run(["adom-wiki", "repo", "push", cfg["page"], "--files", *files,-                            "-m", "Revision 1: the chosen parts, the component table and the KiCad PCM repository (adom-aiflow)"], capture_output=True, text=True, cwd=work, stdin=subprocess.DEVNULL)-        m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)-        pushed = m.group(1) if m else None-        if p.returncode != 0 or not pushed:-            raise SystemExit(json.dumps({"ok": False, "error": "push failed: " + clip(p.stderr or p.stdout, 600)}))-    print(json.dumps({"ok": True, "page": cfg["page"], "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,+        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)")+    print(json.dumps({"ok": True, "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/project-steps.py+37−11
@@ -4,6 +4,9 @@    python3 project-steps.py <run dir> <owner/slug> [<step> ...]     (no step: every step visited so far) +With a page folder (`project-page --page-dir <folder>`, run.json projectPageDir, issue #85) the step+pages are <folder>/steps/<nn>-<step>/ and the section goes in <folder>/README.md.+ steps/<nn>-<step>/README.md holds the step's evidence: what it does, each visit (and why a return came back to it), the proof the binary recorded, the screenshots and the AI's drawings, and the Antigravity tokens the step used. The main README section sits between@@ -66,13 +69,32 @@ def brief(v, depth=0):     return str(v)  -def sub_readme(run, data, events, step, n, stage):+def page_dir(data, page):+    """The project page's folder (projectPageDir) when `page` is the run's project page, else None."""+    d = data.get("projectPageDir") if data.get("projectPage") == page else None+    return d.strip("/") if isinstance(d, str) and d.strip("/") else None+++def under(d, rel):+    return f"{d}/{rel}" if d else rel+++def clips_target(data):+    """The page and folder the clips were pushed to: clips --page, else the run's page (as the binary does)."""+    if data.get("clipsPage"):+        return data["clipsPage"], data.get("clipsPageDir")+    if data.get("page"):+        return data["page"], data.get("pageDir")+    return data.get("projectPage"), page_dir(data, data.get("projectPage"))+++def sub_readme(run, data, events, step, n, stage, folder=None):     flow = flow_of(data)     sdef = next((s for s in flow.get("steps", []) if s.get("name") == step), {})     title = (flow.get("titles") or {}).get(step, step)     does = sdef.get("does") or (flow.get("short") or {}).get(step, "")     visits = (data.get("steps", {}).get(step) or {}).get("visits") or []-    d = os.path.join(stage, "steps", f"{n:02d}-{step}"); os.makedirs(d, exist_ok=True)+    d = os.path.join(stage, under(folder, "steps"), f"{n:02d}-{step}"); os.makedirs(d, exist_ok=True)     md = [f"# Step {n}: {title}", "", does, ""]     md += ["## Visits", ""] + [f"- {v.get('at', '')}" + (f" (a return: {v.get('why')})" if v.get("back") else "") for v in visits] + [""]     proofs = (flow.get("proofs") or {}).get(step) or []@@ -97,8 +119,8 @@ def sub_readme(run, data, events, step, n, stage):         for f, cap in pics[:16]:             name = os.path.basename(f); shutil.copy(f, os.path.join(d, name))             md += [f"**{cap}**", "", f"![{cap}]({name})", ""]-    # the step's clips (pushed by the clips page as docs/clips/<stem>-10x.mp4), in the wiki's video widget-    page = data.get("projectPage") or data.get("clipsPage") or data.get("page")+    # the step's clips (pushed by the clips page as <folder>/docs/clips/<stem>-10x.mp4), in the wiki's video widget+    page, cdir = clips_target(data)     vids = []     for c in data.get("captures") or []:         if c.get("step") != step or c.get("discarded") or not c.get("file"): continue@@ -107,8 +129,8 @@ def sub_readme(run, data, events, step, n, stage):     if vids and page:         md += ["## Clips", ""]         for stem, visit in vids:-            poster = f' poster="/{page}/render/docs/clips/{stem}-poster.jpg"' if os.path.isfile(os.path.join(run, f"{stem}-poster.jpg")) else ""-            md += [f'::adom-video{{video="/{page}/render/docs/clips/{stem}-10x.mp4"{poster} title="{title}, visit {visit} (10x)" muted="true"}}', ""]+            poster = f' poster="/{page}/render/{under(cdir, "docs/clips")}/{stem}-poster.jpg"' if os.path.isfile(os.path.join(run, f"{stem}-poster.jpg")) else ""+            md += [f'::adom-video{{video="/{page}/render/{under(cdir, "docs/clips")}/{stem}-10x.mp4"{poster} title="{title}, visit {visit} (10x)" muted="true"}}', ""]     t = tokens_by_step(data).get(step)     if t:         md += ["## AI usage (Google Antigravity)", "", f"{t['calls']} model calls: {t['freshIn']:,} fresh input, {t['cachedIn']:,} cached input and {t['out']:,} output tokens.", ""]@@ -121,14 +143,17 @@ def main():     data, events = load(run)     seq, seen = steps_done(data)     want = sys.argv[3:] or seen+    folder = page_dir(data, page)     stage = os.path.join(run, "project-page", "steps-stage"); shutil.rmtree(stage, ignore_errors=True); os.makedirs(stage)     files = []     for st in want:         if st not in seen: continue-        rel, _, _ = sub_readme(run, data, events, st, seq.index(st) + 1 if st in seq else 0, stage)+        rel, _, _ = sub_readme(run, data, events, st, seq.index(st) + 1 if st in seq else 0, stage, folder)         for f in os.listdir(os.path.join(stage, rel)): files.append(os.path.join(rel, f))     # the main README's section: every step visited so far, one intro each-    cur = subprocess.run(["adom-wiki", "repo", "show", page, "README.md"], capture_output=True, text=True, stdin=subprocess.DEVNULL).stdout+    readme_rel = under(folder, "README.md")+    shown = subprocess.run(["adom-wiki", "repo", "show", page, readme_rel], capture_output=True, text=True, stdin=subprocess.DEVNULL)+    cur = shown.stdout if shown.returncode == 0 else ""   # a folder with no README yet starts one     flow = flow_of(data)     lines = [BEGIN, "## How this board was built", "", "Every step of the build, in order, each with its own page of evidence (checks, screenshots, the AI's drawings and usage), written by [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow) as the run went.", ""]     for st in seen:@@ -141,10 +166,11 @@ def main():         new = cur[:cur.index(BEGIN)] + block + cur[cur.index(END) + len(END):]     else:         new = cur.rstrip() + "\n\n" + block + "\n"-    open(os.path.join(stage, "README.md"), "w").write(new)-    files.append("README.md")+    os.makedirs(os.path.dirname(os.path.join(stage, readme_rel)), exist_ok=True)+    open(os.path.join(stage, readme_rel), "w").write(new)+    files.append(readme_rel)     if os.environ.get("AIFLOW_PROJECT_STEPS_DRY"):-        print(json.dumps({"ok": True, "dry": True, "stage": stage, "steps": want, "files": len(files)})); return+        print(json.dumps({"ok": True, "dry": True, "stage": stage, "steps": want, "files": len(files), "paths": files})); return     p = subprocess.run(["adom-wiki", "repo", "push", page, "--files", *files, "-m", f"adom-aiflow: step evidence ({', '.join(want)})"], capture_output=True, text=True, cwd=stage, stdin=subprocess.DEVNULL)     m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)     print(json.dumps({"ok": bool(m), "commit": m.group(1) if m else None, "steps": want, "files": len(files), "error": None if m else clip(p.stderr or p.stdout, 400)}))