app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
0.2.10: move altium-bridge-dev to dev-skills/ (dev skills never ship in the install tarball), refresh page.json version
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" ]-}+}
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__":