← All Pull Requests

Hands-on AI Flow: the human chooses who does each step (auto, check in, human, custom), saved as flow profiles #59

Closed opened by noah
Merges hands-on-ai-flow → main

Hands-on AI Flow: the human chooses who does each step, saved as flow profiles

Base: ea4efc2e27dba502e18a2cb389eac475b6fd1ddb (0.2.44). Requested by Noah after building the same AM32 ESC twice, once steering every step himself and once with AI Flow alone. The criticism from that comparison is filed as #127 to #132; this PR is the "new mode" half, and it also covers the screen-takeover part of #132.

What it does

Before any design work, handson plan puts every step of the run on the AI Flow timeline with four choices:

  1. AI takes care of it (auto): the flow as it is today.
  2. AI checks in after it (checkin): the AI does the step, then handson ask. The next step is refused until the human approves this visit. A return to the step needs a new OK.
  3. The human does it (human): the AI hands it over, waits for "done", then runs the step's own check on the human's work (place verify after adopt-board, an empty route plan through gate + land route, verify-pours --adopt, ...).
  4. Custom instructions (custom): printed at every step.

The timeline also asks:

  • how to notify them: Hydrogen, desktop toast, both or none
  • whether AI Flow may bring windows to the front: hands-off by default

They press Start on the timeline, or the AI sets it from their chat words (handson set, handson start).

Profiles are preferences. The choice is saved as a flow profile in ~/.adom/aiflow/profiles/.

  • The first confirmed plan is saved ("My flow", or the name they give) and becomes their default, so the next intake or start applies it automatically.
  • Switch with handson use <slug> (full-ai is built in) and list with handson profiles.

Discoverable. A run that is not hands-on shows a "Want a say in each step?" card on the timeline: the four choices, a "Choose my flow" button and a copyable prompt. The step rows carry mode chips.

Answers from the page. POST handson (custom header required) only queues the human's answer in <run>/timeline/handson-inbox.json. Every step, handson command and finish ingests it under the run's lock and prints HUMAN (timeline): ... lines. The page never writes run.json or a gate.

finish-guard. An open plan, check-in or human step reads as waiting-for-human (mayStop: true, engineeringComplete unchanged), so the AI stops and waits instead of pushing through.

Screen (#132). With screen: hands-off (every profile's default):

  • step skips its per-step desktop_bring_to_front.
  • capture open opens the board without focus or maximise.
  • tour 3d stops and asks, because the 3D viewer only paints in front.

Runs without a hands-on record keep today's behaviour; changing the default for everyone is left to you on #132.

Preserved

  • Every proof, gate, deviation and finish check is unchanged. Hands-on refusals are not deviatable: they are the human's, and the plan changes them.
  • Visits before a plan was confirmed are not re-reviewed.
  • No existing command, verb or flag removed (tools/pr_preflight.py inventory below).

Files

  • crates/adom-aiflow/src/handson.rs (new): profiles, plan, gates, hints, inbox, timeline block, notifications, with 5 unit tests.
  • crates/adom-aiflow/src/main.rs: the handson command; the hands-on gate in step (before the proof gate); hints and notification on a human step; finish missing items; the default profile at intake and start; the screen preference in step, capture open and tour 3d.
  • crates/adom-aiflow/src/timeline_server.rs: POST /handson, with a test.
  • crates/adom-aiflow/src/sequence.rs: mode per step and handsOn in timeline.json.
  • crates/adom-aiflow/src/continuation.rs: waiting-for-human, with a test.
  • crates/aiflow-bridge/src/lib.rs: open_board(..., raise).
  • tools/timeline/index.html: the planner, the review and "your turn" cards, the summary bar, mode chips, the discovery card.
  • tools/timeline/README.md, docs/hands-on.md.
  • skills/aiflow-hands-on/SKILL.md (new), with a section and a companion row in SKILL.md and skills/adom-aiflow/SKILL.md, and a pointer in skills/aiflow-intake/SKILL.md.
  • flows/board.json: intake also names aiflow-hands-on.
  • package.json: discovery triggers and a sample prompt.
  • tests/handson-workflow.py (new).

Tested

  • cargo check --release --workspace --bins passes, with no new warnings.
  • cargo test --release --workspace: all pass, 335 in adom-aiflow. New tests:
    • 5 in handson.rs: validation; check-in gating with changes and re-ask; human steps; history before the plan; the planning block; profiles; the inbox.
    • timeline_server handson_event.
    • continuation an_open_check_in_is_the_humans_turn.
  • tests/handson-workflow.py (new) runs the real binary against a real timeline serve. It checks:
    • plan refuses design work.
    • The POST needs the header, and custom needs instructions.
    • The confirmed plan is saved as the default profile.
    • The check-in gate, with changes then approval from the page.
    • A chat approval needs a quote.
    • The human step and its done.
    • The finish-guard state.
    • The next run inherits the default, and use full-ai.
  • The existing workflow tests pass unchanged: artifact, board-kind, continuation, dashboard, deviation, impedance, page-dir, review, route-variants, rules, rules-layers, sourcing-suggestions, timeline-notify, widget, altium-lane, project-page-check, project-page-library, and the node tests presentation-polling, rules-card and timeline-sse. component-workflow.py was not run; it needs a board and spec fixture and does not touch this code.
  • Isolation: with a default profile in $HOME/.adom/aiflow set to check in on every step, deviation-workflow.py still passes. Headless runs (AIFLOW_DASHBOARD=off) only inherit a default when AIFLOW_PREFS_DIR is set.
  • tools/pr_preflight.py against the base: no problems. CLI command list vs the installed 0.2.44: only handson added.
  • Dashboard in headless Chromium at 900 px and 390 px (no horizontal scroll). The planner was driven by clicking (check in, me, custom with text, notify, save as) and Start; the CLI picked it up and saved the profile. The review card was approved from the page, and the "your turn" card and the discovery card were checked.

Not tested / limitations

  • No native acceptance on a live KiCad board or a real Hydrogen webview yet. The gates, inbox, profiles and the server endpoint are covered by unit tests and the CLI workflow test; the page was rendered in headless Chromium.
  • Notifications call adom-cli notify and adom-bridge notify_user, but were not exercised in tests (AIFLOW_NOTIFY=off).
  • Profiles are per machine (no wiki sync yet). Toast buttons are not wired; the toast points to the timeline and chat.
  • The version is not bumped; release when you merge.

🤖 Generated with Claude Code

Diff Skip to comments (1)

SKILL.md+16−1
@@ -1,7 +1,7 @@ --- name: adom-aiflow description: >--  The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+  The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: hands on, hands-on aiflow, hands-on AI Flow, check in with me, my flow, full AI flow, flow profile, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. ---  # adom-aiflow: the loop, measured@@ -147,6 +147,20 @@ required step grouped by phase, a teal check on each done step, the current one go", and a purple arc for every return with its reason, ending at the finish line (Gerbers, BOM and CPL exported). Every `step` refreshes it within a second. Open it at intake and leave it up. +## Hands-on AI Flow: the human chooses who does each step++When the human asks for it ("use hands-on AI Flow", "check in with me after placement", "I'll route it myself", "use my flow"), run `handson plan` right after `intake` or `start`, before any design work. The timeline shows every step with four choices:+1. **auto**: the AI takes care of it.+2. **checkin**: the AI does it, then `handson ask` and waits for the human's OK. The next step is refused until they approve.+3. **human**: the human does it. The AI waits for "done", then runs the step's own check on their work.+4. **custom**: their written instructions, printed at every `step`.++The timeline also has their notification choice and their screen choice (hands-off by default: AI Flow never raises or maximises windows on their desktop). They press Start there, or you set it from their words (`handson set placement=checkin routing=human`, `handson start --save-as "<name>"`).++The choice is a flow profile in `~/.adom/aiflow/profiles/`. The first one confirmed becomes their default, and every new run follows it. Switch with `handson use <slug>` (`full-ai` is built in); list with `handson profiles`.++While a plan, check-in or human step is open, `finish-guard` answers `waiting-for-human`, a legitimate pause. Proofs and finish checks never change. Never approve for the human. A run that is not hands-on shows a "Want a say in each step?" card on the timeline. Skill: `aiflow-hands-on`; reference: docs/hands-on.md.+ ## A board in Autodesk Fusion  When the human asks for a board in Fusion ("a 12 V to 5 V buck converter using the TPS54202, in Fusion"),@@ -306,6 +320,7 @@ AI Flow's binary is the instrument, the gates and the hands; the judgement for e | step | skill | |---|---| | a brief or a video to reproduce, before any board exists | `aiflow-intake` |+| the human wants a say in each step (hands-on, check-ins, their own steps, flow profiles, screen and notification preferences) | `aiflow-hands-on` | | choosing parts, the fab profile (3rd party fab by default, JLCPCB on request), symbols, footprints and 3D models | `aiflow-sourcing` | | datasheet equations, stocked values, margins | `aiflow-circuit-design` | | loop and transient simulation, vendor PSpice models | `aiflow-simulate` |
crates/adom-aiflow/src/continuation.rs+17−1
@@ -155,6 +155,12 @@ pub fn assess(data: &Value, pending: &[String], stop: Option<Result<Value, Strin             remaining.len()         )     };+    // hands-on (handson.rs): an open check-in, a human step or an unconfirmed plan is the human's turn+    let human = if complete { None } else { crate::handson::waiting(data) };+    if let Some(w) = &human {+        decision = "waiting-for-human";+        reason = format!("Waiting for {} (hands-on flow); the run resumes on their answer. {} requirement(s) remain.", w["what"].as_str().unwrap_or("the human"), remaining.len());+    }     let mut stop_record = Value::Null;     if let Some(evidence) = stop {         match evidence.and_then(|v| stop_reason(&v).map(|kind| (kind, v))) {@@ -174,7 +180,8 @@ pub fn assess(data: &Value, pending: &[String], stop: Option<Result<Value, Strin     }     json!({"schemaVersion":1,"decision":decision,"mayStop":decision!="continue","engineeringComplete":complete,         "remaining":remaining,"currentStep":data["currentStep"],"reason":reason,"stopEvidence":stop_record,-        "nextAction":if complete {"Verify requested scope and hand off the delivery.".into()} else {format!("Continue the earliest unmet requirement ({}). Repair tools or design as needed; preserve all acceptance gates. Report progress in commentary, then execute the next action.",remaining.first().map(String::as_str).unwrap_or("requested scope"))},+        "waitingForHuman":human,+        "nextAction":if complete {"Verify requested scope and hand off the delivery.".into()} else if let Some(w) = &human {format!("Tell the human what you need ({}), then `handson wait`; resume when they answer.", w["what"].as_str().unwrap_or(""))} else {format!("Continue the earliest unmet requirement ({}). Repair tools or design as needed; preserve all acceptance gates. Report progress in commentary, then execute the next action.",remaining.first().map(String::as_str).unwrap_or("requested scope"))},         "runtimeEnforcement":"CLI guard only. An AI host must invoke this before ending a turn; no watchdog or automatic restart is installed."}) } @@ -214,6 +221,15 @@ mod tests {         assert_eq!(r["engineeringComplete"], false);     }     #[test]+    fn an_open_check_in_is_the_humans_turn() {+        let d = json!({"currentStep":"placement","steps":{"placement":{"visits":[{"n":1,"at":"2026-11-03T10:05:00Z"}]}},+            "handsOn":{"state":"active","setAt":"2026-11-03T10:00:00Z","steps":{"placement":{"mode":"checkin"}},"asks":[{"step":"placement","visit":1,"at":"2026-11-03T10:06:00Z","summary":"look"}],"verdicts":[]}});+        let r = assess(&d, &["placement".into()], None);+        assert_eq!(r["decision"], "waiting-for-human");+        assert_eq!(r["mayStop"], true);+        assert_eq!(r["engineeringComplete"], false);+    }+    #[test]     fn finish_without_delivery_still_continues() {         let d = json!({"clock":{"finishTime":"2026-10-10T01:00:00Z"}});         assert_eq!(assess(&d, &[], None)["mayStop"], false);
crates/adom-aiflow/src/handson.rsadded+520
@@ -0,0 +1,520 @@+//! Hands-on AI Flow (Noah, 2026-11-03: "a hands on mode of AI Flow that allows the user to have more+//! involvement with the process"). Before any design work the human chooses, for every step of the+//! run, how much the AI does on its own:+//!   auto     the AI takes care of it with as little human input as possible (the flow as it always was)+//!   checkin  the AI does the step, shows it and waits: the next step is refused until the human approves+//!   human    the human does the step in their own tools; the AI hands it over, waits for "done", then+//!            runs the step's own check on their work (a step's proof never changes)+//!   custom   the human's own instructions for the step, printed every time the AI enters it+//! The choices are a flow profile (`~/.adom/aiflow/profiles/<slug>.json`): saved the first time, the+//! default for the next board, and switchable ("noah flow" for real boards, "full-ai" for simple+//! ones). A profile also says how the AI may reach the human: `notify` (hydrogen, toast, both, none)+//! and `screen` (hands-off: never raise or maximise a window on the human's desktop; allowed).+//! Answers come from the chat (`handson approve|changes|done --quote`) or from the timeline's+//! buttons (POST /handson writes `<run>/timeline/handson-inbox.json`, which every command ingests).+//! This changes who does a step and when the run may move on; it never waives a proof or a finish check.+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++pub const MODES: [&str; 4] = ["auto", "checkin", "human", "custom"];+pub const NOTIFY: [&str; 4] = ["hydrogen", "toast", "both", "none"];+pub const SCREEN: [&str; 2] = ["hands-off", "allowed"];+pub const INBOX: &str = "handson-inbox.json";+const MAX_TEXT: usize = 4000;++pub fn mode_label(mode: &str) -> &'static str {+    match mode { "checkin" => "AI does it, then checks in with you", "human" => "you do it", "custom" => "your custom instructions", _ => "AI takes care of it" }+}++/// Where the human's flow profiles live: `AIFLOW_PREFS_DIR`, else `~/.adom/aiflow`.+pub fn home() -> PathBuf {+    std::env::var("AIFLOW_PREFS_DIR").map(PathBuf::from)+        .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())).join(".adom").join("aiflow"))+}++/// "Noah flow" -> "noah-flow".+pub fn slug(name: &str) -> String {+    let mut s = String::new();+    for c in name.trim().to_lowercase().chars() {+        if c.is_ascii_alphanumeric() { s.push(c) } else if !s.ends_with('-') { s.push('-') }+    }+    s.trim_matches('-').chars().take(48).collect()+}++/// The one built-in profile: every step to the AI, and the human's screen left alone.+fn builtin(s: &str) -> Option<Value> {+    (s == "full-ai").then(|| json!({"name": "Full AI flow", "slug": "full-ai", "builtin": true, "fallback": "auto", "steps": {}, "notify": "hydrogen", "screen": "hands-off"}))+}++fn read(p: &Path) -> Option<Value> { serde_json::from_str(&std::fs::read_to_string(p).ok()?).ok() }++fn write_atomic(p: &Path, v: &Value) -> Result<(), String> {+    if let Some(d) = p.parent() { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }+    let tmp = p.with_extension("json.tmp");+    std::fs::write(&tmp, serde_json::to_string_pretty(v).unwrap()).and_then(|_| std::fs::rename(&tmp, p)).map_err(|e| e.to_string())+}++pub fn load_profile(name: &str) -> Option<Value> {+    let s = slug(name);+    builtin(&s).or_else(|| read(&home().join("profiles").join(format!("{s}.json"))))+}++pub fn default_slug() -> Option<String> {+    read(&home().join("preferences.json")).and_then(|v| v["defaultProfile"].as_str().map(str::to_string))+}++pub fn default_profile() -> Option<Value> { default_slug().and_then(|s| load_profile(&s)) }++pub fn set_default(name: &str) -> Result<(), String> {+    let p = home().join("preferences.json");+    let mut v = read(&p).unwrap_or(json!({}));+    v["defaultProfile"] = json!(slug(name));+    write_atomic(&p, &v)+}++/// The saved profiles (and the built-in full-ai), the default one marked.+pub fn list_profiles() -> Vec<Value> {+    let def = default_slug();+    let mut out: Vec<Value> = std::fs::read_dir(home().join("profiles")).into_iter().flatten().flatten()+        .filter(|e| e.path().extension().is_some_and(|x| x == "json"))+        .filter_map(|e| read(&e.path())).filter(|v| v["slug"].is_string()).collect();+    out.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));+    out.push(builtin("full-ai").unwrap());+    for p in &mut out { p["default"] = json!(def.as_deref() == p["slug"].as_str()); }+    out+}++/// Save the run's plan as a profile under `name`; the first profile a human saves becomes their default.+pub fn save_profile(name: &str, plan: &Value, at: &str) -> Result<String, String> {+    let s = slug(name);+    if s.is_empty() { return Err("a profile needs a name with letters or digits".into()); }+    if builtin(&s).is_some() { return Err(format!("{s} is built in; save under another name")); }+    let p = home().join("profiles").join(format!("{s}.json"));+    let created = read(&p).and_then(|v| v["created"].as_str().map(str::to_string)).unwrap_or_else(|| at.to_string());+    let first = default_slug().is_none();+    write_atomic(&p, &json!({"name": name.trim(), "slug": s, "fallback": plan["fallback"].as_str().unwrap_or("auto"), "steps": plan["steps"],+        "notify": plan["notify"].as_str().unwrap_or("hydrogen"), "screen": plan["screen"].as_str().unwrap_or("hands-off"), "created": created, "updated": at}))?;+    if first { set_default(&s)?; }+    Ok(s)+}++pub fn delete_profile(name: &str) -> Result<(), String> {+    let s = slug(name);+    std::fs::remove_file(home().join("profiles").join(format!("{s}.json"))).map_err(|e| format!("{s}: {e}"))?;+    if default_slug().as_deref() == Some(s.as_str()) {+        let p = home().join("preferences.json");+        let mut v = read(&p).unwrap_or(json!({}));+        v.as_object_mut().map(|o| o.remove("defaultProfile"));+        write_atomic(&p, &v)?;+    }+    Ok(())+}++/// One step's choice, checked: a known mode, and instructions for `custom` (optional for the others).+fn step_choice(step: &str, v: &Value) -> Result<Value, String> {+    let mode = v["mode"].as_str().unwrap_or("auto");+    if !MODES.contains(&mode) { return Err(format!("{step}: mode {mode:?} is not one of {}", MODES.join(", "))); }+    let text = v["instructions"].as_str().map(str::trim).unwrap_or("");+    if text.len() > MAX_TEXT { return Err(format!("{step}: instructions over {MAX_TEXT} characters")); }+    if mode == "custom" && text.is_empty() { return Err(format!("{step}: custom needs the instructions to follow")); }+    Ok(if text.is_empty() { json!({"mode": mode}) } else { json!({"mode": mode, "instructions": text}) })+}++/// A plan as the dashboard or a profile writes it, checked and normalised:+/// {"steps": {name: {mode, instructions}}, "fallback", "notify", "screen"}.+pub fn normalise(plan: &Value) -> Result<Value, String> {+    let mut steps = serde_json::Map::new();+    for (k, v) in plan["steps"].as_object().into_iter().flatten() {+        if k.is_empty() || k.len() > 64 { return Err("a step name is empty or too long".into()); }+        steps.insert(k.clone(), step_choice(k, v)?);+    }+    let pick = |key: &str, set: &[&str], dflt: &str| -> Result<String, String> {+        let v = plan[key].as_str().unwrap_or(dflt);+        if set.contains(&v) { Ok(v.to_string()) } else { Err(format!("{key} {v:?} is not one of {}", set.join(", "))) }+    };+    let fallback = pick("fallback", &["auto", "checkin"], "auto")?;+    Ok(json!({"steps": steps, "fallback": fallback, "notify": pick("notify", &NOTIFY, "hydrogen")?, "screen": pick("screen", &SCREEN, "hands-off")?}))+}++fn hands(data: &Value) -> &Value { &data["handsOn"] }++pub fn active(data: &Value) -> bool { hands(data)["state"] == "active" }+pub fn planning(data: &Value) -> bool { hands(data)["state"] == "planning" }++pub fn mode_of<'a>(data: &'a Value, step: &str) -> &'a str {+    if !active(data) && !planning(data) { return "auto"; }+    let h = hands(data);+    h["steps"][step]["mode"].as_str().or(h["fallback"].as_str()).unwrap_or("auto")+}++pub fn instructions_of<'a>(data: &'a Value, step: &str) -> Option<&'a str> {+    hands(data)["steps"][step]["instructions"].as_str().filter(|s| !s.is_empty())+}++/// Whether AI Flow may raise or maximise windows on the human's desktop (issue #132). Runs with no+/// hands-on record keep the old behaviour; every profile defaults to hands-off.+pub fn may_raise(data: &Value) -> bool { hands(data)["screen"] != "hands-off" }++/// Put a plan on the run. `state` is planning (shown to the human, not yet confirmed) or active.+pub fn apply(data: &mut Value, plan: &Value, state: &str, profile: Option<(&str, &str)>, source: &str, at: &str) {+    let keep = hands(data).clone();+    let mut h = json!({"state": state, "source": source, "steps": plan["steps"], "fallback": plan["fallback"], "notify": plan["notify"], "screen": plan["screen"],+        "verdicts": keep["verdicts"].as_array().cloned().unwrap_or_default(), "asks": keep["asks"].as_array().cloned().unwrap_or_default(),+        "inboxSeq": keep["inboxSeq"].as_u64().unwrap_or(0), "history": keep["history"].as_array().cloned().unwrap_or_default()});+    if let Some((s, n)) = profile { h["profile"] = json!(s); h["profileName"] = json!(n); }+    // a plan confirmed now governs the visits from now; history before it is not re-reviewed+    h["setAt"] = if state == "active" { json!(at) } else { keep["setAt"].clone() };+    let entry = json!({"at": at, "state": state, "source": source, "profile": h["profile"]});+    h["history"].as_array_mut().unwrap().push(entry);+    data["handsOn"] = h;+}++pub fn plan_of(data: &Value) -> Value {+    let h = hands(data);+    json!({"steps": h["steps"], "fallback": h["fallback"], "notify": h["notify"], "screen": h["screen"]})+}++/// The profile as a plan, ready for `apply`.+pub fn profile_plan(p: &Value) -> Value {+    normalise(&json!({"steps": p["steps"], "fallback": p["fallback"], "notify": p["notify"], "screen": p["screen"]})).unwrap_or_else(|_| json!({"steps": {}, "fallback": "auto", "notify": "hydrogen", "screen": "hands-off"}))+}++/// A new run takes the human's default profile, when they saved one.+pub fn apply_default(data: &mut Value, at: &str) -> Option<String> {+    // headless automation (every integration test) never inherits the developer's own default flow;+    // a test that wants profiles names its own folder with AIFLOW_PREFS_DIR+    if std::env::var("AIFLOW_DASHBOARD").as_deref() == Ok("off") && std::env::var("AIFLOW_PREFS_DIR").is_err() { return None; }+    let p = default_profile()?;+    let (s, n) = (p["slug"].as_str().unwrap_or("").to_string(), p["name"].as_str().unwrap_or("").to_string());+    apply(data, &profile_plan(&p), "active", Some((&s, &n)), "default profile", at);+    Some(format!("Hands-on: the human's default flow \"{n}\" applies to this run ({}). Tell them in one line. `handson plan` lets them change it for this board on the timeline; `handson use <profile>` switches (`handson profiles` lists them, full-ai hands every step to you).", summary(data)))+}++/// "placement: checkin, routing: human, 2 custom" style one-liner.+pub fn summary(data: &Value) -> String {+    let h = hands(data);+    let mut parts: Vec<String> = h["steps"].as_object().into_iter().flatten()+        .filter(|(_, v)| v["mode"] != "auto").map(|(k, v)| format!("{k} {}", v["mode"].as_str().unwrap_or(""))).collect();+    if parts.is_empty() { parts.push(format!("every step {}", h["fallback"].as_str().unwrap_or("auto"))); }+    else { parts.push(format!("the rest {}", h["fallback"].as_str().unwrap_or("auto"))); }+    format!("{}; notify {}, screen {}", parts.join(", "), h["notify"].as_str().unwrap_or("hydrogen"), h["screen"].as_str().unwrap_or("hands-off"))+}++fn latest_visit(data: &Value, step: &str) -> Option<(u64, String)> {+    let v = data["steps"][step]["visits"].as_array()?.last()?;+    Some((v["n"].as_u64().unwrap_or(1), v["at"].as_str().unwrap_or("").to_string()))+}++fn latest<'a>(list: &'a Value, step: &str, visit: u64) -> Option<&'a Value> {+    list.as_array()?.iter().rev().find(|e| e["step"] == step && e["visit"].as_u64() == Some(visit))+}++/// The human's latest word on a step's visit: approved, changes or done.+pub fn verdict<'a>(data: &'a Value, step: &str, visit: u64) -> Option<&'a Value> { latest(&hands(data)["verdicts"], step, visit) }++/// What the run is waiting on from the human, if anything: the plan, a check-in or a human step.+pub fn waiting(data: &Value) -> Option<Value> {+    if planning(data) { return Some(json!({"kind": "plan", "what": "the human to choose how hands-on each step is"})); }+    if !active(data) { return None; }+    let set = hands(data)["setAt"].as_str().unwrap_or("");+    let cur = data["currentStep"].as_str().unwrap_or("");+    if let Some((n, at)) = latest_visit(data, cur).filter(|(_, at)| at.as_str() >= set) {+        if mode_of(data, cur) == "human" && !verdict(data, cur, n).is_some_and(|v| v["verdict"] == "done" || v["verdict"] == "approved") {+            return Some(json!({"kind": "human", "step": cur, "visit": n, "since": at, "what": format!("the human to do {cur} and say done")}));+        }+    }+    // an open check-in: the latest ask on a step's latest visit with no verdict after it+    for a in hands(data)["asks"].as_array().into_iter().flatten().rev() {+        let (Some(s), Some(n)) = (a["step"].as_str(), a["visit"].as_u64()) else { continue };+        if latest_visit(data, s).map(|(v, _)| v) != Some(n) { continue; }+        let answered = verdict(data, s, n).is_some_and(|v| v["at"].as_str() >= a["at"].as_str());+        if !answered { return Some(json!({"kind": "checkin", "step": s, "visit": n, "since": a["at"], "summary": a["summary"], "what": format!("the human to review {s}")})); }+    }+    None+}++/// What stops a forward move to `target` on the human's side. Only visits that started after the+/// plan was confirmed count; a step's own proof is still `sequence::blockers`' business.+pub fn blockers(data: &Value, seq: &[String], target: &str) -> Vec<String> {+    if planning(data) && target != "intake" {+        return vec!["hands-on plan (the human has not confirmed how hands-on each step is: they choose on the timeline, or say it in chat for `handson set` + `handson start`)".into()];+    }+    if !active(data) { return vec![]; }+    let Some(pos) = seq.iter().position(|s| s == target) else { return vec![] };+    let set = hands(data)["setAt"].as_str().unwrap_or("").to_string();+    let mut out = Vec::new();+    for s in &seq[..pos] {+        let Some((n, at)) = latest_visit(data, s) else { continue };+        if at < set { continue; }+        let mode = mode_of(data, s);+        let ask = latest(&hands(data)["asks"], s, n);+        let asked = ask.is_some();+        // a verdict older than the latest ask was about the work before it (changes made, asked again)+        let v = verdict(data, s, n).filter(|v| ask.map_or(true, |a| v["at"].as_str() >= a["at"].as_str()));+        let said = |v: Option<&Value>| v.and_then(|v| v["quote"].as_str()).filter(|q| !q.is_empty()).map(|q| format!(": \"{}\"", aiflow_run::clip_text(q, 160))).unwrap_or_default();+        match (mode, v.and_then(|v| v["verdict"].as_str())) {+            (_, Some("changes")) => out.push(format!("{s} (hands-on: the human asked for changes{}; make them, then `handson ask {s}` again)", said(v))),+            ("human", Some("done" | "approved")) => {}+            ("human", _) => out.push(format!("{s} (hands-on: the human's step; they have not said they are done)")),+            (_, Some("approved")) => {}+            ("checkin", None) if !asked => out.push(format!("{s} (hands-on check-in: show the human what you did, then `handson ask {s} --summary \"...\"` and wait for their OK)")),+            (_, None) if asked || mode == "checkin" => out.push(format!("{s} (hands-on: waiting for the human's review)")),+            _ => {}+        }+    }+    out+}++/// What finish still needs from the human: every check-in and human step on the way to finish.+pub fn finish_missing(data: &Value, seq: &[String]) -> Vec<String> {+    let end = seq.iter().position(|s| s == "finish").map(|p| seq[p].clone()).unwrap_or_else(|| "\u{0}end".into());+    let mut s2: Vec<String> = seq.to_vec();+    if !s2.contains(&end) { s2.push(end.clone()); }+    blockers(data, &s2, &end).into_iter().filter(|b| !b.starts_with("hands-on plan")).collect()+}++/// How the human's own work becomes the step's proof: the step's own check, run on what they made.+pub fn human_proof(step: &str, flow: &Value) -> String {+    match step {+        "placement" => "save it, `adopt-board --board <their saved board> --evidence <a note: placed by the human>`, then `place verify`: their placement becomes the placement proof".into(),+        "routing" => "`adopt-board` their routed board, then `route` (it plans only what their copper leaves open; nothing when it is complete), `gate --plan <run>/plan-route.json` and `land route --plan <run>/plan-route.json`: an empty plan lands nothing and records the routing proof. Finish any open connections only if they ask you to".into(),+        "pours" => "`adopt-board`, then `verify-pours --adopt --why \"poured by the human\"`".into(),+        "schematic" => "`schematic check --sch <their schematic>`".into(),+        "silkscreen" => "`silkscreen-audit --input <audit of their silkscreen> --out <result> --record`".into(),+        "simulation" => "`evidence add --kind simulation --file <their result>`".into(),+        _ => {+            let keys: Vec<&str> = flow["proofs"][step].as_array().into_iter().flatten().filter_map(Value::as_str).collect();+            if keys.is_empty() { "nothing to record: the step has no proof".into() } else { format!("run this step's own check on their work so it records {}", keys.join(" or ")) }+        }+    }+}++/// The hints `step <name>` prints for the step's mode.+pub fn step_hints(data: &Value, step: &str, flow: &Value) -> Vec<String> {+    if !active(data) { return vec![]; }+    let mut out = Vec::new();+    let does = flow["short"][step].as_str().unwrap_or("");+    match mode_of(data, step) {+        "checkin" => out.push(format!("HANDS-ON CHECK-IN: do {step}, then show the human what you did (the board or the file, a screenshot, the numbers that matter) and run `handson ask {step} --summary \"<what to look at>\"`. The next step is refused until they approve (timeline button or chat, then `handson approve {step} --quote \"<their words>\"`). Wait with `handson wait`; never approve for them.")),+        "human" => out.push(format!("HANDS-ON HUMAN STEP: the human does {step} ({does}) themselves. Do NOT do its work. Tell them what it needs and where (the editor they use), then wait (`handson wait`). When they say done (timeline button, or chat: `handson done {step} --quote \"<their words>\"`), {}. If the check fails, show them what failed and ask: fix it, let you fix it, or deviate.", human_proof(step, flow))),+        "custom" => {}+        _ => out.push(format!("Hands-on: {step} is yours to do without asking (flow \"{}\").", hands(data)["profileName"].as_str().unwrap_or("this run's plan"))),+    }+    if let Some(t) = instructions_of(data, step) { out.push(format!("THE HUMAN'S INSTRUCTIONS FOR {}: \"{t}\". Follow them; when they ask to see the result, `handson ask {step}` makes it a check-in.", step.to_uppercase())); }+    out+}++fn text_ok(s: &str) -> Result<&str, String> {+    let s = s.trim();+    if s.len() > MAX_TEXT { Err(format!("over {MAX_TEXT} characters")) } else { Ok(s) }+}++/// Record a check-in: the AI asks the human to look at a step's latest visit.+pub fn ask(data: &mut Value, step: &str, summary: &str, at: &str) -> Result<u64, String> {+    if !active(data) { return Err("no hands-on plan on this run (`handson plan` or `handson use <profile>` first)".into()); }+    let (n, _) = latest_visit(data, step).ok_or_else(|| format!("{step} has not been visited; `step {step}` and do it first"))?;+    let summary = text_ok(summary)?;+    data["handsOn"]["asks"].as_array_mut().unwrap().push(json!({"step": step, "visit": n, "summary": summary, "at": at}));+    Ok(n)+}++/// Record the human's word on a step: approved, changes or done. `by` is chat or dashboard.+pub fn record_verdict(data: &mut Value, step: &str, verdict: &str, quote: &str, by: &str, at: &str) -> Result<u64, String> {+    if !["approved", "changes", "done"].contains(&verdict) { return Err(format!("verdict {verdict:?}")); }+    if !active(data) { return Err("no hands-on plan on this run".into()); }+    let (n, _) = latest_visit(data, step).ok_or_else(|| format!("{step} has not been visited"))?;+    let quote = text_ok(quote)?;+    if by == "chat" && quote.is_empty() { return Err("--quote the human's own words: an approval is theirs to give, never inferred".into()); }+    if verdict == "changes" && quote.is_empty() { return Err("changes need the human's words: what to change".into()); }+    data["handsOn"]["verdicts"].as_array_mut().unwrap().push(json!({"step": step, "visit": n, "verdict": verdict, "quote": quote, "by": by, "at": at}));+    Ok(n)+}++/// Apply the timeline's queued answers (POST /handson) to the run; returns one line per event.+/// Profiles named in a plan are saved here, under the human's own name for them.+pub fn ingest(data: &mut Value, timeline_dir: &Path) -> Vec<String> {+    let Some(inbox) = read(&timeline_dir.join(INBOX)) else { return vec![] };+    let seen = hands(data)["inboxSeq"].as_u64().unwrap_or(0);+    let mut out = Vec::new();+    let mut top = seen;+    for e in inbox["events"].as_array().into_iter().flatten() {+        let seq = e["seq"].as_u64().unwrap_or(0);+        if seq <= seen { continue; }+        top = top.max(seq);+        let at = e["at"].as_str().unwrap_or("").to_string();+        let note = e["note"].as_str().unwrap_or("");+        let line = match e["action"].as_str().unwrap_or("") {+            "plan" => match normalise(&e["plan"]) {+                Ok(plan) => {+                    let name = e["plan"]["saveAs"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);+                    let mut msg = String::new();+                    let saved = match &name { Some(n) => match save_profile(n, &plan, &at) { Ok(s) => { msg = format!(", saved as profile \"{n}\" ({s})"); Some((s, n.clone())) } Err(x) => { msg = format!(", NOT saved as a profile: {x}"); None } }, None => None };+                    if e["plan"]["makeDefault"] == true { if let Some((s, _)) = &saved { if set_default(s).is_ok() { msg.push_str(", now their default"); } } }+                    let prof = saved.clone().or_else(|| hands(data)["profile"].as_str().map(|s| (s.to_string(), hands(data)["profileName"].as_str().unwrap_or(s).to_string())));+                    apply(data, &plan, "active", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "timeline", &at);+                    format!("HUMAN (timeline): confirmed the hands-on plan{msg}: {}", summary(data))+                }+                Err(x) => format!("HUMAN (timeline): sent a plan that was refused ({x}); ask them in chat"),+            },+            v @ ("approved" | "changes" | "done") => {+                let step = e["step"].as_str().unwrap_or("");+                match record_verdict(data, step, v, note, "dashboard", &at) {+                    Ok(n) => format!("HUMAN (timeline): {v} {step} (visit {n}){}", if note.is_empty() { String::new() } else { format!(": \"{note}\"") }),+                    Err(x) => format!("HUMAN (timeline): {v} on {step} not recorded ({x})"),+                }+            }+            other => format!("HUMAN (timeline): unknown action {other:?} ignored"),+        };+        out.push(line);+    }+    if top > seen {+        if !data["handsOn"].is_object() { data["handsOn"] = json!({"state": "off"}); }+        data["handsOn"]["inboxSeq"] = json!(top);+    }+    out+}++/// The dashboard's view: the plan, every profile for the picker, and what the run waits on.+pub fn timeline_block(data: &Value) -> Value {+    let h = hands(data);+    let profiles: Vec<Value> = list_profiles().into_iter().map(|p| json!({"slug": p["slug"], "name": p["name"], "default": p["default"], "builtin": p["builtin"].as_bool().unwrap_or(false),+        "steps": p["steps"], "fallback": p["fallback"], "notify": p["notify"], "screen": p["screen"]})).collect();+    let mut steps = serde_json::Map::new();+    if let Some(o) = data["steps"].as_object() {+        for (name, _) in o {+            if let Some((n, _)) = latest_visit(data, name) {+                if let Some(v) = verdict(data, name, n) { steps.insert(name.clone(), json!({"visit": n, "verdict": v["verdict"], "quote": v["quote"], "by": v["by"], "at": v["at"]})); }+            }+        }+    }+    json!({"state": h["state"].as_str().unwrap_or("off"), "profile": h["profile"], "profileName": h["profileName"], "source": h["source"],+        "plan": {"steps": h["steps"], "fallback": h["fallback"].as_str().unwrap_or("auto"), "notify": h["notify"].as_str().unwrap_or("hydrogen"), "screen": h["screen"].as_str().unwrap_or("hands-off")},+        "verdicts": steps, "waiting": waiting(data), "profiles": profiles,+        "modes": MODES.iter().map(|m| json!({"mode": m, "label": mode_label(m)})).collect::<Vec<_>>()})+}++/// Tell the human the run needs them, the way their profile says. Never raises a window.+pub fn notify(data: &Value, ai_thread: &str, title: &str, body: &str) -> String {+    if std::env::var("AIFLOW_NOTIFY").map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "0" | "false")).unwrap_or(false) { return "notification skipped (AIFLOW_NOTIFY=off)".into(); }+    let how = hands(data)["notify"].as_str().unwrap_or("hydrogen");+    let mut sent = Vec::new();+    let run = |prog: &str, args: Vec<String>| -> bool {+        let Ok(mut c) = std::process::Command::new(prog).args(&args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() else { return false };+        let t = std::time::Instant::now();+        while t.elapsed() < std::time::Duration::from_secs(8) { if let Ok(Some(s)) = c.try_wait() { return s.success(); } std::thread::sleep(std::time::Duration::from_millis(100)); }+        true // still running: the notification is on its way+    };+    if matches!(how, "hydrogen" | "both") && run("adom-cli", vec!["notify".into(), "--title".into(), title.into(), "--tag".into(), "aiflow-handson".into(), body.into()]) { sent.push("Hydrogen"); }+    if matches!(how, "toast" | "both") {+        let mut a = vec!["--ai-thread".to_string(), ai_thread.to_string()];+        if let Some(t) = data["target"].as_str().filter(|t| !t.is_empty()) { a.extend(["--target".to_string(), t.to_string()]); }+        a.extend(["notify_user".to_string(), json!({"title": title, "body": body, "scenario": "reminder"}).to_string()]);+        if run("adom-bridge", a) { sent.push("a desktop toast"); }+    }+    if how == "none" { "notifications are off in this flow: tell the human in chat".into() }+    else if sent.is_empty() { format!("could not notify the human ({how}): tell them in chat") }+    else { format!("the human was notified ({})", sent.join(" and ")) }+}++#[cfg(test)]+mod tests {+    use super::*;+    fn seq() -> Vec<String> { ["intake", "rules", "placement", "routing", "pours", "finish"].iter().map(|s| s.to_string()).collect() }+    fn run_with(plan: Value) -> Value {+        let mut d = json!({"steps": {}, "currentStep": "intake"});+        apply(&mut d, &normalise(&plan).unwrap(), "active", Some(("t", "Test flow")), "test", "2026-11-03T10:00:00Z");+        d+    }+    fn visit(d: &mut Value, step: &str, at: &str) {+        let st = d["steps"].as_object_mut().unwrap().entry(step.to_string()).or_insert(json!({"visits": []}));+        let n = st["visits"].as_array().unwrap().len() as u64 + 1;+        st["visits"].as_array_mut().unwrap().push(json!({"n": n, "at": at}));+        d["currentStep"] = json!(step);+    }+    #[test]+    fn slug_and_validation() {+        assert_eq!(slug("  Noah flow! "), "noah-flow");+        assert!(normalise(&json!({"steps": {"placement": {"mode": "custom"}}})).is_err());+        assert!(normalise(&json!({"steps": {"placement": {"mode": "maybe"}}})).is_err());+        assert!(normalise(&json!({"steps": {}, "screen": "grab"})).is_err());+        let p = normalise(&json!({"steps": {"routing": {"mode": "human"}}})).unwrap();+        assert_eq!(p["screen"], "hands-off");+        assert_eq!(p["notify"], "hydrogen");+    }+    #[test]+    fn a_checkin_step_blocks_until_the_human_approves_its_latest_visit() {+        let mut d = run_with(json!({"steps": {"placement": {"mode": "checkin"}}}));+        visit(&mut d, "placement", "2026-11-03T10:05:00Z");+        let b = blockers(&d, &seq(), "routing");+        assert!(b[0].contains("handson ask placement"), "{b:?}");+        ask(&mut d, "placement", "look at the gate loops", "2026-11-03T10:06:00Z").unwrap();+        assert_eq!(waiting(&d).unwrap()["kind"], "checkin");+        assert!(blockers(&d, &seq(), "routing")[0].contains("waiting"));+        assert!(record_verdict(&mut d, "placement", "approved", "", "chat", "2026-11-03T10:07:00Z").is_err(), "chat approvals quote the human");+        record_verdict(&mut d, "placement", "changes", "move U2 next to U1", "dashboard", "2026-11-03T10:07:00Z").unwrap();+        assert!(blockers(&d, &seq(), "routing")[0].contains("move U2"));+        ask(&mut d, "placement", "U2 moved", "2026-11-03T10:08:00Z").unwrap();+        assert!(blockers(&d, &seq(), "routing")[0].contains("waiting"), "a new ask supersedes the old changes");+        record_verdict(&mut d, "placement", "approved", "good", "chat", "2026-11-03T10:09:00Z").unwrap();+        assert!(blockers(&d, &seq(), "routing").is_empty());+        assert!(waiting(&d).is_none());+        // a return to placement is a new visit: it needs its own approval+        visit(&mut d, "routing", "2026-11-03T10:10:00Z");+        visit(&mut d, "placement", "2026-11-03T10:20:00Z");+        assert!(!blockers(&d, &seq(), "routing").is_empty());+    }+    #[test]+    fn a_human_step_waits_for_done_and_history_before_the_plan_is_not_reviewed() {+        let mut d = json!({"steps": {}, "currentStep": "intake"});+        visit(&mut d, "placement", "2026-11-03T09:00:00Z");+        apply(&mut d, &normalise(&json!({"steps": {"placement": {"mode": "checkin"}, "routing": {"mode": "human"}}})).unwrap(), "active", None, "test", "2026-11-03T10:00:00Z");+        assert!(blockers(&d, &seq(), "routing").is_empty(), "placement was visited before the plan");+        visit(&mut d, "routing", "2026-11-03T10:30:00Z");+        assert_eq!(waiting(&d).unwrap()["kind"], "human");+        assert!(blockers(&d, &seq(), "pours")[0].contains("not said they are done"));+        record_verdict(&mut d, "routing", "done", "routed it", "chat", "2026-11-03T11:00:00Z").unwrap();+        assert!(blockers(&d, &seq(), "pours").is_empty());+        assert!(finish_missing(&d, &seq()).is_empty());+    }+    #[test]+    fn planning_blocks_design_work_and_auto_never_blocks() {+        let mut d = json!({"steps": {}, "currentStep": "intake"});+        apply(&mut d, &normalise(&json!({"steps": {}})).unwrap(), "planning", None, "test", "2026-11-03T10:00:00Z");+        assert!(!blockers(&d, &seq(), "rules").is_empty());+        assert!(blockers(&d, &seq(), "intake").is_empty());+        assert_eq!(waiting(&d).unwrap()["kind"], "plan");+        let mut d = run_with(json!({"steps": {"placement": {"mode": "custom", "instructions": "keep U1 near J1"}}}));+        visit(&mut d, "placement", "2026-11-03T10:05:00Z");+        assert!(blockers(&d, &seq(), "routing").is_empty());+        assert!(step_hints(&d, "placement", &json!({})).iter().any(|h| h.contains("keep U1 near J1")));+        assert!(!may_raise(&d));+        assert!(may_raise(&json!({})), "runs without hands-on keep the old screen behaviour");+    }+    #[test]+    fn profiles_and_the_timeline_inbox() {+        let tmp = std::env::temp_dir().join(format!("aiflow-handson-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&tmp);+        std::env::set_var("AIFLOW_PREFS_DIR", tmp.join("prefs"));+        let tl = tmp.join("timeline");+        std::fs::create_dir_all(&tl).unwrap();+        let mut d = json!({"steps": {}, "currentStep": "intake"});+        apply(&mut d, &normalise(&json!({"steps": {}})).unwrap(), "planning", None, "test", "2026-11-03T10:00:00Z");+        write_atomic(&tl.join(INBOX), &json!({"events": [{"seq": 1, "at": "2026-11-03T10:01:00Z", "action": "plan",+            "plan": {"steps": {"routing": {"mode": "human"}}, "notify": "none", "screen": "hands-off", "saveAs": "Noah flow"}}]})).unwrap();+        let lines = ingest(&mut d, &tl);+        assert!(lines[0].contains("saved as profile"), "{lines:?}");+        assert!(active(&d));+        assert_eq!(mode_of(&d, "routing"), "human");+        assert_eq!(default_slug().as_deref(), Some("noah-flow"), "the first saved profile is the default");+        assert!(ingest(&mut d, &tl).is_empty(), "an event is applied once");+        let mut fresh = json!({"steps": {}});+        assert!(apply_default(&mut fresh, "2026-11-03T12:00:00Z").unwrap().contains("Noah flow"));+        assert_eq!(mode_of(&fresh, "routing"), "human");+        assert_eq!(list_profiles().len(), 2);+        assert!(save_profile("full ai", &plan_of(&fresh), "x").is_err());+        delete_profile("noah-flow").unwrap();+        assert!(default_slug().is_none());+        std::env::remove_var("AIFLOW_PREFS_DIR");+        let _ = std::fs::remove_dir_all(&tmp);+    }+}
crates/adom-aiflow/src/main.rs+195−3
@@ -57,6 +57,7 @@ mod impedance_cmd; mod reviews; mod deviations; mod prefs;+mod handson; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -267,6 +268,8 @@ enum Cmd {     SpecAmend { #[arg(long)] why: String },     /// Accept one item `finish` (or a `step`) refused, on purpose, when the check rather than the board is wrong: `deviate "<item>" --why "..."` (the item as the refusal names it, e.g. "gate", "heatsink vias", "step routing"). Recorded, bound to the saved board and spec, and listed in the finish summary; `--list` shows them, `--clear` removes one.     Deviate { item: Option<String>, #[arg(long)] why: Option<String>, #[arg(long)] list: bool, #[arg(long)] clear: bool },+    /// Hands-on AI Flow (docs/hands-on.md): the human chooses, step by step, whether the AI takes care of it (auto), does it and checks in (checkin), leaves it to them (human) or follows their instructions (custom), saved as flow profiles. `plan [--profile P]` shows the choice on the timeline before any design work; `set STEP=MODE ... [--note STEP=TEXT]`, `start [--save-as NAME] [--default]` confirm it from chat; `use <profile>` switches (full-ai is built in); `ask STEP --summary`, `approve|changes|done STEP --quote`, `wait` carry the human's part; `status`, `profiles [--profile P --default] [--delete P]`, `off`.+    Handson { #[arg(value_parser = ["plan", "set", "start", "use", "ask", "approve", "changes", "done", "wait", "status", "profiles", "off"])] action: String, args: Vec<String>, #[arg(long)] profile: Option<String>, #[arg(long)] save_as: Option<String>, #[arg(long)] default: bool, #[arg(long)] quote: Option<String>, #[arg(long)] summary: Option<String>, #[arg(long)] note: Vec<String>, #[arg(long)] notify: Option<String>, #[arg(long)] screen: Option<String>, #[arg(long, default_value_t = 540)] timeout: u64, #[arg(long)] delete: Option<String> },     /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.     Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },     /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.@@ -3082,6 +3085,7 @@ fn main() {             r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));             if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }             if let Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }+            let hands_line = handson::apply_default(&mut r.data, &now());             let idx = r.turn_begin(&command_line());             record_usage(&mut r, "start");             r.save().unwrap();@@ -3097,6 +3101,7 @@ fn main() {             start_hints.extend(converter_warning(&r.data, &spec_v, None, Board::load(&copy.display().to_string()).ok().as_ref()));             if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }             start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);+            start_hints.extend(hands_line);             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);             print_next(&cli);         }@@ -3119,6 +3124,7 @@ fn main() {             if let Some(e) = eda { r.data["eda"] = json!(e); }             // a passive copper-pattern board: the pattern sequence (boardkind.rs)             if let Some(k) = kind { r.data["kind"] = json!(k); }+            let hands_line = handson::apply_default(&mut r.data, &now()).unwrap_or_else(|| "Hands-on AI Flow: when the human asks for it (\"hands-on\", \"check in with me after placement\", \"I'll route it myself\"), run `handson plan` now, before any design work: they choose per step on the timeline, and the choice is saved as their flow profile for the next board (skill aiflow-hands-on).".into());             let idx = r.turn_begin(&command_line());             record_usage(&mut r, "start");             r.save().unwrap();@@ -3131,6 +3137,7 @@ fn main() {                 "When the board exists: `start --board B.kicad_pcb --spec spec.json --engine <you> [--target <box> --remote-board <path>]` on this same --run attaches it; the clock stays at the prompt.".into(),                 if r.data["page"].is_null() { "Name the run's page once (`clips --page <owner/slug>` or `report --page ...`) so every clip lands on docs/clips.md as it stops.".into() } else { String::new() },                 "Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it.".into(),+                hands_line,             ]);             print_next(&cli);         }@@ -4361,6 +4368,11 @@ fn main() {             if placement["mode"]=="verified-existing" && copper_carry::placement_bound(&r.data,&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err() {                 missing.push("place verify (saved board or spec changed; verify again)".into());             }+            {+                let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+                let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&r.dir, &r.data)));+                missing.extend(handson::finish_missing(&r.data, &seq));+            }             let gate = o.get("gate").cloned().unwrap_or(Value::Null);             if !pattern && (gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0)) {                 missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());@@ -4662,7 +4674,7 @@ fn main() {             let engine = r.data["engine"].as_str().unwrap_or("").to_string();             match action.as_str() {                 "open" => {-                    let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));+                    let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box")), handson::may_raise(&r.data)).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));                     r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));                     r.save().unwrap();                     let (revision, reply) = br.revision_with_reply(&rb);@@ -5501,6 +5513,12 @@ fn main() {                     // a viewer opened in the background never paints its GL canvas (a white window with a                     // black bar, "last render time 1 ms"): show it once, maximised, and wait until the                     // bridge's screenshot says the canvas rendered, or refuse rather than film a blank+                    // hands-off (issue #132): the viewer only paints in front, so ask the human rather than take their screen+                    if !handson::may_raise(&r.data) {+                        let note = handson::notify(&r.data, &cli.ai_thread.clone().unwrap_or_default(), "AI Flow: may I use your screen?", "The 3D walkthrough needs KiCad's 3D viewer in front for about a minute. Answer in chat.");+                        err(&format!("the 3D walkthrough needs KiCad's 3D viewer in front for about a minute (it paints only when shown), and the human's screen preference is hands-off; {note}"), &[+                            "Ask the human. When they say yes: `handson set --screen allowed`, `tour 3d`, then `handson set --screen hands-off` straight after. If they say no, go on without it; when `finish` lists the 3d step, `deviate` it with their reason (the human keeps their screen).".into()]);+                    }                     let _ = br.call("desktop_ui_window", &json!({"hwnd": viewer, "action": "maximize", "reason": "aiflow 3D walkthrough: the viewer fills the monitor on the test box"}));                     let _ = br.call("desktop_bring_to_front", &json!({"hwnd": viewer, "reason": "aiflow 3D walkthrough: the viewer must paint before it is filmed"}));                     let mut painted = false;@@ -5782,9 +5800,166 @@ fn main() {             }             println!("{}", step_table(&r));         }+        Cmd::Handson { action, args, profile, save_as, default, quote, summary, note, notify, screen, timeout, delete } => {+            let at = now();+            // the profiles live with the human, not the run: listing, the default and deleting need no run+            if action == "profiles" {+                if let Some(d) = delete { handson::delete_profile(d).unwrap_or_else(|e| err(&e, &[])); }+                if *default { let p = profile.clone().or_else(|| args.first().cloned()).unwrap_or_else(|| err("--default needs the profile: `handson profiles --profile <name> --default`", &[]));+                    if handson::load_profile(&p).is_none() { err(&format!("no profile {p:?}"), &["`handson profiles` lists them.".into()]); }+                    handson::set_default(&p).unwrap_or_else(|e| err(&e, &[])); }+                let list = handson::list_profiles();+                let lines: Vec<String> = list.iter().map(|p| {+                    let st: Vec<String> = p["steps"].as_object().into_iter().flatten().filter(|(_, v)| v["mode"] != "auto").map(|(k, v)| format!("{k} {}", v["mode"].as_str().unwrap_or(""))).collect();+                    format!("{}{} ({}): {}; the rest {}; notify {}, screen {}", if p["default"] == true { "* " } else { "  " }, p["name"].as_str().unwrap_or(""), p["slug"].as_str().unwrap_or(""),+                        if st.is_empty() { "every step auto".into() } else { st.join(", ") }, p["fallback"].as_str().unwrap_or("auto"), p["notify"].as_str().unwrap_or("hydrogen"), p["screen"].as_str().unwrap_or("hands-off"))+                }).collect();+                ok(&format!("{} flow profile(s) in {} (* = the default for a new run):\n{}", list.len(), handson::home().join("profiles").display(), lines.join("\n")), &[+                    "Switch this run: `handson use <slug>`. Make one the default: `handson profiles --profile <slug> --default`. A new one: `handson plan`, then the human saves it from the timeline (or `handson start --save-as \"<name>\"`).".into()]);+                return;+            }+            let read_only = matches!(action.as_str(), "status" | "wait");+            let t = if read_only { cli.ai_thread.clone().unwrap_or_default() } else { thread(&cli) };+            let mut r = load_run(&cli);+            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&dir, &r.data)));+            let tl = dir.join("timeline");+            let mut lines = handson::ingest(&mut r.data, &tl);+            let step_arg = |what: &str| -> String {+                let s = args.first().cloned().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err(&format!("{what} needs the step"), &[]));+                if r.data["steps"][&s].is_null() { err(&format!("{s} has not been visited in this run"), &[format!("`step {s}` first; {what} answers for a step's latest visit.")]); }+                s+            };+            let profile_of = |name: &str| handson::load_profile(name).unwrap_or_else(|| err(&format!("no flow profile {name:?}"), &["`handson profiles` lists them (full-ai is built in).".into()]));+            match action.as_str() {+                "plan" => {+                    // start from the profile named, the run's own plan, the human's default, or every step auto+                    let (plan, prof) = if let Some(p) = profile { let v = profile_of(p); (handson::profile_plan(&v), Some((v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string()))) }+                        else if r.data["handsOn"]["steps"].is_object() { (handson::plan_of(&r.data), r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()))) }+                        else if let Some(v) = handson::default_profile() { (handson::profile_plan(&v), Some((v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string()))) }+                        else { (handson::normalise(&json!({})).unwrap(), None) };+                    handson::apply(&mut r.data, &plan, "planning", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "plan", &at);+                    r.log("handson-plan", json!({"state": "planning", "profile": prof.as_ref().map(|p| p.0.clone())}));+                    r.save().unwrap();+                    let note = handson::notify(&r.data, &t, "AI Flow: choose your flow", "Pick, for each step, whether the AI does it, checks in with you, leaves it to you, or follows your instructions.");+                    ok(&format!("hands-on plan open on the timeline, waiting for the human (starting from {}): {}", prof.as_ref().map(|p| format!("profile \"{}\"", p.1)).unwrap_or_else(|| "every step auto".into()), handson::summary(&r.data)), &[+                        format!("Tell the human: the AI Flow timeline shows every step with four choices (1 AI takes care of it, 2 AI checks in with you after it, 3 you do it, 4 custom instructions), plus how to notify them and whether you may bring windows to the front. They press Start there and can save it as a named flow (the first one becomes their default). {note}."),+                        "Then `handson wait` (it returns when they press Start). If they answer in chat instead: `handson set placement=checkin routing=human --note schematic=\"...\"`, then `handson start --save-as \"<their name for it>\"`.".into(),+                        "No design work until it is confirmed: `step` refuses every step after intake while the plan is open.".into()]);+                }+                "set" => {+                    let mut plan = handson::plan_of(&r.data);+                    if !plan["steps"].is_object() { plan["steps"] = json!({}); }+                    for a in args { let (k, v) = a.split_once('=').unwrap_or_else(|| err(&format!("{a:?}: write STEP=MODE (modes: {})", handson::MODES.join(", ")), &[]));+                        let keep = plan["steps"][k]["instructions"].clone();+                        plan["steps"][k] = json!({"mode": v}); if !keep.is_null() { plan["steps"][k]["instructions"] = keep; } }+                    for n in note { let (k, v) = n.split_once('=').unwrap_or_else(|| err(&format!("{n:?}: write --note STEP=\"text\""), &[]));+                        if plan["steps"][k].is_null() { plan["steps"][k] = json!({"mode": "custom"}); }+                        plan["steps"][k]["instructions"] = json!(v); }+                    if let Some(n) = notify { plan["notify"] = json!(n); }+                    if let Some(sc) = screen { plan["screen"] = json!(sc); }+                    let plan = handson::normalise(&plan).unwrap_or_else(|e| err(&e, &[]));+                    let state = if handson::active(&r.data) { "active" } else { "planning" };+                    let prof = r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()));+                    handson::apply(&mut r.data, &plan, state, prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "chat", &at);+                    r.save().unwrap();+                    ok(&format!("hands-on plan ({state}): {}", handson::summary(&r.data)), &[if state == "planning" { "When the human is happy: `handson start` (`--save-as \"<name>\"` to keep it as a flow profile).".into() } else { "Changed for this run only; `handson start --save-as \"<name>\"` keeps it as a profile.".into() }]);+                }+                "start" => {+                    if !r.data["handsOn"]["steps"].is_object() { err("no hands-on plan on this run", &["`handson plan` first.".into()]); }+                    let plan = handson::plan_of(&r.data);+                    let first = handson::default_slug().is_none();+                    // the first flow a human confirms is kept as their preference, so the next board starts from it+                    let name = save_as.clone().or_else(|| first.then(|| "My flow".to_string()));+                    let mut prof = r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()));+                    let mut saved = String::new();+                    if let Some(n) = &name { let s = handson::save_profile(n, &plan, &at).unwrap_or_else(|e| err(&e, &[])); saved = format!("; saved as flow profile \"{n}\" ({s}){}", if first { ", their default from now on" } else { "" }); prof = Some((s, n.clone())); }+                    if *default { if let Some((s, _)) = &prof { handson::set_default(s).unwrap_or_else(|e| err(&e, &[])); saved.push_str("; now their default"); } }+                    handson::apply(&mut r.data, &plan, "active", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "chat", &at);+                    r.log("handson-plan", json!({"state": "active", "profile": prof.as_ref().map(|p| p.0.clone())}));+                    r.save().unwrap();+                    ok(&format!("hands-on plan confirmed{saved}: {}", handson::summary(&r.data)), &["Every `step` now prints its mode: check-in steps end with `handson ask`, human steps are handed over and waited for, custom steps print the human's instructions.".into()]);+                }+                "use" => {+                    let name = args.first().cloned().or(profile.clone()).unwrap_or_else(|| err("use needs a profile: `handson use full-ai`", &["`handson profiles` lists them.".into()]));+                    let v = profile_of(&name);+                    let (s, n) = (v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string());+                    handson::apply(&mut r.data, &handson::profile_plan(&v), "active", Some((&s, &n)), "use", &at);+                    r.log("handson-plan", json!({"state": "active", "profile": s}));+                    r.save().unwrap();+                    ok(&format!("this run now follows \"{n}\": {}", handson::summary(&r.data)), &["Visits from now on follow it; work already done is not re-reviewed.".into()]);+                }+                "ask" => {+                    let s = step_arg("ask");+                    let sum = summary.clone().unwrap_or_else(|| err("ask needs --summary: what the human should look at, and where", &[]));+                    let n = handson::ask(&mut r.data, &s, &sum, &at).unwrap_or_else(|e| err(&e, &[]));+                    r.log("handson-ask", json!({"step": s, "visit": n, "summary": sum}));+                    r.save().unwrap();+                    let note = handson::notify(&r.data, &t, &format!("Review {s}"), &format!("AI Flow finished {s} and is waiting for your OK: {}", aiflow_run::clip_text(&sum, 180)));+                    ok(&format!("check-in on {s} (visit {n}) is open; {note}"), &["Now wait: `handson wait` returns when they press Approve or Request changes on the timeline. If they answer in chat: `handson approve <step> --quote \"<their words>\"` or `handson changes <step> --quote \"...\"`.".into(),+                        "While you wait, work that does not depend on this step is fine; the next step is refused until they approve.".into()]);+                }+                v @ ("approve" | "changes" | "done") => {+                    let s = step_arg(v);+                    let verdict = match v { "approve" => "approved", other => other };+                    let q = quote.clone().unwrap_or_default();+                    let n = handson::record_verdict(&mut r.data, &s, verdict, &q, "chat", &at).unwrap_or_else(|e| err(&e, &[]));+                    r.log("handson-verdict", json!({"step": s, "visit": n, "verdict": verdict, "quote": q, "by": "chat"}));+                    r.save().unwrap();+                    let next = match verdict { "changes" => format!("Make the changes they asked for, then `handson ask {s} --summary \"<what changed>\"` again."), "done" => format!("Now record {s}'s proof from their work: {}.", handson::human_proof(&s, &flow)), _ => "Go on to the next step.".into() };+                    ok(&format!("{verdict} recorded for {s} (visit {n}), in the human's words"), &[next]);+                }+                "wait" => {+                    let before = handson::waiting(&r.data);+                    let Some(w) = before.clone() else {+                        for l in &lines { println!("{l}"); }+                        if !lines.is_empty() { r.save().unwrap(); refresh_dashboard(&dir); ok("the human had already answered on the timeline", &["Nothing else is waiting on them: go on (the step hints say what each mode needs).".into()]); }+                        else { ok("nothing to wait for: no plan, check-in or human step is open", &[]); }+                        return;+                    };+                    let started = std::time::Instant::now();+                    if !lines.is_empty() { r.save().unwrap(); refresh_dashboard(&dir); }+                    while lines.is_empty() && started.elapsed().as_secs() < *timeout {+                        std::thread::sleep(std::time::Duration::from_secs(1));+                        let Some(mut r2) = Run::open(&dir) else { break };+                        let got = handson::ingest(&mut r2.data, &tl);+                        // an answer recorded from chat by another command also ends the wait+                        if !got.is_empty() || handson::waiting(&r2.data) != before { if !got.is_empty() { r2.save().unwrap(); refresh_dashboard(&dir); } lines = got; r = r2; if lines.is_empty() { lines.push("answered from chat".into()); } }+                    }+                    let secs = started.elapsed().as_secs();+                    r.log("handson-wait", json!({"for": w, "humanWaitSeconds": secs, "answered": !lines.is_empty()}));+                    if lines.is_empty() {+                        ok(&format!("still waiting after {secs} s for {}", w["what"].as_str().unwrap_or("the human")), &["Run `handson wait` again, or stop and tell the human what you need: finish-guard counts an open check-in as a pause, not an unfinished run.".into()]);+                    } else {+                        for l in &lines { println!("{l}"); }+                        ok(&format!("the human answered after {secs} s"), &[handson::waiting(&r.data).map(|w| format!("Still open: {}.", w["what"].as_str().unwrap_or(""))).unwrap_or_else(|| "Nothing else is waiting on them: go on (the step hints say what each mode needs).".into())]);+                    }+                    return;+                }+                "status" => {+                    if !lines.is_empty() { r.save().unwrap(); }+                    for l in &lines { println!("{l}"); }+                    let h = &r.data["handsOn"];+                    let st: Vec<String> = seq.iter().map(|s| { let m = handson::mode_of(&r.data, s); format!("  {s}: {m}{}", handson::instructions_of(&r.data, s).map(|t| format!(" (\"{}\")", aiflow_run::clip_text(t, 80))).unwrap_or_default()) }).collect();+                    ok(&format!("hands-on {} (flow \"{}\"); notify {}, screen {}\n{}\nwaiting: {}", h["state"].as_str().unwrap_or("off"), h["profileName"].as_str().unwrap_or("-"), h["notify"].as_str().unwrap_or("-"), h["screen"].as_str().unwrap_or("-"), st.join("\n"),+                        handson::waiting(&r.data).map(|w| w["what"].as_str().unwrap_or("").to_string()).unwrap_or_else(|| "nothing".into())), &[]);+                    return;+                }+                "off" => {+                    if r.data["handsOn"].is_object() { r.data["handsOn"]["state"] = json!("off"); r.data["handsOn"]["history"].as_array_mut().map(|a| a.push(json!({"at": at, "state": "off"}))); }+                    r.log("handson-plan", json!({"state": "off"}));+                    r.save().unwrap();+                    ok("hands-on is off for this run: every step is the AI's again (the screen and notification choices revert to the defaults)", &["Their saved profiles are kept; `handson use <profile>` turns it back on.".into()]);+                }+                _ => unreachable!(),+            }+            for l in &lines { println!("{l}"); }+            refresh_dashboard(&dir);+        }         Cmd::Step { name, back, why } => {             thread(&cli);             let mut r = load_run(&cli);+            for l in handson::ingest(&mut r.data, &dir.join("timeline")) { println!("{l}"); }             let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());             for s in preboard::PREBOARD_STEPS.iter().chain(reviews::STEPS.iter()) { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } }             let known: Vec<&str> = known.iter().map(String::as_str).collect();@@ -5850,6 +6025,17 @@ fn main() {                     if pos > cp { err(&format!("--back goes to an earlier step, but {name} comes after {} in the flow", seq[cp]), &[format!("`step {name}` without --back: every step before it must be done first.")]); }                 }             }+            // hands-on (handson.rs): the human's plan, check-ins and their own steps; a return is never held+            let earlier = matches!((sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))), (Some(p), Some(c)) if p < c);+            if !*back && !earlier {+                let b = handson::blockers(&r.data, &seq, name);+                if !b.is_empty() {+                    r.save().ok();+                    let wait = handson::waiting(&r.data).map(|w| format!("The run is waiting for {}: `handson wait` (or the human answers in chat). Do other work that does not depend on it meanwhile, or stop and tell them; finish-guard counts this as a pause.", w["what"].as_str().unwrap_or("the human"))).unwrap_or_default();+                    err(&format!("not yet: {name} waits for the human (hands-on flow \"{}\"):\n  {}", r.data["handsOn"]["profileName"].as_str().unwrap_or("this run's plan"), b.join("\n  ")), &[wait,+                        "The human set this; it is not a check to deviate from. To change it, they change the plan (`handson plan`, or `handson set <step>=auto` when they say so in chat).".into()]);+                }+            }             if !seq.is_empty() && !*back {                 if let Some(pos) = sequence::position(&seq, name) {                     let cur = r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c));@@ -5919,7 +6105,9 @@ fn main() {                     .map(|b| aiflow_bridge::eda::for_board(&aiflow_bridge::eda::BoardRef::parse(&format!("{b}:"))));                 let cam = pre_board.unwrap_or(eda);                 let tx = |v: &str, a: &Value| br.raw_call(v, a);-                if let Some(h) = cam.hydrogen_for_step(name) {+                let raise = handson::may_raise(&r.data);+                if !raise { r.log("screen-hands-off", json!({"step": name})); }+                if !raise { /* the human's screen is hands-off: film the windows where they are */ } else if let Some(h) = cam.hydrogen_for_step(name) {                     let w = if h { br.hydrogen_hwnd() } else { cam.main_window(&tx) };                     br.bring_front(w, if h { "Hydrogen" } else { cam.name() });                 } else if cam.id() == "kicad" && r.data["sequence"].as_array().map_or(false, |s| s.iter().any(|x| x == "project-page")) {@@ -6003,10 +6191,14 @@ fn main() {             }             let skill_lines = skill_hints::step_hints(&flow_full, name, &skill_home, skill_pkg.as_deref());             let board_hints = board_step_hints(&r, name);+            let mut hands_hints = handson::step_hints(&r.data, name, &flow_full);+            if handson::active(&r.data) && handson::mode_of(&r.data, name) == "human" {+                hands_hints.push(handson::notify(&r.data, &thread(&cli), &format!("Your turn: {name}"), &format!("AI Flow handed {name} to you. Tell the AI when you are done (timeline button or chat).")));+            }             let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();             let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };             let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };-            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], skill_lines, board_hints, vec![back_hint, report_note, clips_note]].concat());+            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], hands_hints, skill_lines, board_hints, vec![back_hint, report_note, clips_note]].concat());             print_next(&cli);         }         Cmd::ProjectPageCheck { project, live_json } => {
crates/adom-aiflow/src/sequence.rs+2
@@ -284,6 +284,7 @@ pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes:         "status": s.status.as_str(), "visits": s.visits,         "minutes": minutes.get(&s.name).and_then(|m| m["wallSeconds"].as_f64()).map(|x| (x / 6.0).round() / 10.0).unwrap_or(0.0),         "unproven": s.unproven,+        "mode": crate::handson::mode_of(data, &s.name),     })).collect();     // the loops: every return, from the step the run was in to the step it went back to     let mut events: Vec<(String, String, bool, Option<String>)> = Vec::new();@@ -325,6 +326,7 @@ pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes:         "position": idx, "total": st.len(),         "done": steps.iter().filter(|s| s["status"] == "done").count(),         "steps": steps, "loops": loops,+        "handsOn": crate::handson::timeline_block(data),     }) } 
crates/adom-aiflow/src/timeline_server.rs+82
@@ -144,6 +144,37 @@ fn presentation(body: &Value) -> Result<Value, &'static str> {     }     Ok(body["presentation"].clone()) }+/// The human's answer from the timeline (hands-on AI Flow): a plan, or approved / changes / done on a+/// step. It is queued in handson-inbox.json for the CLI to apply under the run's own lock; nothing+/// here touches run.json, so the page can never move a gate itself.+fn handson_event(body: &Value) -> Result<Value, &'static str> {+    let action = body["action"].as_str().ok_or("action_required")?;+    if !["plan", "approved", "changes", "done"].contains(&action) {+        return Err("unknown_action");+    }+    let note = body["note"].as_str().unwrap_or("").trim();+    if note.len() > 4000 {+        return Err("note_too_long");+    }+    let mut event = json!({"action": action, "note": note});+    if action == "plan" {+        let plan = &body["plan"];+        crate::handson::normalise(plan).map_err(|_| "plan_invalid")?;+        let save_as = plan["saveAs"].as_str().unwrap_or("").trim();+        if save_as.len() > 64 {+            return Err("profile_name_too_long");+        }+        event["plan"] = json!({"steps": plan["steps"], "fallback": plan["fallback"], "notify": plan["notify"], "screen": plan["screen"],+            "saveAs": save_as, "makeDefault": plan["makeDefault"] == true});+    } else {+        let step = body["step"].as_str().filter(|s| !s.is_empty() && s.len() <= 64).ok_or("step_required")?;+        if action == "changes" && note.is_empty() {+            return Err("changes_need_a_note");+        }+        event["step"] = json!(step);+    }+    Ok(event)+} fn handle(mut stream: TcpStream, dir: &Path, live: &Live) {     let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));     let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));@@ -285,6 +316,46 @@ fn handle(mut stream: TcpStream, dir: &Path, live: &Live) {         }         return;     }+    if method == "POST" && path == "/handson" {+        // a custom header makes a cross-site form or fetch need a preflight this server never grants+        let header = |name: &str| head.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim().eq_ignore_ascii_case(name)).map(|(_, v)| v.trim().to_string()));+        if header("x-aiflow-handson").as_deref() != Some("1") || !header("content-type").is_some_and(|v| v.eq_ignore_ascii_case("application/json")) {+            response(&mut stream, 415, json!({"ok":false,"error":"json_with_x_aiflow_handson_header_required"}));+            return;+        }+        let Some(length) = header("content-length").and_then(|v| v.parse::<usize>().ok()).filter(|n| *n <= 65536) else {+            response(&mut stream, 413, json!({"ok":false,"error":"bounded_content_length_required"}));+            return;+        };+        while request.len() < header_end + length {+            match stream.read(&mut chunk) {+                Ok(0) | Err(_) => return,+                Ok(n) => request.extend_from_slice(&chunk[..n]),+            }+        }+        let body = serde_json::from_slice::<Value>(&request[header_end..header_end + length]).unwrap_or(Value::Null);+        match handson_event(&body) {+            Ok(mut event) => {+                let Ok(_guard) = live.mutation.lock() else { return };+                let path = dir.join(crate::handson::INBOX);+                let mut inbox = read_json(&path).filter(|v| v["events"].is_array()).unwrap_or(json!({"events": []}));+                let seq = inbox["events"].as_array().unwrap().iter().filter_map(|e| e["seq"].as_u64()).max().unwrap_or(0) + 1;+                event["seq"] = json!(seq);+                event["at"] = json!(aiflow_run::now());+                let events = inbox["events"].as_array_mut().unwrap();+                events.push(event);+                let drop = events.len().saturating_sub(200);+                events.drain(..drop);+                let tmp = dir.join("handson-inbox.tmp");+                match fs::write(&tmp, inbox.to_string()).and_then(|_| fs::rename(tmp, path)) {+                    Ok(()) => response(&mut stream, 200, json!({"ok":true,"seq":seq})),+                    Err(e) => response(&mut stream, 500, json!({"ok":false,"error":e.to_string()})),+                }+            }+            Err(code) => response(&mut stream, 400, json!({"ok":false,"errorCode":code})),+        }+        return;+    }     if method != "GET" {         response(&mut stream, 405, json!({"ok":false}));         return;@@ -362,4 +433,15 @@ mod tests {         }         assert!(presentation(&json!({"aiThread":"load","presentation":{"caption":"Thermal remains open","image":"review.png"}})).is_ok());     }+    #[test]+    fn handson_answers_are_checked_before_they_are_queued() {+        assert!(handson_event(&json!({"action":"approve-everything"})).is_err());+        assert!(handson_event(&json!({"action":"approved"})).is_err(), "a verdict names its step");+        assert!(handson_event(&json!({"action":"changes","step":"placement"})).is_err(), "changes say what to change");+        assert!(handson_event(&json!({"action":"plan","plan":{"steps":{"routing":{"mode":"custom"}}}})).is_err(), "custom needs instructions");+        let e = handson_event(&json!({"action":"plan","plan":{"steps":{"routing":{"mode":"human"}},"saveAs":"Noah flow","makeDefault":true,"extra":1}})).unwrap();+        assert_eq!(e["plan"]["saveAs"], "Noah flow");+        assert!(e["plan"].get("extra").is_none());+        assert_eq!(handson_event(&json!({"action":"approved","step":"placement","note":"good"})).unwrap()["step"], "placement");+    } }
crates/aiflow-bridge/src/lib.rs+9−1
@@ -370,7 +370,15 @@ impl Bridge {     }      // -- capture recipe --------------------------------------------------------------------    pub fn open_board(&self, remote_board: &str, reason: &str) -> Result<i64, Value> {+    /// `raise` false (the human's screen is hands-off, issue #132): open without focus, never+    /// maximise or bring to front; window capture films it where it is.+    pub fn open_board(&self, remote_board: &str, reason: &str, raise: bool) -> Result<i64, Value> {+        if !raise {+            let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": false}));+            let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };+            std::thread::sleep(std::time::Duration::from_secs(40));+            return Ok(hwnd);+        }         let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": true, "foregroundReason": reason}));         let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };         self.call("desktop_ui_window", &json!({"hwnd": hwnd, "action": "maximize", "reason": "aiflow capture: fill the monitor"}));
docs/hands-on.mdadded+64
@@ -0,0 +1,64 @@+# Hands-on AI Flow++Requested by Noah (2026-11-03) after building the same AM32 ESC twice: once steering every step by hand, once with AI Flow alone. Hands-on mode lets the human decide, before any design work, how much the AI does at each step. The decision is kept as a named preference ("flow profile") for the next boards. The AI's skill for it is `skills/aiflow-hands-on/SKILL.md`; the code is `crates/adom-aiflow/src/handson.rs`.++## The four modes++| Mode | The AI | The gate |+|---|---|---|+| `auto` | does the step | none added |+| `checkin` | does the step, then `handson ask <step> --summary` | `step <later>` and `finish` refuse until the human approves that step's latest visit |+| `human` | hands the step over, waits, then runs the step's own check on the human's work | `step <later>` and `finish` refuse until the human says done for the latest visit |+| `custom` | follows the human's instructions, printed at every `step` | none added (a `handson ask` makes it a check-in) |++**Proofs never change.** A human step still needs its proof: the AI runs the step's own check on the human's work (`place verify` after an `adopt-board`, an empty route plan through `gate` and `land route`, `verify-pours --adopt`, `schematic check`, ...). `handson done` prints the recipe for the step. Deviations are untouched. The human's answers gate *when* the run may move on, never *whether* a check passed.++Only visits that start after a plan is confirmed are gated. Switching flow mid-run does not ask the human to re-review work already done. A return to a step is a new visit and needs its own approval.++## Commands++```+handson plan [--profile P]                 open the plan on the timeline (state planning: `step` refuses everything after intake)+handson set STEP=MODE ... [--note STEP=TEXT] [--notify N] [--screen S]   edit it from the human's chat words+handson start [--save-as NAME] [--default] confirm (state active); the first confirmed plan is saved as "My flow" and made the default+handson use P                              apply a profile now (full-ai is built in)+handson ask STEP --summary TEXT            open a check-in on the step's latest visit; notifies the human+handson approve|changes|done STEP --quote  record the human's answer given in chat, in their words (an approval needs a quote)+handson wait [--timeout 540]               block until the human answers on the timeline (or another command records it)+handson status                             the plan, per step, and what the run waits on+handson profiles [--profile P --default] [--delete P]   list, set the default, delete (needs no run)+handson off                                every step back to the AI for this run+```++## Where things live++- **run.json `handsOn`:** `state` (planning, active, off), `profile`, `profileName`, `source`, `setAt`, `steps` ({step: {mode, instructions}}), `fallback`, `notify`, `screen`, `asks[]`, `verdicts[]` (step, visit, verdict approved|changes|done, quote, by chat|dashboard, at), `inboxSeq`, `history[]`. Ledger events: `handson-plan`, `handson-ask`, `handson-verdict`, `handson-wait` (with `humanWaitSeconds`, so measured AI time can leave the wait out), `screen-hands-off`.+- **Profiles:** `~/.adom/aiflow/profiles/<slug>.json` ({name, slug, fallback, steps, notify, screen, created, updated}) and `~/.adom/aiflow/preferences.json` ({defaultProfile}). `AIFLOW_PREFS_DIR` overrides the folder (tests). A headless run (`AIFLOW_DASHBOARD=off`) never inherits the default profile unless `AIFLOW_PREFS_DIR` is set, so a developer's own flow cannot leak into the integration tests. A profile stores modes by step name, so it applies to every lane; steps a lane does not have are ignored. `intake` and `start` apply the default profile to a new run.+- **Timeline:** `timeline.json` carries `handsOn` (state, plan, profiles, waiting, the latest verdict per step) and a `mode` per step.+  - The page shows the planner (four choices per step, notify, screen, save as, use for my next boards).+  - It shows a "Ready for your review" or "Your turn" card with Approve, Request changes and I'm done.+  - It shows a summary bar with Change, mode chips on the step rows, and a "Want a say in each step?" card when the run is not hands-on.+- **Answers from the page:** `POST handson` with `X-Aiflow-Handson: 1` and JSON:+  - `{action: plan, plan: {steps, notify, screen, saveAs, makeDefault}}`+  - or `{action: approved|changes|done, step, note}`++  The server checks the shape and queues the event in `<run>/timeline/handson-inbox.json`; it never touches run.json. Every `step`, `handson` command and `finish` ingests the queue under the run's own lock and prints `HUMAN (timeline): ...` lines for the AI. The custom header means a cross-site page cannot post without a preflight the server never grants. Like the rest of the dashboard, anyone who can reach the server's port can post; that is a process control, not an engineering gate.++## Screen and notifications (issue #132)++- **`screen: hands-off`** (the default for every profile, including the built-in full-ai):+  - `step` skips its per-step `desktop_bring_to_front` calls.+  - `capture open` opens the board without focus and without maximising.+  - `tour 3d` stops and asks, since KiCad's 3D viewer paints only in front: the human allows it with `handson set --screen allowed`.+  - Window capture still records the clips of covered windows. Runs with no hands-on record keep the old behaviour.+- **`notify`:** `hydrogen` (`adom-cli notify`), `toast` (`adom-bridge notify_user`, to the run's target or the relay's own box), `both` or `none`. `AIFLOW_NOTIFY=off` turns it off for tests and headless automation. A notification never raises a window.++## finish-guard++While a plan, a check-in or a human step is open, `finish-guard` answers `waiting-for-human` with `mayStop: true` and `waitingForHuman` naming it. The engineering state is unchanged: `engineeringComplete` stays false until finish and delivery.++## Not done yet++- A profile is local to one machine. Syncing it through the wiki (a private page per user) would make it follow the human across machines.+- Toast buttons (approve from the notification) are not wired; the toast points at the timeline and the chat.+- No native acceptance on a live board yet: unit tests and a CLI workflow test cover the gates, the inbox and the profiles. The dashboard was checked in headless Chromium.
flows/board.json+1−1
@@ -647,7 +647,7 @@   },   "skills": {     "_": "the companion skills to read for each step (the Companion skills table in SKILL.md); `step <name>` names each SKILL.md by absolute path",-    "intake": ["aiflow-intake"],+    "intake": ["aiflow-intake", "aiflow-hands-on"],     "rules": ["aiflow-sourcing", "aiflow-molecule"],     "design": ["aiflow-circuit-design"],     "test-plan": ["aiflow-test-plan"],
package.json+12−1
@@ -45,7 +45,14 @@     "route the board",     "run.jsonl",     "thermal analysis",-    "Help me contribute an improvement to adom-aiflow through a pull request"+    "Help me contribute an improvement to adom-aiflow through a pull request",+    "hands on ai flow",+    "hands-on aiflow",+    "use hands on ai flow",+    "check in with me after each step",+    "let me do the routing myself",+    "ai flow profile",+    "my flow preference"   ],   "dependencies": {     "adom/adom-lbr": "*",@@ -69,6 +76,10 @@       "label": "Run the flow on a board",       "prompt": "aiflow the ESC: place, route, pour, analyze and deliver the video"     },+    {+      "label": "Hands-on: you choose each step",+      "prompt": "use hands on AI Flow for this board: check in with me after placement, I'll do the routing myself"+    },     {       "label": "Route to 100 percent",       "prompt": "route this board to 100 percent and refuse to finish with anything unconnected"
skills/adom-aiflow/SKILL.md+16−1
@@ -1,7 +1,7 @@ --- name: adom-aiflow description: >--  The board loop as one command for KiCad boards on the Adom KiCad Bridge, Autodesk Fusion boards on the Fusion bridge and Altium Designer boards on the Altium bridge (one project library with real 3D and part numbers, the schematic drawn live, the board, current and heat refined until it passes, the Gerbers and BOM): start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: board in Altium, Altium Designer, build it in Altium, Altium schematic, Altium PCB, Altium project, PcbDoc, board in Fusion, build it in Fusion, Fusion electronics, Fusion library for my project, Fusion schematic, Fusion PCB, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+  The board loop as one command for KiCad boards on the Adom KiCad Bridge, Autodesk Fusion boards on the Fusion bridge and Altium Designer boards on the Altium bridge (one project library with real 3D and part numbers, the schematic drawn live, the board, current and heat refined until it passes, the Gerbers and BOM): start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: hands on, hands-on aiflow, hands-on AI Flow, check in with me, my flow, full AI flow, flow profile, board in Altium, Altium Designer, build it in Altium, Altium schematic, Altium PCB, Altium project, PcbDoc, board in Fusion, build it in Fusion, Fusion electronics, Fusion library for my project, Fusion schematic, Fusion PCB, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. ---  # adom-aiflow: the loop, measured@@ -136,6 +136,20 @@ required step grouped by phase, a teal check on each done step, the current one go", and a purple arc for every return with its reason, ending at the finish line (Gerbers, BOM and CPL exported). Every `step` refreshes it within a second. Open it at intake and leave it up. +## Hands-on AI Flow: the human chooses who does each step++When the human asks for it ("use hands-on AI Flow", "check in with me after placement", "I'll route it myself", "use my flow"), run `handson plan` right after `intake` or `start`, before any design work. The timeline shows every step with four choices:+1. **auto**: the AI takes care of it.+2. **checkin**: the AI does it, then `handson ask` and waits for the human's OK. The next step is refused until they approve.+3. **human**: the human does it. The AI waits for "done", then runs the step's own check on their work.+4. **custom**: their written instructions, printed at every `step`.++The timeline also has their notification choice and their screen choice (hands-off by default: AI Flow never raises or maximises windows on their desktop). They press Start there, or you set it from their words (`handson set placement=checkin routing=human`, `handson start --save-as "<name>"`).++The choice is a flow profile in `~/.adom/aiflow/profiles/`. The first one confirmed becomes their default, and every new run follows it. Switch with `handson use <slug>` (`full-ai` is built in); list with `handson profiles`.++While a plan, check-in or human step is open, `finish-guard` answers `waiting-for-human`, a legitimate pause. Proofs and finish checks never change. Never approve for the human. A run that is not hands-on shows a "Want a say in each step?" card on the timeline. Skill: `aiflow-hands-on`; reference: docs/hands-on.md.+ ## A board in Autodesk Fusion  When the human asks for a board in Fusion ("a 12 V to 5 V buck converter using the TPS54202, in Fusion"),@@ -361,6 +375,7 @@ AI Flow's binary is the instrument, the gates and the hands; the judgement for e | step | skill | |---|---| | a brief or a video to reproduce, before any board exists | `aiflow-intake` |+| the human wants a say in each step (hands-on, check-ins, their own steps, flow profiles, screen and notification preferences) | `aiflow-hands-on` | | choosing parts, the fab profile (3rd party fab by default, JLCPCB on request), symbols, footprints and 3D models | `aiflow-sourcing` | | datasheet equations, stocked values, margins | `aiflow-circuit-design` | | loop and transient simulation, vendor PSpice models | `aiflow-simulate` |
skills/aiflow-hands-on/SKILL.mdadded+107
@@ -0,0 +1,107 @@+---+name: aiflow-hands-on+description: >-+  Hands-on AI Flow: the human chooses, for every step of an adom-aiflow run, how much the AI does on its own (1 the AI takes care of it, 2 the AI does it and checks in for their OK, 3 the human does it, 4 their own custom instructions) on the AI Flow timeline before any design work, and the choice is saved as a named flow profile ("my flow", "Full AI flow") that the next board starts from. Covers the check-in loop (`handson ask`, `handson wait`, approve or request changes), handing a step to the human and turning their work into the step's proof, switching profiles, and the screen and notification preferences (never raise windows on the human's desktop unless they allow it). Use when the human says "hands on", "hands-on AI Flow", "check in with me after placement", "let me do the routing myself", "use my flow", "use full AI flow", or names one of their flow profiles. Trigger words: hands on, hands-on aiflow, hands-on AI Flow, use hands on AI Flow, check in with me, ask me before routing, I'll place it myself, I'll route it myself, my flow, noah flow, full AI flow, flow profile, flow preference, switch flow, AI Flow timeline options, approve placement, request changes, don't take over my screen, notify me.+---++Parent skill: **adom-aiflow**++# aiflow-hands-on: the human decides who does each step++AI Flow normally takes a board from the prompt to the finish line on its own. Hands-on mode lets the human take part where they want to, step by step. It comes from Noah's comparison of two builds of the same ESC: the board he steered by hand was better routed and placed, and the fully automatic run took over his screen. Nothing about the engineering gates changes. A step's proof is still required, `finish` still refuses an unfinished board, and nothing the human chooses waives a check.++## The four choices, per step++| # | Mode | What you do | What the binary enforces |+|---|---|---|---|+| 1 | `auto` | Do the step as AI Flow always has, asking only what is truly their decision. | Nothing new. |+| 2 | `checkin` | Do the step, then show the human the result and run `handson ask <step> --summary "..."`. | The next step is refused until they approve this visit. A return to the step is a new visit and needs a new OK. |+| 3 | `human` | Do NOT do the step's work. Tell them what it needs, wait for "done", then run the step's own check on their work. | The next step is refused until they say done. The step's proof is still required. |+| 4 | `custom` | Follow their written instructions for the step. They are printed every time you enter it. | Nothing new, unless you `handson ask` (then it is a check-in). |++## Start: before any design work++1. Open the run as usual (`intake ...` or `start ...`, skill aiflow-intake). If the human has a default flow profile, the run already follows it; the `intake` answer says so in a hint. Tell them in one line which flow applies.+2. When they asked for hands-on (or want to change the plan for this board): `adom-aiflow --run <run> --ai-thread "<thread>" handson plan [--profile <slug>]`.+   - The AI Flow timeline (the dashboard in Hydrogen) shows every step of this run with the four choices, plus "notify me" (Hydrogen, desktop toast, both, none) and "my screen" (hands-off or allowed).+   - It also has "save as a flow" and "use it for my next boards". The first flow a human confirms is saved as their preference automatically ("My flow") and becomes their default.+   - While the plan is open, `step` refuses every step after intake.+3. Tell the human it is on the timeline and what the choices mean. Then `handson wait`, which returns when they press Start. Run it in the background or repeat it: it waits up to `--timeout` seconds, 540 by default.+4. If they answer in chat instead, set it from their words:++   ```bash+   adom-aiflow ... handson set placement=checkin routing=human --note schematic="draw it with wires, blocks left to right" --notify toast --screen hands-off+   adom-aiflow ... handson start --save-as "Noah flow" [--default]+   ```++Never pick modes for them beyond what they said. A step they did not mention stays as their profile has it (or `auto`).++## Profiles: their preferences, kept between boards++- Profiles live on the human's machine in `~/.adom/aiflow/profiles/<slug>.json`. The default is in `~/.adom/aiflow/preferences.json`.+- `handson profiles` lists them, with `*` marking the default. `full-ai` is built in: every step auto, screen hands-off.+- `handson use <slug>` switches this run (for example `handson use full-ai` for a quick, unimportant board).+- `handson profiles --profile <slug> --default` sets their default. `handson profiles --delete <slug>` removes one.+- "Use my noah flow", "do this one with full AI": run `handson use <slug>` and say so in one line.+- A profile stores modes by step name, so it carries across KiCad, Fusion and Altium runs. A step a lane does not have is ignored.++## Check-in steps++1. Do the step fully, with its own command and proof, as in auto mode.+2. Show them. Point at the evidence they need to judge it:+   - the board in their editor (refreshed, not raised; see Screen)+   - a screenshot or drawing (`artifact --file ... --caption ...`)+   - the numbers that matter (loop lengths, vias, DRC, temperatures)+3. `handson ask <step> --summary "<what to look at and where>"`. This notifies them their way and puts Approve / Request changes on the timeline.+4. `handson wait`. On **approved**, go on. On **changes**, their words are printed: make the changes, then `handson ask <step>` again.+5. If they answer in chat, record it in their words: `handson approve <step> --quote "<their words>"` or `handson changes <step> --quote "..."`. A chat approval needs a quote.++**Never approve on their behalf**, and never read silence as approval.++## Human steps++When `step <name>` prints HANDS-ON HUMAN STEP:++1. Do not do the step's work, not even "to help".+2. Tell them what the step needs:+   - its goal and inputs (the spec, the constraints, the ranking of signals or heat if you made one)+   - what files to save and where+   - which check you will run afterwards+3. `handson wait`. They press "I'm done" on the timeline, or tell you in chat (`handson done <step> --quote "..."`).+4. Turn their work into the step's proof with the step's own check. `handson done` prints the recipe for the step:++| Step | Recipe |+|---|---|+| placement | `adopt-board --board <their saved board> --evidence <note>`, then `place verify` |+| routing | `adopt-board`, then `route` (plans only what their copper leaves open), `gate --plan <run>/plan-route.json`, `land route --plan <run>/plan-route.json` (an empty plan records the proof) |+| pours | `adopt-board`, then `verify-pours --adopt --why "poured by the human"` |+| schematic | `schematic check --sch <their file>` |+| silkscreen | `silkscreen-audit ... --record` |+| simulation | `evidence add --kind simulation --file <their result>` |++5. If a check fails, show them exactly what failed and let them choose: they fix it, you fix it, or (only when the check is wrong for this board) a deviation they agree to.++## Custom steps++The human's instructions are printed by `step <name>` under "THE HUMAN'S INSTRUCTIONS FOR ...". They take precedence over your defaults for that step, but never over a safety limit or a gate. If they ask to see the result, `handson ask` makes it a check-in. If an instruction conflicts with a check, say so and ask; do not quietly drop either.++## Screen and notifications++The human's computer is theirs.+- **Screen.** With `screen: hands-off`, the default for every profile, AI Flow never brings KiCad or Hydrogen to the front and never maximises windows:+  - `step` skips the per-step raises.+  - `capture open` opens the board without focus.+  - Step clips are still recorded by window capture, which works on covered windows but not minimised ones.+  - `tour 3d` stops and asks, because KiCad's 3D viewer only paints in front. Ask the human; with their yes, run `handson set --screen allowed`, `tour 3d`, then `handson set --screen hands-off` straight after.+  - Do not use other tools to raise windows either (skill foreground-etiquette).+- **Notify.** `notify` is how you reach them when the run needs them (the plan, a check-in, their turn): `hydrogen` (a Hydrogen notification), `toast` (an Adom Bridge desktop toast), `both` or `none`. With `none`, say it in chat only. A notification never raises a window by itself.++## While waiting++- `finish-guard` reports `waiting-for-human` (mayStop true) while a plan, check-in or human step is open. That is a legitimate pause, not an unfinished run.+- Work that does not depend on the open step is fine meanwhile (updating the project page, the clips, the next step's research). Do not start the next step's design work before the gate opens.+- The time spent waiting is logged as `handson-wait` with `humanWaitSeconds`, so measured AI time can leave it out (aiflow-measurement).++## Discoverability++When a run is not hands-on, the timeline shows a "Want a say in each step?" card with the four choices and a "Choose my flow" button. A human who presses it can confirm a plan from the dashboard; your next command prints `HUMAN (timeline): confirmed the hands-on plan ...`. Follow it from then on. When a human seems frustrated by how much the AI decided alone, mention hands-on mode in one line.
skills/aiflow-intake/SKILL.md+4
@@ -8,6 +8,10 @@ description: >-  Everything before a board used to be unguided and untimed: on the first buck molecule the requirements, part choice, calculations, simulation and schematic took about 91 minutes and landed in the ledger as one lump of "thinking" on the first turn. Intake fixes both halves. It writes one file, `requirements.json`, that every later skill reads (aiflow-sourcing, aiflow-circuit-design, aiflow-simulate, aiflow-schematic-to-board, aiflow-molecule, aiflow-scaffold-probe), and it pins the clock to the prompt. +## 0. Hands-on or not++If the human asked for a hands-on run ("hands-on AI Flow", "check in with me after placement", "I'll route it myself", "use my flow"), open the run as below, then run `handson plan` before anything else: they choose, per step, whether you do it, check in after it, leave it to them, or follow their instructions (skill aiflow-hands-on). If they saved a default flow before, `intake` already applies it and says so in a hint; tell them in one line.+ ## 1. The clock starts at the prompt  Write the UTC of the human's message down before anything else. Use the message's own timestamp, not "now" if you are late.
tests/handson-workflow.pyadded+130
@@ -0,0 +1,130 @@+"""Hands-on AI Flow end to end (docs/hands-on.md): the plan blocks design work until the human+confirms it on the timeline (a real POST to the timeline server), the first confirmed plan is saved+as their default profile, a check-in step holds the run until they approve, a human step until they+say done, finish-guard reads an open check-in as the human's turn, and the next run starts from the+default profile. No CAD, bridge, notifications or dashboard tab."""+import json, os, pathlib, socket, subprocess, sys, tempfile, time, urllib.request++binary = str(pathlib.Path(sys.argv[1]).resolve())+BOARD = """(kicad_pcb (version 20241229) (generator "test") (general (thickness 1.6))+ (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user) (37 "F.SilkS" user))+ (net 0 "") (net 1 "A") (net 2 "B")+ (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts"))+ (footprint "R:R0603" (layer "F.Cu") (at 10 10) (property "Reference" "R1" (at 0 -1.5) (layer "F.SilkS"))+  (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "A"))+  (pad "2" smd rect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "B"))))+"""+WHY = "test fixture: this step's proof is not what this workflow test is about"++with tempfile.TemporaryDirectory(prefix="aiflow-handson-") as td:+    p = pathlib.Path(td)+    env = dict(os.environ, AIFLOW_DASHBOARD="off", AIFLOW_NOTIFY="off", AIFLOW_PREFS_DIR=str(p / "prefs"),+               AIFLOW_NO_PART_SEARCH="1", AIFLOW_NO_PART_LOOKUP="1")+    (p / "board.kicad_pcb").write_text(BOARD)+    (p / "spec.json").write_text("{}")++    def call(*args, run="run"):+        return subprocess.run([binary, "--run", run, "--ai-thread", "handson-test", *args], cwd=p, env=env, capture_output=True, text=True, timeout=120)++    def data(run="run"):+        return json.loads((p / run / "run.json").read_text())++    def step_past(name):+        """`step name`, deviating any proof the fixture cannot give; returns the final answer."""+        for _ in range(12):+            r = call("step", name)+            if r.returncode == 0 or "waits for the human" in r.stdout:+                return r+            items = data().get("lastRefusalItems", [])+            assert items, r.stdout+            for i in items:+                assert call("deviate", i, "--why", WHY).returncode == 0+        raise AssertionError("could not reach " + name)++    r = call("handson", "profiles")+    assert r.returncode == 0 and "Full AI flow" in r.stdout and "1 flow profile" in r.stdout, r.stdout+    r = call("start", "--board", "board.kicad_pcb", "--spec", "spec.json", "--engine", "test")+    assert r.returncode == 0, r.stdout + r.stderr+    assert "handsOn" not in data(), "no default profile yet: the run is not hands-on"+    assert call("step", "intake").returncode == 0+    seq = data()["sequence"]+    a, b, c = seq[1], seq[2], seq[3]++    # the plan is open: no design work until the human confirms it+    r = call("handson", "plan")+    assert r.returncode == 0 and "waiting for the human" in r.stdout, r.stdout+    r = call("step", a)+    assert r.returncode == 2 and "hands-on plan" in r.stdout, r.stdout+    assert call("finish-guard", "--json").stdout.count('"waiting-for-human"') == 1++    # the human confirms it on the timeline: a real POST to the run's timeline server+    with socket.socket() as s:+        s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]+    server = subprocess.Popen([binary, "--run", "run", "timeline", "serve", "--port", str(port)], cwd=p, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+    try:+        def post(body, header=True):+            req = urllib.request.Request(f"http://127.0.0.1:{port}/handson", data=json.dumps(body).encode(), method="POST",+                                         headers={"Content-Type": "application/json", **({"X-Aiflow-Handson": "1"} if header else {})})+            try:+                with urllib.request.urlopen(req, timeout=5) as resp: return json.loads(resp.read())+            except urllib.error.HTTPError as e: return json.loads(e.read())+        for _ in range(50):+            try:+                urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1); break+            except Exception: time.sleep(0.1)+        assert post({"action": "approved", "step": a}, header=False)["ok"] is False, "the header is required"+        assert post({"action": "plan", "plan": {"steps": {a: {"mode": "custom"}}}})["ok"] is False, "custom needs instructions"+        plan = {"steps": {a: {"mode": "checkin"}, b: {"mode": "human"}, c: {"mode": "custom", "instructions": "keep R1 near the edge"}},+                "notify": "none", "screen": "hands-off", "saveAs": "Test flow", "makeDefault": True}+        assert post({"action": "plan", "plan": plan})["ok"] is True+        r = call("handson", "wait", "--timeout", "5")+        assert r.returncode == 0 and "HUMAN (timeline): confirmed" in r.stdout and "Test flow" in r.stdout, r.stdout+        h = data()["handsOn"]+        assert h["state"] == "active" and h["steps"][b]["mode"] == "human" and h["screen"] == "hands-off", h+        assert json.loads((p / "prefs" / "preferences.json").read_text())["defaultProfile"] == "test-flow"++        # a check-in step: done, shown, held until the human approves+        r = step_past(a)+        assert r.returncode == 0 and "HANDS-ON CHECK-IN" in r.stdout, r.stdout+        r = call("step", b)+        assert r.returncode == 2 and f"handson ask {a}" in r.stdout, r.stdout+        call("deviate", f"step {a}", "--why", WHY)+        assert call("step", b).returncode == 2, "a check-in is not deviated around"+        assert call("handson", "ask", a, "--summary", "look at R1").returncode == 0+        assert '"waiting-for-human"' in call("finish-guard", "--json").stdout+        assert "waiting for the human's review" in call("step", b).stdout+        assert call("handson", "approve", a).returncode == 2, "a chat approval quotes the human"+        assert post({"action": "changes", "step": a})["ok"] is False, "changes say what to change"+        assert post({"action": "changes", "step": a, "note": "move R1 left"})["ok"] is True+        r = call("step", b)+        assert r.returncode == 2 and "move R1 left" in r.stdout and "HUMAN (timeline): changes" in r.stdout, r.stdout+        assert call("handson", "ask", a, "--summary", "R1 moved").returncode == 0+        assert post({"action": "approved", "step": a, "note": "good"})["ok"] is True++        # a human step: handed over, held until they say done+        r = step_past(b)+        assert r.returncode == 0 and "HANDS-ON HUMAN STEP" in r.stdout and "Do NOT do its work" in r.stdout, r.stdout+        r = call("step", c)+        assert r.returncode == 2 and "not said they are done" in r.stdout, r.stdout+        r = call("handson", "done", b, "--quote", "routed it myself")+        assert r.returncode == 0 and "proof" in r.stdout, r.stdout+        r = step_past(c)+        assert r.returncode == 0 and "keep R1 near the edge" in r.stdout, r.stdout++        # the timeline carries the plan, the modes and the profiles for the page+        call("timeline", "write")+        tl = json.loads((p / "run" / "timeline" / "timeline.json").read_text())+        assert tl["handsOn"]["state"] == "active" and any(x["slug"] == "test-flow" and x["default"] for x in tl["handsOn"]["profiles"]), tl["handsOn"]+        assert {s["name"]: s["mode"] for s in tl["steps"]}[b] == "human"+        assert "hands-on" in (p / "run" / "timeline" / "index.html").read_text().lower()+    finally:+        server.terminate(); server.wait(timeout=10)++    # the next board starts from the human's default flow; switching is one command+    r = call("start", "--board", "board.kicad_pcb", "--spec", "spec.json", "--engine", "test", run="run2")+    assert r.returncode == 0 and "Test flow" in r.stdout, r.stdout+    assert data("run2")["handsOn"]["steps"][b]["mode"] == "human"+    r = call("handson", "use", "full-ai", run="run2")+    assert r.returncode == 0 and "every step auto" in r.stdout, r.stdout+    assert call("handson", "profiles").stdout.count("* Test flow") == 1+print("ok: hands-on workflow")
tools/timeline/README.md+27
@@ -83,3 +83,30 @@ The optional presentation arrives with timeline SSE state; static exports use po ### Event-driven dashboard state (0.1.90)  The server uses one native filesystem watcher per run and a shared state cache. CLI presentation controls notify SSE clients immediately; external timeline/presentation edits trigger native filesystem notifications, including atomic replacements. Idle SSE connections do not poll or reread state files. The 15-second heartbeat only writes a connection comment. Native watcher failures are reported rather than silently falling back to server-side polling. `/health` reports `stateSource`, `stateFileReads`, and `watcherError` for verification. Keep the existing webview open during server upgrades; SSE reconnects automatically.++### Hands-on AI Flow (handson.rs, docs/hands-on.md)++`timeline.json` carries `handsOn`:++```json+{"state": "planning|active|off", "profile": "noah-flow", "profileName": "Noah flow",+ "plan": {"steps": {"placement": {"mode": "checkin"}, "routing": {"mode": "human"}}, "fallback": "auto", "notify": "hydrogen", "screen": "hands-off"},+ "waiting": {"kind": "plan|checkin|human", "step": "placement", "summary": "..."},+ "verdicts": {"placement": {"visit": 1, "verdict": "approved", "quote": "...", "by": "dashboard"}},+ "profiles": [{"slug": "noah-flow", "name": "Noah flow", "default": true, "steps": {}}], "modes": [...]}+```++Each step also has a `mode`.++The page shows:+- **The planner** (state planning, or Change). Four choices per step (1 AI, 2 Check in, 3 Me, 4 Custom with instructions), "start from" a saved profile, notify and screen choices, save as a named flow, and "use it for my next boards".+- **A review or "your turn" card** while a check-in or human step is open.+- **A one-line summary** with Change.+- **A mode chip** on each step row.+- **A "Want a say in each step?" card** when the run is not hands-on.++Answers go to `POST handson` (relative URL) with `Content-Type: application/json` and `X-Aiflow-Handson: 1`. The body is one of:+- `{"action":"plan","plan":{...,"saveAs":"Noah flow","makeDefault":true}}`+- `{"action":"approved"|"changes"|"done","step":"placement","note":"..."}`++The server queues it in `handson-inbox.json`; the CLI applies it. The page never changes run.json or a gate.
tools/timeline/index.html+257−1
@@ -451,6 +451,85 @@ body.wide .stage { margin-top: 14px; } .rules-card .rc-note { margin: 10px 0 0; font-size: 12px; color: var(--text-2); } @media (max-width: 800px) { .rules-card .rc-body { grid-template-columns: 1fr; } } +/* ---------- hands-on AI Flow: the human's plan, check-ins and turns (handson.rs) ---------- */+.handson { position: relative; z-index: 2; margin: 18px var(--pad) 0; }+.handson[hidden] { display: none; }+.ho-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px 22px; }+.ho-card.plan { border-top: 2px solid var(--accent); }+.ho-card .eyebrow { font-family: var(--f-display); font-size: 12px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-2); }+.ho-card.turn { border: 1px solid rgba(140, 107, 247, .55); background: linear-gradient(180deg, var(--purple-wash), var(--surface) 70%); }+.ho-card h2 { font-family: var(--f-display); font-size: 22px; font-weight: 700; color: var(--text); margin: 8px 0 4px; line-height: 1.2; }+.ho-card .lead { margin: 0; color: var(--text-body); font-size: 15px; max-width: 760px; }+.ho-card .sub { margin: 6px 0 0; color: var(--text-2); font-size: 13px; }+.ho-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 12px 0 4px; padding: 0; list-style: none; font-size: 13px; color: var(--text-2); }+.ho-legend b { font-family: var(--f-mono); color: var(--accent-bright); font-weight: 600; margin-right: 4px; }+.ho-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 14px 0 6px; }+.ho-top label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 8px; }+.ho-top .all { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--text-2); margin-left: auto; }+.ho select, .ho input[type=text], .ho textarea { font: inherit; font-size: 14px; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; }+.ho select:focus, .ho input:focus, .ho textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; }+.ho textarea { width: 100%; min-height: 58px; resize: vertical; line-height: 1.4; }+.ho select, .ho input[type=text] { max-width: 100%; min-width: 0; }+.ho-foot .field { min-width: 0; max-width: 100%; }+.ho-btn { font: inherit; font-size: 13px; font-weight: 600; border-radius: 8px; padding: 8px 14px; border: 1px solid var(--border); background: var(--elevated); color: var(--text-body); cursor: pointer; }+.ho-btn:hover { border-color: var(--accent); color: var(--text); }+.ho-btn:active { transform: translateY(1px); }+.ho-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }+.ho-btn.primary:hover { background: var(--accent-bright); }+.ho-btn.warn { border-color: rgba(140, 107, 247, .7); color: var(--text); }+.ho-btn.small { padding: 4px 9px; font-size: 12px; font-weight: 500; }+.ho-btn[disabled] { opacity: .5; cursor: default; transform: none; }+.ho-phase { font-family: var(--f-display); font-size: 12px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-2); margin: 14px 0 4px; }+.ho-rows { list-style: none; margin: 0; padding: 0; }+.ho-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 14px; align-items: center; padding: 8px 0; border-top: 1px solid var(--border-soft); }+.ho-row .nm { color: var(--text); font-weight: 600; font-size: 14px; }+.ho-row .nm .n { font-family: var(--f-mono); color: var(--text-2); font-weight: 500; margin-right: 8px; font-size: 12px; }+.ho-row .ds { color: var(--text-2); font-size: 12.5px; margin-top: 1px; }+.ho-row .note { grid-column: 1 / -1; }+.ho-row.done-step .nm { color: var(--text-2); }+.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: var(--bg); }+.seg label { position: relative; cursor: pointer; }+.seg input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }+.seg span { display: block; padding: 6px 11px; font-size: 12.5px; color: var(--text-2); white-space: nowrap; border-left: 1px solid var(--border-soft); }+.seg label:first-child span { border-left: 0; }+.seg span b { font-family: var(--f-mono); font-weight: 600; margin-right: 5px; color: var(--text-3); }+.seg input:checked + span { background: var(--accent-wash); color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }+.seg input:checked + span b { color: var(--accent-bright); }+.seg input[value=human]:checked + span { background: var(--purple-wash); box-shadow: inset 0 -2px 0 var(--purple); }+.seg input[value=human]:checked + span b { color: var(--purple); }+.seg input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: -2px; }+.ho-foot { display: flex; flex-wrap: wrap; gap: 12px 18px; align-items: end; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-soft); }+.ho-foot .field { display: grid; gap: 5px; font-size: 12.5px; color: var(--text-2); }+.ho-foot .check { display: flex; gap: 7px; align-items: center; font-size: 13px; color: var(--text-body); padding-bottom: 8px; }+.ho-foot .go { margin-left: auto; display: flex; gap: 10px; align-items: center; }+.ho-status { font-size: 13px; color: var(--text-2); }+.ho-status.err { color: var(--purple); }+.ho-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 12px; }+.ho-quote { margin: 10px 0 0; padding: 10px 12px; border-left: 3px solid var(--purple); background: var(--bg); border-radius: 0 8px 8px 0; color: var(--text-body); font-size: 14px; white-space: pre-wrap; }+.ho-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 10px 16px; border: 1px solid var(--border-soft); border-radius: 12px; background: var(--surface); font-size: 13px; color: var(--text-2); }+.ho-bar b { color: var(--text); font-weight: 600; }+.ho-bar .chips { display: flex; flex-wrap: wrap; gap: 6px; }+.ho-bar .ho-btn { margin-left: auto; }+.ho-pill { border-radius: 999px; padding: 2px 9px; font-size: 12px; border: 1px solid var(--border); color: var(--text-body); }+.ho-pill.checkin { border-color: var(--accent); color: var(--accent-bright); }+.ho-pill.human { border-color: var(--purple); color: var(--text); }+.ho-discover { margin: 0 var(--pad) 24px; }+.ho-discover .ho-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px 24px; align-items: center; }+.ho-discover h2 { font-size: 18px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; }+.ho-discover ol { margin: 8px 0 0; padding-left: 20px; color: var(--text-body); font-size: 14px; display: grid; gap: 2px; }+.ho-discover .mine { margin-top: 8px; font-size: 13px; color: var(--text-2); }+.mode-chip { margin-left: 8px; border-radius: 999px; padding: 0 7px; font-size: 11px; line-height: 17px; font-family: var(--f-body); font-weight: 600; border: 1px solid var(--border); color: var(--text-2); white-space: nowrap; vertical-align: 1px; }+.mode-chip[hidden] { display: none; }+.mode-chip.checkin { border-color: rgba(0, 184, 177, .6); color: var(--accent-bright); }+.mode-chip.human { border-color: rgba(140, 107, 247, .7); color: var(--text); background: var(--purple-wash); }+@media (max-width: 680px) {+ .ho-row { grid-template-columns: 1fr; }+ .seg span { padding: 6px 8px; }+ .ho-top .all { margin-left: 0; }+ .ho-foot .go { margin-left: 0; }+ .ho-discover .ho-card { grid-template-columns: 1fr; }+}+ </style> </head> <body class="waiting-on">@@ -493,6 +572,8 @@ body.wide .stage { margin-top: 14px; }   </div> </section> +<section class="handson" id="handson" hidden aria-label="Hands-on AI Flow" aria-live="polite"></section>+ <div class="section-heading"><h2>From idea to tested board</h2><span>Workflow progress · engineering checks still apply</span></div>  <div class="callout-wrap" id="callout-wrap"><div><div class="callout" id="callout" data-tipkey="callout">@@ -547,6 +628,8 @@ body.wide .stage { margin-top: 14px; }   </div> </section> +<section class="ho-discover" id="ho-discover" hidden aria-label="Hands-on AI Flow"></section>+ <section class="community" aria-label="Contribute to AI Flow">   <div><h2>Built together. Better for everyone. <button class="prompt-hint" id="contribute-prompt" title="Help me contribute an improvement to adom-aiflow through a pull request." aria-label="Copy contribution prompt">ⓘ</button></h2>   <p>AI Flow is MIT open source. Share a fix, a skill, or a lesson from your builds so the whole community benefits.</p></div>@@ -742,6 +825,7 @@ body.wide .stage { margin-top: 14px; }         status: STATUSES[s.status] ? s.status : 'todo',         visits: Math.max(0, parseInt(s.visits, 10) || 0),         minutes: Math.max(0, +s.minutes || 0),+        mode: ['checkin', 'human', 'custom'].includes(s.mode) ? s.mode : 'auto',       });     });     if (!steps.length) return null;@@ -791,12 +875,13 @@ body.wide .stage { margin-top: 14px; }       li.innerHTML =         '<div class="rowbg"></div>' +         '<div class="node">' + ICON_CK + ICON_RD + ICON_CU + '</div>' +-        '<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span></div><div class="does"></div></div>' ++        '<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span><span class="mode-chip" hidden></span></div><div class="does"></div></div>' +         '<div class="meta"></div>';       ol.appendChild(li);       rows.set(s.name, {         li, node: li.querySelector('.node'), nm: li.querySelector('.nm'), badge: li.querySelector('.badge'),         v: li.querySelector('.badge .v'), does: li.querySelector('.does'), meta: li.querySelector('.meta'), status: null,+        mode: li.querySelector('.mode-chip'),       });     });     finishLi = document.createElement('li');@@ -898,6 +983,10 @@ body.wide .stage { margin-top: 14px; }       r.badge.classList.toggle('off', !showBadge);       r.v.textContent = 'x' + s.visits;       r.badge.setAttribute('aria-label', 'Visited ' + s.visits + ' times');+      // hands-on: who does the step (auto shows nothing)+      const chip = { checkin: 'check-in', human: 'you', custom: 'custom' }[s.mode] || '';+      r.mode.hidden = !chip;+      if (r.mode.textContent !== chip) { r.mode.textContent = chip; r.mode.className = 'mode-chip ' + s.mode; r.mode.title = { checkin: 'The AI checks in with you after this step', human: 'You do this step', custom: 'Your custom instructions' }[s.mode] || ''; }       let meta = '';       if (s.status === 'redo') meta = '<span class="redo-tag">rework</span>';       else if (s.minutes > 0) meta = fmtMin(s.minutes) + ' min';@@ -1348,6 +1437,7 @@ body.wide .stage { margin-top: 14px; }     const raw = JSON.stringify(data);     const m = normalize(data);     if (!m) return;+    try { if (window.aiflowHandsOn) window.aiflowHandsOn(data, m); } catch (_) { /* the hands-on panel is optional */ }     lastOkAt = Date.now();     if (raw !== lastRaw) {       lastRaw = raw; model = m; everOk = true;@@ -1403,5 +1493,171 @@ body.wide .stage { margin-top: 14px; }   poll(); })(); </script>+<script>+// Hands-on AI Flow (handson.rs): the human chooses per step how much the AI does, answers check-ins+// and hands their own steps back. Answers are POSTed to `handson` (relative: the page is served behind+// a proxy path) and queued for the CLI; the page never decides a gate itself.+(() => {+  'use strict';+  const $ = id => document.getElementById(id);+  const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));+  const MODES = [['auto', 'AI', 'The AI takes care of it, with as little input from you as possible'], ['checkin', 'Check in', 'The AI does it, then waits for your OK before the next step'], ['human', 'Me', 'You do this step yourself; the AI waits, then checks your work'], ['custom', 'Custom', 'Your own instructions for the AI on this step']];+  const NOTIFY = [['hydrogen', 'Hydrogen notification'], ['toast', 'Desktop toast'], ['both', 'Both'], ['none', 'Don’t notify me']];+  const SCREEN = [['hands-off', 'Hands off: never bring windows to the front'], ['allowed', 'Allowed: the AI may bring KiCad and Hydrogen to the front']];+  let ho = null, steps = [], draft = null, editing = false, pending = null, planKey = '';+  const box = $('handson'), disc = $('ho-discover');++  function post(body) {+    return fetch('handson', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Aiflow-Handson': '1' }, body: JSON.stringify(body), signal: AbortSignal.timeout(6000) })+      .then(r => r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status })));+  }+  function draftFrom(plan, profile) {+    const st = {};+    steps.forEach(s => {+      const c = (plan.steps || {})[s.name] || {};+      st[s.name] = { mode: c.mode || plan.fallback || 'auto', instructions: c.instructions || '' };+    });+    const mine = (ho.profiles || []).filter(p => !p.builtin);+    const prof = (ho.profiles || []).find(p => p.slug === profile);+    return { steps: st, notify: plan.notify || 'hydrogen', screen: plan.screen || 'hands-off', profile: profile || '',+      saveAs: prof && !prof.builtin ? prof.name : (mine.length ? '' : 'My flow'), makeDefault: prof ? !!prof.default : !mine.length };+  }+  function planOf(d) {+    const st = {};+    Object.entries(d.steps).forEach(([k, v]) => { st[k] = v.mode === 'custom' || v.instructions.trim() ? { mode: v.mode, instructions: v.instructions.trim() } : { mode: v.mode }; });+    return { steps: st, fallback: 'auto', notify: d.notify, screen: d.screen, saveAs: d.saveAs.trim(), makeDefault: d.makeDefault };+  }+  function options(list, cur) { return list.map(([v, l]) => '<option value="' + esc(v) + '"' + (v === cur ? ' selected' : '') + '>' + esc(l) + '</option>').join(''); }++  function planner() {+    const mine = (ho.profiles || []).filter(p => !p.builtin);+    let html = '<div class="ho-card plan ho"><div class="eyebrow">Hands-on AI Flow</div>' ++      '<h2>' + (ho.state === 'planning' ? 'Choose how this board gets built' : 'Change your flow') + '</h2>' ++      '<p class="lead">For every step, pick who does it. The AI starts design work only after you press Start.</p>' ++      '<ul class="ho-legend">' + MODES.map(([, l, d], i) => '<li><b>' + (i + 1) + '</b>' + esc(l) + ': ' + esc(d.charAt(0).toLowerCase() + d.slice(1)) + '</li>').join('') + '</ul>' ++      '<div class="ho-top"><label>Start from <select id="ho-profile"><option value="">This run’s plan</option>' ++      (ho.profiles || []).map(p => '<option value="' + esc(p.slug) + '"' + (p.slug === draft.profile ? ' selected' : '') + '>' + esc(p.name) + (p.default ? ' (your default)' : '') + '</option>').join('') + '</select></label>' ++      '<span class="all">All steps: ' + MODES.slice(0, 3).map(([m, l]) => '<button class="ho-btn small" data-all="' + m + '">' + esc(l) + '</button>').join('') + '</span></div>';+    let phase = null;+    steps.forEach((s, i) => {+      if (s.phase !== phase) { if (phase !== null) html += '</ul>'; phase = s.phase; html += '<div class="ho-phase">' + esc(s.phase || 'Steps') + '</div><ul class="ho-rows">'; }+      const c = draft.steps[s.name];+      html += '<li class="ho-row' + (s.status === 'done' ? ' done-step' : '') + '" data-step="' + esc(s.name) + '"><div><div class="nm"><span class="n">' + String(i + 1).padStart(2, '0') + '</span>' + esc(s.title) + '</div><div class="ds">' + esc(s.does) + '</div></div>' ++        '<div class="seg" role="radiogroup" aria-label="' + esc(s.title) + '">' + MODES.map(([m, l, d], k) => '<label title="' + esc(d) + '"><input type="radio" name="ho-' + esc(s.name) + '" value="' + m + '"' + (c.mode === m ? ' checked' : '') + '><span><b>' + (k + 1) + '</b>' + esc(l) + '</span></label>').join('') + '</div>' ++        (c.mode === 'custom' || c.instructions ? '<div class="note"><textarea data-note="' + esc(s.name) + '" placeholder="Your instructions for the AI on this step (for example: place the MCU next to the gate driver; check in after)">' + esc(c.instructions) + '</textarea></div>' : '') + '</li>';+    });+    if (phase !== null) html += '</ul>';+    html += '<div class="ho-foot">' ++      '<label class="field">Notify me when the AI needs me<select id="ho-notify">' + options(NOTIFY, draft.notify) + '</select></label>' ++      '<label class="field">My screen<select id="ho-screen">' + options(SCREEN, draft.screen) + '</select></label>' ++      '<label class="field">Save as a flow' + (mine.length ? '' : ' (your first: saved as your preference)') + '<input type="text" id="ho-save" maxlength="64" placeholder="e.g. My careful flow" value="' + esc(draft.saveAs) + '"></label>' ++      '<label class="check"><input type="checkbox" id="ho-default"' + (draft.makeDefault ? ' checked' : '') + '> Use it for my next boards</label>' ++      '<div class="go"><span class="ho-status" id="ho-status"></span>' + (editing && ho.state !== 'planning' ? '<button class="ho-btn" id="ho-cancel">Cancel</button>' : '') + '<button class="ho-btn primary" id="ho-start">' + (ho.state === 'active' ? 'Apply' : 'Start') + '</button></div></div></div>';+    box.innerHTML = html;+    box.hidden = false;+    box.querySelectorAll('.seg input').forEach(inp => inp.addEventListener('change', () => {+      const name = inp.name.slice(3); draft.steps[name].mode = inp.value; planner(); }));+    box.querySelectorAll('textarea[data-note]').forEach(t => t.addEventListener('input', () => { draft.steps[t.dataset.note].instructions = t.value; }));+    box.querySelectorAll('[data-all]').forEach(b => b.addEventListener('click', () => { Object.values(draft.steps).forEach(c => { c.mode = b.dataset.all; }); planner(); }));+    $('ho-profile').addEventListener('change', e => {+      const p = (ho.profiles || []).find(x => x.slug === e.target.value);+      draft = p ? draftFrom(p, p.slug) : draftFrom(ho.plan || {}, ho.profile || ''); planner(); });+    $('ho-notify').addEventListener('change', e => { draft.notify = e.target.value; });+    $('ho-screen').addEventListener('change', e => { draft.screen = e.target.value; });+    $('ho-save').addEventListener('input', e => { draft.saveAs = e.target.value; });+    $('ho-default').addEventListener('change', e => { draft.makeDefault = e.target.checked; });+    if ($('ho-cancel')) $('ho-cancel').addEventListener('click', () => { editing = false; draw(); });+    $('ho-start').addEventListener('click', () => {+      const missing = Object.entries(draft.steps).filter(([, c]) => c.mode === 'custom' && !c.instructions.trim()).map(([k]) => k);+      if (missing.length) { status('Write the instructions for ' + missing.join(', ') + ' first.', true); return; }+      if (draft.makeDefault && !draft.saveAs.trim()) { status('Give the flow a name to use it for your next boards.', true); return; }+      $('ho-start').disabled = true; status('Sending…');+      post({ action: 'plan', plan: planOf(draft) }).then(r => {+        if (r.ok) { pending = { action: 'plan', seq: r.seq }; editing = false; status('Sent. The AI picks it up on its next command.'); }+        else { $('ho-start').disabled = false; status('Not sent (' + (r.errorCode || r.error || 'error') + '). Tell the AI in chat instead.', true); }+      }).catch(() => { $('ho-start').disabled = false; status('The dashboard server did not answer. Tell the AI in chat instead.', true); });+    });+  }+  function status(t, bad) { const el = $('ho-status'); if (el) { el.textContent = t; el.classList.toggle('err', !!bad); } }+  function titleOf(name) { const s = steps.find(x => x.name === name); return s ? s.title : name; }++  function turn(w) {+    const human = w.kind === 'human';+    const s = steps.find(x => x.name === w.step) || { title: w.step, does: '' };+    const sent = pending && pending.step === w.step;+    box.innerHTML = '<div class="ho-card turn ho"><div class="eyebrow">Hands-on · ' + esc(ho.profileName || 'your flow') + '</div>' ++      '<h2>' + (human ? 'Your turn: ' : 'Ready for your review: ') + esc(s.title) + '</h2>' ++      '<p class="lead">' + (human ? 'You chose to do this step yourself (' + esc(s.does) + '). Work in your own editor; the AI waits, then checks your work with this step’s own checks.' : 'The AI finished this step and is waiting for your OK before it goes on.') + '</p>' ++      (w.summary ? '<div class="ho-quote">' + esc(w.summary) + '</div>' : '') ++      '<div class="ho-actions" style="display:block"><textarea id="ho-note" placeholder="' + (human ? 'Anything the AI should know about what you did (optional)' : 'Your comments (needed for Request changes)') + '"></textarea></div>' ++      '<div class="ho-actions">' + (human ? '<button class="ho-btn primary" data-v="done">I’m done</button>' : '<button class="ho-btn primary" data-v="approved">Approve</button><button class="ho-btn warn" data-v="changes">Request changes</button>') ++      '<span class="ho-status" id="ho-status">' + (sent ? 'Sent. The AI picks it up on its next command.' : 'Or answer the AI in chat.') + '</span></div></div>';+    box.hidden = false;+    box.querySelectorAll('[data-v]').forEach(b => b.addEventListener('click', () => {+      const note = $('ho-note').value.trim();+      if (b.dataset.v === 'changes' && !note) { status('Say what to change first.', true); $('ho-note').focus(); return; }+      box.querySelectorAll('[data-v]').forEach(x => { x.disabled = true; });+      post({ action: b.dataset.v, step: w.step, note }).then(r => {+        if (r.ok) { pending = { action: b.dataset.v, step: w.step, seq: r.seq }; status('Sent. The AI picks it up on its next command.'); }+        else { box.querySelectorAll('[data-v]').forEach(x => { x.disabled = false; }); status('Not sent (' + (r.errorCode || r.error || 'error') + '). Answer the AI in chat instead.', true); }+      }).catch(() => { box.querySelectorAll('[data-v]').forEach(x => { x.disabled = false; }); status('The dashboard server did not answer. Answer the AI in chat instead.', true); });+    }));+  }++  function bar() {+    const st = (ho.plan && ho.plan.steps) || {};+    const by = m => steps.filter(s => ((st[s.name] || {}).mode || (ho.plan && ho.plan.fallback) || 'auto') === m);+    const chips = [['checkin', 'check-in'], ['human', 'yours'], ['custom', 'custom']].map(([m, l]) => { const n = by(m); return n.length ? '<span class="ho-pill ' + m + '" title="' + esc(n.map(s => s.title).join(', ')) + '">' + n.length + ' ' + l + '</span>' : ''; }).join('');+    box.innerHTML = '<div class="ho-bar ho"><span>Hands-on · <b>' + esc(ho.profileName || 'this run’s plan') + '</b></span><span class="chips">' + (chips || '<span class="ho-pill">every step AI</span>') + '</span>' ++      '<span>screen ' + esc((ho.plan && ho.plan.screen) || 'hands-off') + ' · notify ' + esc((ho.plan && ho.plan.notify) || 'hydrogen') + '</span>' ++      '<button class="ho-btn small" id="ho-change">Change</button></div>';+    box.hidden = false;+    $('ho-change').addEventListener('click', () => { editing = true; draft = draftFrom(ho.plan || {}, ho.profile || ''); draw(); });+  }++  function discover() {+    const mine = (ho && ho.profiles || []).filter(p => !p.builtin);+    disc.innerHTML = '<div class="ho-card ho"><div><h2>Want a say in each step? <button class="prompt-hint" id="ho-prompt" title="Use hands-on AI Flow for this board" aria-label="Copy the hands-on prompt">ⓘ</button></h2>' ++      '<p class="lead">Hands-on AI Flow lets you choose, for every step:</p><ol><li>The AI takes care of it</li><li>The AI checks in with you after it</li><li>You do it yourself</li><li>Your own instructions</li></ol>' ++      '<p class="sub">Your choices are saved as a flow for your next boards, and you can keep several: a careful one for real boards, Full AI for quick ones. The AI also stops bringing windows to the front unless you allow it. Ask the AI: “use hands-on AI Flow”.</p>' ++      (mine.length ? '<p class="mine">Your flows: ' + mine.map(p => esc(p.name) + (p.default ? ' (default)' : '')).join(', ') + ', Full AI flow</p>' : '') + '</div>' ++      '<div><button class="ho-btn primary" id="ho-open">Choose my flow</button></div></div>';+    disc.hidden = false;+    $('ho-open').addEventListener('click', () => { editing = true; draft = draftFrom((mine.find(p => p.default)) || {}, (mine.find(p => p.default) || {}).slug || ''); draw(); box.scrollIntoView({ behavior: 'smooth', block: 'start' }); });+    $('ho-prompt').addEventListener('click', async () => {+      const fb = $('copy-feedback'); const prompt = 'Use hands-on AI Flow for this board';+      try { await navigator.clipboard.writeText(prompt); fb.textContent = 'Prompt copied'; } catch { fb.textContent = prompt; }+      fb.hidden = false; setTimeout(() => { fb.hidden = true; }, 6000);+    });+  }++  function draw() {+    if (!ho) return;+    const w = ho.waiting;+    if (ho.state === 'off' && !editing) { box.hidden = true; box.innerHTML = ''; discover(); return; }+    disc.hidden = true;+    if (ho.state === 'planning' || editing) { if (!draft) draft = draftFrom(ho.plan || {}, ho.profile || ''); planner(); return; }+    if (w && (w.kind === 'checkin' || w.kind === 'human')) { turn(w); return; }+    bar();+  }++  // called by the timeline script with every state it receives+  window.aiflowHandsOn = (data, model) => {+    const next = data && data.handsOn;+    if (!next || !model) return;+    steps = model.steps.map(s => ({ name: s.name, title: s.title, does: s.does, phase: s.phase, status: s.status }));+    const key = JSON.stringify([next.state, next.plan, next.waiting, next.profile, (next.profiles || []).map(p => p.slug + p.default), steps.map(s => s.name)]);+    if (key === planKey) return;+    const wasPlanning = ho && ho.state === 'planning';+    planKey = key; ho = next;+    if (ho.state === 'planning' && !wasPlanning && !editing) draft = null;+    if (ho.state !== 'planning' && wasPlanning) { draft = null; pending = null; }+    if (pending && pending.step && !(ho.waiting && ho.waiting.step === pending.step)) pending = null;+    // keep the human's half-made choices while the CLI writes unrelated updates+    if ((ho.state === 'planning' || editing) && box.contains(document.activeElement)) return;+    draw();+  };+})();+</script> </body> </html>

Comments

John Lauer ·

Noah, thanks for this: hands-on AI Flow is a great addition, and it's in adom-aiflow 0.2.49. The PR's base was 0.2.44, so I applied it by hand onto 0.2.48 rather than merging it here. Closing the PR for that reason only; everything below is in the release.

Kept as you built it: the per-step modes, the flow profiles in ~/.adom/aiflow/profiles, the timeline UI, handson ask / wait / approve / changes, finish_missing, the docs and the aiflow-hands-on skill, and your test (it passes).

Adapted to 0.2.48, where AI Flow never refuses:

  • A check-in that is still waiting doesn't block the next step. step proceeds with a note naming the step still waiting for the human, and logs handson-proceeded-without-ok. The AI is told to wait, and the timeline shows it.
  • handson approve still needs a quote of the human's words. That one isn't a gate; it keeps the record honest.
  • Screen:
    • your profile's screen is folded into one rule with #132 and #135 (capture screen, the spec, and a default from the machine), with the latest word winning;
    • tour 3d on a hands-off screen skips with a suggestion instead of an error;
    • capture open keeps a newly launched KiCad behind the human's window (#135).

Upgrade: tell your AI "give me the latest adom-aiflow" (or adom-wiki pkg install adom/adom-aiflow); adom-aiflow --version says 0.2.49.

Log in to comment.