← All Pull Requests

Component gate: resolve LCSC->MPN instead of refusing it, per-part waivers, throttled publish cycle (molecule-pipeline #4) #1

Closed opened by barrett-land 2026-07-27

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

  1. 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.
  2. LCSC→MFR resolution wired. adom-jlcpcb failed for you only because it defaults to 127.0.0.1:8774; the shared john/service-jlcpcb is live at https://jlcpcb-wela51osctvp.adom.cloud and resolves C15525 → CL05A106MQ5NUNC. Results cache to mpn-cache.json, and the plan stores the resolved identity, so gate needs 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.
  3. Granular opt-out: --waive REF,REF keeps 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-components stays, for boards that genuinely have no component pages.
  4. Ask 5, machine-enforced: new publish subcommand — runs the gate, then publishes components → library → board in one cycle, --delay apart, with exponential backoff on a registry rate-limit and a hard stop naming what did publish. --dry-run prints 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

--- a/molecule-publish/scripts/component_graph.py+++ b/molecule-publish/scripts/component_graph.py@@ -0,0 +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> [--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()+--- a/molecule-publish/SKILL.md+++ b/molecule-publish/SKILL.md@@ -0,0 +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, 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>` + `![](render/sheet-NNN.svg)` 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+--- a/molecule-publish/package.json+++ b/molecule-publish/package.json@@ -0,0 +1,47 @@+{+  "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

No comments yet.

Log in to comment.