← Commit history
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()