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.
main
John Lauer
Flow-app template, step --hwnd, jlcpcb fit column (on 0.1.57)
9ddc18b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
//! 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", &[]),
}
}
}
}