app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← All Pull Requests
AI Flow 0.2.64: retry a refused part request once, then assess its supply #81
Merges
aiflow-0.2.64 → mainAI Flow 0.2.64: a part that cannot get a page is retried once, then assessed, before it's called a failure. Closes #193.
part-request <MPN> [--as <orderable MPN>] [--datasheet <link>] [--manufacturer <maker>]sendsadom-wiki part requestand keeps every attempt in the run (partRequests) with its inputs and the factory's answer. The same inputs are turned away;--refreshreads queued answers. A page built under the orderable number is used for the BOM line.supply-riskrecords the AI's assessment (design/supply-risk.json: risk, lifecycle, stock per distributor, lead time, second sources, package, datasheet and land pattern published, up to three alternatives with board changes; each figure with source and date) and lists what it leaves out as findings.--choose <MPN> --pick <alternative|keep> --whyrecords the human's choice; nothing is substituted without it.partsandfinishlead with each part's decision and the next command. Project-library parts can be assessed too.- aiflow-sourcing gains "When a part cannot get a page"; the adom-aiflow skill points to it.
Tests: 680 workspace tests (4 new in supply.rs), plus tools/test_supply_risk.py (end to end with a stand-in adom-wiki) and the existing tool tests; preflight clean.
🤖 Generated with Claude Code
Diff Skip to comments
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.63"+version = "0.2.64" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.63"+version = "0.2.64" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.63"+version = "0.2.64" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.63"+version = "0.2.64" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.63"+version = "0.2.64" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.63"+version = "0.2.64" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.63"+version = "0.2.64" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.63"+version = "0.2.64" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.63"+version = "0.2.64" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.63"+version = "0.2.64" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.63"+version = "0.2.64" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+142−2@@ -1,6 +1,7 @@ //! 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 supply; mod copper_carry; mod artifact_store; mod pourverify;@@ -200,6 +201,12 @@ enum Cmd { /// `parts [--bom <csv>] [--show] [--dwell 4]`: the from-scratch board's parts on the wiki. Every BOM row with an MPN (default: the BOM the sourcing check passed) is matched to its Adom wiki component page (a `wiki` column, owner/slug, names it directly; else the MPN's slug forms, then a page whose component.mpn is the MPN); --show walks a Hydrogen tab through each page so the viewer sees the parts being chosen. A part with no page fails: make it (wiki-component skill) and run again, or give it from the board's own project library (--project-lib). Parts { #[arg(long)] bom: Option<String>, #[arg(long)] show: bool, #[arg(long, default_value_t = 4)] dwell: u64, /// a project library folder for parts with no public component page: KiCad .kicad_sym, .pretty footprints, STEP models and a provenance.json naming each MPN's source (default: the spec's or requirements' "projectLib"). Each listed MPN needs a symbol, a footprint, a model and provenance; project-page packages them, finish reports them. #[arg(long)] project_lib: Option<String> },+ /// `part-request <MPN> [--as <orderable MPN>] [--datasheet <link>] [--manufacturer <maker>]`: ask Adom's part factory for the page of a BOM part with none (issue #193), and keep the attempt in the run. The same inputs are never sent twice: one retry changes at least one (the orderable number from `adom-wiki part candidates`, the maker's product page, package drawing or CAD link, the manufacturer). `--refresh` reads the factory's answers for every pending attempt. After a refused retry, assess the part (`supply-risk`).+ PartRequest { mpn: Option<String>, /// the part number to send, when the BOM's is not the orderable one (a packaging or reel suffix)+ #[arg(long = "as")] as_mpn: Option<String>, #[arg(long)] datasheet: Option<String>, #[arg(long)] manufacturer: Option<String>, #[arg(long)] refresh: bool, /// later, the factory's batch wave (default: now)+ #[arg(long)] later: bool },+ /// `supply-risk [--file design/supply-risk.json]`: record your assessment of the parts that could not get a page (issue #193): per part the risk (high, medium, low), lifecycle, distributors with stock, lead time, second sources, package, whether a datasheet and land pattern are published, and up to three alternatives with what each changes on the board; every figure with its source and date. What the file leaves out is reported, never refused. `--choose <MPN> --pick <alternative|keep> --why "..."` records the human's choice (only after they made it).+ SupplyRisk { #[arg(long, default_value = supply::DEFAULT_FILE)] file: String, #[arg(long)] choose: Option<String>, #[arg(long)] pick: Option<String>, #[arg(long)] why: Option<String> }, /// `project-page --page <owner/slug> [--create]`: revision 1 of the board's project page before any EDA work: a README that says what the board is with the component table, and a KiCad PCM repository of every part (an Adom Cloud Library, exported and repackaged for KiCad's Plugin and Content Manager). Opens the page in Hydrogen. ProjectPage { #[arg(long, default_value = "")] page: String, /// write the design review (design-review/README.md: Fields current and thermal numbers, your analysis figures, a molecule's contact table, the parts table with prices) for the current board into the page's staging folder, or into --out (a molecule-publish package folder); prints the push (issues #147, #164) #[arg(long)] results: bool, /// with --results: the folder to write design-review/ into (a molecule-publish package folder) instead of <run>/page-results@@ -3081,6 +3088,20 @@ fn unacked_orientation(r: &Run, warnings: &[Value]) -> Vec<String> { } /// A wiki page's page.json through `adom-wiki repo show` (None when it cannot be read).+/// Issue #193: the BOM references of an MPN, from the parts record.+fn part_refs(r: &Run, mpn: &str) -> Vec<String> {+ r.data["outcomes"]["parts"]["parts"].as_array().into_iter().flatten().filter(|p| p["mpn"].as_str().map(|m| m.eq_ignore_ascii_case(mpn)).unwrap_or(false))+ .flat_map(|p| p["refs"].as_array().cloned().unwrap_or_default()).filter_map(|v| v.as_str().map(str::to_string)).collect()+}++/// Issue #193: the recorded supply assessment of one MPN, read from the file `supply-risk` recorded.+fn supply_assessed(r: &Run, mpn: &str) -> Option<Value> {+ let f = r.data["outcomes"]["supplyRisk"]["file"].as_str()?;+ let v: Value = serde_json::from_str(&std::fs::read_to_string(f).ok()?).ok()?;+ let (parts, _) = supply::check(&v);+ supply::find(&parts, mpn).cloned()+}+ fn wiki_page_json(page: &str) -> Option<Value> { let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, "page.json"]).stdin(std::process::Stdio::null()).output().ok().filter(|o| o.status.success())?; let t = String::from_utf8_lossy(&o.stdout);@@ -3915,6 +3936,13 @@ fn main() { page = preboard::mpn_page_candidates(&seen_hits, &mpn).into_iter().find(|p| wiki_page_json(p).and_then(|j| j["component"]["mpn"].as_str().map(|m| preboard::page_mpn_matches(m, &mpn))).unwrap_or(false)); if let Some(p) = &page { row["wikiMatch"] = json!(format!("{p} declares MPN {mpn} (component.mpn)")); } }+ // issue #193: the page a part request built, maybe under the orderable number (--as)+ if page.is_none() {+ if let Some((n, p, sent)) = supply::built_page(&r.data, &mpn).filter(|(_, p, _)| wiki_page_json(p).is_some()) {+ row["wikiMatch"] = json!(format!("{p} was built by the part factory on attempt {n} (requested as {sent})"));+ page = Some(p);+ }+ } if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); } if let Some(how) = page.as_deref().and_then(|p| preboard::matched_by_base(p, &mpn)) { row["wikiMatch"] = json!(how); } row["wiki"] = json!(page);@@ -3932,7 +3960,18 @@ fn main() { } let from_lib: Vec<String> = rows.iter().filter(|x| x["projectLib"].is_object()).filter_map(|x| x["mpn"].as_str().map(str::to_string)).collect(); let pass = missing.is_empty();- r.outcome("parts", json!({"pass": pass, "bom": std::fs::canonicalize(&bom).map(|p| p.display().to_string()).unwrap_or(bom.clone()), "parts": rows, "missing": missing, "projectLib": plib, "projectLibParts": from_lib, "projectLibGaps": why_missing.iter().map(|(m, w)| json!({"mpn": m, "why": w})).collect::<Vec<_>>(), "sourceNotes": source_notes}));+ // issue #193: each part with no page leads with the decision it needs; an assessed part (project library too) shows its risk+ let refs_of = |m: &str| -> Vec<String> { rows.iter().filter(|x| x["mpn"] == m).flat_map(|x| x["refs"].as_array().cloned().unwrap_or_default()).filter_map(|v| v.as_str().map(str::to_string)).collect() };+ let decisions: Vec<Value> = missing.iter().map(|m| {+ let (tries, a, ch) = (supply::attempts(&r.data, m), supply_assessed(&r, m), r.data["supplyChoices"].get(m.as_str()).cloned());+ json!({"mpn": m, "decision": supply::decision(m, &refs_of(m), &tries, a.as_ref(), ch.as_ref()), "next": supply::next(m, &tries, a.as_ref(), ch.as_ref()),+ "attempts": tries.len(), "supplyRisk": a.as_ref().map(|a| a["risk"].clone()), "choice": ch})+ }).collect();+ let assessed_found: Vec<String> = rows.iter().filter(|x| !missing.iter().any(|m| x["mpn"] == m.as_str())).filter_map(|x| {+ let m = x["mpn"].as_str()?; let a = supply_assessed(&r, m)?;+ Some(format!("{}: supply risk {}{}", supply::decision(m, &refs_of(m), &[], None, None).split(':').next().unwrap_or(m), a["risk"].as_str().unwrap_or("?"), r.data["supplyChoices"].get(m).and_then(|c| c["pick"].as_str()).map(|p| format!(", chosen: {p}")).unwrap_or_default()))+ }).collect();+ r.outcome("parts", json!({"pass": pass, "bom": std::fs::canonicalize(&bom).map(|p| p.display().to_string()).unwrap_or(bom.clone()), "parts": rows, "missing": missing, "decisions": decisions, "supplyRiskFile": r.data["outcomes"]["supplyRisk"]["file"].clone(), "projectLib": plib, "projectLibParts": from_lib, "projectLibGaps": why_missing.iter().map(|(m, w)| json!({"mpn": m, "why": w})).collect::<Vec<_>>(), "sourceNotes": source_notes})); r.save().unwrap(); if *show { // one named tab walks through the pages (never a tab per part)@@ -3947,13 +3986,108 @@ fn main() { } let list: Vec<String> = rows.iter().map(|x| format!(" {:<22} {:<10} {}", x["mpn"].as_str().unwrap_or(""), x["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default(), x["wiki"].as_str().map(str::to_string).or_else(|| x["projectLib"].is_object().then(|| x["wikiOverridden"].as_str().map(|w| format!("project library (preferred over {w})")).unwrap_or_else(|| "project library".to_string()))).unwrap_or_else(|| "MISSING".into()))).collect(); let list: Vec<String> = if source_notes.is_empty() { list } else { list.into_iter().chain(std::iter::once(" which source each part comes from:".to_string())).chain(source_notes.iter().map(|n| format!(" {n}"))).collect() };+ let list: Vec<String> = if assessed_found.is_empty() { list } else { list.into_iter().chain(std::iter::once(" assessed supply (supply-risk):".to_string())).chain(assessed_found.iter().map(|n| format!(" {n}"))).collect() }; if !pass { let gaps: Vec<String> = why_missing.iter().map(|(m, w)| format!(" {m}: project library: {w}")).collect(); // AI Flow never refuses (0.2.48): the parts record is saved; the missing parts are findings with the ways on- findings(&format!("{} of {} parts have no Adom wiki component page{} yet:\n{}{}", missing.len(), rows.len(), if plib.is_some() { " and no complete project-library entry" } else { "" }, list.join("\n"), if gaps.is_empty() { String::new() } else { format!("\n{}", gaps.join("\n")) }), &["Make each missing part's page (the wiki-component skill: symbol, footprint and STEP from the datasheet), then run `parts` again. The project page and the KiCad library are built from these pages.".into(), "A part whose page cannot be public yet: `parts --project-lib <dir>` with its symbol, footprint, STEP model and a provenance.json entry (aiflow-sourcing skill).".into()]);+ let lead: Vec<String> = decisions.iter().map(|d| format!(" {}\n next: {}", d["decision"].as_str().unwrap_or(""), d["next"].as_str().unwrap_or(""))).collect();+ findings(&format!("{} of {} parts have no Adom wiki component page{} yet; the decision each needs:\n{}\n{}{}", missing.len(), rows.len(), if plib.is_some() { " and no complete project-library entry" } else { "" }, lead.join("\n"), list.join("\n"), if gaps.is_empty() { String::new() } else { format!("\n{}", gaps.join("\n")) }),+ &["For each part with no page: `part-request <MPN>` asks the factory; a refusal gets one retry with a changed input; a part still without a page gets a supply assessment and up to three alternatives (`supply-risk`), and the human chooses (aiflow-sourcing skill, \"When a part cannot get a page\"). The project page and the KiCad library are built from these pages.".into(), "A part whose page cannot be public yet: `parts --project-lib <dir>` with its symbol, footprint, STEP model and a provenance.json entry (aiflow-sourcing skill).".into()]); } ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]); }+ Cmd::PartRequest { mpn, as_mpn, datasheet, manufacturer, refresh, later } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ if *refresh {+ let o = std::process::Command::new("adom-wiki").args(["part", "status", "--json"]).stdin(std::process::Stdio::null()).output()+ .unwrap_or_else(|e| err(&format!("could not run adom-wiki part status: {e}"), &[]));+ let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);+ let Some(list) = v["data"]["requests"].as_array().cloned() else {+ err(&format!("could not read the factory's answers (adom-wiki part status --json): {}", String::from_utf8_lossy(&o.stderr).trim()), &[])+ };+ let n = supply::refresh(&mut r.data, &list);+ r.log("part-request-refresh", json!({"changed": n}));+ r.save().unwrap();+ let lines: Vec<String> = r.data["partRequests"].as_object().into_iter().flatten().map(|(m, a)| {+ let tries = a.as_array().cloned().unwrap_or_default();+ format!(" {}\n next: {}", supply::decision(m, &part_refs(&r, m), &tries, None, None), if tries.last().map(supply::built).unwrap_or(false) { "`parts` again".to_string() } else { supply::next(m, &tries, supply_assessed(&r, m).as_ref(), r.data["supplyChoices"].get(m.as_str())) })+ }).collect();+ ok(&format!("{n} request(s) changed state:\n{}", lines.join("\n")), &[]);+ return;+ }+ let mpn = mpn.clone().unwrap_or_else(|| err("part-request needs the BOM's MPN: `part-request <MPN> [--as <orderable MPN>] [--datasheet <link>]`, or `--refresh`", &[]));+ let sent = as_mpn.clone().unwrap_or_else(|| mpn.clone());+ let inp = supply::inputs(&sent, manufacturer.as_deref(), datasheet.as_deref());+ if let Some((n, a)) = supply::duplicate(&r.data, &mpn, &inp) {+ err(&format!("not sent: attempt {n} for {mpn} sent these same inputs ({}{}), and the factory would answer the same", a["state"].as_str().unwrap_or("?"), a["reason"].as_str().map(|x| format!(": {x}")).unwrap_or_default()),+ &[supply::next(&mpn, &supply::attempts(&r.data, &mpn), None, None)]);+ }+ let prior = supply::attempts(&r.data, &mpn);+ if prior.iter().filter(|a| supply::refused(a)).count() >= 2 {+ note(&format!("{mpn} has had its retry ({} refused attempts): one bounded retry, then the supply assessment, is the way (issue #193)", prior.iter().filter(|a| supply::refused(a)).count()), &[supply::next(&mpn, &prior, None, None)]);+ }+ if let Some((i, _)) = prior.iter().enumerate().rev().find(|(_, a)| supply::pending(a)) {+ note(&format!("attempt {} for {mpn} is still in the factory's queue; `part-request --refresh` reads its answer", i + 1), &[]);+ }+ let changed = prior.last().map(|a| supply::changed(&a["inputs"], &inp)).unwrap_or_default();+ let mut args: Vec<String> = vec!["part".into(), "request".into(), sent.clone(), "--json".into(), if *later { "--later" } else { "--now" }.into(), "--source".into(), "bom".into()];+ if let Some(m) = manufacturer { args.extend(["--manufacturer".into(), m.clone()]); }+ if let Some(d) = datasheet { args.extend(["--datasheet".into(), d.clone()]); }+ let o = std::process::Command::new("adom-wiki").args(&args).stdin(std::process::Stdio::null()).output()+ .unwrap_or_else(|e| err(&format!("could not run adom-wiki part request: {e}"), &[]));+ let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);+ let ans = supply::answer(&v);+ if ans.is_null() && v["status"] != "ok" {+ err(&format!("the part request was not taken: {}", v["error"]["message"].as_str().or(v["error"].as_str()).map(str::to_string).unwrap_or_else(|| format!("{}{}", String::from_utf8_lossy(&o.stdout).trim(), String::from_utf8_lossy(&o.stderr).trim()))), &[]);+ }+ let mut a = json!({"at": now(), "aiThread": t, "inputs": inp, "changed": changed, "state": "sent"});+ supply::absorb(&mut a, &ans);+ let n = supply::record(&mut r.data, &mpn, a.clone());+ r.log("part-request", json!({"mpn": mpn, "attempt": n, "request": a}));+ r.save().unwrap();+ let tries = supply::attempts(&r.data, &mpn);+ let next = if supply::built(&a) { "`parts` again: the page is used for this BOM line".to_string() } else { supply::next(&mpn, &tries, None, None) };+ ok(&format!("attempt {n} for {mpn}{}{}: {}{}", if sent != mpn { format!(" (sent as {sent})") } else { String::new() }, if changed.is_empty() { String::new() } else { format!(", changed: {}", changed.join(", ")) },+ a["state"].as_str().unwrap_or("sent"), a["reason"].as_str().map(|x| format!(" ({x})")).unwrap_or_default()), &[next]);+ }+ Cmd::SupplyRisk { file, choose, pick, why } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ if let Some(m) = choose {+ let pick = pick.clone().unwrap_or_else(|| err("--choose needs --pick <alternative MPN|keep>", &[]));+ let w = why.clone().unwrap_or_default();+ if let Some(a) = supply_assessed(&r, m) {+ let alts: Vec<String> = a["alternatives"].as_array().into_iter().flatten().filter_map(|x| x["mpn"].as_str().map(str::to_string)).collect();+ if pick != "keep" && !alts.iter().any(|x| x.eq_ignore_ascii_case(&pick)) { note(&format!("{pick} is not one of the assessed alternatives for {m} ({}); recorded as the human said it", alts.join(", ")), &[]); }+ } else { note(&format!("{m} has no assessment recorded (`supply-risk` first); the choice is recorded anyway"), &[]); }+ if w.trim().chars().count() < 10 { note("say why in the human's words (`--why`), so the choice reads later", &[]); }+ if !r.data["supplyChoices"].is_object() { r.data["supplyChoices"] = json!({}); }+ let rec = json!({"pick": pick, "why": w.trim(), "at": now(), "aiThread": t});+ r.data["supplyChoices"][m.as_str()] = rec.clone();+ r.log("supply-choice", json!({"mpn": m, "choice": rec}));+ r.save().unwrap();+ ok(&format!("the human's choice for {m}: {pick}"), &[supply::next(m, &supply::attempts(&r.data, m), supply_assessed(&r, m).as_ref(), Some(&rec))]);+ return;+ }+ let text = std::fs::read_to_string(file).unwrap_or_else(|e| err(&format!("{file}: {e}"), &[format!("Write the assessment there (aiflow-sourcing skill, \"When a part cannot get a page\"), or pass --file.")]));+ let v: Value = serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("{file}: not JSON: {e}"), &[]));+ let (parts, gaps) = supply::check(&v);+ let path = std::fs::canonicalize(file).map(|p| p.display().to_string()).unwrap_or(file.clone());+ let summary: Vec<Value> = parts.iter().map(|p| json!({"mpn": p["mpn"], "risk": p["risk"], "alternatives": p["alternatives"].as_array().map(|a| a.len()).unwrap_or(0)})).collect();+ r.outcome("supplyRisk", json!({"file": path, "sha256": sha256_file(&path), "parts": summary, "findings": gaps}));+ r.save().unwrap();+ let lines: Vec<String> = parts.iter().filter_map(|p| p["mpn"].as_str()).map(|m| {+ let tries = supply::attempts(&r.data, m);+ let ch = r.data["supplyChoices"].get(m).cloned();+ format!(" {}\n next: {}", supply::decision(m, &part_refs(&r, m), &tries, supply::find(&parts, m), ch.as_ref()), supply::next(m, &tries, supply::find(&parts, m), ch.as_ref()))+ }).collect();+ let hint = "Bring each decision to the human: the risk, the alternatives and what each changes on the board. Nothing is substituted without their choice; record it with `supply-risk --choose <MPN> --pick <alternative|keep> --why \"...\"`.".to_string();+ if !gaps.is_empty() {+ findings(&format!("assessment recorded for {} part(s) ({path}); it leaves out:\n {}\n{}", parts.len(), gaps.join("\n "), lines.join("\n")), &["Every figure carries its source and date (a URL or the tool that gave it, and the day it was read).".into(), hint]);+ }+ ok(&format!("assessment recorded for {} part(s) ({path}):\n{}", parts.len(), lines.join("\n")), &[hint]);+ } Cmd::ProjectPage { page, results, out, sync, create, existing, private, title, brief, replace_readme, no_push, local_only, page_dir } => { let t = thread(&cli); let no_push = &(*no_push || *local_only);@@ -5404,6 +5538,12 @@ fn main() { let parts_v = o.get("parts").map(|p| p["parts"].clone()).unwrap_or(Value::Null); let lib_parts = project_lib::report(&parts_v); for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); }+ // issue #193: a part with no page is reported with its requests, its assessment and the next command+ for m in o.get("parts").and_then(|p| p["missing"].as_array()).into_iter().flatten().filter_map(Value::as_str) {+ let (tries, a, ch) = (supply::attempts(&r.data, m), supply_assessed(&r, m), r.data["supplyChoices"].get(m).cloned());+ if tries.last().map(supply::built).unwrap_or(false) { missing.push(format!("parts: {m}'s page was built by the factory; run `parts` again")); continue; }+ missing.push(format!("parts: {}; next: {}", supply::decision(m, &part_refs(&r, m), &tries, a.as_ref(), ch.as_ref()), supply::next(m, &tries, a.as_ref(), ch.as_ref())));+ } if let Some(rec) = live_rec.take() { r.outcome(verified::LIVE_DRC, rec); r.save().ok(); } r.outcome("finish-checks", json!({"powerInputs": label_checks["powerInputs"], "testPoints": label_checks["testPoints"], "lines": check_lines})); r.save().ok(); let (bs, ss) = run_hashes(&r);
crates/adom-aiflow/src/supply.rsadded+265@@ -0,0 +1,265 @@+//! Issue #193: when a part's component page cannot be made, retry once with better inputs, then assess+//! the part's long-term supply and propose alternatives before calling it a failure.+//!+//! - `part-request <MPN>` sends the factory request (`adom-wiki part request`) and keeps every attempt in+//! the run (`partRequests.<MPN>`): its inputs, the factory's state and reason. The same inputs twice are+//! not sent; a retry changes at least one (the orderable MPN, another datasheet or land-pattern link,+//! the manufacturer).+//! - `supply-risk` reads the AI's assessment (`design/supply-risk.json`): risk, lifecycle, stock per+//! distributor, lead time, second sources, the package, whether a datasheet and land pattern are+//! published, up to three alternatives, every figure with its source and date. It reports what the+//! file leaves out as findings; it never decides. `--choose` records the human's pick.+//! - `parts` and `finish` lead with the decision a refused part needs and the next command.+use serde_json::{json, Value};++pub const DEFAULT_FILE: &str = "design/supply-risk.json";+pub const MAX_ALTERNATIVES: usize = 3;+const RISKS: [&str; 3] = ["high", "medium", "low"];+/// The factory states that mean "no page from this attempt".+const REFUSED: [&str; 5] = ["cannot_build", "review", "failed", "rejected", "error"];++/// What one request sent: the part number asked for, the manufacturer, the datasheet (or land-pattern) link.+pub fn inputs(requested: &str, manufacturer: Option<&str>, datasheet: Option<&str>) -> Value {+ json!({"mpn": requested.trim(), "manufacturer": manufacturer.map(str::trim), "datasheet": datasheet.map(str::trim)})+}++fn norm(i: &Value) -> [String; 3] {+ let s = |k: &str| i[k].as_str().unwrap_or("").trim().trim_end_matches('/').to_lowercase();+ [s("mpn"), s("manufacturer"), s("datasheet")]+}++/// The inputs that differ between two requests, by name.+pub fn changed(a: &Value, b: &Value) -> Vec<&'static str> {+ let (x, y) = (norm(a), norm(b));+ ["part number", "manufacturer", "datasheet link"].into_iter().zip(x.iter().zip(y.iter())).filter(|(_, (p, q))| p != q).map(|(n, _)| n).collect()+}++pub fn attempts(data: &Value, mpn: &str) -> Vec<Value> {+ data["partRequests"][mpn].as_array().cloned().unwrap_or_default()+}++/// The earlier attempt (1-based) that sent exactly these inputs.+pub fn duplicate(data: &Value, mpn: &str, inp: &Value) -> Option<(usize, Value)> {+ attempts(data, mpn).into_iter().enumerate().find(|(_, a)| changed(&a["inputs"], inp).is_empty()).map(|(i, a)| (i + 1, a))+}++pub fn refused(a: &Value) -> bool { a["state"].as_str().map(|s| REFUSED.contains(&s)).unwrap_or(false) }+pub fn built(a: &Value) -> bool { a["state"] == "built" }+pub fn pending(a: &Value) -> bool { !refused(a) && !built(a) }++/// The request record inside whatever envelope `adom-wiki part request --json` or `part status --json` answers.+pub fn answer(v: &Value) -> Value {+ fn find(v: &Value) -> Option<Value> {+ match v {+ Value::Object(m) if m.contains_key("state") || m.contains_key("status_url") => Some(v.clone()),+ Value::Object(m) => m.values().find_map(find),+ Value::Array(a) => a.iter().find_map(find),+ _ => None,+ }+ }+ find(&v["data"]).or_else(|| find(v)).unwrap_or(Value::Null)+}++/// Fold a factory answer into an attempt.+pub fn absorb(attempt: &mut Value, ans: &Value) {+ for (from, to) in [("state", "state"), ("reason", "reason"), ("page_url", "pageUrl"), ("status_url", "statusUrl"), ("updated_at", "updatedAt"), ("requested_at", "requestedAt")] {+ if !ans[from].is_null() { attempt[to] = ans[from].clone(); }+ }+}++pub fn record(data: &mut Value, mpn: &str, attempt: Value) -> usize {+ if !data["partRequests"].is_object() { data["partRequests"] = json!({}); }+ let list = data["partRequests"].as_object_mut().unwrap().entry(mpn.to_string()).or_insert_with(|| json!([]));+ list.as_array_mut().unwrap().push(attempt);+ list.as_array().unwrap().len()+}++/// Update every pending attempt from `part status --json`'s list: by its status URL, else the latest+/// request for the same part number. Returns how many changed state.+pub fn refresh(data: &mut Value, statuses: &[Value]) -> usize {+ let mut n = 0;+ let Some(all) = data["partRequests"].as_object_mut() else { return 0 };+ for list in all.values_mut() {+ for a in list.as_array_mut().into_iter().flatten().filter(|a| pending(a)) {+ let by_url = a["statusUrl"].as_str().and_then(|u| statuses.iter().find(|s| s["status_url"] == u));+ let by_mpn = || statuses.iter().filter(|s| s["mpn"].as_str().map(|m| m.eq_ignore_ascii_case(a["inputs"]["mpn"].as_str().unwrap_or(""))).unwrap_or(false))+ .max_by_key(|s| s["requested_at"].as_str().unwrap_or("").to_string());+ if let Some(s) = by_url.or_else(by_mpn) {+ let before = a["state"].clone();+ absorb(a, s);+ if a["state"] != before { n += 1; }+ }+ }+ }+ n+}++/// The page a built attempt made (owner/slug), for a part whose page sits under another number.+pub fn built_page(data: &Value, mpn: &str) -> Option<(usize, String, String)> {+ attempts(data, mpn).iter().enumerate().rev().find(|(_, a)| built(a)).and_then(|(i, a)| {+ let p = a["pageUrl"].as_str()?.trim_start_matches('/').to_string();+ Some((i + 1, p, a["inputs"]["mpn"].as_str().unwrap_or(mpn).to_string()))+ })+}++fn figure_gaps(label: &str, f: &Value, out: &mut Vec<String>) {+ if f.is_null() { out.push(format!("{label}: not given")); return; }+ let missing: Vec<&str> = ["source", "date"].into_iter().filter(|k| f[*k].as_str().map(|s| s.trim().is_empty()).unwrap_or(true)).collect();+ if !missing.is_empty() { out.push(format!("{label}: no {}", missing.join(" or "))); }+}++/// The assessed parts, keyed by MPN, and what each entry leaves out (findings, never a stop).+pub fn check(v: &Value) -> (Vec<Value>, Vec<String>) {+ let parts: Vec<Value> = v["parts"].as_array().or(v.as_array()).cloned().unwrap_or_default();+ let mut gaps = Vec::new();+ if parts.is_empty() { gaps.push("the assessment lists no parts (`{\"parts\": [{\"mpn\": ..., \"risk\": ...}]}`)".to_string()); }+ for p in &parts {+ let mpn = p["mpn"].as_str().unwrap_or("?");+ let mut g = Vec::new();+ match p["risk"].as_str() {+ Some(r) if RISKS.contains(&r) => {}+ _ => g.push("risk: say high, medium or low".to_string()),+ }+ for (k, label) in [("lifecycle", "lifecycle"), ("leadTime", "lead time"), ("package", "package"), ("datasheetPublished", "datasheet published"), ("landPatternPublished", "land pattern published")] {+ figure_gaps(label, &p[k], &mut g);+ }+ let dists = p["distributors"].as_array().cloned().unwrap_or_default();+ if dists.is_empty() { g.push("distributors: none listed (name, stock, source, date for each)".into()); }+ for d in &dists { figure_gaps(&format!("distributor {}", d["name"].as_str().unwrap_or("?")), d, &mut g); }+ if p["secondSources"].as_array().is_none() { g.push("second sources: not given (an empty list says there are none)".into()); }+ let alts = p["alternatives"].as_array().cloned().unwrap_or_default();+ if alts.len() > MAX_ALTERNATIVES { g.push(format!("{} alternatives: propose at most {MAX_ALTERNATIVES}, the best", alts.len())); }+ if alts.is_empty() && p["risk"] != "low" { g.push("alternatives: none proposed for a part at this risk (up to three, active, multi-sourced, in stock)".into()); }+ for a in &alts {+ let am = a["mpn"].as_str().unwrap_or("?");+ for k in ["lifecycle", "stock", "boardChanges"] { if a[k].is_null() { g.push(format!("alternative {am}: no {k}")); } }+ if a.get("wikiPage").is_none() { g.push(format!("alternative {am}: say whether it has a wiki page (wikiPage: owner/slug or null)")); }+ figure_gaps(&format!("alternative {am}"), a, &mut g);+ }+ gaps.extend(g.into_iter().map(|x| format!("{mpn}: {x}")));+ }+ (parts, gaps)+}++pub fn find<'a>(parts: &'a [Value], mpn: &str) -> Option<&'a Value> {+ parts.iter().find(|p| p["mpn"].as_str().map(|m| m.eq_ignore_ascii_case(mpn)).unwrap_or(false))+}++fn times(n: usize) -> String { match n { 1 => "once".into(), 2 => "twice".into(), n => format!("{n} times") } }++/// One line that leads with the decision: "U7 (MPN): refused twice (why); supply risk high (...); alternatives A, B, C".+pub fn decision(mpn: &str, refs: &[String], tries: &[Value], assessed: Option<&Value>, choice: Option<&Value>) -> String {+ let who = if refs.is_empty() { mpn.to_string() } else { format!("{} ({mpn})", refs.join(",")) };+ let mut parts = Vec::new();+ let refused_n = tries.iter().filter(|a| refused(a)).count();+ if tries.is_empty() { parts.push("no page request yet".to_string()); }+ if refused_n > 0 {+ let mut why: Vec<String> = tries.iter().filter(|a| refused(a)).filter_map(|a| a["reason"].as_str().map(|s| s.trim_end_matches('.').to_string())).collect();+ why.dedup();+ parts.push(format!("refused {}{}", times(refused_n), if why.is_empty() { String::new() } else { format!(" ({})", why.join("; ")) }));+ }+ if tries.iter().any(pending) { parts.push("a request is still in the factory's queue".into()); }+ if let Some(a) = assessed {+ let dist = a["distributors"].as_array().map(|d| d.len()).unwrap_or(0);+ let life = a["lifecycle"]["value"].as_str().or(a["lifecycle"].as_str()).unwrap_or("?");+ parts.push(format!("supply risk {} ({life}, {dist} distributor{})", a["risk"].as_str().unwrap_or("?"), if dist == 1 { "" } else { "s" }));+ let alts: Vec<&str> = a["alternatives"].as_array().into_iter().flatten().filter_map(|x| x["mpn"].as_str()).collect();+ if !alts.is_empty() { parts.push(format!("alternatives {}", alts.join(", "))); }+ }+ if let Some(c) = choice { parts.push(format!("chosen: {}", c["pick"].as_str().unwrap_or("?"))); }+ format!("{who}: {}", parts.join("; "))+}++/// The next command for a part with no page, by where it stands.+pub fn next(mpn: &str, tries: &[Value], assessed: Option<&Value>, choice: Option<&Value>) -> String {+ let refused_n = tries.iter().filter(|a| refused(a)).count();+ if let Some(c) = choice {+ return match c["pick"].as_str() {+ Some("keep") => format!("keep {mpn}: give it from the project library (`parts --project-lib <dir>`: symbol, footprint, STEP and provenance) and run `parts` again"),+ Some(alt) => format!("put {alt} in the BOM in place of {mpn} (with the board changes the assessment names), then `sourcing check` and `parts` again"),+ None => "record the human's choice: `supply-risk --choose <MPN> --pick <alternative|keep> --why \"...\"`".into(),+ };+ }+ if let Some(_) = assessed {+ return format!("ask the human which way to go for {mpn} (an alternative or keep it), then `supply-risk --choose {mpn} --pick <alternative|keep> --why \"...\"`; nothing is substituted without their choice");+ }+ if tries.is_empty() {+ return format!("`part-request {mpn} [--datasheet <link>]` asks the factory for its page (or give it from the project library)");+ }+ if tries.last().map(pending).unwrap_or(false) {+ return "`part-request --refresh` once the factory has answered".into();+ }+ if refused_n == 1 && tries.len() == 1 {+ return format!("retry once with a changed input: `part-request {mpn} --as <orderable MPN>` (`adom-wiki part candidates {mpn}`), `--datasheet <the maker's product page, package drawing or CAD link>`, or `--manufacturer <maker>`");+ }+ format!("assess {mpn}'s supply: write {DEFAULT_FILE} (lifecycle, stock per distributor, lead time, second sources, package, datasheet and land pattern published, up to three alternatives; each figure with source and date), then `supply-risk`")+}++#[cfg(test)]+mod tests {+ use super::*;++ fn refused_at(mpn: &str, ds: Option<&str>, why: &str) -> Value {+ json!({"inputs": inputs(mpn, None, ds), "state": "cannot_build", "reason": why})+ }++ #[test]+ fn the_same_inputs_are_a_duplicate_and_any_change_is_a_retry() {+ let mut d = json!({});+ record(&mut d, "U7X", refused_at("U7X-TR", Some("https://a/ds.pdf"), "No land-pattern drawing."));+ assert_eq!(duplicate(&d, "U7X", &inputs("u7x-tr", None, Some("https://a/ds.pdf/"))).unwrap().0, 1, "case and a trailing slash are the same input");+ assert!(duplicate(&d, "U7X", &inputs("U7X", None, Some("https://a/ds.pdf"))).is_none());+ assert_eq!(changed(&d["partRequests"]["U7X"][0]["inputs"], &inputs("U7X-TR", Some("Acme"), Some("https://a/ds.pdf"))), vec!["manufacturer"]);+ }++ #[test]+ fn the_next_step_walks_retry_then_assessment_then_the_humans_choice() {+ let one = vec![refused_at("U7X", None, "No land-pattern drawing.")];+ assert!(next("U7X", &[], None, None).contains("part-request U7X"));+ assert!(next("U7X", &one, None, None).contains("retry once"));+ let two = vec![one[0].clone(), refused_at("U7X-1", None, "No distributor sells it.")];+ assert!(next("U7X", &two, None, None).contains("supply-risk"));+ let a = json!({"mpn": "U7X", "risk": "high", "lifecycle": {"value": "NRND"}, "distributors": [{"name": "Mouser"}], "alternatives": [{"mpn": "A1"}, {"mpn": "B2"}]});+ assert!(next("U7X", &two, Some(&a), None).contains("ask the human"));+ assert!(next("U7X", &two, Some(&a), Some(&json!({"pick": "A1"}))).contains("put A1 in the BOM"));+ let line = decision("U7X", &["U7".into()], &two, Some(&a), None);+ assert_eq!(line, "U7 (U7X): refused twice (No land-pattern drawing; No distributor sells it); supply risk high (NRND, 1 distributor); alternatives A1, B2");+ let mut q = refused_at("U7X", None, "");+ q["state"] = json!("queued");+ assert!(next("U7X", &[q], None, None).contains("--refresh"));+ }++ #[test]+ fn the_assessment_check_names_every_figure_without_a_source_or_date() {+ let f = |v: &str| json!({"value": v, "source": "https://mouser.com/x", "date": "2026-11-10"});+ let good = json!({"parts": [{"mpn": "U7X", "risk": "high", "lifecycle": f("NRND"), "leadTime": f("26 weeks"), "package": f("QFN-20, common"),+ "datasheetPublished": f("yes"), "landPatternPublished": f("no"), "secondSources": [],+ "distributors": [{"name": "Mouser", "stock": 300, "source": "https://mouser.com/x", "date": "2026-11-10"}],+ "alternatives": [{"mpn": "A1", "lifecycle": "active", "stock": 12000, "source": "https://digikey.com/a1", "date": "2026-11-10", "wikiPage": "adom/a1", "boardChanges": "same footprint"}]}]});+ let (parts, gaps) = check(&good);+ assert_eq!(parts.len(), 1);+ assert!(gaps.is_empty(), "{gaps:?}");+ let mut bad = good.clone();+ bad["parts"][0]["lifecycle"]["date"] = json!("");+ bad["parts"][0]["alternatives"] = json!([{"mpn": "A1"}, {"mpn": "A2"}, {"mpn": "A3"}, {"mpn": "A4"}]);+ bad["parts"][0]["risk"] = json!("bad");+ let (_, gaps) = check(&bad);+ let all = gaps.join("\n");+ for want in ["U7X: lifecycle: no date", "4 alternatives", "alternative A1: no boardChanges", "alternative A2: say whether it has a wiki page", "risk: say high"] {+ assert!(all.contains(want), "{want} in {all}");+ }+ }++ #[test]+ fn a_status_list_updates_pending_attempts_and_a_built_page_is_found() {+ let mut d = json!({});+ record(&mut d, "U7X", json!({"inputs": inputs("U7X-TR", None, None), "state": "queued", "statusUrl": "/part-request/abc"}));+ record(&mut d, "U9", json!({"inputs": inputs("U9", None, None), "state": "queued"}));+ let n = refresh(&mut d, &[json!({"mpn": "U7X-TR", "state": "built", "page_url": "/adom/u7x-tr", "status_url": "/part-request/abc"}),+ json!({"mpn": "u9", "state": "cannot_build", "reason": "No datasheet.", "requested_at": "2026-11-10 10:00:00"})]);+ assert_eq!(n, 2);+ assert_eq!(built_page(&d, "U7X"), Some((1, "adom/u7x-tr".to_string(), "U7X-TR".to_string())));+ assert!(refused(&d["partRequests"]["U9"][0]));+ assert_eq!(answer(&json!({"status": "ok", "data": {"request": {"state": "queued", "status_url": "/x"}}}))["status_url"], "/x");+ }+}
docs/release-0.2.64.mdadded+14@@ -0,0 +1,14 @@+# AI Flow 0.2.64++- **#193: a part that cannot get a page is retried once, then assessed, before it is called a failure.**+ `part-request <MPN> [--as <orderable MPN>] [--datasheet <link>] [--manufacturer <maker>]` sends the part+ factory request and keeps every attempt in the run with its inputs and the factory's answer; the same+ inputs are not sent twice, and `--refresh` reads the answers of queued requests. A page built under the+ orderable number is used for the BOM line. `supply-risk` records the AI's assessment+ (`design/supply-risk.json`: risk, lifecycle, stock per distributor, lead time, second sources, package,+ datasheet and land pattern published, up to three alternatives with what each changes on the board, every+ figure with its source and date) and lists what it leaves out; `supply-risk --choose <MPN> --pick+ <alternative|keep> --why` records the human's choice. Nothing is substituted without it. `parts` and+ `finish` lead with each part's decision ("U7 (U7X): refused twice (...); supply risk high (...);+ alternatives ...") and the next command. Project-library parts can be assessed too. The aiflow-sourcing+ skill has the three steps.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.63",+ "version": "0.2.64", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.63",+ "version": "0.2.64", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+1−1@@ -322,7 +322,7 @@ The clock starts at the prompt even when there is no board yet. A KiCad run that starts with no board (`intake` with no `start --board`) follows the `kicad-new` sequence: intake, rules, design, test-plan, sourcing, **parts**, **project-page**, **library**, **schematic**, simulation, then components, models and the board steps as usual. Hydrogen is the stage for the steps without KiCad (the step dashboard comes up at every new phase), KiCad's window for the rest. 1. **`sourcing check --bom design/bom.csv`** as above.-2. **`step parts`, `parts --show`**: every BOM row with an MPN is matched to its Adom wiki component page; one Hydrogen tab ("Parts · <run>") steps through the pages so the viewer sees the parts picked. A part with no page: make it with the wiki-component skill (datasheet symbol, footprint, STEP; verified), run `parts --show` again. Never swap in a similar part to dodge a missing page.+2. **`step parts`, `parts --show`**: every BOM row with an MPN is matched to its Adom wiki component page; one Hydrogen tab ("Parts · <run>") steps through the pages so the viewer sees the parts picked. A part with no page: `part-request <MPN>` asks Adom's part factory for it (or make it with the wiki-component skill: datasheet symbol, footprint, STEP; verified), run `parts --show` again. A refused request gets one retry with a changed input (`--as <orderable MPN>`, `--datasheet <the maker's drawing>`); a part still without a page gets a supply assessment and up to three alternatives (`supply-risk`), and the human chooses (`supply-risk --choose`): aiflow-sourcing, "When a part cannot get a page". Never swap in a similar part to dodge a missing page, and never without the human's choice. 3. **`step project-page`, `project-page --page <owner>/<slug> --create`**: revision 1 of the board's one project page before any EDA work: a README saying what the board is, the component table (references, value, MPN, manufacturer, each part's wiki page) and a KiCad PCM repository of every part (an Adom Cloud Library of the parts, exported and repackaged for KiCad's Plugin and Content Manager, served from the page at `/api/pages/<owner>/<slug>/files/pcm/repository.json`). A new board always gets a new, standalone page (a new slug, `--create`); `project-page` refuses an existing page unless `--existing` says it is this same board's own page. Later revisions update this same page (aiflow-project-page skill); never a second page for the board. 4. **`step library`**: install the parts in KiCad on the test box: `kicad_pcm_add_repository {"url": <repositoryUrl>}`, `kicad_pcm_install {"repositoryUrl": ..., "identifier": ...}` (both printed by `project-page`), restart KiCad, then **`pcm check`** (the package is in `kicad_pcm_list`). The library is `PCM_<slug>`. 5. **`step schematic`**: draw the schematic in KiCad's schematic editor, on camera: `kicad_open_schematic`, `kicad_sch_place_symbol` (symbols from `PCM_<slug>`), `kicad_sch_wire`, `kicad_sch_label`, `kicad_sch_set_property`; save; **`schematic check --sch <project>.kicad_sch`** passes when kicad-cli ERC ran and has no errors (no ERC report is a failure, never a clean ERC), every BOM reference is on the sheet and every part comes from the project library. Never write the `.kicad_sch` with a script of your own: the viewer is meant to see it drawn.
skills/aiflow-sourcing/SKILL.md+30−1@@ -1,7 +1,7 @@ --- name: aiflow-sourcing description: >-- Parts and CAD sourcing for an adom-aiflow board, before the schematic is frozen: the fab profile states where parts are preferred from (default `fab` = the 3rd party fab, prefers Mouser plus Adom stocked basic parts; user-selectable "jlcpcb" prefers JLCPCB/LCSC basic parts), and any distributor (DigiKey, Arrow, ...) is fine on any profile as consigned parts the fab is sent, search by spec and in stock before naming an MPN, constrain passives to the stocked set, record stock and lead time with a date, then get each part's symbol, footprint and STEP in a fixed order (manufacturer site, distributor CAD links, Adom wiki component page, KiCad's library model or KiCad's package generator at the datasheet values, draw your own labelled AI-generated) and check every model on the step2glb service. Never adom-chipsmith. Trigger words: aiflow sourcing, source the BOM, pick parts, in stock, lead time, sourcing profile, fab profile, Mouser or JLCPCB, Adom basic parts, stocked values, find the STEP, 3D model for this part, manufacturer CAD, SamacSys, Ultra Librarian, footprint source, symbol source.+ Parts and CAD sourcing for an adom-aiflow board, before the schematic is frozen: the fab profile states where parts are preferred from (default `fab` = the 3rd party fab, prefers Mouser plus Adom stocked basic parts; user-selectable "jlcpcb" prefers JLCPCB/LCSC basic parts), and any distributor (DigiKey, Arrow, ...) is fine on any profile as consigned parts the fab is sent, search by spec and in stock before naming an MPN, constrain passives to the stocked set, record stock and lead time with a date, then get each part's symbol, footprint and STEP in a fixed order (manufacturer site, distributor CAD links, Adom wiki component page, KiCad's library model or KiCad's package generator at the datasheet values, draw your own labelled AI-generated) and check every model on the step2glb service. When a part cannot get a component page: one retry with better inputs, then a supply-risk assessment and up to three alternatives for the human to choose from. Never adom-chipsmith. Trigger words: aiflow sourcing, source the BOM, pick parts, in stock, lead time, sourcing profile, fab profile, Mouser or JLCPCB, Adom basic parts, stocked values, find the STEP, 3D model for this part, manufacturer CAD, SamacSys, Ultra Librarian, footprint source, symbol source, part request refused, cannot build, no component page, supply risk, long-term supply, alternatives, second source, NRND. --- # aiflow-sourcing: parts that exist, CAD that is true@@ -161,6 +161,35 @@ After Pup denial, read adom-native-browser and check `nbrowser_readiness` then ` Verified example, 2026-10-08: Pup showed Mouser Access Denied for MF40200V1-1000U-G99. Native Chrome reached a normal missing-page response for the same incomplete URL. Mouser's normal exact-MPN search then opened the actual product page and its manufacturer-hosted 3D ZIP. The recovered model is a separate provenance/dimensional check, not proof of a qualified cooling design. Record short clips of the observed states when the user requested a process video; label a later walkthrough as such rather than presenting it as the original discovery. +## When a part cannot get a page++`parts` names every BOM part with no component page and the next command for each. Before you call a part a failure, do three things in order (issue #193):++1. **Ask the factory, then retry once with better inputs.** `adom-aiflow part-request <MPN> [--datasheet <link>]` sends `adom-wiki part request` and keeps the attempt in the run (`partRequests`); `part-request --refresh` reads the answers of queued ones. A refusal (`cannot_build`, `review`) gets exactly one retry that changes at least one input. AI Flow does not send the same inputs twice. Pick the change from the refusal:+ - no distributor sells the number, or a packaging/reel suffix: `--as <orderable MPN>` (from `adom-wiki part candidates <MPN>`); the BOM line keeps its MPN, and the page built under the other number is used for it;+ - no datasheet, or no land-pattern drawing: `--datasheet` with the maker's product page, its separate package drawing or its CAD download (section 5's order), or the distributor PDF when the maker's page failed;+ - land-pattern readings disagreed: `--datasheet` with the maker's own footprint or package drawing, which settles it;+ - a factory-internal stop (3D model step, publishing): the same part with another datasheet link, or `--manufacturer`.+ This is one bounded retry, not a loop; the "one search per question, never retry" rule for vendor searches stays as it is.+2. **Still no page: assess its supply.** Write `design/supply-risk.json` and run `adom-aiflow supply-risk`. Every figure carries its source (URL, or the tool that gave it) and the date it was read; `supply-risk` lists what is left out. Read the figures from Parts Search (`adom-parts-search`), the distributors and the maker's site:++ ```json+ {"parts": [{"mpn": "U7X", "risk": "high",+ "lifecycle": {"value": "NRND", "source": "https://...", "date": "2026-11-10"},+ "distributors": [{"name": "Mouser", "stock": 300, "source": "https://...", "date": "2026-11-10"}],+ "buildQty": 500, "productLifeYears": 5,+ "leadTime": {"value": "26 weeks", "source": "...", "date": "..."},+ "secondSources": [{"mpn": "...", "maker": "...", "compatible": "pin", "source": "...", "date": "..."}],+ "package": {"value": "QFN-20 3x3, one maker's own", "source": "...", "date": "..."},+ "datasheetPublished": {"value": "yes", "source": "...", "date": "..."},+ "landPatternPublished": {"value": "no", "source": "...", "date": "..."},+ "alternatives": [{"mpn": "ALT1", "lifecycle": "active", "stock": 12000, "source": "...", "date": "...",+ "wikiPage": "adom/alt1", "boardChanges": "SOT-23-6 footprint; R5 becomes 49.9k; no firmware change"}]}]}+ ```++ `risk`: high (one source, thin stock against the build and the product's life, NRND or last-time-buy, no land pattern published), medium, or low. Assess a project-library part the same way (a part with no public page is often the same part that is hard to buy).+3. **Propose up to three alternatives**: active, multi-sourced, in stock at the profile's preferred source, ideally already on the wiki or in Adom Basic Parts, each with what it changes on the board (footprint, values, firmware). Lead your report with the decision, as `parts` and `finish` do: "U7: refused twice (no land pattern); supply risk high (NRND, 1 distributor); alternatives ALT1, ALT2". **The human chooses; never substitute on your own.** Record their choice with `supply-risk --choose <MPN> --pick <alternative|keep> --why "<their words>"`; then put the alternative in the BOM (and `sourcing check`, `parts` again), or keep the part through the project library below.+ ## Parts with no public component page yet When a part's page must stay private, waits for an owner's sign-off, or the footprint is a reviewed project-only one, give it from the board's project library: `adom-aiflow parts --project-lib <dir>` (or `"projectLib": "<dir>"` in the spec or requirements). The folder holds the KiCad symbols (`*.kicad_sym`), footprints (`*.pretty/*.kicad_mod`), STEP models and a `provenance.json`:
tools/test_supply_risk.pyadded+102@@ -0,0 +1,102 @@+"""Issue #193 end to end, with a stand-in adom-wiki on PATH (no wiki call):+part-request records attempts and turns away the same inputs, --refresh reads the factory's answers,+parts uses a page built under the orderable number, supply-risk reports what the assessment leaves out,+--choose records the human's pick, and parts leads with each decision.+Run: python3 tools/test_supply_risk.py [path/to/adom-aiflow]"""+import json, os, stat, subprocess, sys, tempfile, unittest+from pathlib import Path++BIN = sys.argv[1] if len(sys.argv) > 1 else str(Path(__file__).resolve().parent.parent / 'bin' / 'adom-aiflow')++FAKE = r'''#!/usr/bin/env python3+import json, sys+a = sys.argv[1:]+if a[:1] == ['search']:+ print(json.dumps({"data": {"results": []}})); sys.exit(0)+if a[:2] == ['repo', 'show']:+ if a[2] == 'adom/r9x-1':+ print(json.dumps({"slug": "r9x-1", "component": {"mpn": "R9X-1"}})); sys.exit(0)+ sys.exit(1)+if a[:2] == ['part', 'request']:+ mpn = a[2]+ ans = {"U7X": {"state": "cannot_build", "reason": "The datasheet has no land-pattern drawing."},+ "U7X-T": {"state": "cannot_build", "reason": "No distributor sells this exact part number."},+ "R9X": {"state": "cannot_build", "reason": "Stopped at the 3D model step."},+ "R9X-1": {"state": "queued", "status_url": "/part-request/r9"}}[mpn]+ print(json.dumps({"status": "ok", "data": dict(ans, mpn=mpn)})); sys.exit(0)+if a[:2] == ['part', 'status']:+ print(json.dumps({"status": "ok", "data": {"requests": [+ {"mpn": "R9X-1", "state": "built", "page_url": "/adom/r9x-1", "status_url": "/part-request/r9", "requested_at": "2026-11-10 10:00:00"}]}}))+ sys.exit(0)+sys.exit(2)+'''+++class SupplyRisk(unittest.TestCase):+ def setUp(self):+ self.tmp = Path(tempfile.mkdtemp(prefix='aiflow-supply-'))+ fake = self.tmp / 'bin' / 'adom-wiki'+ fake.parent.mkdir()+ fake.write_text(FAKE)+ fake.chmod(fake.stat().st_mode | stat.S_IEXEC)+ run = self.tmp / 'run'+ run.mkdir()+ (run / 'run.json').write_text(json.dumps({"version": 1, "id": "supplytest", "outcomes": {}, "turns": []}))+ (self.tmp / 'bom.csv').write_text('Ref,MPN\nU7,U7X\nR9,R9X\n')+ self.env = dict(os.environ, PATH=f"{fake.parent}:{os.environ['PATH']}", AIFLOW_RUN=str(run), AIFLOW_DASHBOARD='off')++ def aiflow(self, *args):+ p = subprocess.run([BIN, '--ai-thread', 'test', *args], cwd=self.tmp, env=self.env, capture_output=True, text=True, stdin=subprocess.DEVNULL)+ return p.stdout + p.stderr++ def test_retry_assess_choose(self):+ out = self.aiflow('parts', '--bom', 'bom.csv')+ self.assertIn('U7 (U7X): no page request yet', out)+ self.assertIn('part-request U7X', out)++ out = self.aiflow('part-request', 'U7X')+ self.assertIn('attempt 1 for U7X: cannot_build', out)+ self.assertIn('retry once', out)+ out = self.aiflow('part-request', 'U7X')+ self.assertIn('ERROR: not sent: attempt 1 for U7X sent these same inputs', out)+ out = self.aiflow('part-request', 'U7X', '--as', 'U7X-T')+ self.assertIn('attempt 2 for U7X (sent as U7X-T), changed: part number: cannot_build', out)+ self.assertIn('supply-risk', out)++ # R9X: refused, the retry under the orderable number is queued, then built+ self.aiflow('part-request', 'R9X')+ out = self.aiflow('part-request', 'R9X', '--as', 'R9X-1')+ self.assertIn('queued', out)+ out = self.aiflow('part-request', '--refresh')+ self.assertIn('1 request(s) changed state', out)++ design = self.tmp / 'design'+ design.mkdir()+ fig = lambda v: {"value": v, "source": "https://www.mouser.com/u7x", "date": "2026-11-10"}+ (design / 'supply-risk.json').write_text(json.dumps({"parts": [{+ "mpn": "U7X", "risk": "high", "lifecycle": fig("NRND"), "leadTime": fig("26 weeks"), "package": fig("maker-only QFN"),+ "datasheetPublished": fig("yes"), "landPatternPublished": {"value": "no", "source": "https://maker/u7x"},+ "distributors": [{"name": "Mouser", "stock": 300, "source": "https://www.mouser.com/u7x", "date": "2026-11-10"}], "secondSources": [],+ "alternatives": [{"mpn": "ALT1", "lifecycle": "active", "stock": 12000, "source": "https://www.digikey.com/alt1", "date": "2026-11-10", "wikiPage": None, "boardChanges": "QFN-20 to SOT-23-6 footprint"}]}]}))+ out = self.aiflow('supply-risk')+ self.assertIn('FINDINGS', out)+ self.assertIn('U7X: land pattern published: no date', out)+ self.assertIn('U7 (U7X): refused twice (The datasheet has no land-pattern drawing; No distributor sells this exact part number); supply risk high (NRND, 1 distributor); alternatives ALT1', out)+ self.assertIn('ask the human', out)++ out = self.aiflow('supply-risk', '--choose', 'U7X', '--pick', 'ALT1', '--why', 'Colby: take the multi-sourced part')+ self.assertIn("the human's choice for U7X: ALT1", out)+ self.assertIn('put ALT1 in the BOM', out)++ out = self.aiflow('parts', '--bom', 'bom.csv')+ self.assertIn('1 of 2 parts have no Adom wiki component page', out)+ self.assertIn('chosen: ALT1', out)+ self.assertIn('adom/r9x-1 was built by the part factory on attempt 2 (requested as R9X-1)', out)+ rec = json.loads((self.tmp / 'run' / 'run.json').read_text())+ self.assertEqual(len(rec['partRequests']['U7X']), 2)+ self.assertEqual(rec['outcomes']['parts']['decisions'][0]['supplyRisk'], 'high')+ self.assertEqual(rec['supplyChoices']['U7X']['pick'], 'ALT1')+++if __name__ == '__main__':+ unittest.main(argv=sys.argv[:1])
Comments
Log in to comment.
No comments yet.