skill
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.
← Commit history
Publish 2.10.0
7 files changed
+428−47
SKILL.md+68−30@@ -168,16 +168,27 @@ your anchored GLB is the one `model_3d_path` resolves to. > ```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+> # 2. build/reuse component pages per the plan (Stage 2.6), wire deps on the board > # 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>+> --expect-board <board.kicad_pcb> --resolve > ```-> 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.+> The graph is **two layers — `board -> components`** (#18: the auto-generated+> `<board>-library` middle page was dropped; a board depends on its component+> pages directly). The gate enforces two DISTINCT properties (#17):+> - **DECLARED (coverage)** — every electrical BOM part has a dependency naming+> it. A non-empty `dependencies:{}` is not enough; it must actually cover the+> parts, or APM/Nucleus shows a partial/empty BOM panel.+> - **RESOLVED (existence)** — with `--resolve`, every declared dependency must+> resolve to a real wiki page. A dep on a page that does not exist installs+> broken in APM (core owner-pinning fragility); the old "declared" check never+> caught this. An indeterminate probe (offline/error) degrades to a WARN, never+> a false FAIL.+>+> The gate also FAILS on: any part with no resolved MPN, a slug keyed on an LCSC+> code or value string (#142), and a stale plan from a different board. The ONLY+> bypass is `--allow-no-components` — 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@@ -190,26 +201,20 @@ in APM with no BOM panel (#247, the Taprite carriers). Do it every publish: 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.+4. **Depend on the component pages directly (#18)** — the board's+ `dependencies` are the owner-qualified component refs themselves+ (`{ "adom/<mpn-slug>": "*", ... }`). Graph: **`board -> components`**, two+ layers. The old auto-generated `<board>-library` middle page was dropped:+ it added a page to maintain for no BOM benefit (APM one-hop-expands the+ board's deps directly). Do NOT create a `-library` page. *(A hand-made+ library page is still permitted for genuine reuse groupings; if you make+ one, its tags are `["library", <topical>...]` and MUST NOT contain `board`+ or `molecule` — the kind tag is what Nucleus keys on (#5), so a stray one+ makes the library render as a board/molecule.)*+5. **Order + one version per cycle**: publish the component pages first, then+ the board — a dep must exist and RESOLVE when the board publishes (the+ `--resolve` gate enforces this). 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@@ -218,7 +223,7 @@ in APM with no BOM panel (#247, the Taprite carriers). Do it every publish: 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.+ owner) into the board's `dependencies` 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@@ -352,11 +357,16 @@ 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+- [ ] board `dependencies` name every electrical BOM part directly+ (`board -> components`, #18) — DECLARED coverage; `{}` on a board with electrical parts is a FAILED publish, not a done one+- [ ] `gate --resolve` passes: every declared dependency resolves to a real+ page (#17) — a dangling dep installs broken in APM - [ ] every referenced component page exists (Stage 2.6), or is explicitly flagged pending with what it needs+- [ ] if you actually exercised this molecule this session, stamp it:+ `scripts/tested_with.py stamp package.json --family <your-model>` — adds+ `tested-with:<family>` forward-only (never backfill existing pages, #85) - [ ] `render/manifest.json` on the page with `current` == the version you just published (Stage 3.5) - [ ] `render/board.glb` mesh count == the page `<slug>.glb` (adom/wiki#91) —@@ -375,6 +385,27 @@ landing in APM; nothing changes on this side when it does). irreducibly large it stays gated. Under-budget models get no twin (and need none). +### Tested-with tagging (#85)++When an agent has actually **exercised** a molecule/board this session (loaded it,+verified the render/BOM, ran the gates), stamp the page so a reader can tell+verified pages from merely-published ones:++```bash+python3 scripts/tested_with.py stamp package.json --family <model> [--version <exact>]+```++Rules the helper enforces, per the #85 decision (drew2):+- **Forward-only, NO backfill.** Stamp at publish going forward; never run it+ across existing pages. Absence must stay meaningful — a `tested-with:` tag+ present means a model really verified that page, not that a migration stamped+ it. The helper has no bulk mode on purpose.+- **The tag is the model FAMILY** (`tested-with:claude`, `tested-with:gpt`), not+ the version — families stay accurate as versions roll (Colby). The exact+ version, if given, is recorded in `metadata.tested_with[]`, out of the tag space.+- **Tested-only.** There is deliberately no `authored-with` tag — authorship is+ not a verification signal.+ ## Stage 4 — Handoff Import to Hydrogen is `adom-molecule-import`'s job (ships with@@ -409,6 +440,13 @@ Hard-won from ~10 real imports; each one cost a debugging detour: component keys) so every consumer — 3D tab, hero capture, downstream embeds — agrees. Ray's rule: the home/declared view is right; only the old hardcoded renderer defaults were wrong.+ **Declare it with the helper** (don't hand-edit angles):+ `scripts/hero_camera.py pose <slug>.glb --page page.json` writes Ray's+ canonical `alpha 1.571, beta 0.785` plus a bounds-computed `radiusScale`+ (vary-radius-only) into `component.viewer_3d.camera`, and prints the+ `?camera=α,β,scale` string to capture the hero at that exact pose. Override+ `--alpha/--beta` only for a board whose canonical orientation isn't the+ default iso. - **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).
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "molecule-publish",- "version": "2.9.0",+ "version": "2.10.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": [
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "molecule-publish",- "version": "2.9.0",+ "version": "2.10.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": [
scripts/__pycache__/component_graph.cpython-312.pycadded⋯ 1 unchanged line ⋯
scripts/component_graph.py+110−15@@ -1,18 +1,32 @@ #!/usr/bin/env python3-"""component_graph.py - ENFORCE the board -> library -> components graph.+"""component_graph.py - ENFORCE the board -> components dependency 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. +The graph is TWO layers - board -> components (#18: the auto-generated+intermediate `<board>-library` page was dropped). A board declares a dependency+directly on each component page. The gate enforces two distinct properties (#17):++ - DECLARED (coverage): every electrical BOM part has a dependency naming it.+ A non-empty `dependencies:{}` is NOT enough - it must+ actually cover the parts.+ - RESOLVED (existence): every declared dependency resolves to a real wiki+ page. A dep on a page that does not exist installs+ broken in APM/Nucleus (core owner-pinning fragility);+ "declared" alone never caught this.+ 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+ - electrical parts not covered by any dep+ (DECLARED check, #17)+ - a declared dep that resolves to no page+ (RESOLVED check, #17; needs --resolve) - any slug keyed on an LCSC code or a value string instead of an MPN (#142) opt out ONLY with --allow-no-components@@ -138,6 +152,46 @@ def probe_wiki(mpn, offline=False): return None +def slugify(s):+ return re.sub(r'[^a-z0-9]+', '-', (s or "").lower()).strip('-')+++def dep_slug(ref):+ """Slug portion of a dependency key: 'adom/lm358'->'lm358', 'lm358'->'lm358'.+ A caret/range value lives in the dict VALUE, never the key, so keys are clean."""+ return slugify(ref.split("/")[-1])+++def page_exists(ref, offline=False):+ """Does a dependency resolve to a real wiki page? (#17 RESOLVED check.)+ 'owner/slug' is looked up directly; a bare slug is searched by slug. Returns+ True/False, or None when we could not tell (offline / probe error) so the+ caller can degrade to a warning instead of a false FAIL."""+ if offline or not ref:+ return None+ if "/" in ref:+ code, out, err = sh(["adom-wiki", "api", f"/api/pages/{ref}"])+ if code != 0:+ # a definitive not-found is reported on stderr (and echoed in the JSON+ # body); anything else (network/auth) is indeterminate -> None+ return False if "PAGE_NOT_FOUND" in ((out or "") + (err or "")) else None+ try:+ j = json.loads(out)+ return bool(j.get("page") or j.get("slug") or j.get("id"))+ except Exception:+ return None+ # bare slug: accept any owner that actually owns a page with this slug+ slug = slugify(ref)+ code, out, _ = sh(["adom-wiki", "api", f"/api/v1/search?q={slug}"])+ if code != 0:+ return None+ try:+ res = (json.loads(out).get("response") or json.loads(out)).get("results") or []+ except Exception:+ return None+ return any((r.get("slug") or "").lower() == slug for r in res)++ def cmd_plan(a): rows = parse_bom(a.board) plan = {"board": os.path.basename(a.board), "parts": [],@@ -183,10 +237,11 @@ def cmd_gate(a): 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.+ # #18: the auto-generated `<board>-library` layer was dropped - boards depend+ # on component pages directly. A hand-made library page is still allowed, but+ # it 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 the wrong kind. if is_library: stray = [t for t in ("board", "molecule", "circuit", "project") if t in tags] if stray:@@ -226,14 +281,49 @@ def cmd_gate(a): 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")++ # ── DECLARED (coverage, #17): every RESOLVED electrical part must have a+ # dependency naming it. board -> components directly (#18): the expected+ # dep slug is the part's MPN slug (or the slug of the page probe found).+ dep_slugs = {dep_slug(d) for d in deps}+ uncovered = []+ for p in electrical:+ if p["status"] == "flagged":+ continue # already failed above; no identity to match on+ want = {slugify(p["mpn"])} if p.get("mpn") else set()+ if p.get("page"):+ want.add(dep_slug(p["page"]))+ if want and not (want & dep_slugs):+ uncovered.append(p)+ if uncovered:+ fails.append(+ f"{len(uncovered)} electrical part(s) are NOT covered by any dependency "+ "(declared-coverage, #17): "+ + ", ".join(f"{p['ref']}={p['mpn'] or p.get('value')}" for p in uncovered[:5])+ + " - add a dependency on each part's component page")++ # ── RESOLVED (existence, #17): every declared dep must resolve to a real+ # page. Skipped unless --resolve (it hits the network); a probe that+ # cannot tell (offline/error) degrades to a WARN, never a false FAIL.+ if a.resolve and deps:+ dangling, unknown = [], []+ for d in deps:+ ex = page_exists(d, offline=a.offline)+ if ex is False:+ dangling.append(d)+ elif ex is None:+ unknown.append(d)+ if dangling:+ fails.append(+ f"{len(dangling)} declared dependency/-ies resolve to NO wiki page "+ "(resolved-existence, #17): " + ", ".join(dangling[:5])+ + " - publish the component page(s) first, or fix the dep key")+ if unknown:+ warns.append("could not verify existence of: " + ", ".join(unknown[:5])+ + " (probe unavailable; re-run with network to confirm)")+ elif deps and not a.resolve:+ warns.append("dependency existence not checked - pass --resolve to verify "+ "each dep resolves to a real page (#17)") for f in fails: print(f"FAIL: {f}")@@ -267,6 +357,11 @@ def main(): "(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.add_argument("--resolve", action="store_true",+ help="also verify each declared dependency resolves to a real wiki "+ "page (#17 RESOLVED check; hits the network)")+ g.add_argument("--offline", action="store_true",+ help="never probe the wiki (resolution checks degrade to warnings)") g.set_defaults(fn=cmd_gate) a = ap.parse_args()
scripts/hero_camera.pyadded+136@@ -0,0 +1,136 @@+#!/usr/bin/env python3+"""hero_camera.py - DECLARE a component's canonical hero camera (adom/wiki#87).++The wiki does not render component heroes; the pipeline commits `hero.png` and+the viewer resolves its live pose by precedence `?camera=` > page declaration+(`component.viewer_3d.camera`) > platform home view. The platform home view MOVED+(wiki#102) and can move again, so a component must DECLARE its own pose on the+page - otherwise the hero (baked at publish) and the live 3D tab drift apart and+every page reads as "posed wrong."++Ray's constraint (measured on the workcell boards, #87): the canonical iso pose is+`alpha 1.571, beta 0.785` (45 deg off top-down) and you **vary RADIUS only**. So+this tool writes that fixed alpha/beta and a bounds-informed `radiusScale`, giving+one stable, page-owned pose that the hero capture and the 3D tab both honor.++ pose <glb> --page <page.json> compute radiusScale from the GLB bounds, merge+ {alpha, beta, radiusScale} into+ page.json.component.viewer_3d.camera, and print+ the `?camera=` string to capture the hero at the+ SAME pose. --alpha/--beta/--radius-scale override+ (e.g. a board with a known-good non-default+ orientation). Nothing else in page.json changes.++Stdlib only. Bounds come from POSITION accessor min/max (no mesh decode); node+transforms are not composed - fine for framing a single-component page.+"""+import argparse+import json+import struct+import sys++# Ray's canonical iso (wiki#87). Radius is the only free parameter.+CANON_ALPHA = 1.571 # ~pi/2+CANON_BETA = 0.785 # ~pi/4, 45 deg off top-down+++def glb_json(path):+ with open(path, "rb") as f:+ b = f.read()+ if b[:4] != b"glTF":+ raise ValueError(f"{path} is not a GLB")+ jlen = struct.unpack_from("<I", b, 12)[0]+ return json.loads(b[20:20 + jlen])+++def bounds(doc):+ """Axis-aligned bbox from every POSITION accessor's declared min/max."""+ lo = [float("inf")] * 3+ hi = [float("-inf")] * 3+ accs = doc.get("accessors", [])+ seen = False+ for mesh in doc.get("meshes", []):+ for prim in mesh.get("primitives", []):+ i = prim.get("attributes", {}).get("POSITION")+ if i is None or i >= len(accs):+ continue+ a = accs[i]+ mn, mx = a.get("min"), a.get("max")+ if not (mn and mx and len(mn) >= 3 and len(mx) >= 3):+ continue+ seen = True+ for k in range(3):+ lo[k] = min(lo[k], mn[k])+ hi[k] = max(hi[k], mx[k])+ if not seen:+ return None+ return lo, hi+++def radius_scale(lo, hi):+ """Vary radius only (Ray). The viewer auto-frames to the bbox; radiusScale is+ a margin multiplier. A part that is flat+wide seen at 45 deg needs a touch more+ room than a cube, so scale mildly with the horizontal:vertical aspect. Clamped+ to a sane band so it never zooms to a dot or flies out."""+ size = [hi[k] - lo[k] for k in range(3)]+ # glTF is Y-up: horizontal extent = max(x, z), vertical = y+ horiz = max(size[0], size[2]) or 1e-6+ vert = size[1] or 1e-6+ aspect = horiz / vert+ scale = 1.05 + 0.06 * max(0.0, min(6.0, aspect - 1.0)) # band 1.05 .. ~1.41+ return round(scale, 3)+++def cmd_pose(a):+ doc = glb_json(a.glb)+ bb = bounds(doc)+ alpha = a.alpha if a.alpha is not None else CANON_ALPHA+ beta = a.beta if a.beta is not None else CANON_BETA+ if a.radius_scale is not None:+ rscale = a.radius_scale+ elif bb:+ rscale = radius_scale(*bb)+ else:+ rscale = 1.1+ print("WARN: no POSITION min/max in GLB - using default radiusScale 1.1")++ cam = {"alpha": round(alpha, 4), "beta": round(beta, 4), "radiusScale": rscale}++ pkg = json.load(open(a.page))+ comp = pkg.setdefault("component", {})+ v3d = comp.setdefault("viewer_3d", {})+ old = v3d.get("camera")+ v3d["camera"] = cam+ if not a.dry_run:+ json.dump(pkg, open(a.page, "w"), indent=2)+ open(a.page, "a").write("\n")++ print(f"component.viewer_3d.camera = {json.dumps(cam)}"+ + (f" (was {json.dumps(old)})" if old else "")+ + (" [dry-run]" if a.dry_run else ""))+ if bb:+ size = [round(bb[1][k] - bb[0][k], 2) for k in range(3)]+ print(f"bounds size (x,y,z) = {size}")+ print("capture the hero at THIS pose so it matches the live 3D tab:")+ print(f" ?camera={cam['alpha']},{cam['beta']},{cam['radiusScale']}")+ 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("pose", help="declare component.viewer_3d.camera (#87)")+ p.add_argument("glb")+ p.add_argument("--page", required=True, help="page.json to merge the camera into")+ p.add_argument("--alpha", type=float, help="override canonical alpha (1.571)")+ p.add_argument("--beta", type=float, help="override canonical beta (0.785)")+ p.add_argument("--radius-scale", type=float, help="override computed radiusScale")+ p.add_argument("--dry-run", action="store_true")+ p.set_defaults(fn=cmd_pose)+ a = ap.parse_args()+ sys.exit(a.fn(a))+++if __name__ == "__main__":+ main()
scripts/tested_with.pyadded+112@@ -0,0 +1,112 @@+#!/usr/bin/env python3+"""tested_with.py - stamp a `tested-with:<family>` tag at publish (wiki #85).++DECISION (drew2, 2026-08-05): tested-only, NO backfill, NO 'authored' tag.+ - Forward-only: the tag is added by the publishing agent when it ACTUALLY+ exercised the molecule this session (loaded it, verified the render/BOM) -+ never stamped in bulk across existing pages. Absence stays meaningful:+ a `tested-with:` tag present means a model really verified that page.+ - The TAG is the model FAMILY (`claude`, `gpt`, `gemini`) so it stays accurate+ as versions roll (Colby #85). The specific version is recorded in page+ metadata (`metadata.tested_with[]`) for anyone who wants the exact build,+ without multiplying the tag space.+ - There is deliberately NO 'authored-with'/'authored-by' tag. Authorship is not+ a verification signal; only testing is.++ stamp <package.json> --family <fam> [--version <ver>]+ Idempotently add `tested-with:<family>` to tags[] and append+ {family, version} to metadata.tested_with[] (deduped). Operates on the ONE+ file given - it has no bulk/backfill mode by design.++ check <package.json>+ Print the tested-with tags/metadata already on the package (exit 0).++Stdlib only. `--family` is normalised (claude-opus-4-8 -> claude).+"""+import argparse+import json+import re+import sys++# model id / name -> family. Extend as new providers are exercised.+FAMILY_RULES = [+ (re.compile(r'claude|anthropic', re.I), "claude"),+ (re.compile(r'gpt|o[0-9]|openai|codex', re.I), "gpt"),+ (re.compile(r'gemini|google', re.I), "gemini"),+ (re.compile(r'llama|meta', re.I), "llama"),+ (re.compile(r'mistral|mixtral', re.I), "mistral"),+ (re.compile(r'grok|xai', re.I), "grok"),+]+++def to_family(s):+ """Map a model id/name to its family; fall back to a slug of the input so an+ unknown provider still produces a stable, lowercase tag rather than failing."""+ s = (s or "").strip()+ for rx, fam in FAMILY_RULES:+ if rx.search(s):+ return fam+ slug = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-')+ return slug or None+++def cmd_stamp(a):+ fam = to_family(a.family)+ if not fam:+ print("FAIL: could not derive a family from --family (give a model id/name)")+ return 1+ pkg = json.load(open(a.package))+ tags = pkg.get("tags") or []+ tag = f"tested-with:{fam}"+ changed = False+ if tag not in tags:+ tags.append(tag)+ pkg["tags"] = tags+ changed = True+ # record the specific version out of the tag space, in metadata+ if a.version:+ meta = pkg.setdefault("metadata", {})+ tw = meta.setdefault("tested_with", [])+ entry = {"family": fam, "version": a.version}+ if entry not in tw:+ tw.append(entry)+ changed = True+ if changed and not a.dry_run:+ json.dump(pkg, open(a.package, "w"), indent=2)+ open(a.package, "a").write("\n")+ verb = "would add" if a.dry_run else ("added" if changed else "already present -")+ print(f"{verb} {tag}" + (f" (version {a.version} -> metadata.tested_with)" if a.version else "")+ + (" [dry-run]" if a.dry_run else ""))+ print("NOTE: tested-only, forward-only. Do NOT run this across existing pages (#85).")+ return 0+++def cmd_check(a):+ pkg = json.load(open(a.package))+ tags = [t for t in (pkg.get("tags") or []) if str(t).startswith("tested-with:")]+ tw = (pkg.get("metadata") or {}).get("tested_with") or []+ print("tags:", ", ".join(tags) if tags else "(none)")+ print("metadata.tested_with:", json.dumps(tw) if tw else "(none)")+ return 0+++def main():+ ap = argparse.ArgumentParser(description=__doc__,+ formatter_class=argparse.RawDescriptionHelpFormatter)+ sub = ap.add_subparsers(dest="cmd", required=True)+ s = sub.add_parser("stamp", help="add tested-with:<family> (forward-only, #85)")+ s.add_argument("package")+ s.add_argument("--family", required=True,+ help="model id or family the agent tested with (e.g. claude-opus-4-8)")+ s.add_argument("--version", help="specific model version -> recorded in metadata, not the tag")+ s.add_argument("--dry-run", action="store_true")+ s.set_defaults(fn=cmd_stamp)+ c = sub.add_parser("check", help="show existing tested-with tags/metadata")+ c.add_argument("package")+ c.set_defaults(fn=cmd_check)+ a = ap.parse_args()+ sys.exit(a.fn(a))+++if __name__ == "__main__":+ main()