← Commit history

0.2.10: move altium-bridge-dev to dev-skills/ (dev skills never ship in the install tarball), refresh page.json version

Colby Knox ·7c70f8661c ·1mo ago ·parent a606b22
3 files changed +436−45
dev-skills/altium-bridge-dev/SKILL.mdadded+214
@@ -0,0 +1,214 @@+---+name: altium-bridge-dev+user-invocable: false+description: >-+  DEVELOPER skill (never offered to everyday users) for working ON the altium-bridge: the+  resident DelphiScript agent architecture (file-drop REPL, per-digest generations,+  stale-agent auto-restart), the dialog pre-emption doctrine (the debugger breaks on ANY+  exception, so a raise IS a modal), the transaction-hygiene rule (dangling PreProcess = "a+  command is currently active"), the deploy loop (cross-compile bridge-rs + send_files the exe+  into the bridge cache + bridge_kill; agent_stop before agent_start for template changes), and+  the engine-halting identifier list. Read before editing agent/AdomAgent.pas.tmpl or+  bridge-rs/src/. Trigger words — edit altium bridge, altium agent template, delphiscript+  agent, deploy altium bridge, altium engine halt, undeclared identifier altium, bridge-rs.+---++# altium-bridge-dev++Everything you need to change the bridge without re-learning what this session paid for.++## Architecture++- **Resident DelphiScript agent** (`agent/AdomAgent.pas.tmpl`): bootstrapped once via+  `X2.EXE -RScriptingSystem:RunScript(...StartAgent)`, then a polling loop over a file-drop+  inbox (`request.txt` → `response_<id>.json` in the workspace). One script at a time; DO NOT+  `run_script` while it is resident. `eval` runs STATEMENTS ONLY (no Var block).+- **Per-digest generations**: the tmpl is rendered to `AdomAgent_<digest>.pas`; the digest is+  hashed from the RAW template (NOT the rendered source — that recursed and crashed the bridge+  once). The heartbeat carries `gen`; `agent_start` auto-restarts a stale in-Altium loop.+- **The runtime is RUST (0.2.0+)**: `bridge-rs/` → `altium-bridge.exe`, `spawn.kind: "exe"`.+  `src/server.py` and `src/handlers/` are the retired Python reference — behaviour questions can+  still be answered there, but code changes go in `bridge-rs/src/` (verbs.rs KNOWN is the+  dispatch of record; scripts/release.py lints bridge.json against it and refuses drift).+- **Deploy loop**: edit `bridge-rs/src/` → `cargo test && cargo build --release --target+  x86_64-pc-windows-gnu` → `send_files` the exe into+  `C:\Users\john\AppData\Local\Adom Bridge\bridges-cache\altium\` → `bridge_kill` (respawns the+  exe). Template changes (`src/agent/AdomAgent.pas.tmpl`) additionally need+  `altium_agent_stop` THEN `altium_agent_start` — bridge_kill alone leaves the in-Altium loop+  running old code. TRAP from the 0.2.0 swap: after an in-place bridge_install, the OLD process+  can keep squatting the runtime port while reporting the NEW version (it re-reads+  BRIDGE_VERSION per request), so ab thinks the respawn happened — `process_list` for a+  `server.py`/stale exe on the bridge port and kill it if the reply shape looks wrong.++## Deploying a big template (measured 2026-08-31)++`write_file` passes the content as a CLI argument; once `AdomAgent.pas.tmpl` passed ~130 KB it+failed with "Argument list too long" and NOTHING deployed (the reload then ran old code —+"Unknown command" for the new verbs, zero dialogs, easy to misread as a compile pass). Deploy+big files with `send_files {filePaths:[...]}` (streams; ignores destDir and lands in+`C:\Users\john\Downloads`) then `Move-Item` into `bridges-cache\altium\{agent\,}`.++## "Current document" semantics++`PCBServer.GetCurrentPCBBoard` / `SchServer.GetCurrentSchDocument` follow the ACTIVE document.+Opening a SchDoc makes board verbs answer "no current PCB board" until a PcbDoc is opened+again. Read verbs return that error with a `_hint`; order your calls, or open the right doc first.+Schematic iteration needs the sheet SHOWN (activated) — a background `open_document` can leave+`SchIterator` empty (measured: count 0 until `show_schematic`).++## Dialog pre-emption (John: a dismissed dialog is a defect)++The DelphiScript debugger breaks on ANY raised exception even inside Try/Except — a raise IS+an Error modal. So: `ReadAll` probes with `FileOpen` (returns -1, never raises) before+`LoadFromFile`. `TxClear` (an unbalanced `PostProcess`, harmless) runs at agent start, before+every save, and after every recover, because a command that halted between PreProcess and+PostProcess leaves "a command is currently active" and the next save draws the save-a-copy+Warning. When you add a mutating command, wrap file reads and never leave a transaction open.++## Engine-halting identifiers (all measured, all cost a recover)++`BottomXSize`/`MidXSize` (pad size props don't exist — Top covers a multi-layer pad),+`IPCB_BoardOutline.Clear`, `eProcessFreePrimitives`, `IPCB_Board.GroupIterator_Create` (use+`BoardIterator_Create`), `IPCB_Component.Name.Text :=` (use `NameOn`/`Inst.Name.MoveToXY`),+`GroupIterator` without `AddFilter_LayerSet(AllLayers)` (mech-layer children invisible),+`RunProcessWithParameters` (use ResetParameters+AddStringParameter+RunProcess), `IPCB_ObjectClass.SuperClass :=` (not declared in AD26 scripting — a new class from `PCBClassFactory` is already non-super; just omit it), a nested `{}`+inside a brace comment (breaks compilation). An unknown identifier is a RUNTIME error that+halts the debugger; `altium_agent_recover` clears it.++## The brace rule for JSON-building code++Result strings build JSON with `'{' + ... + '}'`; that's fine in a STRING LITERAL. It is only+BRACE COMMENTS that cannot contain a nested `{`. Lint new commands:+`re.sub(r"'[^']*'","",code)` then assert no `\{[^}]*\{`.++## Two sessions on one engine = a modal storm (measured 2026-08-31)++The resident loop is ONE script engine. When a second session (another Claude thread, a+delegated agent) also drives `altium_*` verbs, a halt raised by one session's command surfaces+as the OTHER session's error (it saw "Undeclared identifier: SuperClass/Name" on a read-only+verb that was innocent), both sessions then run `agent_recover`, and every recover's `-R`+helper puts the single-instance busy notice on the user's screen. Rule: exactly one driver+per Altium instance. Coordinate ownership (cross-session message) before any second session+touches the engine, and never delegate live-engine work without that handshake.++## Recover costs the user a modal — measured 2026-08-31 on John's screen++Each `agent_recover` fires `X2 -REditScript:Stop`; against a halted (busy) instance Windows raises the+single-instance notice ("There is another instance of running, but it is busy") on the USER's+screen, and 0.1.9's `_reap_busy_notice` did NOT catch it (twice). Until the reaper is proven:+never iterate a suspected-halting command live on the user's box — halt → recover → retry is a+modal per cycle. Eval-probe identifiers in a throwaway session, or ship the verb unwired.+`create_net_class` is parked unwired for exactly this reason (`SuperClass` halts; a second+identifier halts after it).++## When the bridge "vanishes" from ab++`bridge_list` without `altium` + `Unknown verb altium_*` = ab could not parse the cached `bridge.json`+(verbs[] must be strings — see the publish skill). ab's lifecycle log is+`%LOCALAPPDATA%\inc.adom.bridge\logs\Adom Bridge.log`; `bridges-cache\altium\server.out.log` only+covers a process that actually spawned.++## Verify like production fails++Frame-check EVERY generated look/variant (a 3-of-10 spot check shipped three defect classes).+Trust `altium_pcb_report` read-backs, not return codes. A timeout is usually success that+outran ab's 60s HTTP budget — poll/verify, don't blind-retry. Board frames are BLACK under a+locked Windows session (DirectX) — check `desktop_get_foreground` first.++## The Rust bridge (bridge-rs/) — status 2026-08-31++Decision (John): the bridge PROCESS moves to a Rust exe that (1) calls ab's verbs for everything+desktop-shaped (no Win32 in the bridge), (2) absorbs the DelphiScript agent's shaping logic so the+agent shrinks to generic read/apply primitives, (3) ships no MCP server (external agents use+`adom-bridge-cli`). Vertical slice VERIFIED live on AdomLapper: `/health` (Altium detected through+`desktop_find_window`, title parsed incl. the ADDevelop shape), `/verbs`, `/command` for status,+eval, run_command, open_document and the 11 Phase-1 introspection verbs — speaking the existing+agent's file-IPC protocol unchanged. Build: cross-compile from the container (`rustup` + target+`x86_64-pc-windows-gnu` + `mingw-w64` + host `gcc` for build scripts; linker pinned in+`.cargo/config.toml`); the box has cargo but no MSVC linker. Test manually on a spare port until+ab ships `spawn.kind: "exe"` (ab#987). Kill the running exe BEFORE `send_files` or the copy+silently fails (file lock).++**ab direct-API envelope (bit us):** every `/command` reply is `{error, output, status, success}`+with the verb's real result JSON-ENCODED AS A STRING in `output`. Unwrap it (`ab::unwrap_output`).+`desktop_find_window` returns `windows[]` + `best`, not `matches`.++**Latency:** each ab round trip is ~400-800 ms; never gate every verb on a live ab call — cache+licence/window state (5 s TTL) so the per-verb cost is just the agent round trip.++## Never let a script modal reach the user's screen (hard rule)++Altium's script engine keeps counting a **halted** script as *executing*. In that state:++- `altium_agent_status` → `alive: false` (the heartbeat file is gone), and+- `altium_check_dialogs` → `0`++…so both gates look clean while the engine is still occupied. Firing `altium_agent_start`+(or any `X2 -R` lane) then raises **"Another script executing now"** on the user's actual screen.+There is no read-only way to ask whether the engine is free.++**Rule:** an `X2 -R` lane is a one-shot, not a retry loop. Fire it only when the previous lane is+known to have exited, or after the user says Altium was restarted. If a start fails once: clear the+dialog, tell the user, and keep working **offline** (template edits, Rust bridge, docs) until the+engine is known-free. Two failed starts in a row means stop, not try harder.++## Two file-write laws inside the script engine (measured 2026-08-31)++1. **`Break` inside a `Try..Except` does not break.** This engine implements `Break` as an exception,+   the `Except` swallows it, and the loop runs to completion. A `For K := 1 To 20 … Try … Break;+   Except Sleep(10); End` retry loop therefore always runs 20 iterations and sleeps 200 ms —+   this was the mysterious ~300 ms per file write, not disk latency. Use a flag:+   `Done := False; While (Not Done) And (K < 20) Do … Done := True;`+2. **`hb.txt` must be written tmp+rename; responses may be written directly.** Readers hold the+   heartbeat's final path open, so `SaveToFile` straight onto `hb.txt` raises `EFCreateError`+   ("used by another process") and halts the engine with a modal. `response_<id>.json` is a fresh+   unique name nobody has open → a direct write is safe and measures **0 ms**.++In-engine writes to the workspace, `%TEMP%` and `Documents` are all **0 ms** (new, overwrite and+delete), `RenameFile` 0 ms, `Sleep(1)` ≈ 15 ms, 1000 empty loop iterations 0 ms. Round-trip floor is+the agent's poll interval, not I/O.++**Confirmed end to end (0.1.12, live):** ping round trip **31–47 ms** (avg 42) where it was+450–600; through the Python bridge on a real board — list_designators 44 ms, list_nets 57 ms,+read_rules 68 ms, layers 47 ms, copper_layers 67 ms, component_data 87 ms. Read `agentPhases` in any+response to confirm: `hbSaveTries: 1, hbSaveExcept: 0, directTries: 1, directExcept: 0` is healthy;+tries climbing means real file contention, not the swallowed-Break bug returning.++Diagnostic: the agent command **`io_bench`** (agent-side only, no bridge verb) times SaveToFile+new/overwrite/delete against three directories plus rename/Sleep/loop cost. Drop a request file with+`command=io_bench` when a latency regression shows up — it is what localized this one.++## The Rust bridge is unblocked: `spawn.kind: "exe"` (ab, on #987)++ab already ships the compiled-binary runtime kind — spelled **`exe`**, not `binary`. ab spawns the+entrypoint directly with `--port <n>`, same env + supervision contract as node/python bridges;+`bridge_readiness` reports `runtime: "none"`. Integrity is the manifest's sha256 over the **zip**+(no spawn-time re-hash by design). Manifests are single-zip, so a compiled bridge is per-OS —+fine for Windows-only Altium. Set `killImageName` to the exe basename.++Fork target: `adom/hello-rust-bridge` v1.3.1 — the full contract in ~290 lines, including the+`describe` verb (ab's Verbs tab renders it verbatim) and the `uninstall` cascade verb.++## Board reads: the shape the Rust bridge is built on (2026-09-01)++`read_board` (agent) returns components + nets + layers + copper + rules in ONE round trip. It is+composed in DelphiScript from the EXISTING per-verb readers — deliberately: no new Altium API+surface means it cannot introduce an undeclared identifier, which is the one mistake that halts the+engine behind a modal on the user's screen.++The bridge then derives every view (`src/board.rs`). Two rules that came out of doing it:++0. **Name verbs for the caller's intent: `read_*` out, `write_*` / `apply_*` back.** The first draft+   of this shipped as `dump_board`; John rejected it — "dump" names the implementation (a blob out of+   memory), not the intent, and it has no counterpart, so it breaks the moment writes arrive.+1. **Record the RAW agent payload as a fixture, not the bridge's own response.** `altium_read_board {raw: true}` returns the unshaped payload; that is what `fixtures/read_board.json` holds, so the+   tests exercise the shaping rather than re-reading its output.+2. **Never invent a label for an ordinal.** Altium emits `TRuleKind` as a number. The mapping in+   `rule_kind_name()` was READ OFF a live default rule set (every default rule is named after its+   own kind — 31 ordinals, with the five `Fanout_*` defaults sharing ordinal 49, which is why+   grouping by ordinal beats grouping by name). Unknown ordinals render as `kind <n>`, and a test+   fails loudly rather than silently mislabelling.++Known gap: the agent enumerates every copper layer Altium CAN address (48: top + 30 mid + 16 planes++ bottom), not the stack in use — there is no "in stack" flag in the read yet, so the summary calls+it `copperLayersAddressable`. Do NOT report that number to a user as the board's layer count.
page.json+1−1
@@ -141,4 +141,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}
scripts/release.py+221−44
@@ -1,18 +1,36 @@ #!/usr/bin/env python3-"""Build the RUNTIME release zip + manifest for altium-bridge (SDK payload rules:-runtime-only, bridge.json at the zip ROOT, no media/docs).+"""Build + publish altium-bridge on the TWO-TIER release model (adom/altium-bridge#9;+pattern: adom/insiders skills + adom/pup-bridge tools/ship.sh, which did it first). -The runtime is the RUST bridge (bridge-rs → altium-bridge.exe, spawn.kind "exe"): the zip carries-the exe, the DelphiScript agent template (the in-process lane — that one stays Pascal by nature),-the taskbar badge asset and the AdomBridge extension payload. No Python ships.+  python3 scripts/release.py                       # build + lint only (dist/ artifact)+  python3 scripts/release.py --publish "<log>"     # -> INSIDERS tier (the default tier):+                                                   #    release zip on THIS page, manifest to+                                                   #    adom/insiders altium-bridge/version.json,+                                                   #    release-log entry, source stamps pushed,+                                                   #    skills pkg published in lockstep.+                                                   #    The PUBLIC manifest is NEVER touched here.+  python3 scripts/release.py --promote <ver> --attest "<human's exact words>" --by "<name>"+                                                   # -> PUBLIC tier. REFUSES without the words;+                                                   #    they are recorded in the manifest+                                                   #    (promotion_attestation/promoted_by/_at).+  python3 scripts/release.py --rollback <ver> --attest "<words>" --by "<name>"+                                                   # re-point PUBLIC at an older archived build. -  python3 scripts/release.py            # -> dist/adom-bridge-altium-v<ver>.zip + dist/adom-bridge-altium-manifest.json-  python3 scripts/release.py --publish  # + adom-wiki release create/upload + POST manifest to the page+Rules (insiders-tier-publish, verbatim where it matters): every publish is insiders; public only+on a human's words; an AI never promotes on its own judgment; one plain semver per build in both+tiers; every insiders publish appends its release-log entry before it is called done. -Versions live in ONE place: src/bridge.json (package.json and bridge-rs/src/main.rs BRIDGE_VERSION-are checked against it, so a half-bumped release refuses to build).+Versions live in ONE place: src/bridge.json (BRIDGE_VERSION, main.rs, package.json are checked). """-import hashlib, json, os, re, subprocess, sys, zipfile+import datetime+import hashlib+import json+import os+import re+import shutil+import subprocess+import sys+import zipfile from pathlib import Path  ROOT = Path(__file__).resolve().parent.parent@@ -21,8 +39,14 @@ RS = ROOT / "bridge-rs" TARGET = "x86_64-pc-windows-gnu" EXE = RS / "target" / TARGET / "release" / "altium-bridge.exe" DIST = ROOT / "dist"+ARCHIVE = ROOT / "scripts" / "releases" PAGE = "adom/altium-bridge" PAGE_SLUG = "altium-bridge"+INSIDERS_PAGE = "adom/insiders"+INSIDERS_REL = "altium-bridge/version.json"+INSIDERS_LOG = "altium-bridge/README.md"+INSIDERS_URL = f"https://wiki.adom.inc/api/v1/pages/insiders/files/{INSIDERS_REL}"+PUBLIC_MANIFEST = ROOT / "adom-bridge-altium-manifest.json"  # (zip path, source path). bridge.json at the ROOT; everything else beside the exe exactly where # the exe looks for it (agent/, resources/, extension/ relative to current_exe).@@ -42,12 +66,17 @@ INCLUDE = [ ]  +def run(*a, **kw):+    return subprocess.run(a, check=True, **kw)+++def now_iso():+    return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")++ def lint_verbs():-    """bridge.json's verbs[] is a PROMISE ab routes on. Drift breaks it in both directions:-    a declared verb with no handler answers "Unknown command" after ab has already routed it, and a-    working handler that is not declared is unreachable. Both were live on 2026-09-01 (3 phantom-    verbs, 3 unreachable ones), so the release refuses rather than shipping it again.-    The dispatch of record is bridge-rs/src/verbs.rs (KNOWN)."""+    """bridge.json's verbs[] is a PROMISE ab routes on; bridge-rs/src/verbs.rs KNOWN is the+    dispatch of record. Drift shipped live bugs on 2026-09-01, so the release refuses instead."""     rs = (RS / "src" / "verbs.rs").read_text(encoding="utf-8", errors="replace")     m = re.search(r"pub const KNOWN: &\[&str\] = &\[(.*?)\];", rs, re.S)     if not m:@@ -58,13 +87,13 @@ def lint_verbs():     if phantom or unreachable:         raise SystemExit(             "bridge.json does not match bridge-rs/src/verbs.rs\n"-            f"  declared but NOT dispatchable (ab routes them, the bridge 404s): {phantom}\n"-            f"  dispatchable but NOT declared (unreachable through ab):          {unreachable}")+            f"  declared but NOT dispatchable: {phantom}\n"+            f"  dispatchable but NOT declared: {unreachable}")     return len(declared)   def lint_versions(ver):-    """One version, three stamps — refuse a half-bumped release."""+    """One version, three stamps beside bridge.json — refuse a half-bumped release."""     stamps = {         "src/BRIDGE_VERSION": (SRC / "BRIDGE_VERSION").read_text().strip(),         "package.json": json.loads((ROOT / "package.json").read_text())["version"],@@ -74,22 +103,19 @@ def lint_versions(ver):     bad = {k: v for k, v in stamps.items() if v != ver}     if bad:         raise SystemExit(f"version drift against bridge.json {ver}: {bad}")+    bj = json.loads((SRC / "bridge.json").read_text())+    if bj.get("insidersManifestUrl") != INSIDERS_URL:+        raise SystemExit(f"bridge.json insidersManifestUrl must be {INSIDERS_URL} (#9)")   def build_exe():-    """Cross-compile the Windows exe + run the test suite first (a red test never ships)."""     subprocess.run(["cargo", "test", "--quiet"], cwd=RS, check=True)     subprocess.run(["cargo", "build", "--release", "--target", TARGET], cwd=RS, check=True)     if not EXE.exists():         raise SystemExit(f"exe missing after build: {EXE}")  -def main():-    ver = json.loads((SRC / "bridge.json").read_text())["version"]-    lint_versions(ver)-    n = lint_verbs()-    print(f"verb lint ok: {n} verbs declared and dispatchable (rust)")-    build_exe()+def build_zip(ver):     DIST.mkdir(exist_ok=True)     zname = f"adom-bridge-altium-v{ver}.zip"     zpath = DIST / zname@@ -99,31 +125,182 @@ def main():                 sys.exit(f"missing {p}")             z.write(p, rel)     sha = hashlib.sha256(zpath.read_bytes()).hexdigest()-    manifest = {+    print(f"built {zpath} ({zpath.stat().st_size} bytes) sha256={sha[:12]}")+    return zname, zpath, sha+++def manifest_for(ver, zname, zpath, sha, tier):+    m = {         "name": "altium", "version": ver,         "url": f"https://wiki.adom.inc/download/{PAGE}/{ver}/{zname}",         "sha256": sha, "size": zpath.stat().st_size,         "verbPrefixes": ["altium_"], "healthEndpoint": "/health",         "updateManifestUrl": f"https://wiki.adom.inc/api/v1/pages/{PAGE_SLUG}/files/adom-bridge-altium-manifest.json",+        "insidersManifestUrl": INSIDERS_URL,+        "released_at": now_iso(),     }-    mpath = DIST / "adom-bridge-altium-manifest.json"-    mpath.write_text(json.dumps(manifest, indent=2))-    (ROOT / "adom-bridge-altium-manifest.json").write_text(json.dumps(manifest, indent=2))-    print(f"built {zpath} ({zpath.stat().st_size} bytes) sha256={sha[:12]}")-    with zipfile.ZipFile(zpath) as z:-        big = sorted(z.infolist(), key=lambda i: -i.file_size)[:5]-        for i in big:-            print(f"  {i.file_size:>8}  {i.filename}")-    if "--publish" in sys.argv:-        run = lambda *a: subprocess.run(a, check=True)-        run("adom-wiki", "release", "create", PAGE, ver, "--title", f"altium-bridge {ver}", "--changelog", "see CHANGELOG.md")-        run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "windows", "--arch", "x64")-        # `repo push` requires --files now; the version stamps travel with the manifest so the-        # page repo can never disagree with the release it serves-        run("adom-wiki", "repo", "push", PAGE, "-m", f"release {ver}: manifest + version stamps",-            "--files", "adom-bridge-altium-manifest.json", "CHANGELOG.md", "src/bridge.json",-            "src/BRIDGE_VERSION", "package.json")-        print("published")+    if tier == "insiders":+        m["tier"] = "insiders"+    return m+++def two_curl_split_check():+    """The tier's whole security story: the insiders manifest must be bearer-readable and+    anonymously invisible. Refuse to call the publish done otherwise."""+    import urllib.request+    anon_ok = False+    try:+        urllib.request.urlopen(INSIDERS_URL, timeout=15)+    except urllib.error.HTTPError as e:+        anon_ok = e.code in (401, 403, 404)+    if not anon_ok:+        raise SystemExit(f"TIER LEAK: {INSIDERS_URL} is anonymously readable (or unreachable in an unexpected way)")+    token = Path("/var/run/adom/api-key").read_text().strip()+    req = urllib.request.Request(INSIDERS_URL, headers={"Authorization": f"Bearer {token}"})+    with urllib.request.urlopen(req, timeout=15) as r:+        served = json.loads(r.read())+    print(f"   two-curl split ok: anonymous blocked, bearer serves {served.get('version')}")+    return served+++def ship_insiders(log_line):+    ver = json.loads((SRC / "bridge.json").read_text())["version"]+    lint_versions(ver)+    n = lint_verbs()+    print(f"verb lint ok: {n} verbs declared and dispatchable")+    build_exe()+    zname, zpath, sha = build_zip(ver)++    print(f"== release {ver} on {PAGE} (bytes live on the product page, both tiers) ==")+    subprocess.run(["adom-wiki", "release", "create", PAGE, ver, "--title", f"altium-bridge {ver}",+                    "--changelog", "see CHANGELOG.md"], check=False, capture_output=True)+    run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "windows", "--arch", "x64")++    print(f"== insiders manifest + release log -> {INSIDERS_PAGE} ==")+    ins = manifest_for(ver, zname, zpath, sha, "insiders")+    work = DIST / "insiders-repo"+    (work / "altium-bridge").mkdir(parents=True, exist_ok=True)+    (work / INSIDERS_REL).write_text(json.dumps(ins, indent=2) + "\n")+    ARCHIVE.mkdir(exist_ok=True)+    (ARCHIVE / f"{ver}.json").write_text(json.dumps(ins, indent=2) + "\n")++    log_path = work / INSIDERS_LOG+    today = datetime.date.today().isoformat()+    entry = f"- **{ver}** insiders, {today}. {log_line} Not promoted.\n"+    if log_path.exists():+        s = log_path.read_text()+    else:+        s = (+            "# altium-bridge on the insiders tier\n\n"+            "**Publishing thread:** the altium-bridge thread owns this file. Every insiders publish "+            "(`scripts/release.py --publish` in the altium-bridge repo) appends an entry to the release log "+            "below before the publish is called done; a promote (`--promote`, which refuses without a human's "+            "exact words) edits that version's entry with the date and the words. Newest first.\n\n"+            "| | Insiders | Public |\n|---|---|---|\n"+            f"| Manifest | [altium-bridge/version.json](https://wiki.adom.inc/adom/insiders/files/altium-bridge/version.json) | [adom-bridge-altium-manifest.json](https://wiki.adom.inc/adom/altium-bridge/files/adom-bridge-altium-manifest.json) |\n"+            "| Bridge zip | [adom/altium-bridge releases](https://wiki.adom.inc/adom/altium-bridge/releases) (unlisted until promoted) | same files, same URLs |\n"+            "| Skills pkg | `pkg install adom/altium-bridge` follows the newest row until the registry insiders dist-tag lands (adom/wiki#168) | same |\n\n"+            "The bridge declares `insidersManifestUrl` in `src/bridge.json`; a desktop on ab 2.0.99+ whose "+            "login can read this page follows the insiders manifest, everyone else follows the public one "+            "and never sees this file.\n\n## Release log\n\n"+        )+    if f"- **{ver}**" not in s:+        s = re.sub(r"(## Release log\n\n)", r"\1" + entry.replace("\\", "\\\\"), s, count=1)+    log_path.write_text(s)+    run("adom-wiki", "repo", "push", INSIDERS_PAGE, "--files", INSIDERS_REL, INSIDERS_LOG,+        "-m", f"altium-bridge {ver} (insiders): {log_line}", cwd=work)++    print("== source stamps to the product page (public manifest NOT touched) ==")+    run("adom-wiki", "repo", "push", PAGE, "-m", f"v{ver} (insiders): {log_line}",+        "--files", "CHANGELOG.md", "src/bridge.json", "src/BRIDGE_VERSION", "package.json",+        cwd=ROOT)++    print("== skills pkg in lockstep (release rows share the version namespace, adom/wiki#170) ==")+    run("adom-wiki", "pkg", "publish", "--org", "adom", cwd=ROOT)++    print("== verify ==")+    served = two_curl_split_check()+    if served.get("version") != ver:+        raise SystemExit(f"insiders manifest serves {served.get('version')}, expected {ver}")+    pub = json.loads(PUBLIC_MANIFEST.read_text())+    print(f"SHIPPED {ver} to INSIDERS; public tier still serves {pub['version']}.")+    print(f'To promote: python3 scripts/release.py --promote {ver} --attest "<the human\'s exact words>" --by "<name>"')+++def attestation_gate(words, by):+    a = re.sub(r"\s+", " ", words or "").strip()+    if len(a) < 8 or re.fullmatch(r"(?i)(ok|yes|promote|ship it|go|approved|test|placeholder|x+)", a):+        raise SystemExit('REFUSED: the PUBLIC tier moves only on the human\'s exact words (>= 8 chars, not a placeholder). An AI never promotes on its own judgment.')+    if not by:+        raise SystemExit('REFUSED: say who promoted (--by "<name>").')+    return a+++def promote(ver, words, by, rollback=False):+    a = attestation_gate(words, by)+    arch = ARCHIVE / f"{ver}.json"+    if not arch.exists():+        raise SystemExit(f"no archived insiders manifest for {ver} ({arch}) - ship it first")+    ins = json.loads(arch.read_text())+    import urllib.request+    with urllib.request.urlopen(ins["url"], timeout=120) as r:+        remote_sha = hashlib.sha256(r.read()).hexdigest()+    if remote_sha != ins["sha256"]:+        raise SystemExit(f"REMOTE ASSET MISMATCH: {ins['url']} serves {remote_sha}, record says {ins['sha256']}")+    print(f"   asset verified: {ins['url']} sha={remote_sha[:12]}")+    m = dict(ins)+    m.pop("tier", None)+    now = now_iso()+    if rollback:+        m.update({"rollback_attestation": a, "rolled_back_by": by, "rolled_back_at": now})+    else:+        m.update({"promotion_attestation": a, "promoted_by": by, "promoted_at": now,+                  "insiders_released_at": ins.get("released_at")})+    PUBLIC_MANIFEST.write_text(json.dumps(m, indent=2) + "\n")+    verb = "rolled back to" if rollback else "promoted to PUBLIC"+    run("adom-wiki", "repo", "push", PAGE, "--files", "adom-bridge-altium-manifest.json",+        "-m", f"v{ver}: {verb} - {by}: {a}", cwd=ROOT)+    # mark the release-log entry+    work = DIST / "insiders-repo"+    log_path = work / INSIDERS_LOG+    if log_path.exists() and not rollback:+        s = log_path.read_text()+        line = f"- **{ver}** insiders,"+        if line in s and "Promoted to public" not in s.split(line)[1].split("\n")[0]:+            s = s.replace(" Not promoted.\n", f' Promoted to public on {datetime.date.today().isoformat()} by {by}: "{a}".\n', 1)+            log_path.write_text(s)+            run("adom-wiki", "repo", "push", INSIDERS_PAGE, "--files", INSIDERS_LOG,+                "-m", f"altium-bridge {ver}: promoted to public - {by}", cwd=work)+    print(f"PUBLIC tier now serves {ver} ({'rollback' if rollback else 'promote'}), attested by {by}.")+++def main():+    args = sys.argv[1:]+    if "--promote" in args:+        i = args.index("--promote")+        ver = args[i + 1]+        words = args[args.index("--attest") + 1] if "--attest" in args else ""+        by = args[args.index("--by") + 1] if "--by" in args else ""+        promote(ver, words, by)+    elif "--rollback" in args:+        i = args.index("--rollback")+        ver = args[i + 1]+        words = args[args.index("--attest") + 1] if "--attest" in args else ""+        by = args[args.index("--by") + 1] if "--by" in args else ""+        promote(ver, words, by, rollback=True)+    elif "--publish" in args:+        i = args.index("--publish")+        log_line = args[i + 1] if len(args) > i + 1 and not args[i + 1].startswith("--") else ""+        if not log_line:+            raise SystemExit('usage: release.py --publish "<one changelog sentence for the release log>"')+        ship_insiders(log_line)+    else:+        ver = json.loads((SRC / "bridge.json").read_text())["version"]+        lint_versions(ver)+        n = lint_verbs()+        print(f"verb lint ok: {n} verbs")+        build_exe()+        build_zip(ver)   if __name__ == "__main__":