← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.77"+version = "0.1.78" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.77"+version = "0.1.78" dependencies = [  "serde",  "serde_json",@@ -33,7 +33,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.77"+version = "0.1.78" dependencies = [  "roxmltree",  "serde",@@ -42,7 +42,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.77"+version = "0.1.78" dependencies = [  "aiflow-board",  "serde",@@ -51,7 +51,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.77"+version = "0.1.78" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.77"+version = "0.1.78" dependencies = [  "aiflow-board",  "serde",@@ -70,7 +70,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.77"+version = "0.1.78" dependencies = [  "aiflow-board",  "serde",@@ -79,7 +79,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.77"+version = "0.1.78" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.77"+version = "0.1.78" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.77"+version = "0.1.78" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.77"+version = "0.1.78" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+12
@@ -22,6 +22,18 @@ Reserve a final handoff for the completed requested scope, an explicit pause, or  Skills and CLI hints guide the calling AI; they cannot restart an ended chat turn or guarantee autonomous execution outside the host's runtime. Do not claim a background continuation mechanism unless it is implemented and verified. +### Before every final answer: executable finish guard++Run `adom-aiflow --run <run> finish-guard --json` before ending a turn. Exit 2 / `decision: continue` means give a short progress update in commentary and immediately execute the next useful action. Do not send a final checkpoint and wait for “keep going.” This applies after skill/code changes, releases, dashboard work, diagnostics, status questions and context recovery. Run this check again after any later work changes the state.++A failed engineering gate, an inaccurate heuristic, missing automation, a source bug, a slow solve, or a fix that needs implementation is work to do. Inspect evidence, repair the design/tool, test it, synchronize the dashboard and continue. A pending owner review is not a global blocker while isolated experiments, a safe alternative, or other requested work remains. Do not classify your own unfinished implementation as an external dependency. Never lower acceptance thresholds, fabricate receipts or mark work passed to satisfy this guard.++`done` now refuses ordinary closure until the sequence, finish and delivery are complete. It is no longer a way to close an intermediate progress update. The guard checks recorded gates and delivery artifacts; the AI must also verify the user's full scope, including firmware, molecule, wiki, dashboard and edited video when requested. Track additional deliverables in the project's completion contract; optional `run.json` `continuation.requiredDeliverables` rows use `name`, `complete` and an existing `evidence` file. Evidence existence is not independent validation of its contents.++Only the user's explicit pause/cancellation, or an unavoidable external dependency after exhausting useful authorized work, permits an incomplete handoff. Use `finish-guard --stop-evidence <json>` and `done --stop-evidence <json> --message ...`. See `docs/continuation.md` for the evidence schemas. Preserve the exact user instruction when pausing. Stop evidence never completes a gate and must not be reused after a resume or an external-state change.++The guard is read-only and suitable for a host's pre-stop integration. It does not install a watcher, inject user messages, or restart a terminated AI turn. Never promise universal nonstop execution: host/runtime interruption, user control, limits and real external dependencies still apply. When a host has no stop integration, following this protocol remains the calling AI's responsibility.+ ## The flow  The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with Zoom to Selected Objects, one net at a time on camera. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/continuation.rsadded+260
@@ -0,0 +1,260 @@+//! Fail-closed completion check; this never supplies engineering proof or restarts a host.+use serde_json::{json, Value};++pub fn failed_proof(v: &Value) -> bool {+    v["pass"] == false+        || v["passed"] == false+        || v["failed"] == true+        || v["status"] == "fail"+        || v["status"] == "failed"+}++fn text(v: &Value, key: &str) -> bool {+    v[key].as_str().is_some_and(|s| !s.trim().is_empty())+}+fn strings(v: &Value, key: &str) -> bool {+    v[key].as_array().is_some_and(|a| {+        !a.is_empty()+            && a.iter()+                .all(|s| s.as_str().is_some_and(|s| !s.trim().is_empty()))+    })+}+fn stop_reason(v: &Value) -> Result<&'static str, String> {+    if !text(v, "reason") {+        return Err("stop evidence needs a concrete reason".into());+    }+    match v["kind"].as_str() {+        Some("user-pause") if text(v, "userInstruction") && text(v, "instructionAt") => {+            Ok("user-paused")+        }+        Some("external-dependency") => {+            let allowed = [+                "hardware",+                "credentials",+                "permission",+                "external-service",+                "owner-review",+                "user-input",+            ];+            if !allowed.contains(&v["category"].as_str().unwrap_or(""))+                || !text(v, "dependency")+                || !text(v, "resumeWhen")+                || !strings(v, "attemptedRemedies")+                || !strings(v, "evidenceFiles")+                || v["availableWork"].as_array().is_none_or(|a| !a.is_empty())+            {+                return Err("external dependency needs category, dependency, resumeWhen, attemptedRemedies, evidenceFiles and an empty availableWork list; fixable software and engineering failures mean continue".into());+            }+            for p in v["evidenceFiles"].as_array().unwrap() {+                if !std::path::Path::new(p.as_str().unwrap()).is_file() {+                    return Err(format!("missing dependency evidence: {p}"));+                }+            }+            Ok("external-dependency")+        }+        _ => Err(+            "only an explicit user-pause or evidenced external-dependency can stop incomplete work"+                .into(),+        ),+    }+}++pub fn assess(data: &Value, pending: &[String], stop: Option<Result<Value, String>>) -> Value {+    let mut remaining = pending.to_vec();+    let finish = data["clock"]["finishTime"]+        .as_str()+        .filter(|s| !s.is_empty());+    let delivered = data["clock"]["deliveredTime"]+        .as_str()+        .filter(|s| !s.is_empty());+    if finish.is_none() {+        remaining.push("finish: engineering acceptance has not passed".into());+    }+    if delivered.is_none()+        || data["delivery"]["at"].as_str() != delivered+        || finish.zip(delivered).is_some_and(|(f, d)| d < f)+    {+        remaining.push("deliver: requested delivery has not been recorded after finish".into());+    }+    if !data["delivery"]["video"]+        .as_str()+        .is_some_and(|p| std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.len() > 0))+    {+        remaining.push("deliver: final video artifact is missing or empty".into());+    }+    if data["clock"]["lastPromptTime"]+        .as_str()+        .zip(delivered)+        .is_some_and(|(p, d)| p > d)+    {+        remaining.push("follow-up: deliver the scope requested after the previous delivery".into());+    }+    if let Some(items) = data["continuation"]["requiredDeliverables"].as_array() {+        for item in items {+            if item["complete"] != true+                || !item["evidence"]+                    .as_str()+                    .is_some_and(|p| std::path::Path::new(p).is_file())+            {+                remaining.push(format!(+                    "requested deliverable: {}",+                    item["name"].as_str().unwrap_or("unnamed")+                ));+            }+        }+    } else if !data["continuation"]["requiredDeliverables"].is_null() {+        remaining.push("invalid requiredDeliverables: expected an array".into());+    }+    for key in [+        "gate",+        "land-route",+        "land-vias",+        "land-pours",+        "measure",+        "analyze-current",+        "analyze-thermal",+    ] {+        if !data["outcomeAt"][key].is_null()+            && (data["outcomes"][key].is_null() || failed_proof(&data["outcomes"][key]))+        {+            remaining.push(format!("invalidated or failed evidence: {key}"));+        }+        if finish+            .zip(data["outcomeAt"][key].as_str())+            .is_some_and(|(f, t)| t > f)+        {+            remaining.push(format!(+                "finish must be repeated after changed {key} evidence"+            ));+        }+    }+    let complete = remaining.is_empty();+    let mut decision = if complete { "complete" } else { "continue" };+    let mut reason = if complete {+        "Recorded sequence, finish, delivery and registered deliverables are complete. Verify the user's full requested scope before final handoff.".into()+    } else {+        format!(+            "{} requirement(s) remain. A failed gate is work to resolve, not permission to stop.",+            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))) {+            Ok((kind, v)) => {+                decision = kind;+                reason = format!(+                    "Recorded stop: {}. Engineering completion is unchanged.",+                    v["reason"].as_str().unwrap()+                );+                stop_record = v;+            }+            Err(e) => {+                decision = "continue";+                reason = format!("Stop refused: {e}");+            }+        }+    }+    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"))},+        "runtimeEnforcement":"CLI guard only. An AI host must invoke this before ending a turn; no watchdog or automatic restart is installed."})+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn failed_outcome_is_not_a_completion_proof() {+        for v in [+            json!({"pass":false}),+            json!({"failed":true}),+            json!({"status":"fail"}),+            json!({"passed":false}),+        ] {+            assert!(failed_proof(&v));+        }+        assert!(!failed_proof(&json!({"pass":true})));+        assert!(!failed_proof(&Value::Null));+    }+    #[test]+    fn removed_proof_and_post_finish_evidence_block_closure() {+        let d = json!({"clock":{"finishTime":"2026-10-10T01:00:00Z"},"outcomeAt":{"land-pours":"2026-10-10T02:00:00Z"}});+        let r = assess(&d, &[], None);+        let a = r["remaining"].as_array().unwrap();+        assert!(a+            .iter()+            .any(|x| x.as_str().unwrap().contains("invalidated or failed")));+        assert!(a+            .iter()+            .any(|x| x.as_str().unwrap().contains("finish must be repeated")));+    }+    #[test]+    fn partial_progress_is_not_completion() {+        let d = json!({"currentStep":"current"});+        let r = assess(&d, &["current".into()], None);+        assert_eq!(r["mayStop"], false);+        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);+    }+    #[test]+    fn explicit_pause_does_not_complete_engineering() {+        let e = json!({"kind":"user-pause","reason":"User asked to pause","userInstruction":"pause until I resume","instructionAt":"2026-10-10T01:00:00Z"});+        let r = assess(&json!({}), &[], Some(Ok(e)));+        assert_eq!(r["mayStop"], true);+        assert_eq!(r["engineeringComplete"], false);+    }+    #[test]+    fn fixable_failure_is_not_external_dependency() {+        let e = json!({"kind":"external-dependency","category":"tool-bug","reason":"current check failed"});+        assert_eq!(assess(&json!({}), &[], Some(Ok(e)))["mayStop"], false);+    }+    #[test]+    fn missing_or_malformed_evidence_fails_closed() {+        assert_eq!(+            assess(&json!({}), &[], Some(Err("missing file".into())))["mayStop"],+            false+        );+        assert_eq!(+            assess(&json!({}), &[], Some(Ok(json!({"kind":"user-pause"}))))["mayStop"],+            false+        );+    }+    #[test]+    fn available_work_prevents_dependency_stop() {+        let e = json!({"kind":"external-dependency","category":"hardware","reason":"no assembly","dependency":"board","resumeWhen":"assembled","attemptedRemedies":["checked inventory"],"evidenceFiles":["Cargo.toml"],"availableWork":["finish firmware"]});+        assert_eq!(assess(&json!({}), &[], Some(Ok(e)))["mayStop"], false);+    }+    #[test]+    fn genuine_external_dependency_requires_existing_evidence() {+        let p = std::env::temp_dir().join(format!("aiflow-dependency-{}.json", std::process::id()));+        std::fs::write(&p, b"hardware unavailable").unwrap();+        let e = json!({"kind":"external-dependency","category":"hardware","reason":"assembly absent","dependency":"assembled board","resumeWhen":"board connected","attemptedRemedies":["inspected fixture"],"evidenceFiles":[p],"availableWork":[]});+        let r = assess(&json!({}), &[], Some(Ok(e.clone())));+        assert_eq!(r["mayStop"], true);+        assert_eq!(r["engineeringComplete"], false);+        std::fs::remove_file(&p).unwrap();+        assert_eq!(assess(&json!({}), &[], Some(Ok(e)))["mayStop"], false);+    }+    #[test]+    fn stale_delivery_and_returns_do_not_complete() {+        let p = std::env::temp_dir().join(format!("aiflow-guard-{}.mp4", std::process::id()));+        std::fs::write(&p, b"fixture").unwrap();+        let mut d = json!({"clock":{"finishTime":"2026-10-10T01:00:00Z","deliveredTime":"2026-10-10T02:00:00Z"},"delivery":{"at":"2026-10-10T02:00:00Z","video":p}});+        assert_eq!(assess(&d, &[], None)["mayStop"], true);+        let mut extra = d.clone();+        extra["continuation"] =+            json!({"requiredDeliverables":[{"name":"firmware","complete":false}]});+        assert_eq!(assess(&extra, &[], None)["mayStop"], false);+        extra["continuation"]["requiredDeliverables"] = json!("malformed");+        assert_eq!(assess(&extra, &[], None)["mayStop"], false);+        assert_eq!(assess(&d, &["routing".into()], None)["mayStop"], false);+        d["clock"]["lastPromptTime"] = json!("2026-10-10T03:00:00Z");+        assert_eq!(assess(&d, &[], None)["mayStop"], false);+        std::fs::remove_file(p).unwrap();+    }+}
crates/adom-aiflow/src/main.rs+35−4
@@ -1,5 +1,6 @@ //! adom-aiflow: the board loop as one command, with the clock, the negotiation and the finish line. //! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run.+mod continuation; mod dashboard; mod silkscreen_sections; mod sequence;@@ -180,7 +181,11 @@ enum Cmd {     /// A new human prompt on this run (a follow-up: "add the walkthroughs", "recut the video"): the AI's time for it counts from here to its `done`. --at is the paste time when you start late.     Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String> },     /// Stop session accounting after the requested scope is complete or a genuine blocking handoff. This does not complete the engineering run.-    Done { #[arg(long)] message: String },+    Done { #[arg(long)] message: String,+    /// JSON evidence of an explicit user pause or an unavoidable external dependency. Never a gate waiver.+    #[arg(long)] stop_evidence: Option<PathBuf> },+    /// Read-only pre-final check. Exit 2 means continue working; exit 0 means delivered or an evidenced stop. Does not restart an AI runtime.+    FinishGuard { #[arg(long)] json: bool, #[arg(long)] stop_evidence: Option<PathBuf> },     /// The run as sessions: each human prompt, the AI's active time on it, the idle gaps cut out (a gap over --idle-minutes with no command running is unobserved activity, not proven human idle).     Sessions { #[arg(long)] json: bool }, }@@ -1966,8 +1971,29 @@ fn project_dir_of(r: &Run) -> PathBuf {     src.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from(".")) } +fn continuation_report(r: &Run, evidence: Option<&Path>) -> Value {+    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::lane(&r.dir, &r.data)));+    let states = sequence::states(&r.dir, &r.data, &flow, &seq);+    let pending: Vec<String> = states.iter().filter(|s| {+        let failed = flow["proofs"][s.name.as_str()].as_array().is_some_and(|keys| keys.iter().filter_map(Value::as_str).any(|key| continuation::failed_proof(&r.data["outcomes"][key]) || (!r.data["outcomeAt"][key].is_null() && r.data["outcomes"][key].is_null())));+        matches!(s.status, sequence::Status::Todo | sequence::Status::Redo) || !s.unproven.is_empty() || failed+    }).map(|s| s.name.clone()).collect();+    let stop = evidence.map(|p| std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str::<Value>(&s).map_err(|e| e.to_string())));+    continuation::assess(&r.data, &pending, stop)+}+ fn main() {     let cli = Cli::parse();+    // A host stop hook must not open windows, start captures or append timing events.+    if let Cmd::FinishGuard { json: as_json, stop_evidence } = &cli.cmd {+        let r = load_run(&cli);+        let report = continuation_report(&r, stop_evidence.as_deref());+        if *as_json { println!("{}", serde_json::to_string_pretty(&report).unwrap()); }+        else { println!("{}: {}", report["decision"].as_str().unwrap_or("continue"), report["reason"].as_str().unwrap_or(""));+            println!("Next: {}", report["nextAction"].as_str().unwrap_or("review remaining scope")); }+        std::process::exit(if report["mayStop"] == true { 0 } else { 2 });+    }     let dir = run_dir(&cli);     // All engines share the same default. Server/write children must never open tabs recursively.     if !matches!(cli.cmd, Cmd::Timeline { .. }) {@@ -3328,14 +3354,19 @@ fn main() {             r.save().unwrap();             ok(&format!("prompt {n} at {at}: the AI's time on it counts from here"), &["Continue the original requested scope after supporting tasks and status updates. Use `done --message \"...\"` only at completion, an explicit pause, or a genuine blocking handoff; it stops session accounting, not the engineering run.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into(), "Measure external work with adom-aiflow exec -- <program> <args>. Unwrapped long gaps are unobserved and may undercount active work, not proof of idle time.".into()]);         }-        Cmd::Done { message } => {+        Cmd::FinishGuard { .. } => unreachable!("handled before side effects"),+        Cmd::Done { message, stop_evidence } => {             thread(&cli);             let mut r = load_run(&cli);+            let verdict = continuation_report(&r, stop_evidence.as_deref());+            if verdict["mayStop"] != true {+                err("finish line not reached; session remains open", &[verdict["reason"].as_str().unwrap_or("").into(), verdict["nextAction"].as_str().unwrap_or("").into(), "A status update, a passing diagnostic, a fixable tool bug or a failed engineering gate is not a stopping point. Run finish-guard before any final answer.".into()]);+            }             let ss = r.sessions();             let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };             if cur.done_at.is_some() && !cur.open { err(&format!("session {} is already done", cur.n), &["A new human prompt is `prompt --text \"...\"`; then `done` closes that one.".into()]); }             let t = now();-            r.log("done", json!({"n": cur.n, "message": message}));+            r.log("done", json!({"n": cur.n, "message": message, "stopDecision": verdict}));             r.save().unwrap();             let ss = r.sessions();             let cur = ss.last().unwrap();@@ -3350,7 +3381,7 @@ fn main() {                 return;             }             println!("{}", sessions_table(&r));-            ok(&format!("{} session(s), {:.1} min of AI time in all; idle threshold {} min", ss.len(), r.active_minutes_total(), r.idle_minutes()), &["A session without a `done` ends at its last event; say `done` when an answer is complete so nothing idle is charged.".into()]);+            ok(&format!("{} session(s), {:.1} min of AI time in all; idle threshold {} min", ss.len(), r.active_minutes_total(), r.idle_minutes()), &["Run finish-guard before a final answer. Intermediate progress stays in commentary; done requires completed delivery or explicit stop evidence. Unobserved time is not automatically charged.".into()]);         }         Cmd::Usage => {             thread(&cli);
docs/continuation.mdadded+35
@@ -0,0 +1,35 @@+# Continue to the requested finish line++AIFlow 0.1.78 adds an executable pre-final check. It addresses an observed failure: the AI reported step 12 as a final checkpoint even though the next work was a fixable current-checker problem. That should have been a progress update followed by more work.++```+adom-aiflow --run path/to/run finish-guard --json+```++Exit 2 means continue; exit 0 permits a completed handoff or the evidenced stop supplied to this invocation. The JSON contains `decision`, `mayStop`, `engineeringComplete`, `remaining`, `nextAction`, and the explicit runtime limitation. This command performs no recording, dashboard opening, board mutation or ledger accounting. A missing run is an error, never permission to stop.++Ordinary `done --message ...` now refuses while the required sequence, finish, final delivery, or registered extra deliverables are incomplete. `finish` alone is insufficient. Later prompts and returns invalidate old completion as a stopping justification. The guard uses the existing sequence proof rules and requires the final video file to exist and be nonempty. It does not independently assess video quality or the truth of evidence, and it is not a substitute for engineering acceptance.++Optional `continuation.requiredDeliverables` in run.json is an array of `{ "name": "firmware", "complete": false, "evidence": "path/to/review.json" }`. A pending item or missing evidence blocks normal closure. Keep the full human scope in the project's completion contract; do not omit required work merely because it is outside the PCB sequence.++## Explicit user pause++Write actual evidence, never invent a request:++```json+{"kind":"user-pause","reason":"User requested a pause","userInstruction":"pause until I tell you to resume","instructionAt":"2026-10-10T00:00:00Z"}+```++Pass its file to `finish-guard --stop-evidence pause.json`, then to `done --stop-evidence pause.json --message "Paused at the user's request"`. This closes accounting without passing or completing engineering. On resume, do not reuse it.++## External dependency++```json+{"kind":"external-dependency","category":"hardware","reason":"Physical calibration requires the assembled board","dependency":"Assembled board on the fixture","resumeWhen":"Board is assembled and connected","attemptedRemedies":["Checked fixture and assembly inventory"],"evidenceFiles":["design/fixture-status.json"],"availableWork":[]}+```++Allowed categories: hardware, credentials, permission, external-service, owner-review, user-input. Evidence files must exist. `availableWork` must be explicitly empty. The agent must truthfully check alternatives and all remaining authorized work; JSON validation cannot prove that judgment. A code bug or failed design check is not an external dependency. Owner review does not excuse stopping if an isolated test or other requested deliverable can proceed.++## Host integration boundary++The calling AI must invoke the guard before a final answer and continue on exit 2. A host may integrate this read-only command into its own stop lifecycle, treating malformed output/errors as refusal, preserving explicit user cancellation and preventing simultaneous turns. Do not retry indefinitely through authorization failures or user pauses. No automatic host hook, scheduler, queue injector or watchdog is installed by this release. The CLI cannot prevent an AI from ending its turn without invoking it. The shipped skills require the check; a universally enforced host-level guarantee would require provider-specific runtime integration and separate validation.
docs/release-0.1.78.mdadded+9
@@ -0,0 +1,9 @@+# AIFlow 0.1.78++Premature final checkpoints now have an executable check: `finish-guard --json` returns exit 2 while sequence proofs, finish, delivery, or registered requested deliverables remain incomplete. It identifies the next unmet requirement without opening a dashboard, recording, or changing the run.++`done` refuses ordinary session closure before that finish line. Explicit user pauses and unavoidable external dependencies require structured evidence; pauses never pass an engineering gate. A fixable software/design failure, partial diagnostic or status update is work to continue. New prompts and returned steps cannot reuse an old delivery as completion.++The main and measurement skills require the check before a final answer and immediate continuation in the same turn on refusal. This is a CLI guard and calling-agent protocol, not an installed host stop hook or automatic AI restart. The runtime boundary and stop-evidence schemas are in [continuation](continuation.md).++Base: `947fd4f7f5f28d134c109d78af9fa62890be9e23`. Acceptance includes production compilation, workspace tests, CLI refusal/read-only/pause/resume tests, existing component/widget/dashboard and geometry checks, and the actual electronic-load run returning continue at its unfinished current gate. No PCB geometry or engineering acceptance thresholds changed.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.77",+  "version": "0.1.78",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.77",+  "version": "0.1.78",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+12
@@ -12,6 +12,18 @@ Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kic  Every state-changing command takes `--ai-thread "<your thread name>"`. Every run lives in one directory (`--run DIR`, default `./aiflow-run`) with a `run.json` manifest: the clock, the plan, the decisions, every stage's start and end, the outcomes, the capture markers and the summary. The manifest is the comparison; nothing counts that is not in it. +### Before every final answer: executable finish guard++Run `adom-aiflow --run <run> finish-guard --json` before ending a turn. Exit 2 / `decision: continue` means give a short progress update in commentary and immediately execute the next useful action. Do not send a final checkpoint and wait for “keep going.” This applies after skill/code changes, releases, dashboard work, diagnostics, status questions and context recovery. Run this check again after any later work changes the state.++A failed engineering gate, an inaccurate heuristic, missing automation, a source bug, a slow solve, or a fix that needs implementation is work to do. Inspect evidence, repair the design/tool, test it, synchronize the dashboard and continue. A pending owner review is not a global blocker while isolated experiments, a safe alternative, or other requested work remains. Do not classify your own unfinished implementation as an external dependency. Never lower acceptance thresholds, fabricate receipts or mark work passed to satisfy this guard.++`done` now refuses ordinary closure until the sequence, finish and delivery are complete. It is no longer a way to close an intermediate progress update. The guard checks recorded gates and delivery artifacts; the AI must also verify the user's full scope, including firmware, molecule, wiki, dashboard and edited video when requested. Track additional deliverables in the project's completion contract; optional `run.json` `continuation.requiredDeliverables` rows use `name`, `complete` and an existing `evidence` file. Evidence existence is not independent validation of its contents.++Only the user's explicit pause/cancellation, or an unavoidable external dependency after exhausting useful authorized work, permits an incomplete handoff. Use `finish-guard --stop-evidence <json>` and `done --stop-evidence <json> --message ...`. See `docs/continuation.md` for the evidence schemas. Preserve the exact user instruction when pausing. Stop evidence never completes a gate and must not be reused after a resume or an external-state change.++The guard is read-only and suitable for a host's pre-stop integration. It does not install a watcher, inject user messages, or restart a terminated AI turn. Never promise universal nonstop execution: host/runtime interruption, user control, limits and real external dependencies still apply. When a host has no stop integration, following this protocol remains the calling AI's responsibility.+ ## The flow  The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with Zoom to Selected Objects, one net at a time on camera. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.
skills/aiflow-measurement/SKILL.md+2
@@ -59,3 +59,5 @@ Wrap actual external work with `adom-aiflow --ai-thread "<thread>" --run <run> e A long interval without ledger events is **unobserved**, not proof the human was away. Session reports label these excluded intervals explicitly; the legacy JSON `idleMinutes` field remains for compatibility, alongside `unobservedMinutes`. Existing delivery timestamps and ledger events are never rewritten. Historical reports with unwrapped external work can undercount AI activity. Do not claim harness-complete measurement or invent retrospective times.  Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage=binary` records an explicit owner.++Before closing a session or emitting a final checkpoint, run `adom-aiflow finish-guard --json` with the active run. A continue verdict means resume work in the same turn. `done` refuses intermediate closure; explicit user-pause/external-dependency evidence is required for an incomplete handoff. See the main AIFlow skill.
tests/continuation-workflow.pyadded+27
@@ -0,0 +1,27 @@+"""Actual CLI refusal and read-only guard; no CAD, real dashboard or AI queue."""+import json, os, pathlib, subprocess, sys, tempfile+binary=str(pathlib.Path(sys.argv[1]).resolve())+with tempfile.TemporaryDirectory(prefix='aiflow-continuation-') as td:+ p=pathlib.Path(td);run=p/'run';env=dict(os.environ,AIFLOW_DASHBOARD='off')+ def call(*args):+  return subprocess.run([binary,'--run',str(run),'--ai-thread','continuation-test',*args],env=env,capture_output=True,text=True,timeout=30)+ assert call('intake','--prompt-time','2026-10-10T00:00:00Z','--engine','test').returncode==0+ assert call('prompt','--text','Finish the entire design').returncode==0+ before={f.name:f.read_bytes() for f in run.iterdir() if f.is_file()}+ r=call('finish-guard','--json');assert r.returncode==2,r.stderr+ d=json.loads(r.stdout);assert d['decision']=='continue' and not d['mayStop']+ assert before=={f.name:f.read_bytes() for f in run.iterdir() if f.is_file()},'guard mutated run files'+ data=json.loads((run/'run.json').read_text());data['sequence']=['current'];data['currentStep']='current';data['steps']={'current':{'visits':[{'at':'2026-10-10T00:00:00Z'}]}};data['outcomeAt']={'analyze-current':'2026-10-10T00:01:00Z'};data['outcomes']={'analyze-current':{'pass':False}};(run/'run.json').write_text(json.dumps(data))+ r=call('finish-guard','--json');assert 'current' in json.loads(r.stdout)['remaining'],'failed analysis was counted as proof'+ r=call('done','--message','Made good progress');assert r.returncode==2,(r.stdout,r.stderr)+ events=[json.loads(l) for l in (run/'run.jsonl').read_text().splitlines()]+ assert not any(e.get('event')=='done' or e.get('kind')=='done' for e in events)+ assert call('finish-guard','--json','--stop-evidence',str(p/'missing.json')).returncode==2+ pause=p/'pause.json';pause.write_text(json.dumps(dict(kind='user-pause',reason='User requested pause',userInstruction='pause until I resume',instructionAt='2026-10-10T00:01:00Z')))+ r=call('finish-guard','--json','--stop-evidence',str(pause));assert r.returncode==0+ assert not json.loads(r.stdout)['engineeringComplete']+ assert call('done','--message','Paused by user','--stop-evidence',str(pause)).returncode==0+ assert call('prompt','--text','Resume').returncode==0+ assert call('done','--message','Stop again').returncode==2,'old pause reused after resume'+ assert call('finish-guard','--json').returncode==2+ print('PASS: incomplete closure refused, guard read-only, missing evidence fails closed, explicit pause recorded without passing gates, resume requires fresh decision')