app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
Comparing main ← flow-app-template
Changes on flow-app-template that are not yet on main (three-dot, from the merge base).
25 files changed, 595 insertions(+), 16 deletions(-)
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.50"+version = "0.1.51" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.50"+version = "0.1.51" dependencies = [ "serde", "serde_json",@@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.50"+version = "0.1.51" dependencies = [ "roxmltree", "serde",@@ -42,7 +42,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.50"+version = "0.1.51" dependencies = [ "aiflow-board", "serde",@@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.50"+version = "0.1.51" dependencies = [ "aiflow-board", "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.50"+version = "0.1.51" dependencies = [ "aiflow-board", "serde",@@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.50"+version = "0.1.51" dependencies = [ "aiflow-board", "serde",@@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.50"+version = "0.1.51" dependencies = [ "aiflow-board", "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.50"+version = "0.1.51" dependencies = [ "aiflow-board", "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.50"+version = "0.1.51" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.50"+version = "0.1.51" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
README.md+8@@ -13,6 +13,14 @@ adom-aiflow --version The same flow runs a board that lives in Autodesk Fusion: the binary plans, routes and gates offline, and every placement, trace, pour and refinement lands live in Fusion through the Fusion bridge, read back from Fusion's own export each time. See [the Fusion lane](docs/fusion-lane.md). +## Flow apps: the stages after the board++AI Flow ends at a finished board. Each later stage is its own small app with its own page, owned by whoever+builds it: ordering from JLCPCB (`adom-aiflow-jlcpcb`), probing and testing, and more to come. They start from the+board release this flow finishes with and keep their own ledger and clips. To build one, copy+[template/flow-app](template/flow-app/README.md): a Rust Linux CLI with the step engine, ledger, window recording+and the handoff schema, open source under MIT.+ ## Silkscreen that helps at the bench > Available in v0.1.26 on the insiders channel. Install with `adom-wiki pkg install adom/adom-aiflow`. The KiCad workflow is demonstrated below; Fusion and Altium adapters still require validation.
crates/adom-aiflow/src/main.rs+4−3@@ -115,7 +115,8 @@ enum Cmd { /// The finish line; refuses until every check passes Finish, /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.- Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },+ Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String>, /// film this window (hwnd on the target box) for this step and the next ones, instead of the PCB editor: a pup window driving a vendor site, a datasheet, Hydrogen's Parts Search+ #[arg(long)] hwnd: Option<i64> }, /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file. Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message) #[arg(long)] accept_suspect: bool },@@ -3568,7 +3569,7 @@ fn main() { } println!("{}", step_table(&r)); }- Cmd::Step { name, back, why } => {+ Cmd::Step { name, back, why, hwnd: hwnd_arg } => { thread(&cli); let mut r = load_run(&cli); let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());@@ -3606,7 +3607,7 @@ fn main() { clip_stopped = true; } // the fields step films the app's window through `tour fields`: no editor clip for it- let hwnd = if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };+ let hwnd = if let Some(h) = hwnd_arg { Some(*h) } else if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) }; let disk = disk_check(&dir, &br, name); if let Err(e) = &disk { r.log("clip-refused", json!({"step": name, "why": e}));
crates/adom-aiflow/src/preboard.rs+12@@ -103,6 +103,9 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri let c_src = col(header, &["source", "supplier", "distributor", "vendor"]); let c_vpn = col(header, &["vendor_pn", "supplier_pn", "distributor_pn", "mouser_pn", "vendor_part_number", "supplier_part_number"]); let c_lcsc = col(header, &["lcsc", "lcsc_pn", "lcsc_part", "lcsc_part_number", "jlcpcb_part", "jlc_pn"]);+ // fit: "post" (or dnp, hand, after) = fitted after the fab's assembly (molecule contacts, machine pins, hand-soldered parts):+ // the row keeps its MPN and source, needs no LCSC number on the jlcpcb profile, and `fab export` leaves it out of the BOM and CPL+ let c_fit = col(header, &["fit", "dnp", "assembly"]); // the dated stock column: a header carrying the date (stock_checked_2026-09-29), or a stock // column beside a stock_date / checked column that carries the date per row let c_stock_dated = header.iter().position(|h| h.to_ascii_lowercase().contains("stock") && date_in(h).is_some());@@ -142,6 +145,8 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri if adom { adom_rows += 1; } else { distributor_rows += 1; } match profile { "jlcpcb" => {+ let fit = get(row, c_fit).to_ascii_lowercase();+ if ["post", "dnp", "hand", "after", "no"].iter().any(|k| fit == *k || fit.starts_with(&format!("{k} "))) { continue; } if lcsc.is_empty() && !is_lcsc_number(&vpn) { errors.push(format!("{label} ({mpn}): no LCSC number on the jlcpcb profile (add an lcsc column, e.g. C12345)")); } } _ => {@@ -390,6 +395,13 @@ mod tests { assert_eq!(out["boardChecked"], false); } + #[test]+ fn jlcpcb_profile_skips_rows_fitted_after_assembly() {+ let bom = "ref,mpn,source,lcsc,fit,stock_checked_2026-10-05\nU1,TPS,JLCPCB,C123456,,5000\nJ1,MachineContactMedium,Adom stock,,post,\n";+ let out = sourcing_check(bom, None, "jlcpcb", 2);+ assert!(!out["errors"].to_string().contains("J1"), "{}", out["errors"]);+ }+ #[test] fn jlcpcb_profile_needs_lcsc_numbers() { let bom = "ref,mpn,source,lcsc,stock_checked_2026-09-29\nU1,TPS,JLCPCB,C123456,5000\nU2,ABC,JLCPCB,,5000\n";
docs/release-0.1.51.mdadded+8@@ -0,0 +1,8 @@+# AI Flow 0.1.51++AI Flow becomes a family. This app's scope is a finished board: prompt to a routed, poured, analysed, DRC-clean board. Every later stage (ordering from a fab, probing and testing) is its own small app, built from a template that now ships here.++- New `template/flow-app/`: the starting point for a flow app. A Rust Linux CLI with `init` (from a board release), `next` (the current step and what the AI must do, from a flow file), `steps`, `ledger`, and `clip start/stop` (background window recording through Adom Bridge, pulled and checked). `new-flow.sh <stage> <dir>` copies it into a new app; `build.sh` builds from source. Open source, MIT. Each app owns its copy and never depends on this repo.+- New handoff schema `adom/board-release@1` (`template/flow-app/schema/`): the board, BOM, spec and their sha256, which every later stage starts from.+- `step --hwnd <window>` records a named window for a step that is not about the PCB editor (a browser, Hydrogen's Parts Search).+- `sourcing check` on the jlcpcb profile skips rows fitted after assembly (`fit` column: post, dnp, hand, after, no).
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.50",+ "version": "0.1.51", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.50",+ "version": "0.1.51", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
template/flow-app/.adomignoreadded+1@@ -0,0 +1 @@+target/
template/flow-app/Cargo.tomladded+25@@ -0,0 +1,25 @@+# A flow app: one later-stage step of making a board (ordering, probing, ...) as its own Linux CLI.+# Copied from adom/adom-aiflow template/flow-app. This copy is yours: change anything, never depend on aiflow.+[package]+name = "adom-aiflow-example"+version = "0.1.0"+edition = "2021"+license = "MIT"++[[bin]]+name = "adom-aiflow-example"+path = "src/main.rs"++[dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = "1"+clap = { version = "4", features = ["derive"] }++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true++# standalone: not a member of any surrounding workspace+[workspace]
template/flow-app/LICENSEadded+9@@ -0,0 +1,9 @@+MIT License++Copyright (c) 2026 Adom Industries Inc.++Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
template/flow-app/README.mdadded+55@@ -0,0 +1,55 @@+# Flow app template++AI Flow is a family of small apps, one per stage of making a board. The board flow (adom-aiflow) takes a prompt+to a finished, routed, DRC-clean board. Every later stage (ordering from a fab, probing and testing, ...) is its+own flow app built from this template. Each one is a separate wiki page and a separate Linux CLI, owned by+whoever builds it.++## Rules++- **Its own app.** One wiki page, one Cargo project, one binary named `adom-aiflow-<stage>`. Copy this folder and+ own the copy. Never depend on adom-aiflow as a crate, and never need a PR to adom-aiflow to change your app.+ Pull improvements from this template when you want them, by hand.+- **Rust, Linux only.** One Linux binary, no Node and no Python. It runs where the AI runs (the container) and+ reaches Windows desktops through `adom-bridge`.+- **Open source.** MIT. The full source ships on the page so anyone can read it, build it with `./build.sh`, and+ send a PR. The binary in `bin/` is a convenience; `install.sh` builds from source when it does not run.+- **The binary tells the AI what to do next.** `next` names the current step and what the AI must do in it,+ from `flows/flow.json`. Every command answers `OK:` or `ERROR:` with `Hint:` lines. The binary does the+ deterministic work; the AI does the judgement the step's `do` text asks for.+- **Start from a board release.** `init --release <board-release.json>` (schema in+ `schema/board-release.schema.json`, `adom/board-release@1`). The board flow writes it when the board is+ finished; `init` refuses when a named file's sha256 no longer matches, so a stage never acts on a board that+ changed after it was finished.+- **Keep your state in one folder.** `<project>/<stage>-flow/`: `state.json`, `run.jsonl` (one line per command:+ time, AI thread, what happened), `clips/`, and the evidence each step produces. Never edit earlier ledger lines.+- **Record every step.** `clip start --name <step> --hwnd <window>` / `clip stop` records one window in the+ background. Drive browsers with pup (`pup_eval`, `pup_input_dispatch`), never the OS mouse, so clips show no+ moving cursor.+- **Ask before money and before publishing.** A step that pays, orders, posts or publishes waits for the human's+ explicit OK, recorded in the ledger.++## Start a new flow app++```bash+template/flow-app/new-flow.sh probing ~/project/adom-aiflow-probing+cd ~/project/adom-aiflow-probing+# write flows/flow.json (your steps), skills/adom-aiflow-probing/SKILL.md, add verbs in src/main.rs+./build.sh # tests, release build, bin/adom-aiflow-probing+# before the first publish: fill package.json (brief, discovery_*, sample_prompts), make the hero in Hero Studio+# (docs/hero.png; the human clicks Generate), then `adom-wiki pkg lint` and `adom-wiki pkg publish --org adom --public`+```++## What is in here++| File | What it is |+|---|---|+| `src/main.rs` | the CLI: `init`, `next`, `steps`, `ledger`, `clip start/stop`; add your stage's verbs |+| `src/flow.rs` | the step engine: the first step whose `done` artifacts are missing is the current one |+| `src/ledger.rs` | the project folder, `state.json` and `run.jsonl` |+| `src/bridge.rs` | Adom Bridge calls: window recording, pull_file with a check, window screenshots, pup |+| `src/out.rs` | `OK:` / `ERROR:` with hints |+| `flows/flow.json` | your stage's steps, their artifacts and what the AI does in each |+| `schema/board-release.schema.json` | the handoff from the board flow |+| `build.sh`, `install.sh`, `uninstall.sh` | build from source; the package install and uninstall hooks |+| `new-flow.sh` | copies this folder into a new app with the names changed |
template/flow-app/build.shadded+9@@ -0,0 +1,9 @@+#!/bin/bash+# Build the Linux binary from source and put it where the package ships it (bin/). Anyone can run this.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -1)"+cargo test --release+cargo build --release+mkdir -p bin && cp "target/release/$NAME" "bin/$NAME"+echo "OK: bin/$NAME ($(bin/$NAME --version))"
template/flow-app/flows/flow.jsonadded+9@@ -0,0 +1,9 @@+{+ "name": "example",+ "description": "TEMPLATE: replace with your stage, from a board release to what this stage delivers.",+ "steps": [+ {"name": "intake", "done": ["state.json"], "do": "Run `init --release <board-release.json> --target <desktop>`. Read the release: board, BOM, spec, and what the board flow said is left for later stages."},+ {"name": "work", "done": ["work/*.json"], "do": "TEMPLATE: what the AI must do in this step, which verb does the deterministic part, and which artifact proves it is done."},+ {"name": "report", "done": ["report.md"], "do": "TEMPLATE: write the stage's report: what was delivered, the evidence, and anything the next stage must know."}+ ]+}
template/flow-app/install.shadded+18@@ -0,0 +1,18 @@+#!/bin/bash+# adom-wiki package install hook: the binary to ~/.local/bin, the skill to ~/.claude/skills (and ~/.codex/skills).+# Builds from source when no prebuilt binary ships or it does not run here.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' "$HERE/Cargo.toml" | head -1)"+if ! "$HERE/bin/$NAME" --version >/dev/null 2>&1; then+ command -v cargo >/dev/null || { echo "ERROR: no prebuilt binary for this machine and no cargo to build one (https://rustup.rs)"; exit 1; }+ (cd "$HERE" && cargo build --release && mkdir -p bin && cp "target/release/$NAME" "bin/$NAME")+fi+mkdir -p "$HOME/.local/bin"+install -m 755 "$HERE/bin/$NAME" "$HOME/.local/bin/.$NAME-new" && mv -f "$HOME/.local/bin/.$NAME-new" "$HOME/.local/bin/$NAME"+for skill in "$HERE"/skills/*; do+ [ -f "$skill/SKILL.md" ] || continue+ for consumer in .claude .codex; do mkdir -p "$HOME/$consumer/skills/$(basename "$skill")"; cp "$skill/SKILL.md" "$HOME/$consumer/skills/$(basename "$skill")/SKILL.md"; done+done+command -v adom-bridge >/dev/null 2>&1 || echo "Hint: desktop steps need adom-bridge (the Adom Bridge CLI)"+echo "OK: $NAME installed. Run '$NAME --help'."
template/flow-app/new-flow.shadded+13@@ -0,0 +1,13 @@+#!/bin/bash+# Start a new flow app from this template: new-flow.sh <stage> <dest dir>+# e.g. new-flow.sh probing ~/project/adom-aiflow-probing -> binary adom-aiflow-probing, project folder probing-flow/+set -e+STAGE="$1"; DEST="$2"+[ -n "$STAGE" ] && [ -n "$DEST" ] || { echo "usage: new-flow.sh <stage> <dest dir>"; exit 1; }+[ -e "$DEST" ] && { echo "ERROR: $DEST exists"; exit 1; }+HERE="$(cd "$(dirname "$0")" && pwd)"+cp -r "$HERE" "$DEST"; rm -rf "$DEST/target" "$DEST/new-flow.sh"+grep -rl "adom-aiflow-example\|example-flow" "$DEST" | xargs sed -i "s/adom-aiflow-example/adom-aiflow-$STAGE/g; s/example-flow/$STAGE-flow/g"+sed -i "s/\"name\": \"example\"/\"name\": \"$STAGE\"/" "$DEST/flows/flow.json"+mv "$DEST/skill" "$DEST/skills-tmp"; mkdir -p "$DEST/skills/adom-aiflow-$STAGE"; mv "$DEST/skills-tmp/SKILL.md" "$DEST/skills/adom-aiflow-$STAGE/SKILL.md"; rmdir "$DEST/skills-tmp"+echo "OK: adom-aiflow-$STAGE in $DEST. Next: write flows/flow.json and skills/adom-aiflow-$STAGE/SKILL.md, add your verbs, ./build.sh"
template/flow-app/package.jsonadded+32@@ -0,0 +1,32 @@+{+ "slug": "adom-aiflow-example",+ "type": "app",+ "version": "0.1.0",+ "title": "TEMPLATE flow app",+ "description": "TEMPLATE: what this stage does, from a finished board to what it delivers. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.",+ "category": "Developer Tools",+ "license": "MIT",+ "bin": {+ "adom-aiflow-example": "bin/adom-aiflow-example"+ },+ "dependencies": {},+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "bundled": true,+ "hero": {+ "path": "docs/hero.png"+ },+ "brief": "TEMPLATE: one line, what this stage does.",+ "discovery_pitch": "TEMPLATE: when an AI should reach for this app.",+ "discovery_triggers": [+ "TEMPLATE: everyday phrasings a user would say"+ ],+ "sample_prompts": [+ {+ "label": "TEMPLATE",+ "prompt": "TEMPLATE: a real user request"+ }+ ]+}
template/flow-app/schema/board-release.schema.jsonadded+25@@ -0,0 +1,25 @@+{+ "$schema": "https://json-schema.org/draft/2020-12/schema",+ "$id": "adom/board-release@1",+ "title": "Board release: the handoff from the board flow to every later stage",+ "description": "Written when a board is finished. Every later flow app (ordering, probing, ...) starts from this file and checks the sha256 of each file it names, so it never builds a board that changed after it was finished.",+ "type": "object",+ "required": ["schema", "board", "bom"],+ "properties": {+ "schema": {"const": "adom/board-release@1"},+ "name": {"type": "string", "description": "board name, e.g. buck-12v5v-molecule"},+ "page": {"type": ["string", "null"], "description": "the board's wiki page (owner/slug), if it has one"},+ "board": {"$ref": "#/$defs/file", "description": "the finished .kicad_pcb (or .brd)"},+ "schematic": {"$ref": "#/$defs/file"},+ "bom": {"$ref": "#/$defs/file", "description": "BOM csv: ref, mpn, value, plus vendor columns (lcsc, mouser...) and fit (post|dnp|hand for parts fitted after assembly)"},+ "spec": {"$ref": "#/$defs/file"},+ "sourcingProfile": {"type": "string", "description": "the profile the BOM was checked against (fab, jlcpcb, ...)"},+ "finished": {"type": "string", "format": "date-time"},+ "producedBy": {"type": "string", "description": "app and version that wrote this, e.g. adom-aiflow 0.1.52"},+ "checks": {"type": "object", "description": "what the board flow proved: drc, routed, analyses, with their results"},+ "notes": {"type": "array", "items": {"type": "string"}, "description": "anything a later stage must know"}+ },+ "$defs": {+ "file": {"type": "object", "required": ["path", "sha256"], "properties": {"path": {"type": "string"}, "sha256": {"type": "string"}}}+ }+}
template/flow-app/skill/SKILL.mdadded+22@@ -0,0 +1,22 @@+---+name: adom-aiflow-example+description: >-+ TEMPLATE: one line on the stage this flow app runs, from a finished board (board-release.json) to what it delivers,+ then the trigger words an AI should match on.+---++# adom-aiflow-example++Run `adom-aiflow-example next` whenever you are unsure what to do: it names the current step and what you must do in it.+Every command answers `OK:` or `ERROR:` with `Hint:` lines; every command you run goes in `<project>/example-flow/run.jsonl`.++```+adom-aiflow-example init --release <board-release.json> --target <desktop> --ai-thread <you>+adom-aiflow-example next+adom-aiflow-example clip start --name <step> --hwnd <window> --ai-thread <you> # record each step for the video+adom-aiflow-example clip stop --ai-thread <you>+```++## The rules that matter++TEMPLATE: the things this stage gets wrong without them.
template/flow-app/src/bridge.rsadded+82@@ -0,0 +1,82 @@+#![allow(dead_code)] // template helpers: keep the ones your stage uses+//! Adom Bridge from a flow app: every desktop action (a browser through pup, a native app, a window+//! recording, files to and from the desktop) is one `adom-bridge` call. The app runs on Linux; the+//! desktop it drives is whatever `--target` names. Copied from aiflow-bridge; keep what you use.+use serde_json::{json, Value};+use std::path::{Path, PathBuf};+use std::process::Command;++#[derive(Clone)]+pub struct Bridge { pub target: String, pub ai_thread: String }++pub struct ClipStop { pub remote: Option<String>, pub local: Option<PathBuf>, pub pulled: bool, pub reply: Value }++impl Bridge {+ /// One verb with JSON args. Gated verbs need a per-call `reason` inside `args`.+ pub fn call(&self, verb: &str, args: &Value) -> Value {+ let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();+ match out {+ Ok(o) => {+ let text = String::from_utf8_lossy(&o.stdout).to_string();+ let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}));+ // some verbs wrap their answer as a JSON string in `output`+ match v.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()) { Some(inner) => inner, None => v }+ }+ Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),+ }+ }++ /// A field from a reply, at the top level or under `data` (verbs answer both ways).+ pub fn field<'a>(v: &'a Value, k: &str) -> Option<&'a Value> {+ v.get(k).or_else(|| v.get("data").and_then(|d| d.get(k)))+ }++ pub fn ok(v: &Value) -> bool {+ v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())+ }++ /// Record ONE window (Windows Graphics Capture: works in the background, nobody at the desk is+ /// disturbed). Capped at an hour whatever the AI forgets. Returns the recording id.+ pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {+ let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));+ Self::field(&r, "recordingId").or_else(|| Self::field(&r, "id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+ }++ /// Stop a recording and pull the file into `save_to`, checked: the local copy must exist and be+ /// non-empty. The desktop cleans its recordings folder, so pull now or lose the clip.+ pub fn record_stop(&self, id: &str, save_to: &Path) -> ClipStop {+ let r = self.call("desktop_record_stop", &json!({"recordingId": id}));+ let remote = Self::field(&r, "filePath").or_else(|| Self::field(&r, "path"))+ .and_then(|v| v.as_str()).map(|s| s.replace('\\', "/"));+ match remote {+ Some(p) => { let (pulled, local) = self.pull(&p, save_to, 3); ClipStop { remote: Some(p), local: Some(local), pulled, reply: r } }+ None => ClipStop { remote: None, local: None, pulled: false, reply: r },+ }+ }++ /// pull_file one desktop file into `save_to` until it lands non-empty (at most `attempts`).+ pub fn pull(&self, remote: &str, save_to: &Path, attempts: usize) -> (bool, PathBuf) {+ let local = save_to.join(remote.rsplit(['/', '\\']).next().unwrap_or(remote));+ for k in 0..attempts.max(1) {+ self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to.display().to_string(), "reason": "flow app: bring a desktop file into the project"}));+ if std::fs::metadata(&local).map(|m| m.len() > 0).unwrap_or(false) { return (true, local); }+ if k + 1 < attempts { std::thread::sleep(std::time::Duration::from_secs(3)); }+ }+ (false, local)+ }++ /// A background screenshot of one window, copied to `dest`.+ pub fn screenshot_window(&self, hwnd: i64, dest: &Path) -> Option<PathBuf> {+ let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd, "reason": "flow app: evidence screenshot"}));+ let d = r.get("data").cloned().unwrap_or(r.clone());+ let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);+ let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;+ std::fs::copy(&local, dest).ok()?;+ Some(dest.to_path_buf())+ }++ /// JavaScript in a pup (Puppeteer) browser window. No OS cursor moves, so recordings stay clean.+ pub fn pup_eval(&self, window: &str, expression: &str, reason: &str) -> Value {+ self.call("pup_eval", &json!({"window": window, "expression": expression, "reason": reason}))+ }+}
template/flow-app/src/flow.rsadded+49@@ -0,0 +1,49 @@+//! The flow file (flows/flow.json, compiled in): the ordered steps, the artifacts that prove each one+//! is done, and what the AI must do in it. `next` names the first step whose artifacts are missing.+//! The binary does the deterministic parts; the AI does the thinking the `do` text asks for.+use serde::Deserialize;+use std::path::Path;++#[derive(Deserialize, Clone)]+pub struct Step { pub name: String, pub done: Vec<String>, #[serde(rename = "do")] pub todo: String }++#[derive(Deserialize)]+#[allow(dead_code)]+pub struct Flow { pub name: String, pub description: String, pub steps: Vec<Step> }++pub fn load() -> Flow { serde_json::from_str(include_str!("../flows/flow.json")).expect("flows/flow.json is valid") }++/// One artifact pattern, relative to the flow folder; `*` matches within the last path segment.+pub fn exists(flow_dir: &Path, pat: &str) -> bool {+ let full = flow_dir.join(pat);+ let name = full.file_name().and_then(|n| n.to_str()).unwrap_or("");+ if !name.contains('*') { return full.exists(); }+ let (pre, post) = name.split_once('*').unwrap_or((name, ""));+ let dir = full.parent().unwrap_or(flow_dir);+ std::fs::read_dir(dir).map(|rd| rd.flatten().any(|e| {+ let n = e.file_name().to_string_lossy().to_string();+ n.starts_with(pre) && n.ends_with(post) && n.len() >= pre.len() + post.len()+ })).unwrap_or(false)+}++pub fn current<'a>(flow: &'a Flow, flow_dir: &Path) -> Option<&'a Step> {+ flow.steps.iter().find(|s| !s.done.iter().all(|p| exists(flow_dir, p)))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn current_step_is_the_first_with_missing_artifacts() {+ let d = std::env::temp_dir().join(format!("flow-test-{}", std::process::id()));+ std::fs::create_dir_all(d.join("work")).unwrap();+ std::fs::write(d.join("state.json"), "{}").unwrap();+ let f = load();+ assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("work"));+ std::fs::write(d.join("work/a.json"), "{}").unwrap();+ assert!(exists(&d, "work/*.json"));+ assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("report"));+ std::fs::remove_dir_all(&d).ok();+ }+}
template/flow-app/src/ledger.rsadded+50@@ -0,0 +1,50 @@+//! The project's flow folder (`<project>/<flow>/`): state.json plus run.jsonl, one line per command+//! with the time, the calling AI thread and what it did. Never rewrite earlier lines.+use serde_json::{json, Value};+use std::io::Write;+use std::path::{Path, PathBuf};++pub fn now() -> String {+ let out = std::process::Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]).output();+ out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default()+}++#[allow(dead_code)]+pub struct Project { pub dir: PathBuf, pub flow_dir: PathBuf, pub state: Value }++impl Project {+ /// The nearest folder at or above `start` that holds `<flow>/state.json`.+ pub fn find(start: &Path, flow: &str) -> Option<Project> {+ let mut d = Some(start.to_path_buf());+ while let Some(p) = d {+ let s = p.join(flow).join("state.json");+ if s.is_file() {+ let state = std::fs::read_to_string(&s).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ return Some(Project { flow_dir: p.join(flow), dir: p, state });+ }+ d = p.parent().map(Path::to_path_buf);+ }+ None+ }++ pub fn create(dir: &Path, flow: &str, state: Value) -> std::io::Result<Project> {+ let flow_dir = dir.join(flow);+ std::fs::create_dir_all(&flow_dir)?;+ let p = Project { dir: dir.to_path_buf(), flow_dir, state };+ p.save()?;+ Ok(p)+ }++ pub fn save(&self) -> std::io::Result<()> {+ std::fs::write(self.flow_dir.join("state.json"), serde_json::to_string_pretty(&self.state)? + "\n")+ }++ pub fn path(&self, rel: &str) -> PathBuf { self.flow_dir.join(rel) }++ pub fn log(&self, thread: &str, ev: &str, data: Value) {+ let line = json!({"t": now(), "aiThread": thread, "ev": ev, "data": data});+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(self.flow_dir.join("run.jsonl")) {+ let _ = writeln!(f, "{line}");+ }+ }+}
template/flow-app/src/main.rsadded+131@@ -0,0 +1,131 @@+//! adom-aiflow-example: TEMPLATE for a flow app. Rename it with template/flow-app/new-flow.sh, write+//! flows/flow.json, then add a verb per deterministic job your stage has. Keep the verbs the AI+//! leans on everywhere: init, next, step, ledger, clip.+mod bridge;+mod flow;+mod ledger;+mod out;++use clap::{Parser, Subcommand};+use out::{err, ok};+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++/// The folder this flow keeps inside the project (state.json, run.jsonl, clips/, evidence).+const FLOW_DIR: &str = "example-flow";++#[derive(Parser)]+#[command(name = "adom-aiflow-example", version, about = "TEMPLATE flow app: one later stage of making a board, as a CLI that tells the AI what to do next")]+struct Cli {+ /// The name of the AI conversation calling (required for commands that change state)+ #[arg(long, global = true)]+ ai_thread: Option<String>,+ /// Project folder (default: the nearest one above the current folder that has this flow's state)+ #[arg(long, global = true)]+ project: Option<String>,+ #[command(subcommand)]+ cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// Start the flow on a finished board: `init --release <board-release.json>` (written by the board flow at finish) or `init --board <B.kicad_pcb> --bom <csv>`+ Init { #[arg(long)] release: Option<String>, #[arg(long)] board: Option<String>, #[arg(long)] bom: Option<String>, #[arg(long)] target: Option<String> },+ /// What to do now: the first step whose artifacts are missing, and what the AI must do in it+ Next,+ /// Every step with its state+ Steps,+ /// The run ledger, one line per command+ Ledger,+ /// `clip start --name <step> --hwnd <window>` / `clip stop`: record one desktop window for the video+ Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },+}++fn thread(cli: &Cli) -> String {+ cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[]))+}++fn project(cli: &Cli) -> ledger::Project {+ let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+ ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-example init --release <board-release.json> --ai-thread <you>".into()]))+}++fn sha256(p: &Path) -> String {+ let o = std::process::Command::new("sha256sum").arg(p).output();+ o.ok().and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string)).unwrap_or_default()+}++fn main() {+ let cli = Cli::parse();+ let fl = flow::load();+ match &cli.cmd {+ Cmd::Init { release, board, bom, target } => {+ let th = thread(&cli);+ let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+ // the handoff: a board release from the board flow, or the board and BOM named directly+ let rel: Value = match release {+ Some(r) => serde_json::from_str(&std::fs::read_to_string(r).unwrap_or_else(|e| err(&format!("{r}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{r}: not JSON: {e}"), &[])),+ None => {+ let (Some(b), Some(m)) = (board, bom) else { err("init needs --release <board-release.json>, or --board and --bom", &["The board flow writes board-release.json when it finishes (schema adom/board-release@1, template/flow-app/schema).".into()]) };+ json!({"schema": "adom/board-release@1", "board": {"path": b, "sha256": sha256(Path::new(b))}, "bom": {"path": m, "sha256": sha256(Path::new(m))}})+ }+ };+ for k in ["board", "bom"] {+ let p = rel[k]["path"].as_str().unwrap_or("");+ if !Path::new(p).is_file() { err(&format!("the release's {k} is not here: {p}"), &[]); }+ let want = rel[k]["sha256"].as_str().unwrap_or("");+ if !want.is_empty() && want != sha256(Path::new(p)) { err(&format!("{p} changed since the release (sha256 differs)"), &["Finish the board again so the release names the board you mean to build.".into()]); }+ }+ let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "release": rel, "target": target, "started": ledger::now()})).unwrap_or_else(|e| err(&format!("cannot write {FLOW_DIR}/: {e}"), &[]));+ p.log(&th, "init", json!({"release": release}));+ let next = flow::current(&fl, &p.flow_dir).map(|s| format!("next step: {}: {}", s.name, s.todo)).unwrap_or_default();+ ok(&format!("{} flow started in {}", fl.name, p.flow_dir.display()), &[next]);+ }+ Cmd::Next => {+ let p = project(&cli);+ match flow::current(&fl, &p.flow_dir) {+ Some(s) => ok(&format!("step {}", s.name), &[s.todo.clone()]),+ None => ok("every step has its artifacts: the flow is done", &[]),+ }+ }+ Cmd::Steps => {+ let p = project(&cli);+ let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());+ for s in &fl.steps {+ let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x));+ println!("{} {}", if done { "done" } else if Some(&s.name) == cur.as_ref() { "NOW " } else { " " }, s.name);+ }+ }+ Cmd::Ledger => {+ let p = project(&cli);+ print!("{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default());+ }+ Cmd::Clip { what, name, hwnd } => {+ let th = thread(&cli);+ let mut p = project(&cli);+ let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));+ let br = bridge::Bridge { target, ai_thread: th.clone() };+ match what.as_str() {+ "start" => {+ let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &["Find the window with `adom-bridge desktop_list_windows`. Drive browsers with pup (no OS cursor), so the clip shows no moving mouse.".into()]) };+ if p.state["clip"].is_object() { err("a clip is already recording: clip stop first", &[]); }+ let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));+ p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()});+ p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());+ ok(&format!("recording {n} (window {h})"), &[]);+ }+ "stop" => {+ let c = p.state["clip"].clone();+ let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));+ let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok();+ let s = br.record_stop(id, &dir);+ p.state["clip"] = Value::Null; p.save().ok();+ p.log(&th, "clip-stop", json!({"name": c["name"], "remote": s.remote, "local": s.local, "pulled": s.pulled}));+ if !s.pulled { err(&format!("the clip did not land locally: {}", s.reply), &["Pull it by hand with pull_file before the desktop cleans its recordings folder.".into()]); }+ ok(&format!("clip {} saved to {}", c["name"].as_str().unwrap_or(""), s.local.unwrap().display()), &[]);+ }+ _ => err("clip start|stop", &[]),+ }+ }+ }+}
template/flow-app/src/out.rsadded+13@@ -0,0 +1,13 @@+//! Every command answers the AI the same way: `OK: <what happened>` or `ERROR: <what is wrong>`,+//! then `Hint:` lines that depend on this project and this run. Exit 1 on ERROR.++pub fn ok(msg: &str, hints: &[String]) {+ println!("OK: {msg}");+ for h in hints.iter().filter(|h| !h.is_empty()) { println!("Hint: {h}"); }+}++pub fn err(msg: &str, hints: &[String]) -> ! {+ println!("ERROR: {msg}");+ for h in hints.iter().filter(|h| !h.is_empty()) { println!("Hint: {h}"); }+ std::process::exit(1)+}
template/flow-app/uninstall.shadded+7@@ -0,0 +1,7 @@+#!/bin/bash+# adom-wiki package uninstall hook: remove the binary and the skills this app installed.+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' "$HERE/Cargo.toml" | head -1)"+rm -f "$HOME/.local/bin/$NAME"+for skill in "$HERE"/skills/*; do for consumer in .claude .codex; do rm -rf "$HOME/$consumer/skills/$(basename "$skill")"; done; done+echo "OK: $NAME removed"