molecule-publish
Public Made by Adomby adom
Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.
Component gate: resolve LCSC->MPN instead of refusing it, per-part waivers, throttled publish cycle (molecule-pipeline #4) #2
Follow-up to molecule-pipeline#4 — the gate is the right shape, but it refuses the board the issue was filed about. This fixes the false positive, wires the LCSC→MFR resolution you couldn't reach, and makes ask 5 machine-enforced.
The false positive
Ran 2.4.0's gate against adom/probing-workcell-load-cell-board — the board that
motivated #4, now carrying a correct graph (board → -library → 34 component
pages, v1.1.0). It flags 75 of 82 electrical parts and exits 1.
Cause is in identity(): a footprint carrying an LCSC property is flagged as
"only an LCSC/distributor code" even when Value holds a real MPN, and the
message then quotes the Value as if it were the distributor code:
U100 flagged only an LCSC/distributor code (AP2112K-3.3) # lcsc=C51118, value=AP2112K-3.3
U451 flagged only an LCSC/distributor code (ADS1220IRVAR) # lcsc=C2651338
The only way past it was --allow-no-components, which also switches off every
other graph check — so on a correct board the escape hatch was mandatory.
Changes
- Identity precedence: MPN property > LCSC code resolved via
adom-jlcpcb> MPN-shaped Value > FLAG. A distributor code is an input to resolution, not a disqualifier. Generic stdlib symbol names (Conn_01x04_Pin,Jumper_2_Open) and tolerance-suffixed values (51k/1%) are now correctly flagged. - LCSC→MFR resolution wired.
adom-jlcpcbfailed for you only because it defaults to127.0.0.1:8774; the sharedjohn/service-jlcpcbis live athttps://jlcpcb-wela51osctvp.adom.cloudand resolvesC15525→CL05A106MQ5NUNC. Results cache tompn-cache.json, and the plan stores the resolved identity, sogateneeds no network at all. On your sidecar concern: resolution can only ever upgrade an identity. Verified on the load-cell board — backend up: 6 flagged; backend down: 61 flagged; the set of parts flagged with it up is a strict subset. A gate can never pass because a service happened to be running. - Granular opt-out:
--waive REF,REFkeeps individual unresolvable parts out of the graph, each printed as a WARN so the omission is on the record. Stale waivers are called out too.--allow-no-componentsstays, for boards that genuinely have no component pages. - Ask 5, machine-enforced: new
publishsubcommand — runs the gate, then publishes components → library → board in one cycle,--delayapart, with exponential backoff on a registry rate-limit and a hard stop naming what did publish.--dry-runprints the order.
Verification
Two real boards, old vs new (both with namespace probing on):
| board | 2.4.0 | this PR |
|---|---|---|
| probing-workcell-load-cell-board | reuse=5 create=2 FLAGGED=75 | reuse=75 create=1 FLAGGED=6 |
| usb3-to-ethernet-dataonly-molecule | reuse=9 create=6 FLAGGED=27 | reuse=11 create=6 FLAGGED=25 |
The load-cell board's 75 reuses resolve to exactly the pages that were hand-built
after the incident (adom/0402wgf1001tce, adom/ap2112k-3-3trg1,
adom/rp2350b, adom/ads1220irvar…), including 4 parts owned by barrett-land/
— reuse-over-recreate working across owners. Its 6 remaining flags are true
positives (3× bare 100nF, 2× Conn_01x04_Pin, 1× Jumper_2_Open — no identity
in the board file). The usb3 molecule carries no LCSC properties at all, so
nothing resolves and its value-named symbols stay flagged, correctly.
Every failure path you verified still fails (each exits 1): empty deps with
electrical BOM rows, raw-parts deps instead of the library page (#15), an
LCSC-coded slug, a value-string slug, a stale plan from another board, and no
plan with empty deps. --allow-no-components still exits 0. The publish cycle
refuses to start on a failing gate.
Docs in Stage 2.5 updated to match; version 2.6.0.
Diff Skip to comments (1)
@@ -1,277 +1,509 @@-#!/usr/bin/env python3-"""component_graph.py - ENFORCE the board -> library -> components graph.--Stages 2.5/2.6 were documented but not enforced, so boards kept publishing with-`dependencies: {}` - the publish looks successful (page renders, 3D + schematics-show) while Nucleus/APM shows an empty BOM panel (molecule-pipeline #4).-This script turns that convention into a gate a publish has to pass.-- plan <board> [--sch S] [--owner O] parse BOM -> classify -> probe the wiki- namespace -> emit plan.json + a table- of reused / create / FLAGGED parts- gate <package.json> [--plan P] HARD GATE, non-zero exit on failure:- - electrical BOM rows but no deps- - board deps that are not a library page- - library deps missing electrical parts- - any slug keyed on an LCSC code or a- value string instead of an MPN (#142)- opt out ONLY with --allow-no-components--Stdlib only. Wiki probing shells to `adom-wiki` (skipped with --offline).-"""-import argparse-import json-import os-import re-import subprocess-import sys-import time--# ── identity rules (#142: key by MPN, never by value or distributor code) ──-LCSC_RE = re.compile(r'^c\d{3,}$', re.I)-VALUE_RE = re.compile(- r'^(?:[rcl]_)?\d+(?:\.\d+)?\s*(?:[kmunp]|meg)?(?:ohm|r|f|h)?(?:_\d{4})?$', re.I)-VALUE_NAMED_RE = re.compile(r'^[rcl]_[\d.]+[kmunp]?_?\d{4}$', re.I)--# ── classification: what actually belongs in the dependency graph ──-NON_ELECTRICAL_REFS = re.compile(r'^(LOGO|FID|MH|H|G|TP|NT|REF\*\*)\d*$', re.I)-NON_ELECTRICAL_FP = re.compile(- r'logo|fiducial|mounting\s*hole|test\s*point|silk|graphic|icon|keepout', re.I)-FURNITURE_REFS = re.compile(r'^(MP|MC)\d+$', re.I) # Adom machine pins/contacts---def sh(cmd, timeout=60):- try:- r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)- return r.returncode, r.stdout, r.stderr- except Exception as e:- return 1, "", str(e)---def parse_bom(board_path):- """Rows from a .kicad_pcb (footprint blocks) or an EAGLE .brd (XML elements).- Returns [{ref, value, footprint, lcsc, mpn}] with whatever the file carries."""- txt = open(board_path, encoding="utf-8", errors="replace").read()- rows = []- if board_path.lower().endswith((".kicad_pcb", ".kicad_mod")):- for m in re.finditer(r'\(footprint "([^"]+)"', txt):- start = m.start()- depth, i = 0, start- while i < len(txt):- if txt[i] == '(':- depth += 1- elif txt[i] == ')':- depth -= 1- if depth == 0:- break- i += 1- blk = txt[start:i + 1]- def prop(name):- mm = re.search(r'\(property "%s" "([^"]*)"' % name, blk)- return mm.group(1) if mm else ""- rows.append({- "ref": prop("Reference"),- "value": prop("Value"),- "footprint": m.group(1),- "lcsc": prop("LCSC") or prop("LCSC Part") or prop("JLCPCB"),- "mpn": prop("MPN") or prop("Manufacturer Part Number") or prop("MFR#"),- "dnp": "(dnp" in blk or '"dnp"' in blk,- })- else: # EAGLE .brd XML- for m in re.finditer(r'<element\s+([^>]*?)/?>', txt):- attrs = dict(re.findall(r'(\w+)="([^"]*)"', m.group(1)))- rows.append({- "ref": attrs.get("name", ""),- "value": attrs.get("value", ""),- "footprint": attrs.get("package", ""),- "lcsc": "", "mpn": "", "dnp": False,- })- return [r for r in rows if r["ref"]]---def classify(row):- """electrical | furniture | skip - only `electrical` is graph-mandatory."""- ref, fp = row["ref"], row["footprint"]- if row.get("dnp"):- return "skip"- if NON_ELECTRICAL_REFS.match(ref) or NON_ELECTRICAL_FP.search(fp):- return "skip"- if FURNITURE_REFS.match(ref):- return "furniture"- return "electrical"---def identity(row):- """(mpn, problem). An LCSC code or a value string is NOT an identity (#142)."""- mpn = (row.get("mpn") or "").strip()- if mpn:- return mpn, None- val = (row.get("value") or "").strip()- if not val:- return None, "no MPN and no value - unresolvable from the board file"- if LCSC_RE.match(val) or LCSC_RE.match((row.get("lcsc") or "").strip()):- return None, (f"only an LCSC/distributor code ({val or row.get('lcsc')}) - "- "resolve the manufacturer part number "- "(adom-jlcpcb search <code>) before creating a page")- if VALUE_RE.match(val) or VALUE_NAMED_RE.match(val):- return None, (f"value string '{val}' is a property, not an identity - "- "resolve the real MPN (#142)")- return val, None---def probe_wiki(mpn, offline=False):- """Global-namespace probe by resolved MPN -> existing '<owner>/<slug>' or None.- Reuse beats recreate: the component namespace is shared across owners."""- if offline or not mpn:- return None- slug = re.sub(r'[^a-z0-9]+', '-', mpn.lower()).strip('-')- code, out, _ = sh(["adom-wiki", "api", f"/api/v1/search?q={slug}&type=component"])- if code != 0:- return None- try:- res = (json.loads(out).get("response") or json.loads(out)).get("results") or []- except Exception:- return None- for r in res:- if (r.get("slug") or "").lower() == slug:- return f"{r.get('owner')}/{r.get('slug')}"- return None---def cmd_plan(a):- rows = parse_bom(a.board)- plan = {"board": os.path.basename(a.board), "parts": [],- "counts": {"electrical": 0, "furniture": 0, "skip": 0,- "reuse": 0, "create": 0, "flagged": 0}}- for row in rows:- kind = classify(row)- plan["counts"][kind] = plan["counts"].get(kind, 0) + 1- if kind == "skip":- continue- mpn, problem = identity(row)- existing = probe_wiki(mpn, a.offline)- status = ("flagged" if problem else "reuse" if existing else "create")- if kind == "electrical":- plan["counts"][status] += 1- plan["parts"].append({"ref": row["ref"], "kind": kind, "mpn": mpn,- "value": row.get("value"), "footprint": row.get("footprint"),- "status": status, "page": existing, "problem": problem})-- w = max([len(p["ref"]) for p in plan["parts"]] + [4])- print(f"{'REF'.ljust(w)} {'STATUS'.ljust(8)} {'KIND'.ljust(10)} IDENTITY")- for p in sorted(plan["parts"], key=lambda x: (x["status"] != "flagged", x["ref"])):- ident = p["page"] or p["mpn"] or (p["problem"] or "")- print(f"{p['ref'].ljust(w)} {p['status'].ljust(8)} {p['kind'].ljust(10)} {ident}")- c = plan["counts"]- print(f"\nelectrical={c['electrical']} furniture={c['furniture']} skipped={c['skip']}"- f" -> reuse={c['reuse']} create={c['create']} FLAGGED={c['flagged']}")- if c["flagged"]:- print("\nFLAGGED parts have no resolvable MPN. Resolve them (or explicitly drop\n"- "them from the graph) before publishing - the gate refuses them.")- if a.out:- json.dump(plan, open(a.out, "w"), indent=2)- print(f"\nplan -> {a.out}")- return 1 if c["flagged"] else 0---def cmd_gate(a):- pkg = json.load(open(a.package))- deps = pkg.get("dependencies") or {}- slug = pkg.get("slug", "")- fails, warns = [], []-- tags = [str(t).lower() for t in (pkg.get("tags") or [])]- is_library = "library" in tags or slug.endswith("-library")-- # A library page must NOT carry a board/molecule KIND tag (#5): Nucleus keys- # on the kind (project > molecule > circuit > board > library), so a stray- # 'board'/'molecule' wins and the library renders as a board/molecule. The- # library inherits the parent's kind tag if the generator isn't careful.- if is_library:- stray = [t for t in ("board", "molecule", "circuit", "project") if t in tags]- if stray:- fails.append(f"library page carries kind tag(s) {stray} - a library is none of "- "these; tag it ['library', <topical>...] only (#5)")-- # the board's OWN slug must be MPN-shaped for components (#142) - NOT libraries- elif pkg.get("type") == "component" and pkg.get("component", {}).get("category") != "board":- if LCSC_RE.match(slug):- fails.append(f"slug '{slug}' is an LCSC/distributor code, not an MPN (#142)")- if VALUE_NAMED_RE.match(slug) or VALUE_RE.match(slug):- fails.append(f"slug '{slug}' is a value string, not an MPN (#142)")-- plan = json.load(open(a.plan)) if a.plan and os.path.exists(a.plan) else None- # A plan from a DIFFERENT board silently "passes" a board it never inspected -- # the exact class of mistake this gate exists to stop. Compare stems.- if plan and a.expect_board:- stem = re.sub(r'[^a-z0-9]+', '', os.path.splitext(plan.get("board", ""))[0].lower())- want = re.sub(r'[^a-z0-9]+', '', a.expect_board.lower())- if stem and want and stem not in want and want not in stem:- fails.append(f"plan is for '{plan.get('board')}' but this package is "- f"'{a.expect_board}' - re-run `plan` for THIS board")- electrical = [p for p in (plan or {}).get("parts", []) if p["kind"] == "electrical"]- flagged = [p for p in electrical if p["status"] == "flagged"]-- if a.allow_no_components:- warns.append("--allow-no-components: dependency graph intentionally skipped")- elif plan is None:- warns.append("no --plan given: cannot verify the graph covers the BOM "- "(run `component_graph.py plan` first)")- if not deps:- fails.append("board publishes with dependencies:{} and no plan to justify it")- else:- if electrical and not deps:- fails.append(f"{len(electrical)} electrical BOM parts but dependencies:{{}} - "- "APM/Nucleus will show an empty BOM panel (molecule-pipeline #4)")- if flagged:- fails.append(f"{len(flagged)} part(s) have no resolved MPN: "- + ", ".join(f"{p['ref']}({p['problem'][:40]})" for p in flagged[:4]))- # the board should depend on ONE library page, not raw parts (#15)- libs = [d for d in deps if d.endswith("-library")]- if electrical and not libs:- fails.append("board deps contain no '<board-slug>-library' page - the graph must be "- "board -> library -> components (#15)")- if libs and len(deps) > len(libs):- warns.append("board depends on raw component pages as well as its library; "- "the library should carry the parts")-- for f in fails:- print(f"FAIL: {f}")- for w in warns:- print(f"WARN: {w}")- if fails:- print("\nGATE FAILED - do not publish. Fix the graph, or pass --allow-no-components "- "if this board genuinely has no component pages.")- return 1- print("GATE PASSED" + (" (with warnings)" if warns else ""))- return 0---def main():- ap = argparse.ArgumentParser(description=__doc__,- formatter_class=argparse.RawDescriptionHelpFormatter)- sub = ap.add_subparsers(dest="cmd", required=True)-- p = sub.add_parser("plan", help="parse the BOM, classify, probe the wiki namespace")- p.add_argument("board")- p.add_argument("--sch")- p.add_argument("--owner")- p.add_argument("--out", default="component-plan.json")- p.add_argument("--offline", action="store_true", help="skip wiki namespace probing")- p.set_defaults(fn=cmd_plan)-- g = sub.add_parser("gate", help="HARD GATE before publish (non-zero exit = do not publish)")- g.add_argument("package", help="the board's package.json")- g.add_argument("--plan", default="component-plan.json")- g.add_argument("--expect-board", help="board filename this package should match "- "(guards against passing a stale plan from another board)")- g.add_argument("--allow-no-components", action="store_true",- help="explicit opt-out for a board that genuinely has no component pages")- g.set_defaults(fn=cmd_gate)-- a = ap.parse_args()- sys.exit(a.fn(a))---if __name__ == "__main__":- main()+#!/usr/bin/env python3+"""component_graph.py - ENFORCE the board -> library -> components graph.++Stages 2.5/2.6 were documented but not enforced, so boards kept publishing with+`dependencies: {}` - the publish looks successful (page renders, 3D + schematics+show) while Nucleus/APM shows an empty BOM panel (molecule-pipeline #4).+This script turns that convention into a gate a publish has to pass.++ plan <board> [--api URL] [--offline] parse BOM -> classify -> RESOLVE+ identity -> probe the wiki namespace+ -> emit plan.json + a table of+ reused / create / FLAGGED parts+ gate <package.json> [--plan P] HARD GATE, non-zero exit on failure:+ - electrical BOM rows but no deps+ - board deps that are not a library page+ - library deps missing electrical parts+ - any slug keyed on an LCSC code or a+ value string instead of an MPN (#142)+ opt out per-part with --waive REF,REF+ or wholesale with --allow-no-components+ publish --plan P --board D ... ordered + throttled publish cycle:+ components -> library -> board, paced+ with backoff (the registry rate-limits+ after ~10 rapid publishes)++Identity precedence (#142): explicit MPN property > LCSC code resolved through+`adom-jlcpcb` > an MPN-shaped Value > FLAG. A distributor code is not an+identity, but it IS resolvable, so the pipeline resolves it instead of refusing+it. Resolution can only ever UPGRADE an identity: with the backend down the+gate is strictly stricter, never looser, so a publish can never pass because a+sidecar happened to be up.++Stdlib only. Wiki probing and MPN resolution shell out (skipped with --offline).+"""+import argparse+import json+import os+import re+import subprocess+import sys+import time++# ── identity rules (#142: key by MPN, never by value or distributor code) ──+LCSC_RE = re.compile(r'^c\d{3,}$', re.I)+VALUE_RE = re.compile(+ r'^(?:[rcl]_)?\d+(?:\.\d+)?\s*(?:[kmunpµ]|meg)?(?:ohm|r|f|h)?(?:_\d{4})?$', re.I)+VALUE_NAMED_RE = re.compile(r'^[rcl]_[\d.]+[kmunp]?_?\d{4}$', re.I)+# generic library symbol names (KiCad stdlib) are not identities either+GENERIC_SYM_RE = re.compile(+ r'^(?:conn_|jumper_|mountinghole|testpoint|net-|solderjumper|screw|'+ r'fiducial|logo|[rcldqu]$|led$|gnd$|vcc$)', re.I)++# ── classification: what actually belongs in the dependency graph ──+NON_ELECTRICAL_REFS = re.compile(r'^(LOGO|FID|MH|H|G|TP|NT|REF\*\*)\d*$', re.I)+NON_ELECTRICAL_FP = re.compile(+ r'logo|fiducial|mounting\s*hole|test\s*point|silk|graphic|icon|keepout', re.I)+FURNITURE_REFS = re.compile(r'^(MP|MC)\d+$', re.I) # Adom machine pins/contacts++RATE_LIMIT_RE = re.compile(r'rate.?limit|429|too many requests', re.I)+++def sh(cmd, timeout=60, cwd=None):+ try:+ r = subprocess.run(cmd, capture_output=True, text=True,+ timeout=timeout, cwd=cwd)+ return r.returncode, r.stdout, r.stderr+ except Exception as e:+ return 1, "", str(e)+++def parse_bom(board_path):+ """Rows from a .kicad_pcb (footprint blocks) or an EAGLE .brd (XML elements).+ Returns [{ref, value, footprint, lcsc, mpn}] with whatever the file carries."""+ txt = open(board_path, encoding="utf-8", errors="replace").read()+ rows = []+ if board_path.lower().endswith((".kicad_pcb", ".kicad_mod")):+ for m in re.finditer(r'\(footprint "([^"]+)"', txt):+ start = m.start()+ depth, i = 0, start+ while i < len(txt):+ if txt[i] == '(':+ depth += 1+ elif txt[i] == ')':+ depth -= 1+ if depth == 0:+ break+ i += 1+ blk = txt[start:i + 1]+ def prop(name):+ mm = re.search(r'\(property "%s" "([^"]*)"' % name, blk)+ return mm.group(1) if mm else ""+ rows.append({+ "ref": prop("Reference"),+ "value": prop("Value"),+ "footprint": m.group(1),+ "lcsc": prop("LCSC") or prop("LCSC Part") or prop("JLCPCB"),+ "mpn": prop("MPN") or prop("Manufacturer Part Number") or prop("MFR#"),+ "dnp": "(dnp" in blk or '"dnp"' in blk,+ })+ else: # EAGLE .brd XML+ for m in re.finditer(r'<element\s+([^>]*?)/?>', txt):+ attrs = dict(re.findall(r'(\w+)="([^"]*)"', m.group(1)))+ rows.append({+ "ref": attrs.get("name", ""),+ "value": attrs.get("value", ""),+ "footprint": attrs.get("package", ""),+ "lcsc": "", "mpn": "", "dnp": False,+ })+ return [r for r in rows if r["ref"]]+++def classify(row):+ """electrical | furniture | skip - only `electrical` is graph-mandatory."""+ ref, fp = row["ref"], row["footprint"]+ if row.get("dnp"):+ return "skip"+ if NON_ELECTRICAL_REFS.match(ref) or NON_ELECTRICAL_FP.search(fp):+ return "skip"+ if FURNITURE_REFS.match(ref):+ return "furniture"+ return "electrical"+++def is_value_string(val):+ """'100nF', 'R_10K_0402', '51k/1%', '10uF 16V' - a property, not an identity."""+ if not val:+ return False+ if VALUE_RE.match(val) or VALUE_NAMED_RE.match(val):+ return True+ head = re.split(r'[/\s,]', val, maxsplit=1)[0] # '51k/1%' -> '51k'+ return bool(head and VALUE_RE.match(head))+++def looks_like_mpn(val):+ """An MPN carries letters AND digits and isn't a generic stdlib symbol name."""+ if not val or len(val) < 4:+ return False+ if GENERIC_SYM_RE.match(val) or is_value_string(val) or LCSC_RE.match(val):+ return False+ return bool(re.search(r'[A-Za-z]', val) and re.search(r'\d', val))+++class Resolver:+ """LCSC code -> manufacturer part number, via `adom-jlcpcb search`.++ Cached to a file so a plan is reproducible and `gate` never needs the+ network (it reads the identities the plan already resolved).+ """++ def __init__(self, api=None, cache_path=None, offline=False):+ self.api, self.offline = api, offline+ self.cache_path = cache_path+ self.cache = {}+ self.unreachable = False+ if cache_path and os.path.exists(cache_path):+ try:+ self.cache = json.load(open(cache_path))+ except Exception:+ self.cache = {}++ def __call__(self, code):+ code = (code or "").strip().upper()+ if not code or self.offline:+ return None+ if code in self.cache:+ return self.cache[code]+ env = dict(os.environ)+ if self.api:+ env["JLCPCB_API"] = self.api+ try:+ r = subprocess.run(["adom-jlcpcb", "search", code, "--limit", "1"],+ capture_output=True, text=True, timeout=45, env=env)+ out = r.stdout+ except Exception:+ self.unreachable = True+ return None+ if "backend unreachable" in (out + r.stderr).lower():+ self.unreachable = True+ return None+ try:+ body = json.loads(out[out.index("{"):])+ comps = body.get("components") or []+ except Exception:+ return None+ for c in comps:+ if (c.get("lcsc") or "").upper() == code and (c.get("mfr") or "").strip():+ mpn = c["mfr"].strip()+ self.cache[code] = mpn+ return mpn+ return None++ def save(self):+ if self.cache_path and self.cache:+ json.dump(self.cache, open(self.cache_path, "w"), indent=2, sort_keys=True)+++def identity(row, resolve=None):+ """(mpn, source, problem).++ Precedence: explicit MPN property > resolved LCSC code > MPN-shaped Value.+ A part that carries BOTH an LCSC code and a real MPN in Value used to be+ flagged as 'only an LCSC code' - which refused 75/82 parts of a board whose+ graph was correct (molecule-pipeline #4). The code is now an input to+ resolution, not a disqualifier.+ """+ mpn = (row.get("mpn") or "").strip()+ if mpn:+ return mpn, "property", None++ lcsc = (row.get("lcsc") or "").strip()+ val = (row.get("value") or "").strip()+ if not lcsc and LCSC_RE.match(val): # the Value field IS the code+ lcsc, val = val, ""++ if lcsc and resolve:+ got = resolve(lcsc)+ if got:+ return got, "lcsc:" + lcsc.upper(), None++ if looks_like_mpn(val):+ # usable, but a resolved code is canonical - flag the gap as a warning+ return val, ("value+lcsc:" + lcsc.upper() if lcsc else "value"), None++ if lcsc:+ return None, None, (+ f"distributor code {lcsc} did not resolve to a manufacturer part "+ "number - run `adom-jlcpcb search %s` (or point --api at the shared "+ "service) before creating a page" % lcsc)+ if not val:+ return None, None, "no MPN and no value - unresolvable from the board file"+ if GENERIC_SYM_RE.match(val):+ return None, None, (f"'{val}' is a generic library symbol name, not an "+ "identity - resolve the real MPN (#142)")+ return None, None, (f"value string '{val}' is a property, not an identity - "+ "resolve the real MPN (#142)")+++def probe_wiki(mpn, offline=False):+ """Global-namespace probe by resolved MPN -> existing '<owner>/<slug>' or None.+ Reuse beats recreate: the component namespace is shared across owners."""+ if offline or not mpn:+ return None+ slug = re.sub(r'[^a-z0-9]+', '-', mpn.lower()).strip('-')+ code, out, _ = sh(["adom-wiki", "api", f"/api/v1/search?q={slug}&type=component"])+ if code != 0:+ return None+ try:+ res = (json.loads(out).get("response") or json.loads(out)).get("results") or []+ except Exception:+ return None+ for r in res:+ if (r.get("slug") or "").lower() == slug:+ return f"{r.get('owner')}/{r.get('slug')}"+ return None+++def cmd_plan(a):+ rows = parse_bom(a.board)+ resolver = Resolver(api=a.api, cache_path=a.mpn_cache, offline=a.offline or a.no_resolve)+ plan = {"board": os.path.basename(a.board), "parts": [],+ "counts": {"electrical": 0, "furniture": 0, "skip": 0,+ "reuse": 0, "create": 0, "flagged": 0}}+ for row in rows:+ kind = classify(row)+ plan["counts"][kind] = plan["counts"].get(kind, 0) + 1+ if kind == "skip":+ continue+ mpn, source, problem = identity(row, resolver)+ existing = probe_wiki(mpn, a.offline)+ status = ("flagged" if problem else "reuse" if existing else "create")+ if kind == "electrical":+ plan["counts"][status] += 1+ plan["parts"].append({"ref": row["ref"], "kind": kind, "mpn": mpn,+ "source": source, "lcsc": row.get("lcsc"),+ "value": row.get("value"), "footprint": row.get("footprint"),+ "status": status, "page": existing, "problem": problem})+ resolver.save()++ w = max([len(p["ref"]) for p in plan["parts"]] + [4])+ print(f"{'REF'.ljust(w)} {'STATUS'.ljust(8)} {'KIND'.ljust(10)} IDENTITY")+ for p in sorted(plan["parts"], key=lambda x: (x["status"] != "flagged", x["ref"])):+ ident = p["page"] or p["mpn"] or (p["problem"] or "")+ src = f" [{p['source']}]" if p.get("source") and p["source"] != "property" else ""+ print(f"{p['ref'].ljust(w)} {p['status'].ljust(8)} {p['kind'].ljust(10)} {ident}{src}")+ c = plan["counts"]+ print(f"\nelectrical={c['electrical']} furniture={c['furniture']} skipped={c['skip']}"+ f" -> reuse={c['reuse']} create={c['create']} FLAGGED={c['flagged']}")++ unverified = [p for p in plan["parts"]+ if (p.get("source") or "").startswith("value+lcsc")]+ if unverified:+ print(f"\n{len(unverified)} part(s) took identity from Value while carrying an "+ "unresolved LCSC code.\nThe page slug is only as canonical as that Value - "+ "re-run with MPN resolution\nreachable (--api) to key them on the "+ "manufacturer part number instead.")+ if resolver.unreachable:+ print("\nNOTE: the JLCPCB backend was unreachable, so no distributor code was "+ "resolved.\nPoint --api at the shared service "+ "(https://jlcpcb-wela51osctvp.adom.cloud) and re-plan.")+ if c["flagged"]:+ print("\nFLAGGED parts have no resolvable MPN. Resolve them (or explicitly drop\n"+ "them from the graph) before publishing - the gate refuses them.")+ if a.out:+ json.dump(plan, open(a.out, "w"), indent=2)+ print(f"\nplan -> {a.out}")+ return 1 if c["flagged"] else 0+++def run_gate(a):+ """Returns (fails, warns). Shared by `gate` and the pre-flight in `publish`."""+ pkg = json.load(open(a.package))+ deps = pkg.get("dependencies") or {}+ slug = pkg.get("slug", "")+ fails, warns = [], []+ waived = {r.strip().upper() for r in (a.waive or "").split(",") if r.strip()}++ # the board's OWN slug must be MPN-shaped for components (#142)+ if pkg.get("type") == "component" and pkg.get("component", {}).get("category") != "board":+ if LCSC_RE.match(slug):+ fails.append(f"slug '{slug}' is an LCSC/distributor code, not an MPN (#142)")+ if VALUE_NAMED_RE.match(slug) or VALUE_RE.match(slug):+ fails.append(f"slug '{slug}' is a value string, not an MPN (#142)")++ plan = json.load(open(a.plan)) if a.plan and os.path.exists(a.plan) else None+ # A plan from a DIFFERENT board silently "passes" a board it never inspected -+ # the exact class of mistake this gate exists to stop. Compare stems.+ if plan and a.expect_board:+ stem = re.sub(r'[^a-z0-9]+', '', os.path.splitext(plan.get("board", ""))[0].lower())+ want = re.sub(r'[^a-z0-9]+', '', a.expect_board.lower())+ if stem and want and stem not in want and want not in stem:+ fails.append(f"plan is for '{plan.get('board')}' but this package is "+ f"'{a.expect_board}' - re-run `plan` for THIS board")+ electrical = [p for p in (plan or {}).get("parts", []) if p["kind"] == "electrical"]+ flagged = [p for p in electrical if p["status"] == "flagged"]+ still = [p for p in flagged if p["ref"].upper() not in waived]+ for p in flagged:+ if p["ref"].upper() in waived:+ warns.append(f"--waive {p['ref']}: unresolved part kept OUT of the graph "+ f"on purpose ({(p['problem'] or '')[:60]})")+ stale_waivers = waived - {p["ref"].upper() for p in flagged}+ if stale_waivers:+ warns.append("waived refs that are not flagged (stale waiver?): "+ + ", ".join(sorted(stale_waivers)))++ if a.allow_no_components:+ warns.append("--allow-no-components: dependency graph intentionally skipped")+ elif plan is None:+ warns.append("no --plan given: cannot verify the graph covers the BOM "+ "(run `component_graph.py plan` first)")+ if not deps:+ fails.append("board publishes with dependencies:{} and no plan to justify it")+ else:+ if electrical and not deps:+ fails.append(f"{len(electrical)} electrical BOM parts but dependencies:{{}} - "+ "APM/Nucleus will show an empty BOM panel (molecule-pipeline #4)")+ if still:+ fails.append(f"{len(still)} part(s) have no resolved MPN: "+ + ", ".join(f"{p['ref']}({(p['problem'] or '')[:40]})" for p in still[:4])+ + " - resolve them, or keep them out of the graph with "+ "--waive " + ",".join(p["ref"] for p in still[:4]))+ unverified = [p for p in electrical+ if (p.get("source") or "").startswith("value+lcsc")]+ if unverified:+ warns.append(f"{len(unverified)} part(s) keyed on a Value while carrying an "+ "unresolved LCSC code; slugs may not be the canonical MPN")+ # the board should depend on ONE library page, not raw parts (#15)+ libs = [d for d in deps if d.endswith("-library")]+ if electrical and not libs:+ fails.append("board deps contain no '<board-slug>-library' page - the graph must be "+ "board -> library -> components (#15)")+ if libs and len(deps) > len(libs):+ warns.append("board depends on raw component pages as well as its library; "+ "the library should carry the parts")+ return fails, warns+++def cmd_gate(a):+ fails, warns = run_gate(a)+ for f in fails:+ print(f"FAIL: {f}")+ for w in warns:+ print(f"WARN: {w}")+ if fails:+ print("\nGATE FAILED - do not publish. Fix the graph, waive individual parts with "+ "--waive REF,REF,\nor pass --allow-no-components if this board genuinely has "+ "no component pages.")+ return 1+ print("GATE PASSED" + (" (with warnings)" if warns else ""))+ return 0+++def publish_one(d, delay, retries, extra, dry_run):+ """One `adom-wiki pkg publish` with pacing + backoff on a registry rate-limit."""+ name = json.load(open(os.path.join(d, "package.json"))).get("slug", d)+ if dry_run:+ print(f" would publish {name} ({d})")+ return True+ wait = delay+ for attempt in range(1, retries + 1):+ code, out, err = sh(["adom-wiki", "pkg", "publish"] + list(extra), timeout=300, cwd=d)+ blob = out + err+ if code == 0:+ print(f" published {name}")+ return True+ if RATE_LIMIT_RE.search(blob) and attempt < retries:+ wait *= 2+ print(f" rate-limited on {name}; backing off {wait:.0f}s "+ f"(attempt {attempt}/{retries})")+ time.sleep(wait)+ continue+ print(f" FAILED {name}: {blob.strip()[:300]}")+ return False+ return False+++def cmd_publish(a):+ """Ordering + throttle (molecule-pipeline #4 ask 5): components -> library ->+ board in ONE cycle, paced, so a dep always exists before its depender and the+ registry does not rate-limit a large BOM into a half-published graph."""+ a.package = os.path.join(a.board, "package.json")+ fails, warns = run_gate(a)+ for w in warns:+ print(f"WARN: {w}")+ if fails:+ for f in fails:+ print(f"FAIL: {f}")+ print("\nGATE FAILED - refusing to start the publish cycle.")+ return 1++ comps = list(a.components or [])+ order = comps + ([a.library] if a.library else []) + [a.board]+ print(f"publish cycle: {len(comps)} component(s) -> "+ f"{'library -> ' if a.library else ''}board"+ f" (delay {a.delay}s{', DRY RUN' if a.dry_run else ''})")+ extra = []+ if a.org:+ extra += ["--org", a.org]+ if a.private:+ extra.append("--private")++ for i, d in enumerate(order):+ if not os.path.exists(os.path.join(d, "package.json")):+ print(f" FAILED {d}: no package.json")+ return 1+ if not publish_one(d, a.delay, a.retries, extra, a.dry_run):+ print(f"\nSTOPPED at {d} - {i} of {len(order)} published. Fix and re-run; "+ "already-published pages are skipped by version.")+ return 1+ if i < len(order) - 1 and not a.dry_run:+ time.sleep(a.delay)+ print("publish cycle complete: components -> library -> board")+ return 0+++def main():+ ap = argparse.ArgumentParser(description=__doc__,+ formatter_class=argparse.RawDescriptionHelpFormatter)+ sub = ap.add_subparsers(dest="cmd", required=True)++ p = sub.add_parser("plan", help="parse the BOM, classify, resolve MPNs, probe the wiki")+ p.add_argument("board")+ p.add_argument("--sch")+ p.add_argument("--owner")+ p.add_argument("--out", default="component-plan.json")+ p.add_argument("--offline", action="store_true", help="skip wiki probing AND resolution")+ p.add_argument("--no-resolve", action="store_true",+ help="probe the wiki but do not resolve LCSC codes")+ p.add_argument("--api", default=os.environ.get("JLCPCB_API"),+ help="JLCPCB service URL for LCSC->MPN resolution "+ "(env JLCPCB_API; shared: https://jlcpcb-wela51osctvp.adom.cloud)")+ p.add_argument("--mpn-cache", default="mpn-cache.json",+ help="LCSC->MPN cache so plans are reproducible offline")+ p.set_defaults(fn=cmd_plan)++ g = sub.add_parser("gate", help="HARD GATE before publish (non-zero exit = do not publish)")+ g.add_argument("package", help="the board's package.json")+ g.add_argument("--plan", default="component-plan.json")+ g.add_argument("--expect-board", help="board filename this package should match "+ "(guards against passing a stale plan from another board)")+ g.add_argument("--waive", default="", help="comma-separated REFs kept out of the graph "+ "on purpose - a per-part opt-out, so one unresolvable part does not "+ "force the whole gate off")+ g.add_argument("--allow-no-components", action="store_true",+ help="explicit opt-out for a board that genuinely has no component pages")+ g.set_defaults(fn=cmd_gate)++ u = sub.add_parser("publish", help="ordered + throttled publish cycle "+ "(components -> library -> board), gated first")+ u.add_argument("--board", required=True, help="dir holding the board's package.json")+ u.add_argument("--library", help="dir holding the '<board>-library' package.json")+ u.add_argument("--components", nargs="*", default=[], help="component package dirs")+ u.add_argument("--plan", default="component-plan.json")+ u.add_argument("--expect-board")+ u.add_argument("--waive", default="")+ u.add_argument("--allow-no-components", action="store_true")+ u.add_argument("--delay", type=float, default=2.0, help="seconds between publishes")+ u.add_argument("--retries", type=int, default=4, help="attempts per package on rate-limit")+ u.add_argument("--org")+ u.add_argument("--private", action="store_true")+ u.add_argument("--dry-run", action="store_true", help="print the order, publish nothing")+ u.set_defaults(fn=cmd_publish)++ a = ap.parse_args()+ sys.exit(a.fn(a))+++if __name__ == "__main__":+ main()+@@ -1,381 +1,399 @@-name: molecule-publish-description: Publish a molecule OR a generic KiCad/EAGLE board to the Adom wiki - one publisher, two shapes — runs the OCCT molecule-mode conversion (anchored GLB + footprint + symbol with stats gates), assembles the component package, and publishes via adom-wiki. The EDA-agnostic middle of the pipeline; companion to adom-molecule-import (which does wiki → Hydrogen). Use when the user says "publish this molecule to the wiki", "upload molecule bundle", "convert and publish my molecule", "publish my board" (plain, non-molecule boards use BOARD MODE - no anchoring gates), or an EDA export skill (kicad-export-molecule, fusion-export-molecule, altium-export-molecule) hands off here after exporting. Every publish also emits the render/ assets (including APM's per-version render archive behind the board version selector) and component dependencies adom-project-manager (APM) reads.-user-invocable: true-argument-hint: "<name> --step <f.step> --board <f.kicad_pcb|f.brd> --sch <schematic> [--pin medium|large] [--silk-top png --silk-bottom png] [--org adom]"--# molecule-publish — source bundle → wiki molecule--The EDA-agnostic middle of the molecule pipeline. Input is the **bare-minimum-bundle** any EDA export produces; output is a published, pipeline-ready wiki-component. The Hydrogen half is `adom-molecule-import` — this skill is its-publish-side companion.--```-EDA export skill THIS SKILL adom-molecule-import-(kicad/fusion/altium) → convert → package → publish → wiki → Hydrogen-```--## Two shapes: molecule vs board--| | **Molecule** (machine pins + MP markers) | **Board** (plain KiCad/EAGLE) |-|---|---|---|-| Convert | `--molecule --board` (anchor/gold/footprint, ALL stats gates) | plain `step2glb convert` (no molecule flags, no anchor gates) |-| Tag | `molecule` | `board` |-| Footprint/symbol JSON | required (wire attach points) | not applicable |-| render/ + deps (APM) | yes | yes |--Decide by the board itself: Adom machine pins + MP1-MP4 markers -> molecule;-otherwise board. Both shapes get `<slug>.glb` from step2glb (never an-EDA-native GLB), `model_3d_path` pointing at it, and the APM stages below.--## Contract — inputs vs the published set--**The user supplies sources; this skill generates and attaches the rest.**-The published page always carries the full set — **GLB + STEP + schematic +-board + metadata** — and every downstream tool expects all five (import-reuses the GLB, wiring reads the metadata). The GLB and footprint/symbol-JSON are never user-supplied: they come out of the OCCT conversion below.--### Inputs — the bare-minimum bundle--| Required | File | Notes |-|---|---|---|-| yes | STEP (`.step`) | exported ON the design machine so all 3D models (machine pins, contacts, MP markers) are baked in |-| yes | Board/layout (`.kicad_pcb` or `.brd`) | what footprint+symbol generation parses |-| yes | Schematic — `.kicad_sch` (EVERY sheet: root + sub-sheets) or `.sch` | source completeness + APM's hierarchical schematic view renders from these |-| board shape | `.kicad_pro` + any `.kicad_sym`/`.pretty` used | APM render sources |-| generated | GLB | OCCT conversion output — never user-supplied, always published |-| generated | Metadata | OCCT footprint/symbol JSONs + the authored `package.json` |-| optional | Silk PNGs (top/bottom) | white artwork on transparent |-| optional | Extras | `.f3d`/`.usdz`/gerbers/BOM/CPL — carry what the EDA produced |--**Rule: never accept an EDA-native GLB.** KiCad, Fusion, and service-kicad can-all emit GLBs — those bypass anchoring, gold materials, meter normalization,-optimization, and footprint emit. The ONLY GLB in a molecule package comes-from the OCCT conversion below.--## Stage 1 — Convert (OCCT molecule mode)--```bash-step2glb convert <name>.step --board <name>.kicad_pcb --pin medium \- -o <slug>.glb # --board implies --molecule; add --silk-top/--silk-bottom PNGs-```--Requires `step2glb` >= 0.11.0 (`adom-wiki pkg install adom/adom-step2glb`).-(No `adom-wiki` on the container? Bootstrap it first:-`curl -fsSL https://wiki.adom.inc/download/adom/adom-wiki-cli/1.0.2/adom-wiki-cli-v1.0.2 -o ~/.local/bin/adom-wiki && chmod +x ~/.local/bin/adom-wiki` — then keep using `adom-wiki`, not legacy tools.)-It posts multipart to `/convert?molecule=true&pin=...`, polls the job,-downloads the GLB **and** `<slug>_footprint.json` + `<slug>_symbol.json`-beside it, prints the full stats block, and emits gate hints on failures.-Fallback (older CLI): `curl -X POST ".../convert?molecule=true&pin=..."--H "X-Client: <agent>" -H "X-Job-Name: <name>" -F "step=@..." -F "board=@..."`.--**Board shape:** use a plain convert instead — `step2glb convert <name>.step--o <slug>.glb` — and SKIP the molecule stats gates below (they do not apply;-if the output warns `molecule_markers_detected`, it IS a molecule — switch).--`pin` = machine-pin size on the board (`medium` | `large`). Poll-`GET /jobs/{id}` until complete, then **gate on the stats — never publish a-degraded conversion**:--| Field | Must be | If not |-|---|---|---|-| `anchored` | `true` | MP markers missing/undetected — fix the board, re-export |-| `footprint_applied` | `true` | board parse failed — read `warnings[]` |-| `contact_z` | ≈2.8–8.4 mm | wrong `pin` param or missing contact models |-| `warnings[]` | empty | each entry is a stop-sign, not a footnote |--Artifacts: `GET /jobs/{id}/result` → `<name>.glb`,-`/jobs/{id}/footprint` → `<name>_footprint.json`,-`/jobs/{id}/symbol` → `<name>_symbol.json`.--## Stage 2 — Package--```-<slug>/- package.json # see REQUIRED fields below- <slug>.glb # OCCT artifact — MUST be named exactly <slug>.glb (see below)- <name>.kicad_pcb|.brd # board source- <name>.kicad_sch|.sch # schematic source- <name>_footprint.json # OCCT artifact — per-pad wire-attach Z- <name>_symbol.json # OCCT artifact- README.md- [extras: silk PNGs, .f3d, .usdz, gerbers, bom, cpl]-```--**Key components by MPN, never by value (#142).** The `slug` is the-normalized MPN (lowercase, non-alphanumerics -> dashes), the page title IS-the MPN, and `component.mpn` carries it verbatim. Value-named symbols like-`R_4.3K_0402` are properties, not identities — resolve the real MPN before-publishing, and never mint a page keyed on a value string.--`package.json` REQUIRED fields (publish 400s or silently under-registers-without them):--```json-{- "slug": "<slug>", "version": "1.0.0", "type": "component",- "description": "<≥20 chars>", "tags": ["molecule", "..."],- "component": { "mpn": "<name>", "manufacturer": "", "category": "other" },- "dependencies": {},- "hero": { "headline": "...", "subhead": "...", "screenshot": "screenshots/hero.png" }-}-```--- The **`component` block is what creates the page's component_metadata row —- without it `metadata.model_3d_path` is never set and imports fail** with- "no published GLB" even though the file is there.-- The **`hero` block + `screenshots/hero.png` are hard-required** at publish.--Do NOT include a `page.json` — the server generates it, and a hand-made one-can blank the page brief or override org/visibility on reindex.--**Name the GLB exactly `<slug>.glb`.** At publish the wiki auto-converts the-STEP to a PLAIN `<slug>.glb` (no anchoring, no gold) and points-`model_3d_path` at it — if your anchored OCCT GLB has any other name, the-plain one shadows it and every import silently gets an unanchored model.-(This is exactly what broke the COSMIIC boards.) Matching the name ensures-your anchored GLB is the one `model_3d_path` resolves to.--## Stage 2.5 — Component dependencies (ENFORCED, BEFORE publish)--> **This is a GATE, not a convention** (molecule-pipeline #4). Boards kept-> publishing with `dependencies: {}` — the page renders, the 3D and schematics-> show, and Nucleus/APM displays an empty BOM panel. A publish that skips this-> LOOKS successful, which is why it must be machine-checked.->-> ```bash-> # 1. plan: parse the BOM, classify, probe the global namespace by MPN-> python3 scripts/component_graph.py plan <board.kicad_pcb|.brd> --out component-plan.json-> # 2. build/reuse pages per the plan (Stage 2.6), assemble the library, wire deps-> # 3. GATE: refuses to let you publish a broken graph (non-zero exit)-> python3 scripts/component_graph.py gate package.json --plan component-plan.json \-> --expect-board <board.kicad_pcb>-> ```-> The gate FAILS on: electrical BOM parts with empty `dependencies`, a board that-> depends on raw parts instead of its `-library` page, any part with no resolved-> MPN, a slug keyed on an LCSC code or a value string, and a stale plan from a-> different board. The ONLY bypass is `--allow-no-components`, which is an-> explicit statement that this board has no component pages on purpose.--APM lists a board/molecule's components from `package.json.dependencies`-(one-hop expand). This stage is NOT optional and NOT the user's job — a-publish that skips it produces a board that looks successful but shows up-in APM with no BOM panel (#247, the Taprite carriers). Do it every publish:--1. **Parse the BOM** (BOM CSV beside the sources, else the `.kicad_sch`).-2. **Electrical rows only** — skip logos, silkscreen art, fiducials,- mounting holes, DIN adapters, graphic/mechanical symbols.-3. **Resolve the real MPN** for each row (never value names — #142). A row- with no MPN and no unambiguous standard part = FLAG for human- resolution; report it, never silently drop it.-4. **Publish the refs through a LIBRARY page (#15)** — the board does NOT- carry the raw parts list. Create/update `<board-slug>-library`- (`type: component`, `tags: ["library"]`, a `library.step` so the 3D- lint passes, hero + description) whose `dependencies` are the- owner-qualified component refs; then the board's `dependencies` is- exactly `{ "<owner>/<board-slug>-library": "*" }`. Graph:- `board -> library -> components` (matches APM's board->library- architecture; reference shape:- barrett-land/usb3-to-ethernet-dataonly-molecule-library).- **A library page's tags are `["library", <topical>...]` and MUST NOT- contain `board` or `molecule` (#5).** The kind tag is what Nucleus keys- on (priority `project > molecule > circuit > board > library`), so a- stray `board`/`molecule` on a library wins and the page renders as a- board/molecule in the gallery instead of a hidden library. Do NOT copy- the parent board's/molecule's kind tag onto its library — the library is- neither.-5. **Order + one version per cycle**: publish component pages first, then- the library, then the board — a dep must exist when the depender- publishes. Merge everything BEFORE the board's Stage 3 publish so each- cycle produces exactly ONE registry version.--- **Resolve a real MPN for every electrical part.** An LCSC/distributor code- (`c11702`) or a value string (`R_10K_0402`, `100nF`) is a property, not an- identity — the gate refuses both (#142). Resolve codes with- `adom-jlcpcb search <code>` before minting a page; a part you cannot resolve- is FLAGGED for a human, never silently dropped.-- **Reuse beats recreate.** The component namespace is shared across owners, so- `plan` probes it by resolved MPN and wires the EXISTING page (whatever its- owner) into the library instead of publishing a duplicate.-- **Throttle component publishes.** The registry rate-limits after ~10 rapid- publishes; pace them (~2 s apart) on boards with large BOMs.-- Mapping helper: `skills/publish-board/kicad_board_bundle.py` on the- `adom/adom-project-manager` page (`adom-wiki repo clone adom/adom-project-manager`).- Mind its Value-fallback caveat: value-named symbols like `R_4.3K_0402` are- NOT component slugs — resolve the real MPN.-- Write owner-qualified refs (`adom/<slug>`) into `dependencies`. **MERGE,- never overwrite** — preserve `component`, `hero`, `tags`, `model_3d_path`.-- Verify each referenced component page exists; flag missing ones.-- The dependency graph is a REGISTRY concept — it rides Stage 3's- `pkg publish`, not the repo push.-- Known behavior to keep in mind: deps are real registry deps, so- `pkg install <board>` will pull the component pages too.--## Stage 2.6 — Auto-create missing component pages--A dep that references a nonexistent page dangles. For each electrical BOM-part with no wiki page, CREATE one (then reference it) — report created vs-reused vs flagged:--- **Page standard**: `type: component`, slug/title = normalized MPN, with- `<MPN>.kicad_sym` + `<MPN>.kicad_mod` + STEP + **step2glb** GLB +- `<MPN>.adom-lbr.json` + hero + `component` block.-- **Asset sources, in order**: KiCad stdlib via `service-kicad`- (`fp fetch <lib> <name>`, `sym fetch <lib>`, `model fetch <lib>.3dshapes/- <name>.step`); the board's own embedded footprints/symbols for custom- parts (extract the `(footprint ...)` block from the `.kicad_pcb`, the- lib symbol from the `.kicad_sch`).-- **adom-lbr JSON**: `adom-lbr import-kicad --sym <f> --fp <f> --mpn <MPN>`;- also ship the Fusion `.lbr` from `adom-lbr generate`.-- **3D is hard-required by publish lint.** If the part's model exists only- in the designer's local library (footprint `(model ...)` pointing at a- local path), FLAG the page as pending with the exact missing file — do- NOT fabricate geometry and do NOT drop the part. The dep is added only- once the page exists.-- Hero: render the GLB with adom-glb2png (iso view), or for pending-3D- parts a real KiCad footprint render (`service-kicad pcb export svg` on a- small custom sheet).--## Stage 3 — Publish + verify--```bash-adom-wiki pkg publish [--org adom]-adom-wiki repo push <owner>/<slug> --files <every file above> -m "<name> molecule"-adom-wiki page visibility <owner>/<slug> --preset open # or private-```--Verify before declaring done:-- `adom-wiki api /api/v1/pages/<slug>` → `metadata.model_3d_path` is set-- **the GLB at `model_3d_path` is byte-identical to YOUR gated conversion- output** — this is the anchoring proof. (Do NOT inspect the GLB for a- `molecule_anchor` node: the optimizer flattens it away by design — the- anchoring survives as baked geometry, not as a node. A gate on that node- false-fails every properly converted molecule.)- ```bash- curl -fsSL "https://wiki.adom.inc/blob/component/<slug>/<slug>.glb" -o /tmp/check.glb- sha256sum /tmp/check.glb <slug>.glb # MUST match — if not, the page is- # serving a different GLB (e.g. the wiki's plain auto-conversion): re-push- # your OCCT GLB as <slug>.glb and re-verify.- ```- Your conversion's stats block (`molecule_anchored: true`, no warnings) plus- this checksum equals a verified anchored GLB on the page.-- the page's Files tab shows the full bundle (registry and git layers are- separate — publish alone does not populate Files)-- the registry tarball is the **source of record per version**: Stage 2's- layout means every publish carries the full board source, and- `adom-wiki pkg install <owner>/<slug>@<version>` delivers any past- version's files (APM's version selector relies on this for "give me the- source of an older version" — render/ is previews only, never source).--## Stage 3.5 — APM render assets + version archive (AFTER publish, every time)--APM shows PCB + hierarchical schematics + the board **version selector**-from a pre-rendered `render/` folder; without it, boards trip APM's red-"missing render folder" bar and fall back to slow live rendering.--```bash-adom-wiki pkg install adom/adom-nucleus # once per container (APM was RENAMED-adom-nucleus --render-board <slug> # to adom-nucleus; the old-# adom-project-manager name still works via the rename shim's symlink)-```--**Ordering is load-bearing: run this AFTER Stage 3's `pkg publish` + repo-push.** `--render-board` stamps the archive with the version it reads from-the PUBLISHED `package.json` on the page — run it before the publish and it-stamps the previous version.--`--render-board` (APM >= 0.4.0) **fully owns the version archive** — never-write `render/<version>/` or edit `render/manifest.json` yourself-(double-writing fights its content dedup). On each run it:-- renders + pushes the top-level `render/*` = current version (including- `render/board.glb` whenever a GLB exists);-- content-hashes the rendered assets and, ONLY if they differ from the last- archived version, writes `render/<version>/...` and prepends a- `versions[]` entry to `manifest.json` (v2: `{v:2, current, versions[]}`,- newest-first). A deps/metadata-only republish adds NO entry.--Verify after the run:-- `render/manifest.json` is on the page, parses, and `current` == the- version you just published;-- a content-changing publish added a `versions[]` entry + `render/<version>/`- files; a metadata-only republish did NOT (dedup working as intended).--**Make the sheets VISIBLE (#3):** component pages have no schematic viewer-slot, so rendered `render/sheet-*.svg` are invisible unless embedded. After-the render, append a `## Schematics` section to the page README — one-`### <sheet name>` + `` per `manifest.sheets[]`-entry (+ `## Board layout` with `render/pcb.svg` if absent). Skip when the-README already has the section; refresh it when sheet count/names change.-(Interim until the wiki component page renders `manifest.sheets` natively —-FR filed on adom-wiki-cli.)--Re-run on every content update so it never goes stale. KiCad sources render-today; EAGLE/Fusion (`.brd`/`.sch`) pages report "nothing rendered" and skip-gracefully — APM shows a static version label for them (Fusion rendering is-landing in APM; nothing changes on this side when it does).--## Publish completion checklist (a publish is NOT done until all pass)--- [ ] `metadata.model_3d_path` set; page GLB sha256 == your gated conversion-- [ ] board `dependencies` = its library page; library page `dependencies`- = every electrical BOM part (Stage 2.5) — `{}` on a board with- electrical parts is a FAILED publish, not a done one-- [ ] every referenced component page exists (Stage 2.6), or is explicitly- flagged pending with what it needs-- [ ] `render/manifest.json` on the page with `current` == the version you- just published (Stage 3.5)-- [ ] molecule shape only: provenance stamp + footprint/symbol artifacts-- [ ] **web-grade GLB** — the conversion stats' `web_grade.overBudget` is- `false` (< 8 MB / < 4M verts). Over budget, the component-page 3D- viewer click-to-load-gates the model instead of rendering it inline- (adom/wiki#74: a 17.5 MB GLB crashed the page and its host webview).- A heavy assembly (workcell, multi-board scene) needs a decimated- web-grade GLB for the page; keep the full-res one as a download.--## Stage 4 — Handoff--Import to Hydrogen is `adom-molecule-import`'s job (ships with-`adom/adom-molecule`): authenticated clone + `adom-molecule create --glb ...--**Attach-points caveat:** `adom-molecule import <slug>` (the one-shot verb)-currently carries ONLY the GLB — no footprint/symbol reach Hydrogen, so the-molecule has no wire attach points. Until that's fixed, register with the-board included so Hydrogen generates and attaches the footprint at create:-`adom-molecule create --owner <o> --name <n> --glb <slug>.glb --pcb <board>--## Page polish rules (field-tested — molecule-pipeline #190)--Hard-won from ~10 real imports; each one cost a debugging detour:--- **Heroes on board/component pages are static images** — `page hero --image`,- never `--screenshot`/billboard (billboard composes an app-style browser- frame around the render: "images within images").-- **Browse-card thumbnails bake at publish time** — a repo-only hero push- does NOT refresh the card. Republish with a version bump, then verify the- served card PNG actually changed (sha256 the blob).-- **Keep board pages PDF-free.** Any PDF in the repo becomes an unwanted- auto-scanned "Datasheet" section with no manifest opt-out — ship docs on a- dedicated docs page or as release assets instead.-- **Draco-compress big scene GLBs only.** Multi-MB workcell/scene models:- `gltf-transform optimize --compress draco` (35 MB → ~1.3 MB, <3 s load).- Chip/molecule-sized GLBs: do NOT draco — it comes out ~40% BIGGER than- quantized and the wiki readme sandbox can't decode it (adom-step2glb #3).- Your published molecule GLB is already optimized by OCCT — leave it alone.-- **Plain components (no MP markers) skip axis normalization** — molecule- mode anchors Z-up, but a plain `step2glb convert` preserves the source- STEP's axes; check orientation per board and bake a ±90° X root rotation- only when the source needs it.-- **Wiki page animations are baked glTF animations** in the viewer GLB —- GIFs only work inside README sections.--## Related--- `adom-molecule-import` — the wiki → Hydrogen companion-- `kicad-export-molecule` / `fusion-export-molecule` / `altium-export-molecule` — the EDA → bundle front-ends that hand off here-- `adom-step2glb` — the conversion CLI/service this skill drives-- `molecule-pipeline` — the top-level router across the whole chain+---+name: molecule-publish+description: Publish a molecule OR a generic KiCad/EAGLE board to the Adom wiki - one publisher, two shapes — runs the OCCT molecule-mode conversion (anchored GLB + footprint + symbol with stats gates), assembles the component package, and publishes via adom-wiki. The EDA-agnostic middle of the pipeline; companion to adom-molecule-import (which does wiki → Hydrogen). Use when the user says "publish this molecule to the wiki", "upload molecule bundle", "convert and publish my molecule", "publish my board" (plain, non-molecule boards use BOARD MODE - no anchoring gates), or an EDA export skill (kicad-export-molecule, fusion-export-molecule, altium-export-molecule) hands off here after exporting. Every publish also emits the render/ assets (including APM's per-version render archive behind the board version selector) and component dependencies adom-project-manager (APM) reads.+user-invocable: true+argument-hint: "<name> --step <f.step> --board <f.kicad_pcb|f.brd> --sch <schematic> [--pin medium|large] [--silk-top png --silk-bottom png] [--org adom]"+---++# molecule-publish — source bundle → wiki molecule++The EDA-agnostic middle of the molecule pipeline. Input is the **bare-minimum+bundle** any EDA export produces; output is a published, pipeline-ready wiki+component. The Hydrogen half is `adom-molecule-import` — this skill is its+publish-side companion.++```+EDA export skill THIS SKILL adom-molecule-import+(kicad/fusion/altium) → convert → package → publish → wiki → Hydrogen+```++## Two shapes: molecule vs board++| | **Molecule** (machine pins + MP markers) | **Board** (plain KiCad/EAGLE) |+|---|---|---|+| Convert | `--molecule --board` (anchor/gold/footprint, ALL stats gates) | plain `step2glb convert` (no molecule flags, no anchor gates) |+| Tag | `molecule` | `board` |+| Footprint/symbol JSON | required (wire attach points) | not applicable |+| render/ + deps (APM) | yes | yes |++Decide by the board itself: Adom machine pins + MP1-MP4 markers -> molecule;+otherwise board. Both shapes get `<slug>.glb` from step2glb (never an+EDA-native GLB), `model_3d_path` pointing at it, and the APM stages below.++## Contract — inputs vs the published set++**The user supplies sources; this skill generates and attaches the rest.**+The published page always carries the full set — **GLB + STEP + schematic ++board + metadata** — and every downstream tool expects all five (import+reuses the GLB, wiring reads the metadata). The GLB and footprint/symbol+JSON are never user-supplied: they come out of the OCCT conversion below.++### Inputs — the bare-minimum bundle++| Required | File | Notes |+|---|---|---|+| yes | STEP (`.step`) | exported ON the design machine so all 3D models (machine pins, contacts, MP markers) are baked in |+| yes | Board/layout (`.kicad_pcb` or `.brd`) | what footprint+symbol generation parses |+| yes | Schematic — `.kicad_sch` (EVERY sheet: root + sub-sheets) or `.sch` | source completeness + APM's hierarchical schematic view renders from these |+| board shape | `.kicad_pro` + any `.kicad_sym`/`.pretty` used | APM render sources |+| generated | GLB | OCCT conversion output — never user-supplied, always published |+| generated | Metadata | OCCT footprint/symbol JSONs + the authored `package.json` |+| optional | Silk PNGs (top/bottom) | white artwork on transparent |+| optional | Extras | `.f3d`/`.usdz`/gerbers/BOM/CPL — carry what the EDA produced |++**Rule: never accept an EDA-native GLB.** KiCad, Fusion, and service-kicad can+all emit GLBs — those bypass anchoring, gold materials, meter normalization,+optimization, and footprint emit. The ONLY GLB in a molecule package comes+from the OCCT conversion below.++## Stage 1 — Convert (OCCT molecule mode)++```bash+step2glb convert <name>.step --board <name>.kicad_pcb --pin medium \+ -o <slug>.glb # --board implies --molecule; add --silk-top/--silk-bottom PNGs+```++Requires `step2glb` >= 0.11.0 (`adom-wiki pkg install adom/adom-step2glb`).+(No `adom-wiki` on the container? Bootstrap it first:+`curl -fsSL https://wiki.adom.inc/download/adom/adom-wiki-cli/1.0.2/adom-wiki-cli-v1.0.2 -o ~/.local/bin/adom-wiki && chmod +x ~/.local/bin/adom-wiki` — then keep using `adom-wiki`, not legacy tools.)+It posts multipart to `/convert?molecule=true&pin=...`, polls the job,+downloads the GLB **and** `<slug>_footprint.json` + `<slug>_symbol.json`+beside it, prints the full stats block, and emits gate hints on failures.+Fallback (older CLI): `curl -X POST ".../convert?molecule=true&pin=..."+-H "X-Client: <agent>" -H "X-Job-Name: <name>" -F "step=@..." -F "board=@..."`.++**Board shape:** use a plain convert instead — `step2glb convert <name>.step+-o <slug>.glb` — and SKIP the molecule stats gates below (they do not apply;+if the output warns `molecule_markers_detected`, it IS a molecule — switch).++`pin` = machine-pin size on the board (`medium` | `large`). Poll+`GET /jobs/{id}` until complete, then **gate on the stats — never publish a+degraded conversion**:++| Field | Must be | If not |+|---|---|---|+| `anchored` | `true` | MP markers missing/undetected — fix the board, re-export |+| `footprint_applied` | `true` | board parse failed — read `warnings[]` |+| `contact_z` | ≈2.8–8.4 mm | wrong `pin` param or missing contact models |+| `warnings[]` | empty | each entry is a stop-sign, not a footnote |++Artifacts: `GET /jobs/{id}/result` → `<name>.glb`,+`/jobs/{id}/footprint` → `<name>_footprint.json`,+`/jobs/{id}/symbol` → `<name>_symbol.json`.++## Stage 2 — Package++```+<slug>/+ package.json # see REQUIRED fields below+ <slug>.glb # OCCT artifact — MUST be named exactly <slug>.glb (see below)+ <name>.kicad_pcb|.brd # board source+ <name>.kicad_sch|.sch # schematic source+ <name>_footprint.json # OCCT artifact — per-pad wire-attach Z+ <name>_symbol.json # OCCT artifact+ README.md+ [extras: silk PNGs, .f3d, .usdz, gerbers, bom, cpl]+```++**Key components by MPN, never by value (#142).** The `slug` is the+normalized MPN (lowercase, non-alphanumerics -> dashes), the page title IS+the MPN, and `component.mpn` carries it verbatim. Value-named symbols like+`R_4.3K_0402` are properties, not identities — resolve the real MPN before+publishing, and never mint a page keyed on a value string.++`package.json` REQUIRED fields (publish 400s or silently under-registers+without them):++```json+{+ "slug": "<slug>", "version": "1.0.0", "type": "component",+ "description": "<≥20 chars>", "tags": ["molecule", "..."],+ "component": { "mpn": "<name>", "manufacturer": "", "category": "other" },+ "dependencies": {},+ "hero": { "headline": "...", "subhead": "...", "screenshot": "screenshots/hero.png" }+}+```++- The **`component` block is what creates the page's component_metadata row —+ without it `metadata.model_3d_path` is never set and imports fail** with+ "no published GLB" even though the file is there.+- The **`hero` block + `screenshots/hero.png` are hard-required** at publish.++Do NOT include a `page.json` — the server generates it, and a hand-made one+can blank the page brief or override org/visibility on reindex.++**Name the GLB exactly `<slug>.glb`.** At publish the wiki auto-converts the+STEP to a PLAIN `<slug>.glb` (no anchoring, no gold) and points+`model_3d_path` at it — if your anchored OCCT GLB has any other name, the+plain one shadows it and every import silently gets an unanchored model.+(This is exactly what broke the COSMIIC boards.) Matching the name ensures+your anchored GLB is the one `model_3d_path` resolves to.++## Stage 2.5 — Component dependencies (ENFORCED, BEFORE publish)++> **This is a GATE, not a convention** (molecule-pipeline #4). Boards kept+> publishing with `dependencies: {}` — the page renders, the 3D and schematics+> show, and Nucleus/APM displays an empty BOM panel. A publish that skips this+> LOOKS successful, which is why it must be machine-checked.+>+> ```bash+> # 1. plan: parse the BOM, classify, RESOLVE identity, probe the namespace by MPN+> python3 scripts/component_graph.py plan <board.kicad_pcb|.brd> \+> --api https://jlcpcb-wela51osctvp.adom.cloud --out component-plan.json+> # 2. build/reuse pages per the plan (Stage 2.6), assemble the library, wire deps+> # 3. GATE: refuses to let you publish a broken graph (non-zero exit)+> python3 scripts/component_graph.py gate package.json --plan component-plan.json \+> --expect-board <board.kicad_pcb>+> # 4. ordered + throttled publish: components -> library -> board, gated first+> python3 scripts/component_graph.py publish --board . --library ../<slug>-library \+> --components ../parts/* --plan component-plan.json --delay 2+> ```+> The gate FAILS on: electrical BOM parts with empty `dependencies`, a board that+> depends on raw parts instead of its `-library` page, any part with no resolved+> MPN, a slug keyed on an LCSC code or a value string, and a stale plan from a+> different board.+>+> **Identity is resolved, not just refused.** Precedence is MPN property > LCSC+> code resolved through `adom-jlcpcb` > an MPN-shaped Value > FLAG. A part+> carrying BOTH a distributor code and a real MPN is resolved, not flagged —+> flagging it refused 75 of 82 parts on a board whose graph was correct. With+> the JLCPCB backend down the gate is strictly *stricter* (nothing resolves), so+> a publish can never pass because a sidecar happened to be up.+>+> **Two opt-outs, smallest first.** `--waive REF,REF` keeps individual+> unresolvable parts out of the graph (each one printed as a WARN, so the+> omission is on the record); `--allow-no-components` skips the graph wholesale+> and is only for a board that genuinely has no component pages. Reach for the+> waiver — a single bad part must never switch the whole gate off.++APM lists a board/molecule's components from `package.json.dependencies`+(one-hop expand). This stage is NOT optional and NOT the user's job — a+publish that skips it produces a board that looks successful but shows up+in APM with no BOM panel (#247, the Taprite carriers). Do it every publish:++1. **Parse the BOM** (BOM CSV beside the sources, else the `.kicad_sch`).+2. **Electrical rows only** — skip logos, silkscreen art, fiducials,+ mounting holes, DIN adapters, graphic/mechanical symbols.+3. **Resolve the real MPN** for each row (never value names — #142). A row+ with no MPN and no unambiguous standard part = FLAG for human+ resolution; report it, never silently drop it.+4. **Publish the refs through a LIBRARY page (#15)** — the board does NOT+ carry the raw parts list. Create/update `<board-slug>-library`+ (`type: component`, `tags: ["library"]`, a `library.step` so the 3D+ lint passes, hero + description) whose `dependencies` are the+ owner-qualified component refs; then the board's `dependencies` is+ exactly `{ "<owner>/<board-slug>-library": "*" }`. Graph:+ `board -> library -> components` (matches APM's board->library+ architecture; reference shape:+ barrett-land/usb3-to-ethernet-dataonly-molecule-library).+5. **Order + one version per cycle**: publish component pages first, then+ the library, then the board — a dep must exist when the depender+ publishes. Merge everything BEFORE the board's Stage 3 publish so each+ cycle produces exactly ONE registry version.++- **Resolve a real MPN for every electrical part.** An LCSC/distributor code+ (`c11702`) or a value string (`R_10K_0402`, `100nF`) is a property, not an+ identity — a page may never be keyed on one (#142). A distributor code IS+ resolvable, so `plan` resolves it for you via `adom-jlcpcb search <code>`+ against the shared service (`--api https://jlcpcb-wela51osctvp.adom.cloud`,+ or `JLCPCB_API`) and keys the page on the manufacturer part number+ (`C11702` → `0402WGF1001TCE`). Resolutions are cached to `mpn-cache.json`+ so a plan replays offline. A part with no code and only a value or a generic+ stdlib symbol name (`Conn_01x04_Pin`) is FLAGGED for a human, never silently+ dropped.+- **Reuse beats recreate.** The component namespace is shared across owners, so+ `plan` probes it by resolved MPN and wires the EXISTING page (whatever its+ owner) into the library instead of publishing a duplicate.+- **Throttle component publishes** — now machine-enforced, not advice.+ `component_graph.py publish` runs the gate, then publishes components →+ library → board in one cycle, `--delay 2` apart, with exponential backoff on+ a registry rate-limit and a hard stop (naming what did publish) on any other+ failure, so a large BOM can't rate-limit itself into a half-published graph.+- Mapping helper: `skills/publish-board/kicad_board_bundle.py` on the+ `adom/adom-project-manager` page (`adom-wiki repo clone adom/adom-project-manager`).+ Mind its Value-fallback caveat: value-named symbols like `R_4.3K_0402` are+ NOT component slugs — resolve the real MPN.+- Write owner-qualified refs (`adom/<slug>`) into `dependencies`. **MERGE,+ never overwrite** — preserve `component`, `hero`, `tags`, `model_3d_path`.+- Verify each referenced component page exists; flag missing ones.+- The dependency graph is a REGISTRY concept — it rides Stage 3's+ `pkg publish`, not the repo push.+- Known behavior to keep in mind: deps are real registry deps, so+ `pkg install <board>` will pull the component pages too.++## Stage 2.6 — Auto-create missing component pages++A dep that references a nonexistent page dangles. For each electrical BOM+part with no wiki page, CREATE one (then reference it) — report created vs+reused vs flagged:++- **Page standard**: `type: component`, slug/title = normalized MPN, with+ `<MPN>.kicad_sym` + `<MPN>.kicad_mod` + STEP + **step2glb** GLB ++ `<MPN>.adom-lbr.json` + hero + `component` block.+- **Asset sources, in order**: KiCad stdlib via `service-kicad`+ (`fp fetch <lib> <name>`, `sym fetch <lib>`, `model fetch <lib>.3dshapes/+ <name>.step`); the board's own embedded footprints/symbols for custom+ parts (extract the `(footprint ...)` block from the `.kicad_pcb`, the+ lib symbol from the `.kicad_sch`).+- **adom-lbr JSON**: `adom-lbr import-kicad --sym <f> --fp <f> --mpn <MPN>`;+ also ship the Fusion `.lbr` from `adom-lbr generate`.+- **3D is hard-required by publish lint.** If the part's model exists only+ in the designer's local library (footprint `(model ...)` pointing at a+ local path), FLAG the page as pending with the exact missing file — do+ NOT fabricate geometry and do NOT drop the part. The dep is added only+ once the page exists.+- Hero: render the GLB with adom-glb2png (iso view), or for pending-3D+ parts a real KiCad footprint render (`service-kicad pcb export svg` on a+ small custom sheet).++## Stage 3 — Publish + verify++```bash+adom-wiki pkg publish [--org adom]+adom-wiki repo push <owner>/<slug> --files <every file above> -m "<name> molecule"+adom-wiki page visibility <owner>/<slug> --preset open # or private+```++Verify before declaring done:+- `adom-wiki api /api/v1/pages/<slug>` → `metadata.model_3d_path` is set+- **the GLB at `model_3d_path` is byte-identical to YOUR gated conversion+ output** — this is the anchoring proof. (Do NOT inspect the GLB for a+ `molecule_anchor` node: the optimizer flattens it away by design — the+ anchoring survives as baked geometry, not as a node. A gate on that node+ false-fails every properly converted molecule.)+ ```bash+ curl -fsSL "https://wiki.adom.inc/blob/component/<slug>/<slug>.glb" -o /tmp/check.glb+ sha256sum /tmp/check.glb <slug>.glb # MUST match — if not, the page is+ # serving a different GLB (e.g. the wiki's plain auto-conversion): re-push+ # your OCCT GLB as <slug>.glb and re-verify.+ ```+ Your conversion's stats block (`molecule_anchored: true`, no warnings) plus+ this checksum equals a verified anchored GLB on the page.+- the page's Files tab shows the full bundle (registry and git layers are+ separate — publish alone does not populate Files)+- the registry tarball is the **source of record per version**: Stage 2's+ layout means every publish carries the full board source, and+ `adom-wiki pkg install <owner>/<slug>@<version>` delivers any past+ version's files (APM's version selector relies on this for "give me the+ source of an older version" — render/ is previews only, never source).++## Stage 3.5 — APM render assets + version archive (AFTER publish, every time)++APM shows PCB + hierarchical schematics + the board **version selector**+from a pre-rendered `render/` folder; without it, boards trip APM's red+"missing render folder" bar and fall back to slow live rendering.++```bash+adom-wiki pkg install adom/adom-nucleus # once per container (APM was RENAMED+adom-nucleus --render-board <slug> # to adom-nucleus; the old+# adom-project-manager name still works via the rename shim's symlink)+```++**Ordering is load-bearing: run this AFTER Stage 3's `pkg publish` + repo+push.** `--render-board` stamps the archive with the version it reads from+the PUBLISHED `package.json` on the page — run it before the publish and it+stamps the previous version.++`--render-board` (APM >= 0.4.0) **fully owns the version archive** — never+write `render/<version>/` or edit `render/manifest.json` yourself+(double-writing fights its content dedup). On each run it:+- renders + pushes the top-level `render/*` = current version (including+ `render/board.glb` whenever a GLB exists);+- content-hashes the rendered assets and, ONLY if they differ from the last+ archived version, writes `render/<version>/...` and prepends a+ `versions[]` entry to `manifest.json` (v2: `{v:2, current, versions[]}`,+ newest-first). A deps/metadata-only republish adds NO entry.++Verify after the run:+- `render/manifest.json` is on the page, parses, and `current` == the+ version you just published;+- a content-changing publish added a `versions[]` entry + `render/<version>/`+ files; a metadata-only republish did NOT (dedup working as intended).++**Make the sheets VISIBLE (#3):** component pages have no schematic viewer+slot, so rendered `render/sheet-*.svg` are invisible unless embedded. After+the render, append a `## Schematics` section to the page README — one+`### <sheet name>` + `` per `manifest.sheets[]`+entry (+ `## Board layout` with `render/pcb.svg` if absent). Skip when the+README already has the section; refresh it when sheet count/names change.+(Interim until the wiki component page renders `manifest.sheets` natively —+FR filed on adom-wiki-cli.)++Re-run on every content update so it never goes stale. KiCad sources render+today; EAGLE/Fusion (`.brd`/`.sch`) pages report "nothing rendered" and skip+gracefully — APM shows a static version label for them (Fusion rendering is+landing in APM; nothing changes on this side when it does).++## Publish completion checklist (a publish is NOT done until all pass)++- [ ] `metadata.model_3d_path` set; page GLB sha256 == your gated conversion+- [ ] board `dependencies` = its library page; library page `dependencies`+ = every electrical BOM part (Stage 2.5) — `{}` on a board with+ electrical parts is a FAILED publish, not a done one+- [ ] every referenced component page exists (Stage 2.6), or is explicitly+ flagged pending with what it needs+- [ ] `render/manifest.json` on the page with `current` == the version you+ just published (Stage 3.5)+- [ ] molecule shape only: provenance stamp + footprint/symbol artifacts+- [ ] **web-grade GLB** — the conversion stats' `web_grade.overBudget` is+ `false` (< 8 MB / < 4M verts). Over budget, the component-page 3D+ viewer click-to-load-gates the model instead of rendering it inline+ (adom/wiki#74: a 17.5 MB GLB crashed the page and its host webview).+ A heavy assembly (workcell, multi-board scene) needs a decimated+ web-grade GLB for the page; keep the full-res one as a download.++## Stage 4 — Handoff++Import to Hydrogen is `adom-molecule-import`'s job (ships with+`adom/adom-molecule`): authenticated clone + `adom-molecule create --glb ...+--no-optimize`. Do not re-implement it here.++**Attach-points caveat:** `adom-molecule import <slug>` (the one-shot verb)+currently carries ONLY the GLB — no footprint/symbol reach Hydrogen, so the+molecule has no wire attach points. Until that's fixed, register with the+board included so Hydrogen generates and attaches the footprint at create:+`adom-molecule create --owner <o> --name <n> --glb <slug>.glb --pcb <board>+--no-optimize` (use `--brd` for EAGLE/Fusion boards).++## Page polish rules (field-tested — molecule-pipeline #190)++Hard-won from ~10 real imports; each one cost a debugging detour:++- **Heroes on board/component pages are static images** — `page hero --image`,+ never `--screenshot`/billboard (billboard composes an app-style browser+ frame around the render: "images within images").+- **Browse-card thumbnails bake at publish time** — a repo-only hero push+ does NOT refresh the card. Republish with a version bump, then verify the+ served card PNG actually changed (sha256 the blob).+- **Keep board pages PDF-free.** Any PDF in the repo becomes an unwanted+ auto-scanned "Datasheet" section with no manifest opt-out — ship docs on a+ dedicated docs page or as release assets instead.+- **Draco-compress big scene GLBs only.** Multi-MB workcell/scene models:+ `gltf-transform optimize --compress draco` (35 MB → ~1.3 MB, <3 s load).+ Chip/molecule-sized GLBs: do NOT draco — it comes out ~40% BIGGER than+ quantized and the wiki readme sandbox can't decode it (adom-step2glb #3).+ Your published molecule GLB is already optimized by OCCT — leave it alone.+- **Plain components (no MP markers) skip axis normalization** — molecule+ mode anchors Z-up, but a plain `step2glb convert` preserves the source+ STEP's axes; check orientation per board and bake a ±90° X root rotation+ only when the source needs it.+- **Wiki page animations are baked glTF animations** in the viewer GLB —+ GIFs only work inside README sections.++## Related++- `adom-molecule-import` — the wiki → Hydrogen companion+- `kicad-export-molecule` / `fusion-export-molecule` / `altium-export-molecule` — the EDA → bundle front-ends that hand off here+- `adom-step2glb` — the conversion CLI/service this skill drives+- `molecule-pipeline` — the top-level router across the whole chain+@@ -1,47 +1,47 @@-{- "slug": "molecule-publish",- "version": "2.5.1",- "type": "skill",- "description": "Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.",- "tags": [- "molecule",- "pipeline",- "wiki",- "hydrogen",- "skill"- ],- "dependencies": {- "adom/adom-molecule": "^1.1.3"- },- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- },- "hero": {- "headline": "Molecule bundle to wiki page",- "subhead": "OCCT convert, stats gates, package, publish - EDA-agnostic",- "screenshot": "docs/shot.png"- },- "discovery_triggers": [- "publish this molecule to the wiki",- "upload molecule bundle",- "convert and publish my molecule",- "molecule to the wiki",- "publish molecule package",- "publish my board",- "publish board to the wiki",- "board to project manager",- "render board for apm"- ],- "discovery_pitch": "Publish a molecule source bundle (STEP + schematic + board + metadata) to the Adom wiki: OCCT molecule-mode conversion with stats gates, component packaging, adom-wiki publish. EDA-agnostic middle of the pipeline; companion to adom-molecule-import.",- "sample_prompts": [- {- "label": "Publish a bundle",- "prompt": "I have ABC.step, ABC.kicad_pcb and ABC.kicad_sch - publish this molecule to the wiki"- },- {- "label": "Convert and publish",- "prompt": "Convert my molecule through OCCT and publish it"- }- ]-}+{+ "slug": "molecule-publish",+ "version": "2.6.0",+ "type": "skill",+ "description": "Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.",+ "tags": [+ "molecule",+ "pipeline",+ "wiki",+ "hydrogen",+ "skill"+ ],+ "dependencies": {+ "adom/adom-molecule": "^1.1.3"+ },+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "hero": {+ "headline": "Molecule bundle to wiki page",+ "subhead": "OCCT convert, stats gates, package, publish - EDA-agnostic",+ "screenshot": "docs/shot.png"+ },+ "discovery_triggers": [+ "publish this molecule to the wiki",+ "upload molecule bundle",+ "convert and publish my molecule",+ "molecule to the wiki",+ "publish molecule package",+ "publish my board",+ "publish board to the wiki",+ "board to project manager",+ "render board for apm"+ ],+ "discovery_pitch": "Publish a molecule source bundle (STEP + schematic + board + metadata) to the Adom wiki: OCCT molecule-mode conversion with stats gates, component packaging, adom-wiki publish. EDA-agnostic middle of the pipeline; companion to adom-molecule-import.",+ "sample_prompts": [+ {+ "label": "Publish a bundle",+ "prompt": "I have ABC.step, ABC.kicad_pcb and ABC.kicad_sch - publish this molecule to the wiki"+ },+ {+ "label": "Convert and publish",+ "prompt": "Convert my molecule through OCCT and publish it"+ }+ ]+}
Comments
Log in to comment.