← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.11+0.9.12
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.11"+version = "0.9.12" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.11"+version = "0.9.12" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+15−1
@@ -460,7 +460,21 @@ not.  ## Roadmap -- v0.9.11 (this): **a session cannot be starting for hours.** Measured LIVE on+- v0.9.12 (this): **an assembly session closes completely.** 0.9.11 was proven+  LIVE on 2026-10-02 on Drew's 34-document crate session (lock-out fixed; the+  presence guard refused the sweep until Drew's own words overrode it; Part1+  preserved, 33 closed in place untouched; quit 3.5 s) — and the sweep could not+  close it unaided: parts listed first were "still open after CloseDoc" behind+  their assembly. The sweep now preserves everything first, closes in a global+  order (assemblies, the active one first, before parts), defers what is held+  behind an open assembly and counts what went out with its parent. Sweeping+  verbs declare ab budgets longer than the sweep (ab had cut a reply at 120 s),+  `swx_progress` shows a sweep's stage and counts, a task dialog whose words+  cannot be read says so, a relaunch waiting on the user's sign-in says+  `waitingOn:"userSignIn"` (the launch gate exits 4), and `tools/release.sh+  --publish` is the whole wiki sync in one command, verified end to end.+  510 tests.+- v0.9.11: **a session cannot be starting for hours.** Measured LIVE on   2026-10-01: Drew's three-hour-old session with 34 documents open was locked   out of every gated verb after an update respawned the bridge — the log's   `"UNLOAD_ADDIN" "SWK3DCompassAddin.dll"` line contains `LOAD_ADDIN` and was
bridge.json+7−3
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.11",+  "version": "0.9.12",   "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",@@ -113,7 +113,7 @@       "set_parameter": 420,       "activate_document": 180,       "close_all_documents": 240,-      "reset_workspace": 420,+      "reset_workspace": 600,       "show_part": 600,       "show_assembly": 600,       "show_drawing": 600,@@ -141,7 +141,11 @@       "action_ledger": 20,       "verb_times": 20,       "set_driving_badge": 120,-      "close_doc": 180+      "close_doc": 180,+      "close_all_safely": 600,+      "shutdown": 900,+      "close_import_temps": 600,+      "stop": 420     }   },   "detect": {
docs/ECOSYSTEM-TABLE.md+1−1
@@ -118,7 +118,7 @@ templates**, alisamsam **22**. | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 47 | **Interconnect-aware import: refuses honestly, with the reason and the setting** | ✗ | **✓ MEASURED + shipped 0.8.2** — on SOLIDWORKS Connected 2026 with `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic neutral-file import route at all: LoadFile4 returns nothing (`errors=1`) both with `Nothing` and with a real `GetImportFileData` object, LoadFile2 returns false, OpenDoc6 answers `swFileRequiresRepairError` (2097152) across four option values and `swInvalidFileTypeError` (1024) for docType 0. So `swx_import` makes ONE LoadFile4 attempt (a future edition that accepts it simply works) and then fails with `interconnect_blocks_import`, naming the exact setting — Tools > Options > System Options > Import > Enable 3D Interconnect — the measurement date, and the fact that **this bridge does not change user settings, not even temporarily**. No MCP server reads that toggle at all: they report a translator failure, which sends the reader hunting a missing translator that is not the problem | ✗ | ✗ | ✗ | ✗ | ✗ | | 46 | **3D Interconnect is understood, obeyed and reported** (the user's own setting decides how a neutral file comes in; the bridge reads it and never writes it) | ✗ | **✓ LIVE-MEASURED 2026-09-17, routed in 0.8.0** — `GetUserPreferenceToggle(691)` = TRUE on the install, so a STEP is not translated at all: `LoadFile4` executes and returns nothing with `errors=1`, and SOLIDWORKS instead OPENS the file with `OpenDoc6(path, swDocPART, …)` as a part holding a LINKED 3D Interconnect feature. `swx_import` reads the toggle, routes accordingly, and reports `interconnect` / `linked` / `sourcePath` so a caller knows the part is not self-contained. The preference is NEVER written — not even flip-and-restore, because a crash between the halves would leave the user's CAD configured differently than they left it — and `dissolve:true` is REFUSED until the link-breaking call is measured on this edition. Import-diagnostics auto-run (toggle 291) is also TRUE, so the modal dialog it raises is classified, reported as `stoppedByDialog`, and never answered. Toggle ids read from the installed `SolidWorks.Interop.swconst.dll` | ✗ | ✗ | ✗ | ✗ | ✗ |-| 45 | **Closes its own documents without asking — and loses nothing doing it** (preserve-then-close: every open document written to a recovery folder and verified there BEFORE anything closes) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (0.7.7)** — `swx_shutdown {reason}` / `swx_close_all_safely {reason}` save every open document into `%LOCALAPPDATA%\adom-solidworks-bridge\recovery\<timestamp>\` (0.9.0: `Adom Bridge\solidworks-data\…`, inside the directory ab's installer owns; before that `…\bridges-cache\solidworks\recovery\…`, inside ab's install cache, which an update empties: row 49), verify it, close parents before children, then quit; no write opt-in, no per-document confirmation (Drew, 2026-09-11: *"I despise every time you ask for help closing"*). The authorization is to CLOSE, not to lose anything, so the consent question is replaced by checkable rules: the sweep REFUSES TO START when the recovery volume cannot hold the session it is about to write (`insufficient_headroom` with free/needed/shortfall, nothing written, nothing closed), re-checked before every save, with `headroomBytesBefore` and `estimatedWriteBytes` always reported — because a recovery file can be the RIGHT document CUT SHORT and no check on the file can tell (the marker is at offset 4, so every prefix carries it); the recovery file is verified STRUCTURALLY — it must be a recognised SOLIDWORKS container AND at least as big as the smallest real document of its KIND (part 24 KB, assembly 80 KB, drawing 256 KB — one floor for all three would have to be the lowest of them, letting an assembly stop at a third of its size), with the KIND carried from the COM read to the verdict and NEVER re-derived from the recovery file's name, because that name is chosen from the kind and so cannot check it; an unreadable document type falls back to the document's OWN PATH and then its title (evidence about the document, not names this sweep chose) and only then to UNKNOWN, which takes a neutral `.sldrecover` name and the strictest floor — and if SOLIDWORKS refuses to write that name the file is re-saved as `.SLDPRT` with the fallback RECORDED and the unknown-kind verification unchanged; `swx_recover_list` re-runs the verdict on every file it lists and reports `verified` with the reason — a file the sweep refused is listed (never deleted: those bytes are the only ones there are) but never called a rescue, never offered as openable, and its entry says the document was left OPEN — and gives every genuine rescue its own `howToOpen`, because SOLIDWORKS will not open a `.sldrecover` file until it is renamed, published as `recoverySignatureOk` + `recoveryBytes`, and it fails CLOSED (`exists()` passes a zero-byte file and any byte floor passes a truncated one; both are what a full disk leaves). The container marker was MEASURED on 166 real documents from three hardware repositories, because the signature every reference names for a SOLIDWORKS document (OLE compound file) appears in NONE of them — verifying that, as pass 23 specified, would have refused to close every document on a modern install; **FIRST AUTONOMOUS CLOSE, live 2026-09-17**: `swx_close_all_safely {userAuthorized, batch:1}` on a bridge-launched session took 4 documents (3 same-titled pathless import temps + a throwaway part) to `recovery\20260917-150646\` under unique names (25,834 / 26,031 / 25,773 / 26,047 bytes, `recoverySignatureOk:true`, `namingFallback:null`), closed each BY PATH, count 4 → 0, `done:true`, `stoppedBy:null`, headroom 1.59 TB — nobody was asked to close anything. A document that could not be written is left OPEN and named in `remaining`; the user's saved-then-edited file is copied to recovery under a new name and their original is asserted byte-for-byte unchanged; `SaveAs3` uses `swSaveAsOptions_Silent` ALONE so the document RE-POINTS at the recovery file and the close key is read from the document after the save; a dialog stops the sweep and is never answered, with the census taken before EVERY save and EVERY close (a save can raise a prompt of its own, which a per-batch census could not see until the closes had happened); and an UNREADABLE window census is its own outcome — saves proceed, nothing closes, `stoppedBy:"dialogCensusUnknown"`. Ordering is proven from a journal, not from the end state. Every rule is mutation-verified (disable it and exactly one test fails). **Ownership measured live 2026-09-30, both halves**: a pathless `Part2` preserved (25,959 bytes, verified, listed from the manifest) and Drew's saved `R_0603_1608Metric_1.step-1.sldprt` closed with its original untouched (same 32,311 bytes, same mtime). Since 0.9.0 such a document is RECORDED as `closedInPlace` with its original's size and mtime, and `swx_recover_list` says "closed at its own path, untouched" — or that it has changed since, or is gone — by measuring the file, not by assuming it | ✗ | ✗ | ✗ | ✗ | ✗ |+| 45 | **Closes its own documents without asking — and loses nothing doing it** (preserve-then-close: every open document written to a recovery folder and verified there BEFORE anything closes) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (0.7.7)** — `swx_shutdown {reason}` / `swx_close_all_safely {reason}` save every open document into `%LOCALAPPDATA%\adom-solidworks-bridge\recovery\<timestamp>\` (0.9.0: `Adom Bridge\solidworks-data\…`, inside the directory ab's installer owns; before that `…\bridges-cache\solidworks\recovery\…`, inside ab's install cache, which an update empties: row 49), verify it, close parents before children, then quit; no write opt-in, no per-document confirmation (Drew, 2026-09-11: *"I despise every time you ask for help closing"*). The authorization is to CLOSE, not to lose anything, so the consent question is replaced by checkable rules: the sweep REFUSES TO START when the recovery volume cannot hold the session it is about to write (`insufficient_headroom` with free/needed/shortfall, nothing written, nothing closed), re-checked before every save, with `headroomBytesBefore` and `estimatedWriteBytes` always reported — because a recovery file can be the RIGHT document CUT SHORT and no check on the file can tell (the marker is at offset 4, so every prefix carries it); the recovery file is verified STRUCTURALLY — it must be a recognised SOLIDWORKS container AND at least as big as the smallest real document of its KIND (part 24 KB, assembly 80 KB, drawing 256 KB — one floor for all three would have to be the lowest of them, letting an assembly stop at a third of its size), with the KIND carried from the COM read to the verdict and NEVER re-derived from the recovery file's name, because that name is chosen from the kind and so cannot check it; an unreadable document type falls back to the document's OWN PATH and then its title (evidence about the document, not names this sweep chose) and only then to UNKNOWN, which takes a neutral `.sldrecover` name and the strictest floor — and if SOLIDWORKS refuses to write that name the file is re-saved as `.SLDPRT` with the fallback RECORDED and the unknown-kind verification unchanged; `swx_recover_list` re-runs the verdict on every file it lists and reports `verified` with the reason — a file the sweep refused is listed (never deleted: those bytes are the only ones there are) but never called a rescue, never offered as openable, and its entry says the document was left OPEN — and gives every genuine rescue its own `howToOpen`, because SOLIDWORKS will not open a `.sldrecover` file until it is renamed, published as `recoverySignatureOk` + `recoveryBytes`, and it fails CLOSED (`exists()` passes a zero-byte file and any byte floor passes a truncated one; both are what a full disk leaves). The container marker was MEASURED on 166 real documents from three hardware repositories, because the signature every reference names for a SOLIDWORKS document (OLE compound file) appears in NONE of them — verifying that, as pass 23 specified, would have refused to close every document on a modern install; **FIRST AUTONOMOUS CLOSE, live 2026-09-17**: `swx_close_all_safely {userAuthorized, batch:1}` on a bridge-launched session took 4 documents (3 same-titled pathless import temps + a throwaway part) to `recovery\20260917-150646\` under unique names (25,834 / 26,031 / 25,773 / 26,047 bytes, `recoverySignatureOk:true`, `namingFallback:null`), closed each BY PATH, count 4 → 0, `done:true`, `stoppedBy:null`, headroom 1.59 TB — nobody was asked to close anything. A document that could not be written is left OPEN and named in `remaining`; the user's saved-then-edited file is copied to recovery under a new name and their original is asserted byte-for-byte unchanged; `SaveAs3` uses `swSaveAsOptions_Silent` ALONE so the document RE-POINTS at the recovery file and the close key is read from the document after the save; a dialog stops the sweep and is never answered, with the census taken before EVERY save and EVERY close (a save can raise a prompt of its own, which a per-batch census could not see until the closes had happened); and an UNREADABLE window census is its own outcome — saves proceed, nothing closes, `stoppedBy:"dialogCensusUnknown"`. Ordering is proven from a journal, not from the end state. Every rule is mutation-verified (disable it and exactly one test fails). **Ownership measured live 2026-09-30, both halves**: a pathless `Part2` preserved (25,959 bytes, verified, listed from the manifest) and Drew's saved `R_0603_1608Metric_1.step-1.sldprt` closed with its original untouched (same 32,311 bytes, same mtime). Since 0.9.0 such a document is RECORDED as `closedInPlace` with its original's size and mtime, and `swx_recover_list` says "closed at its own path, untouched" — or that it has changed since, or is gone — by measuring the file, not by assuming it **0.9.12, from a live 34-document crate session**: an assembly session closes completely — everything preserved first, assemblies (active first) before parts, held components deferred and counted closed when they go out with their parent | ✗ | ✗ | ✗ | ✗ | ✗ |  *(51 numbered rows; rows 29–31 are the N/A block that accounts for most of the Fusion verb-count gap. Rows 36–42 added 2026-09-08 from the crate build and the launch-resilience rounds; rows 43–45 from the import-leak, quit and safe-close work, and 46–49 from the live runs of 2026-09-17 and 2026-09-30 — the bar is "significantly more powerful than the MCP servers", so every robustness capability gets a row with its evidence level, not a slogan.)* 
docs/LAUNCH-RESILIENCE.md+10
@@ -1187,6 +1187,16 @@ platform add-in loading after the marker — "a start". | L4 — swx_user_presence is exempt | It answers during a start or a wedge: foreground and last input from the process census; documents asked of COM only on a verified-ready session, otherwise `documentsUnknown:true`, which counts as present. The exemption list is 17 verbs | | L5 — the latch survives a respawn | A session seen ready is persisted in the data root (`ready-sessions.json`, by pid + creation time); a respawned bridge reads it (`readyBeforeThisBridgeRestarted`) | +**LIVE 2026-10-02 on 0.9.11 — the relaunch waited on the user's sign-in.** After+the quit, the relaunch sat at `stage:"helperSignIn"`: window class+`DSYSysLoginPanelWebView2Class`, title "Login | 3DEXPERIENCE ID | Dassault+Systèmes", owned by SWXDesktopLauncher — handled correctly (nothing killed,+deadlines paused), but `tools/launch-gate.py` reported five FAILs ("COM attached+never"). **0.9.12:** `swx_start` and `swx_status` carry `waitingOn:"userSignIn"`+and `signInWindow` (the title) while the window is up, and the launch gate exits+**4 — BLOCKED ON THE USER'S SIGN-IN**, naming the window, instead of failing+claims nobody could measure.+ ## Live proof 4 — 0.9.3 on Drew's machine (2026-10-01)  | | Measured |
docs/QUIT-INVESTIGATION.md+47
@@ -2191,3 +2191,50 @@ declined rung and the prime — the prime is refused, the decline stands). The adversary's process notes are kept in `tests/adversary44.rs`: the simulator adds the user's document only when app state is read, and `swx_user_presence` itself marks a session taken over, so tests must not call it as a probe.++### 5.25 LIVE 2026-10-02 on 0.9.11 — the guard proven on Drew's crate session; 0.9.12 fixes what failed on the way++* **Proven live** (pid 26924, 34 documents, ~3 h old): the lock-out is fixed —+  ready, `performanceLog.verdict:"onDemandAddInAfterComplete"`. The presence+  guard held: `swx_close_all_safely` without the override → `user_is_active`+  (`takenOverEarlier`, 34 foreign documents, foreground Chrome, last input+  167 s). With the override and Drew's words as the reason, the sweep preserved+  the pathless Part1 (26,617 bytes, verified) and closed 33 documents in place,+  all 33 re-measured UNTOUCHED; `swx_stop` quit in 3.5 s.+* **S1 — the sweep could not close an assembly session (availability).** The+  open set listed 28 parts FIRST, then ASM-CRATE.SLDASM (top level) and four+  sub-assemblies. The default sweep and `batch:4` walked the first documents+  (parts); every close was "still open after CloseDoc" (a component stays+  loaded while its assembly is open); `closed_this_pass == 0` → the no-progress+  stop ended the sweep with 33 open, `success:true, done:false`. `batch:40` (one+  pass over everything) worked because the per-pass sort put the assemblies+  first. **0.9.12:** the whole session is preserved before anything closes (a+  closing assembly unloads its components, so each must be on disk first);+  closes run in a GLOBAL order — drawings, assemblies with the active document+  first, parts, unknown; a component or sub-assembly still open after CloseDoc+  while another assembly is open is DEFERRED (not a failure, not counted against+  `batch`); one that is no longer open when its turn comes — or a preserved+  document gone once the session is empty — is closed `closedByParent`; no+  progress means a pass that tried every preserved document closed none, and+  anything still deferred then is reported as a close failure. `batch` now+  paces the closes of a pass.+* **S2 — ab cut the reply at 120 s** ("did not respond within ab's 120s+  budget") while the sweep ran on: `swx_close_all_safely`, `swx_shutdown`,+  `swx_close_import_temps`, `swx_reset_workspace` and `swx_stop` had no declared+  budget. `bridge.json` `timeouts.verbs` now declares 600 / 900 / 600 / 600 /+  420 s; a sweep's own ceiling is 540 s (`SWEEP_MAX_SECS`), so its reply —+  `done:false` if it ran out — arrives first. `swx_progress` answered `{}`+  during that sweep (not reproduced here: the bridge serves requests on their+  own threads); it now carries the sweep's stage and counts in `detail`.+* **S3 — the License Manager, measured again.** Two QUEUED, identical notices —+  "Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional: Unable to+  connect to server. The session will end after the next 5 unsuccessful+  attempts." — OK only. Census record: `#32770`, owner NULL (`ownerEnabled`+  null → blocking: "whether its owner frame is enabled could not be read"),+  `text: []` with `contentsReadable: true` — false: its body is a+  `DirectUIHWND` task dialog, whose words are not in child controls. The+  coordinator acknowledged both by hand on Drew's "do whatever you need"; the+  bridge still never answers. **0.9.12:** no text and no buttons beside a+  `DirectUIHWND` child (or no children) is `contentsReadable:false`, `text`+  null, with `contentsUnreadableWhy`; it is judged as unread (blocking).+  Dialogs can QUEUE: closing one may reveal an identical one beneath it.
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.11",+  "version": "0.9.12",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip \u2014 a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
page.json+29−1
@@ -28,5 +28,33 @@   "hero": {     "type": "image",     "path": "screenshots/hero.png"-  }+  },+  "tags": [+    "solidworks",+    "mcad",+    "cad",+    "bom",+    "bridge",+    "ab",+    "adom-bridge",+    "desktop-app"+  ],+  "discovery_triggers": [+    "solidworks",+    "swx",+    "open my part in solidworks",+    "solidworks bom",+    "bill of materials from a solidworks assembly",+    "export step from solidworks",+    "solidworks mass properties",+    "interference check",+    "pack and go",+    "sldprt",+    "sldasm",+    "slddrw",+    "close solidworks",+    "quit solidworks",+    "recover my solidworks file",+    "solidworks recovery folder"+  ] }
references/closing-and-recovery.md+6
@@ -133,6 +133,12 @@ Read this before closing any document, quitting SOLIDWORKS, draining leaked impo   alone. `swx_user_presence` reads the   same measurement; `userPresentOverride:true` (with `userAuthorized:true` and   a reason) is only for the user's own explicit request.+- **Assemblies close first; components go with them.** The sweep preserves+  every document, then closes assemblies (the active one first) before parts; a+  component still held by an open assembly is deferred, and one that went out+  with its parent is listed in `closedByParent`. A long sweep answers inside+  ab's budget with `done:false` — poll `swx_progress` (its `detail` has the+  stage and counts) and call the sweep again. - **`swx_shutdown` is idempotent.** SOLIDWORKS not running answers `success:true,   alreadyStopped:true`. It is the last thing you do, and the last thing should be   safe to repeat.
references/launch-and-sign-in.md+3
@@ -204,3 +204,6 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE     `launch_process` — launching it DIRECTLY, bypassing the launcher chain, is     what produces the *"SOLIDWORKS Design must be launched from the     3DEXPERIENCE Platform"* modal.+- **`waitingOn:"userSignIn"`** on `swx_start` / `swx_status` means the launch is+  waiting for the user to sign in to 3DEXPERIENCE in the window `signInWindow`+  names. Only the user can answer it; tell them, and poll `swx_status`.
src/comhost/com.rs+11−1
@@ -1140,13 +1140,23 @@ impl ComBackend {                 }));             }         }+        // ALREADY GONE WITH ITS PARENT (0.9.12): closing an assembly unloads+        // the components it held; one of them is then no longer open, and that+        // is a close (after its own recovery write), reported as such.+        if Self::open_document_by_name(&sw, &path).is_none() {+            self.created.retain(|(_, p)| p.as_deref() != Some(path.as_str()));+            return Ok(json!({+                "phase": "close", "path": path, "closed": true, "alreadyClosed": true,+                "documentCount": Self::document_count(&sw), "why": Value::Null,+            }));+        }         let _ = sw.call("CloseDoc", &[&Var::bstr(&path)]);         let still_open = Self::open_document_by_name(&sw, &path).is_some();         if !still_open {             self.created.retain(|(_, p)| p.as_deref() != Some(path.as_str()));         }         Ok(json!({-            "phase": "close", "path": path, "closed": !still_open,+            "phase": "close", "path": path, "closed": !still_open, "alreadyClosed": false,             "documentCount": Self::document_count(&sw),             "why": if still_open { json!("still open after CloseDoc") } else { Value::Null },         }))
src/comhost/mock.rs+11−2
@@ -1534,7 +1534,8 @@ impl MockBackend {                     // ALREADY GONE WITH ITS PARENT: the assembly that held this                     // component closed it on the way out. That is a close, and it                     // happened after this document's own recovery write.-                    let closed = if self.find_by_name(&path).is_none() {+                    let already = self.find_by_name(&path).is_none();+                    let closed = if already {                         true                     } else {                         self.close_doc_by_name(&path) && self.find_by_name(&path).is_none()@@ -1542,7 +1543,7 @@ impl MockBackend {                     let after = mock_doc_count(self.docs.len());                     self.publish_session();                     return Ok(json!({-                        "phase": "close", "path": path, "closed": closed,+                        "phase": "close", "path": path, "closed": closed, "alreadyClosed": already,                         "documentCount": after,                         "why": if closed { Value::Null } else { json!("still open after CloseDoc") },                     }));@@ -1581,6 +1582,14 @@ impl MockBackend {                     return Err(e);                 }                 let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+                // A SLOW SESSION (0.9.12): each preserve step takes this long, so+                // a sweep runs long enough to be watched through swx_progress.+                if let Some(ms) = std::fs::read_to_string(crate::scratch::root().join("mock-preserve-delay-ms"))+                    .ok()+                    .and_then(|t| t.trim().parse::<u64>().ok())+                {+                    std::thread::sleep(std::time::Duration::from_millis(ms));+                }                 let _ = std::fs::create_dir_all(&recovery);                 // A TYPE THE BACKEND CANNOT READ. On the real backend this is                 // `GetType` failing on a document being closed under memory
src/jobs.rs+14−1
@@ -21,6 +21,9 @@ struct Entry {     step_label: String,     estimated_sec: u64,     started: Instant,+    /// What the verb has done so far, for `swx_progress` (0.9.12): a sweep's+    /// stage and its counts.+    detail: Value, }  #[derive(Default)]@@ -43,7 +46,7 @@ impl Jobs {     ) -> JobGuard {         let id = self.seq.fetch_add(1, Ordering::SeqCst) + 1;         let started = Instant::now();-        let entry = Entry { phase, step_label: step_label.into(), estimated_sec, started };+        let entry = Entry { phase, step_label: step_label.into(), estimated_sec, started, detail: Value::Null };         self.live.lock().unwrap_or_else(|e| e.into_inner()).push((id, entry));         JobGuard { jobs: Arc::clone(self), id, started }     }@@ -63,6 +66,7 @@ impl Jobs {                     "etaSec": round1((e.estimated_sec as f64 - elapsed).max(0.0)),                     "confidence": "estimate",                     "startKind": "warm",+                    "detail": e.detail,                 })             })             .collect()@@ -76,6 +80,15 @@ pub struct JobGuard { }  impl JobGuard {+    /// Say what the verb is doing now — shown by `swx_progress` while it runs.+    pub fn update(&self, step_label: impl Into<String>, detail: Value) {+        let mut live = self.jobs.live.lock().unwrap_or_else(|e| e.into_inner());+        if let Some((_, e)) = live.iter_mut().find(|(id, _)| *id == self.id) {+            e.step_label = step_label.into();+            e.detail = detail;+        }+    }+     /// The terminal frame that rides on the verb's own response.     pub fn finished(&self) -> Value {         let live = self.jobs.live.lock().unwrap_or_else(|e| e.into_inner());
src/launch.rs+12
@@ -725,6 +725,9 @@ pub struct Attempt {     /// changes what every timing in this record MEANS — a launch that took eight     /// minutes because somebody was typing a password is not a slow launch.     pub sign_in_seen: bool,+    /// THE SIGN-IN WINDOW ON SCREEN NOW, by title (0.9.12) — `None` when none is+    /// up. What `waitingOn:"userSignIn"` names.+    pub sign_in_window: Option<String>,     /// LAUNCHER PROCESSES THAT APPEARED AFTER OUR INVOKE AND CANNOT BE PROVED     /// EITHER WAY. An unattributable route (`explorer`, `cmdStart`) cannot say     /// what it started, so a launcher that shows up while one is running is@@ -817,6 +820,7 @@ impl Attempt {             "launcherLog": self.launcher_log,             "runBeginSeen": self.run_begin_seen,             "signInSeen": self.sign_in_seen,+            "signInWindow": self.sign_in_window,             // Launcher processes that appeared while an UNATTRIBUTABLE route was             // running: not proved ours, not proved anybody else's, and never             // terminated on either reading.@@ -957,6 +961,7 @@ pub fn load() -> LaunchStore {         launcher_log: a.get("launcherLog").and_then(|x| x.as_str()).map(String::from),         run_begin_seen: a.get("runBeginSeen").and_then(|x| x.as_bool()).unwrap_or(false),         sign_in_seen: a.get("signInSeen").and_then(|x| x.as_bool()).unwrap_or(false),+        sign_in_window: a.get("signInWindow").and_then(|x| x.as_str()).map(String::from),         possibly_ours: a             .get("possiblyOurs")             .and_then(|x| x.as_array())@@ -1319,6 +1324,7 @@ pub fn begin(             launcher_log: None,             run_begin_seen: false,             sign_in_seen: false,+            sign_in_window: None,             possibly_ours: Vec::new(),             chain_last_seen: Vec::new(),             attributed_sldworks: None,@@ -1381,6 +1387,7 @@ fn unfinished_placeholder(shortcut: &str) -> Attempt {         launcher_log: None,         run_begin_seen: false,         sign_in_seen: false,+        sign_in_window: None,         possibly_ours: Vec::new(),         chain_last_seen: Vec::new(),         attributed_sldworks: None,@@ -2124,6 +2131,10 @@ fn watch_route(channel: &Channel, os: &dyn LaunchOs, route: &str, w: &Watch) ->                 // is alive. Both pause.                 Some(Some((hwnd, title))) => {                     if sign_in_since.is_none() {+                        {+                            let t = title.clone();+                            update(move |a| a.sign_in_window = Some(t));+                        }                         sign_in_since = Some(std::time::Instant::now());                         sign_in_hwnd = Some(hwnd);                         set_stage_at_least(Stage::HelperSignIn);@@ -2154,6 +2165,7 @@ fn watch_route(channel: &Channel, os: &dyn LaunchOs, route: &str, w: &Watch) ->                 }                 Some(None) => {                     if let Some(t) = sign_in_since.take() {+                        update(|a| a.sign_in_window = None);                         signed_in_paused += t.elapsed();                         crate::ledger::record(                             "solidworks_launch_sign_in_done",
src/launchos.rs+12
@@ -349,6 +349,18 @@ impl DialogContents {     }     /// Does any child control have the Win32 class `Button` (a push button, a     /// checkbox or a radio — anything that is something to click)?+    /// THE BODY COULD NOT BE READ (0.9.12). MEASURED 2026-10-02: the License+    /// Manager notice's body is a `DirectUIHWND` task dialog — no child Statics+    /// or Buttons carry its words — so the child-control walk read nothing, and+    /// reporting that as "read, and it says nothing" was false. No text and no+    /// buttons beside a DirectUIHWND child (or no children at all) is UNREADABLE.+    pub fn body_unreadable(&self) -> bool {+        self.text.is_empty()+            && self.buttons.is_empty()+            && (self.controls.is_empty()+                || self.controls.iter().any(|(c, _)| c.eq_ignore_ascii_case("DirectUIHWND")))+    }+     pub fn has_button_class_child(&self) -> bool {         self.controls.iter().any(|(c, _)| c.eq_ignore_ascii_case("button"))     }
src/quit.rs+15−6
@@ -461,14 +461,16 @@ pub fn dialog_census(windows: &[WindowInfo], os: &dyn crate::launchos::LaunchOs)     let mut out = DialogCensus { blocking: None, notifications: Vec::new() };     for w in windows.iter().filter(|w| w.is_dialog() && w.visible) {         let contents = os.dialog_contents(w.hwnd).ok();-        let (mut blocking, mut kind, mut why) = judge_window(w, contents.as_ref());+        // A body that could not be read is judged as unread (0.9.12).+        let readable = contents.as_ref().filter(|c| !c.body_unreadable());+        let (mut blocking, mut kind, mut why) = judge_window(w, readable);         let mut owner_settle = None;         if blocking {-            if let Some((enabled_later, history)) = settle_owner(w, contents.as_ref(), os) {+            if let Some((enabled_later, history)) = settle_owner(w, readable, os) {                 if enabled_later {                     let mut assumed = w.clone();                     assumed.owner_enabled = Some(true);-                    let (b, k, y) = judge_window(&assumed, contents.as_ref());+                    let (b, k, y) = judge_window(&assumed, readable);                     blocking = b;                     kind = k;                     why = format!(@@ -510,6 +512,7 @@ impl BlockingDialog {         let c = self.contents.as_ref();         // A TEMPLATE'S CAPTIONS ARE NOT THE WINDOW'S WORDS (measured 2026-10-01).         let placeholder = c.map(text_is_template_placeholder).unwrap_or(false);+        let body_unreadable = c.map(|c| c.body_unreadable()).unwrap_or(false);         json!({             "hwnd": self.hwnd,             "title": self.title,@@ -527,9 +530,15 @@ impl BlockingDialog {             // unactionable: the window actually said the licence SERVER was             // unreachable and the session would end after five more failed             // attempts. That is a countdown somebody needs to see.-            "text": if placeholder { Value::Null } else { c.map(|c| json!(c.text)).unwrap_or(Value::Null) },-            "buttons": c.map(|c| json!(c.buttons)).unwrap_or(Value::Null),-            "contentsReadable": c.is_some(),+            "text": if placeholder || body_unreadable { Value::Null } else { c.map(|c| json!(c.text)).unwrap_or(Value::Null) },+            "buttons": if body_unreadable { Value::Null } else { c.map(|c| json!(c.buttons)).unwrap_or(Value::Null) },+            // HONEST (0.9.12): a DirectUIHWND task dialog's words are not in+            // child controls, so a walk that read none of them did not read it.+            "contentsReadable": c.is_some() && !body_unreadable,+            "contentsUnreadableWhy": if body_unreadable {+                json!("its body is a DirectUIHWND task dialog (or has no child controls): its words are not \+                       in child controls this bridge can read — take a screenshot to see them")+            } else if c.is_none() { json!("the controls could not be read") } else { Value::Null },             // Whether the only thing on offer is acknowledgement. This bridge             // still NEVER presses it — see `never_answer` — and whether it ever             // may is a question for the user, not an inference from a control
src/verbs.rs+17
@@ -3375,6 +3375,23 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {     // A SESSION'S HISTORY: whether it has ever held a document decides how the     // quit ladder treats a declined ExitApp (see src/session_history.rs).     crate::session_history::note_reply(ctx, command, &out);+    // BLOCKED ON THE USER'S SIGN-IN (0.9.12): said plainly, with the window.+    if command == "swx_start" || command == "swx_status" {+        if let Some(a) = crate::launch::attempt() {+            if let Some(title) = a.sign_in_window.clone().filter(|_| !a.finished) {+                if let Value::Object(m) = &mut out {+                    m.insert("waitingOn".into(), json!("userSignIn"));+                    m.insert("signInWindow".into(), json!(title));+                    m.insert(+                        "_signInHint".into(),+                        json!("SOLIDWORKS is waiting for the user to sign in to 3DEXPERIENCE in the \+                               window named here. Only the user can answer it; the launch's deadlines \+                               are paused and nothing is killed. Tell the user, then poll swx_status."),+                    );+                }+            }+        }+    }     // `attached` IS NOT `ready`: swx_start and swx_status say which, and on what.     if command == "swx_start" || command == "swx_status" {         let attached = out.get("attached").and_then(|v| v.as_bool()) == Some(true)
src/verbs3.rs+117−14
@@ -785,6 +785,12 @@ fn dialog_census_of( /// happens. If any dialog DOES appear the sweep stops and reports it rather than /// answering it — and if the window census cannot be READ, the saves still happen /// (they lose nothing) while the closes do not (they cannot be taken back).+/// The longest a sweep or drain runs before it answers `done:false` — always+/// inside the budget `bridge.json` declares to ab for these verbs (600 s), so+/// the reply arrives before ab gives up on it (MEASURED 2026-10-02: undeclared,+/// ab cut a sweep's reply at its 120 s default while the sweep ran on).+pub const SWEEP_MAX_SECS: u64 = 540;+ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {     gate!(ctx);     if let Some(g) = require_reason(@@ -851,7 +857,7 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {     let dir_s = dir.to_string_lossy().to_string();     let started = std::time::Instant::now();     let budget = std::time::Duration::from_secs(-        args.get("maxSeconds").and_then(|v| v.as_u64()).unwrap_or(240).clamp(5, 580),+        args.get("maxSeconds").and_then(|v| v.as_u64()).unwrap_or(240).clamp(5, SWEEP_MAX_SECS),     );     let batch = args.get("batch").and_then(|v| v.as_u64()).unwrap_or(25).clamp(1, 200); @@ -966,6 +972,9 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {             Some(None) => Look::Clear,         }     };+    let mut preserved_keys: std::collections::HashSet<String> = std::collections::HashSet::new();+    let mut deferred_now: Vec<String> = Vec::new();+    let mut closed_by_parent: Vec<String> = Vec::new();     'sweep: while stopped_by_headroom.is_none() {         // ── PHASE 1: PRESERVE, one document at a time, censusing before each.         //    Nothing closes during this phase, which is what makes the walk index@@ -974,7 +983,15 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {         let mut ready: Vec<(String, Option<crate::disk::DocKind>, bool, Option<u64>)> = Vec::new();         let mut walked = 0u64;         let mut index = 0u64;-        while index < batch {+        // THE WHOLE SESSION IS PRESERVED BEFORE ANYTHING CLOSES (0.9.12). The+        // close order must be GLOBAL: closing an assembly unloads the components+        // it holds, so every one of them must be on disk first, and a walk that+        // stopped after `batch` documents never reached the assembly at all —+        // MEASURED 2026-10-02: 28 parts first in enumeration order, then the+        // top-level assembly and four sub-assemblies; every close of a part was+        // "still open after CloseDoc", and the sweep stopped with 33 documents+        // open. `batch` now paces the CLOSES of a pass, not the preservation.+        loop {             match look(ctx) {                 Look::Dialog(d) => {                     stopped_by_dialog = Some(d);@@ -1001,6 +1018,11 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {                     break 'sweep;                 }             }+            job.update(+                format!("Preserving document {} of {}", index + 1, before.map(|b| b.to_string()).unwrap_or_else(|| "?".into())),+                json!({"stage": "preserve", "index": index, "preserved": recovered.len(), "closed": closed,+                       "documentCountBefore": before, "elapsedSec": started.elapsed().as_secs()}),+            );             let r = match ctx.call(                 "preserve_and_close",                 json!({"phase": "preserve", "recoveryDir": dir_s, "index": index}),@@ -1031,7 +1053,17 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {                 break; // the walk is exhausted             }             walked += 1;-            if let Some(rec) = r.get("record").filter(|v| !v.is_null()) {+            // A DOCUMENT WALKED AGAIN on a later pass (deferred behind its+            // assembly) was preserved already: its record is not repeated.+            let ready_path = r+                .pointer("/ready/path")+                .and_then(|v| v.as_str())+                .map(|p| p.replace('/', "\\").to_lowercase());+            let seen_before = ready_path.as_ref().map(|p| preserved_keys.contains(p)).unwrap_or(false);+            if let Some(p) = &ready_path {+                preserved_keys.insert(p.clone());+            }+            if let Some(rec) = r.get("record").filter(|v| !v.is_null() && !seen_before) {                 recovered.push(rec.clone());                 // AFTER EVERY SAVE. A sweep that dies here leaves a record of                 // everything decided up to this document.@@ -1059,15 +1091,38 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {         //    the census is unreadable: everything is already safe on disk.         //    A document of UNKNOWN kind is closed LAST: it might be a part held         //    open by an assembly, and going late costs nothing.-        ready.sort_by_key(|(_, k, _, _)| match k {-            Some(crate::disk::DocKind::Drawing) => 0,-            Some(crate::disk::DocKind::Assembly) => 1,-            Some(crate::disk::DocKind::Part) => 2,-            None => 3,+        // GLOBAL ORDER: drawings, then assemblies — the ACTIVE document first,+        // it is usually the top level — then parts, then unknown kinds.+        let active = ownership_context(ctx).2.map(|t| t.to_lowercase());+        let is_active = |p: &str| {+            let base = p.rsplit(['\\', '/']).next().unwrap_or(p).to_lowercase();+            active.as_deref().map(|a| a == base || base.starts_with(&format!("{a}."))).unwrap_or(false)+        };+        ready.sort_by_key(|(p, k, _, _)| {+            let kind_rank = match k {+                Some(crate::disk::DocKind::Drawing) => 0,+                Some(crate::disk::DocKind::Assembly) => 1,+                Some(crate::disk::DocKind::Part) => 2,+                None => 3,+            };+            (kind_rank, if is_active(p) { 0 } else { 1 })         });         let mut closed_this_pass = 0u64;+        let mut attempts_this_pass = 0u64;+        deferred_now.clear();+        // Assemblies preserved in this pass and not (yet) closed: a component+        // or sub-assembly still open after CloseDoc while one of these is open+        // is DEFERRED behind it — neither a failure nor "no progress".+        let mut open_assemblies: Vec<String> = ready+            .iter()+            .filter(|(_, k, _, _)| *k == Some(crate::disk::DocKind::Assembly))+            .map(|(p, _, _, _)| p.clone())+            .collect();         if !dialog_unreadable {             for (path, kind, written_here, original_bytes) in &ready {+                if attempts_this_pass >= batch {+                    break;+                }                 match look(ctx) {                     Look::Dialog(d) => {                         stopped_by_dialog = Some(d);@@ -1081,6 +1136,12 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {                     }                     Look::Clear => {}                 }+                job.update(+                    format!("Closing {}", path.rsplit(['\\', '/']).next().unwrap_or(path)),+                    json!({"stage": "close", "preserved": recovered.len(), "closed": closed,+                           "deferred": deferred_now.len(), "documentCountBefore": before,+                           "documentCountNow": after, "elapsedSec": started.elapsed().as_secs()}),+                );                 match ctx.call(                     "preserve_and_close",                     json!({@@ -1099,7 +1160,15 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {                         if r.get("closed").and_then(|v| v.as_bool()).unwrap_or(false) {                             closed += 1;                             closed_this_pass += 1;+                            open_assemblies.retain(|a| a != path);                             closed_paths.push(path.clone());+                            if r.get("alreadyClosed").and_then(|v| v.as_bool()) == Some(true) {+                                // GONE WITH ITS PARENT — after its own recovery+                                // write. Free: it does not count against `batch`.+                                closed_by_parent.push(path.clone());+                            } else {+                                attempts_this_pass += 1;+                            }                             // AND AFTER EVERY CLOSE, so the record says which                             // documents are gone from SOLIDWORKS and now exist                             // only as these files.@@ -1111,19 +1180,41 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {                                 &close_failed,                             );                         } else {-                            close_failed.push(json!({-                                "path": path,-                                "why": r.get("why").and_then(|v| v.as_str())-                                    .unwrap_or("still open after CloseDoc"),-                            }));+                            let why = r.get("why").and_then(|v| v.as_str()).unwrap_or("still open after CloseDoc");+                            let held = why.contains("still open after CloseDoc")+                                && *kind != Some(crate::disk::DocKind::Drawing)+                                && open_assemblies.iter().any(|a| a != path);+                            if held {+                                deferred_now.push(path.clone());+                            } else {+                                attempts_this_pass += 1;+                                close_failed.push(json!({"path": path, "why": why}));+                            }                         }                     }                 }             }         }+        // AN EMPTY SESSION after this pass: every preserved document not yet+        // accounted for went out with its parent (after its own recovery write).+        if after == Some(0) && !dialog_unreadable {+            for (path, _, _, _) in &ready {+                if !closed_paths.contains(path) {+                    closed += 1;+                    closed_this_pass += 1;+                    closed_paths.push(path.clone());+                    closed_by_parent.push(path.clone());+                }+            }+            deferred_now.clear();+            write_manifest(&dir, arg_str(args, "reason").unwrap_or_default(), &recovered, &closed_paths, &close_failed);+        }         // DONE means the session is EMPTY and we can prove it — not that this         // pass finished.         done = !dialog_unreadable && after == Some(0) && close_failed.is_empty();+        // NOTHING CLOSED IN A PASS THAT TRIED EVERY READY DOCUMENT is no+        // progress, deferrals included: the next pass would ask the same+        // questions in the same order. Anything still deferred then is a failure.         // A preserve-only pass closes nothing by design, so it runs ONCE.         // NO PROGRESS IS A STOP, NOT A LOOP: a pass that walked documents and         // closed none will close none next time either — the commonest cause is@@ -1135,6 +1226,15 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {             break;         }     }+    // A DOCUMENT STILL DEFERRED WHEN THE SWEEP ENDS was not closed, and says why.+    for p in deferred_now.drain(..) {+        if !closed_paths.contains(&p) {+            close_failed.push(json!({"path": p, "why": "still open after CloseDoc: deferred behind an assembly that is still open"}));+        }+    }+    if !close_failed.is_empty() {+        done = false;+    }     // AN EMPTY RESCUE FOLDER IS NOT A RESCUE. The folder is created before the     // sweep knows whether it may run; a refusal must not leave one behind for     // swx_recover_list to present as preserved work, or for the next reader to@@ -1214,6 +1314,9 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {         "closed": closed,         "remaining": remaining,         "closeFailed": close_failed,+        // Components that went out WITH their assembly, each after its own+        // recovery write (0.9.12).+        "closedByParent": closed_by_parent,         "documentCountBefore": before,         "documentCountAfter": after,         "done": done && stopped_by_dialog.is_none() && !dialog_unreadable@@ -2324,7 +2427,7 @@ pub fn close_import_temps(ctx: &Ctx, args: &Value) -> Value {     let batch = args.get("batch").and_then(|v| v.as_u64()).unwrap_or(200).clamp(1, 1000);     let started = std::time::Instant::now();     let budget = std::time::Duration::from_secs(-        args.get("maxSeconds").and_then(|v| v.as_u64()).unwrap_or(240).clamp(5, 580),+        args.get("maxSeconds").and_then(|v| v.as_u64()).unwrap_or(240).clamp(5, SWEEP_MAX_SECS),     );     let mut total_closed = 0u64;     let mut batches = 0u64;
tests/launch.rs+8
@@ -1230,6 +1230,14 @@ fn a_sign_in_window_pauses_every_deadline_instead_of_killing_the_chain() {         s(launch)     );     check!(b, "...and the attempt is not finished", launch["finished"] != true, s(launch));+    // 0.9.12: SAID PLAINLY, with the window — "waitingOn: userSignIn".+    check!(+        b,+        "...swx_status says it is waiting on the USER's sign-in, naming the window",+        st["waitingOn"] == "userSignIn"+            && st["signInWindow"].as_str().unwrap_or("").contains("3DEXPERIENCE ID"),+        s(&st)+    );      // The user signs in: the window closes and SOLIDWORKS comes up.     std::fs::remove_file(m.dir.join("sign-in.json")).unwrap();
tests/shutdown.rs+171
@@ -2232,3 +2232,174 @@ fn a_pre_0_9_record_of_a_clean_document_is_listed_with_its_close_unknown() {         s(&ip[0])     ); }++/// THE CRATE SESSION, MEASURED 2026-10-02 (pid 26924): 28 parts FIRST in+/// enumeration order, then the top-level assembly and four sub-assemblies. Every+/// part and sub-assembly is held open by the top level, so "still open after+/// CloseDoc" until it closes. 0.9.11's sweep walked the first `batch` documents+/// (parts), every close failed, and the no-progress stop ended it with 33 open.+/// 0.9.12 preserves the whole session first, closes in a GLOBAL order+/// (assemblies, the active one first, before parts), DEFERS what is held behind+/// an open assembly, and counts what went out with its parent as closed.+fn crate_session(tag: &str) -> (Bridge, TempDir, TempDir) {+    crate_session_held(tag, false, "ASM-CRATE.SLDASM")+}++fn crate_session_held(tag: &str, top_level_held: bool, active: &str) -> (Bridge, TempDir, TempDir) {+    let scratch = TempDir::new(tag);+    let root = "C:\\Users\\Public\\adom-crate\\";+    let mut docs: Vec<Value> = (1..=28)+        .map(|i| json!({"docType": "part", "path": format!("{root}PART-{i:02}.SLDPRT"),+                        "title": format!("PART-{i:02}.SLDPRT"), "saved": true, "dirty": false,+                        "heldOpenByAssembly": true}))+        .collect();+    docs.push(json!({"docType": "assembly", "path": format!("{root}ASM-CRATE.SLDASM"),+                     "title": "ASM-CRATE.SLDASM", "saved": true, "dirty": false,+                     "heldOpenByAssembly": top_level_held}));+    for i in 1..=4 {+        docs.push(json!({"docType": "assembly", "path": format!("{root}SUB-{i}.SLDASM"),+                         "title": format!("SUB-{i}.SLDASM"), "saved": true, "dirty": false,+                         "heldOpenByAssembly": true}));+    }+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({"running": true, "untitled": 0, "active": format!("{root}{active}"),+               "docs": docs, "files": {}})+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine(&format!("{tag}-m"));+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+    b.call("swx_start", json!({}));+    (b, scratch, machine)+}++#[test]+fn an_assembly_session_with_its_parts_listed_first_is_closed_completely() {+    for batch in [None, Some(1u64), Some(4)] {+        let (b, _s, _m) = crate_session(&format!("crate-sweep-{}", batch.map(|n| n.to_string()).unwrap_or("default".into())));+        let mut args = json!({"userPresentOverride": true, "userAuthorized": true, "reason": "Drew: close the crate"});+        if let Some(n) = batch {+            args["batch"] = json!(n);+        }+        let r = b.call("swx_close_all_safely", args);+        let docs = b.call("swx_docs", json!({}));+        check!(+            b,+            &format!("batch {batch:?}: the sweep ends at 0 documents, done, nothing failed"),+            r["done"] == true && docs["count"] == 0 && r["closeFailed"].as_array().map(|a| a.is_empty()).unwrap_or(false),+            s(&r)+        );+        check!(+            b,+            &format!("batch {batch:?}: the components went out with their parent, after their own records"),+            r["closedByParent"].as_array().map(|a| a.len() >= 28).unwrap_or(false)+                && r["recovered"].as_array().map(|a| a.len() == 33).unwrap_or(false),+            s(&r)+        );+    }+}++/// THE USER WAS LOOKING AT A SUB-ASSEMBLY: it is the active document, so it is+/// asked first — and it is held by the top level. That close is DEFERRED, not a+/// failure and not "no progress": the top level closes next, and the session+/// ends at 0, even one close per pass.+#[test]+fn a_held_sub_assembly_asked_first_is_deferred_not_failed() {+    let (b, _s, _m) = crate_session_held("crate-sub-active", false, "SUB-2.SLDASM");+    let r = b.call(+        "swx_close_all_safely",+        json!({"userPresentOverride": true, "userAuthorized": true, "reason": "close", "batch": 1}),+    );+    let docs = b.call("swx_docs", json!({}));+    check!(+        b,+        "the sub-assembly was deferred behind its parent, and the sweep ended at 0 documents",+        r["done"] == true && docs["count"] == 0 && r["closeFailed"].as_array().map(|a| a.is_empty()).unwrap_or(false),+        s(&r)+    );+}++/// A DEFERRED CLOSE IS NOT A FAILURE — but an assembly that genuinely cannot be+/// closed leaves its components deferred, and the sweep ends honestly: no+/// progress, done:false, every held document named.+#[test]+fn components_behind_an_assembly_that_will_not_close_are_reported_not_looped() {+    // The top level refuses to close as well: nothing can release anything.+    let (b, _scratch, _m) = crate_session_held("crate-stuck", true, "ASM-CRATE.SLDASM");+    let r = b.call("swx_close_all_safely", json!({"userPresentOverride": true, "userAuthorized": true, "reason": "close"}));+    check!(+        b,+        "nothing could close: done false, the stop is honest, and every document is named",+        r["done"] == false && r["closed"] == 0+            && r["closeFailed"].as_array().map(|a| a.len() == 33).unwrap_or(false),+        s(&r)+    );+}++/// swx_progress SHOWS A SWEEP WHILE IT RUNS (0.9.12): its stage and counts —+/// measured 2026-10-02 it answered `{}` while a sweep ran.+#[test]+fn a_running_sweep_is_visible_in_swx_progress() {+    let (b, scratch, _m) = crate_session("crate-progress");+    std::fs::write(scratch.join("mock-preserve-delay-ms"), "40").unwrap();+    let frames = std::thread::scope(|sc| {+        let sweeper = sc.spawn(|| {+            b.call("swx_close_all_safely", json!({"userPresentOverride": true, "userAuthorized": true, "reason": "close"}))+        });+        let mut seen = Vec::new();+        for _ in 0..60 {+            std::thread::sleep(std::time::Duration::from_millis(50));+            let p = b.call("swx_progress", json!({}));+            if let Some(f) = p["active"].as_array().and_then(|a| a.iter().find(|f| f["phase"] == "close_all_safely").cloned()) {+                seen.push(f);+            }+            if sweeper.is_finished() {+                break;+            }+        }+        let r = sweeper.join().unwrap();+        assert!(r["done"] == true, "precondition: the sweep finished: {}", s(&r));+        seen+    });+    check!(+        b,+        "swx_progress showed the sweep's stage and counts while it ran",+        frames.iter().any(|f| f["detail"]["stage"] == "preserve" && f["detail"]["preserved"].is_u64()),+        format!("{frames:?}")+    );+}++/// THE LICENSE MANAGER AS MEASURED 2026-10-02 — ownerless, and its body a+/// DirectUIHWND task dialog: the child-control walk reads NO text. 0.9.11 said+/// `text: []` with `contentsReadable: true` — "read, and it says nothing". It is+/// unreadable, says so, still stops the sweep, and is still never answered.+#[test]+fn a_task_dialog_whose_words_cannot_be_read_says_so() {+    let machine = TempDir::new("shutdown-taskdialog-machine");+    std::fs::write(+        machine.join("dialog-open.json"),+        json!({+            "hwnd": 9401, "className": "#32770", "title": "License Manager", "visible": true,+            "controls": [["DirectUIHWND", 0x5600_0000u64, "", true]]+        })+        .to_string(),+    )+    .unwrap();+    let scratch = TempDir::new("shutdown-taskdialog");+    a_difficult_session(&scratch);+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_close_all_safely", json!({"userPresentOverride": true, "userAuthorized": true, "reason": "closing"}));+    let d = r["stoppedByDialog"].clone();+    check!(+        b,+        "the sweep stops on it; contentsReadable is false, the text is null, and the reply says why",+        r["errorCode"] == "stopped_by_dialog"+            && d["contentsReadable"] == false+            && d["text"].is_null()+            && d["contentsUnreadableWhy"].as_str().unwrap_or("").contains("DirectUIHWND")+            && d["neverAnswer"] == true,+        s(&d)+    );+}
tests/wiring.rs+106
@@ -1099,3 +1099,109 @@ fn the_gates_refuse_on_user_presence_and_never_override_it() {         assert!(!src.contains("\"userPresentOverride\""), "{gate} passes the user-present override");     } }++/// AB WAITS AS LONG AS THE BRIDGE SAYS (0.9.12). MEASURED 2026-10-02: with no+/// declared budget, ab cut swx_close_all_safely's reply at its 120 s default+/// while the sweep ran on. Every verb that sweeps declares a budget longer than+/// the sweep's own ceiling, so the reply — `done:false` if it ran out — always+/// comes back first.+#[test]+fn every_sweeping_verb_declares_a_budget_longer_than_the_sweep() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let src = std::fs::read_to_string(root.join("src/verbs3.rs")).unwrap();+    let max: u64 = src+        .lines()+        .find_map(|l| l.trim().strip_prefix("pub const SWEEP_MAX_SECS: u64 = ").map(|r| r.trim_end_matches(';').parse().unwrap()))+        .expect("SWEEP_MAX_SECS");+    let bridge: serde_json::Value =+        serde_json::from_str(&std::fs::read_to_string(root.join("bridge.json")).unwrap()).unwrap();+    for (verb, floor) in [+        ("close_all_safely", max + 30),+        ("close_import_temps", max + 30),+        ("reset_workspace", 300),+        ("shutdown", max + 120),+        ("stop", 300),+    ] {+        let t = bridge["timeouts"]["verbs"][verb].as_u64().unwrap_or(0);+        assert!(t >= floor, "bridge.json timeouts.verbs.{verb} = {t}, needs >= {floor}");+    }+}++/// THE README'S VERB TABLE IS THE DECLARED SURFACE (0.9.12): it said 63 with 67+/// declared. The heading's count and the table's verbs must match bridge.json.+#[test]+fn the_readme_verb_table_matches_bridge_json() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let bridge: serde_json::Value =+        serde_json::from_str(&std::fs::read_to_string(root.join("bridge.json")).unwrap()).unwrap();+    let declared: std::collections::BTreeSet<String> =+        bridge["verbs"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();+    let readme = std::fs::read_to_string(root.join("README.md")).unwrap();+    let start = readme.find("## What it does — ").expect("the verb heading");+    let heading = readme[start..].lines().next().unwrap();+    let n: usize = heading+        .trim_start_matches("## What it does — ")+        .split_whitespace()+        .next()+        .and_then(|w| w.parse().ok())+        .expect("a count in the heading");+    assert_eq!(n, declared.len(), "the README heading says {n} verbs; bridge.json declares {}", declared.len());+    let table_end = start + readme[start..].find("\n\n|").map(|i| i + 2).unwrap_or(0);+    let table: String = readme[table_end..]+        .lines()+        .take_while(|l| l.starts_with('|'))+        .collect::<Vec<_>>()+        .join("\n");+    let mut listed = std::collections::BTreeSet::new();+    for tok in table.split('`') {+        let tok = tok.trim();+        if !tok.starts_with("swx_") {+            continue;+        }+        // `swx_export_step/iges/…` and `swx_show_part/_assembly/_drawing`.+        let mut parts = tok.split('/');+        let first = parts.next().unwrap().to_string();+        let stem = first.rsplit_once('_').map(|(a, _)| a.to_string()).unwrap_or(first.clone());+        listed.insert(first.clone());+        for p in parts {+            let p = p.trim_start_matches('_');+            listed.insert(format!("{stem}_{p}"));+        }+    }+    let missing: Vec<&String> = declared.difference(&listed).collect();+    let extra: Vec<&String> = listed.difference(&declared).collect();+    assert!(missing.is_empty() && extra.is_empty(), "README table vs bridge.json — missing: {missing:?}, not declared: {extra:?}");+}++/// THE WIKI SYNC IS ONE COMMAND, IN ORDER (0.9.12): release, manifest, commit,+/// source, package, page.json LAST, verify — and page.json carries package.json's+/// tags (the page showed `tags: []`).+#[test]+fn the_release_script_syncs_the_wiki_in_order_and_verifies() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let sh = std::fs::read_to_string(root.join("tools/release.sh")).unwrap();+    let publish = &sh[sh.find("--publish\" ]; then").expect("the publish block")..];+    let order = [+        "adom-wiki release create",+        "adom-wiki release upload",+        "--files \"$MAN\"",+        "git commit",+        "--files $(git ls-files)",+        "adom-wiki pkg publish --org adom",+        "--files page.json",+        "tools/verify-wiki.py",+    ];+    let mut at = 0usize;+    for step in order {+        let i = publish[at..].find(step).map(|i| i + at).unwrap_or_else(|| panic!("release.sh: {step:?} missing or out of order"));+        at = i + step.len();+    }+    let verify = std::fs::read_to_string(root.join("tools/verify-wiki.py")).unwrap();+    for claim in ["hero_path", "the title", "the tags", "BRIDGE_VERSION", "the committed manifest"] {+        assert!(verify.contains(claim), "verify-wiki.py does not check {claim}");+    }+    let page: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(root.join("page.json")).unwrap()).unwrap();+    let pkg: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(root.join("package.json")).unwrap()).unwrap();+    assert_eq!(page["tags"], pkg["tags"], "page.json tags drifted from package.json — run tools/sync-page.py");+    assert_eq!(page["hero"]["path"], "screenshots/hero.png", "page.json must keep the hero");+}
tools/launch-gate.py+37−2
@@ -70,6 +70,23 @@ ATTACH_BUDGET = 260        # the bridge's own budget is 240s POLL = 3  +class BlockedOnSignIn(Exception):+    """The launch is waiting for the USER to sign in to 3DEXPERIENCE. Not a+    failure of any claim — nothing after attach can be measured until a person+    answers the window. Exit code 4, naming the window (0.9.12; measured+    2026-10-02: reported as five FAILs, "COM attached never")."""+++def attach_verdict(attached_at, last_status):+    """("attached" | "signIn" | "failed", detail) from the attach loop's outcome."""+    if attached_at is not None:+        return ("attached", "%.1fs" % attached_at)+    st = last_status if isinstance(last_status, dict) else {}+    if st.get("waitingOn") == "userSignIn":+        return ("signIn", st.get("signInWindow") or "(untitled sign-in window)")+    return ("failed", "never")++ class Unreadable(Exception):     """A probe could not be ANSWERED. Not an empty answer — no answer. @@ -317,6 +334,13 @@ def self_test():         subprocess.run = real_run     sent = base64.b64decode(json.loads(captured["args"][-1])["command"].split()[-1]).decode("utf-16le")     ok = ok and "trap" in sent and "PS-NATIVE-EXIT" in sent and "Get-Date" in sent+    # The sign-in verdict (0.9.12): a launch waiting on the user's sign-in is its+    # own outcome, never "COM attached never".+    ok = ok and attach_verdict(None, {"waitingOn": "userSignIn",+                                      "signInWindow": "Login | 3DEXPERIENCE ID | Dassault Systemes"}) \+        == ("signIn", "Login | 3DEXPERIENCE ID | Dassault Systemes")+    ok = ok and attach_verdict(None, {"launch": {"stage": "attaching"}})[0] == "failed"+    ok = ok and attach_verdict(12.3, {"waitingOn": "userSignIn"})[0] == "attached"     print("\n" + "=" * 72)     print("SELF-TEST PASSED" if ok else "SELF-TEST FAILED")     return 0 if ok else 1@@ -541,9 +565,11 @@ def main():      # ── 6. ATTACH ──────────────────────────────────────────────────────────     attached_at, sldworks_at = None, None+    last_status = {}     deadline = time.time() + ATTACH_BUDGET     while time.time() < deadline:         s = verb("swx_status", {}, 40)+        last_status = s         launch = s.get("launch", {})         if sldworks_at is None and s.get("solidworks", {}).get("processId"):             sldworks_at = time.time() - t0@@ -556,8 +582,10 @@ def main():         print(f"  ... stage={launch.get('stage')} route={launch.get('route')} "               f"{int(time.time() - t0)}s")         time.sleep(POLL)-    check(attached_at is not None, f"COM attached within {ATTACH_BUDGET}s",-          f"{attached_at:.1f}s" if attached_at else "never")+    verdict, detail = attach_verdict(attached_at, last_status)+    if verdict == "signIn":+        raise BlockedOnSignIn(detail)+    check(attached_at is not None, f"COM attached within {ATTACH_BUDGET}s", detail)      # ── 6b. READY — attached is not ready ──────────────────────────────────     # MEASURED 2026-10-01: document commands sent 8 s after COM attach crashed@@ -673,3 +701,10 @@ if __name__ == "__main__":         print("  %s" % e)         print("This is 'could not ask', not 'nothing found'. Fix the probe and re-run.")         sys.exit(3)+    except BlockedOnSignIn as e:+        print("\n" + "=" * 72)+        print("BLOCKED ON THE USER'S SIGN-IN — the launch is waiting for a person to sign in to "+              "3DEXPERIENCE in the window %r." % str(e))+        print("Nothing was killed and the launch's deadlines are paused. Only the user can answer "+              "it; re-run the gate once they have signed in.")+        sys.exit(4)
tools/release.sh+30−3
@@ -57,12 +57,39 @@ unzip -l "$DIST/$ZIP" | sort -k1 -n -r | head -6 [ "$SIZE" -lt 3000000 ] || { echo "ERROR: zip > 3 MB - something non-runtime got in"; exit 1; }  if [ "${1:-}" = "--publish" ]; then+  # ── ONE COMMAND, IN ORDER (0.9.12). The wiki sync drifted when its steps were+  #    run by hand (a hero/title/brief unlinked by a pkg publish; a README verb+  #    count three behind). Every step below runs here, in this order, and the+  #    last one verifies the page end to end and FAILS LOUDLY on any mismatch.+  #+  #    PRECONDITIONS, before anything touches the network: page.json carries+  #    package.json's tags and discovery triggers, the tree is committed, and the+  #    committed manifest is the one being shipped (it is rewritten above, so the+  #    only difference allowed is in the manifest itself).+  python3 tools/sync-page.py --check || { echo "ERROR: page.json is out of sync with package.json - run tools/sync-page.py and commit"; exit 1; }+  DIRTY="$(git status --porcelain | grep -v " $MAN\$" || true)"+  [ -z "$DIRTY" ] || { echo "ERROR: uncommitted changes besides the manifest:"; echo "$DIRTY"; exit 1; }+  PKGV="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"+  [ "$PKGV" = "$VER" ] || { echo "ERROR: package.json version $PKGV != BRIDGE_VERSION $VER"; exit 1; }   TITLE="${RELEASE_TITLE:-v$VER}"; LOG="${RELEASE_CHANGELOG:-see docs/}"+  echo "== 1/7 release create + upload"   adom-wiki release create "$PAGE" "$VER" --title "$TITLE" --changelog "$LOG" \     || echo "(release $VER may already exist - continuing to upload)"   adom-wiki release upload "$PAGE" "$VER" "$DIST/$ZIP" --platform any+  echo "== 2/7 manifest push"   # manifest lives at /api/v1/pages/<slug>/files/<name> - pushed as a page file-  adom-wiki repo push "$PAGE" --files "$MAN"-  echo "verify:"; curl -sS "https://wiki.adom.inc/api/v1/pages/$SLUG/files/$MAN" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(" manifest version",d["version"],"size",d["size"])'-  curl -sSL -o /tmp/verify.zip "https://wiki.adom.inc/download/$PAGE/$VER/$ZIP" && sha256sum /tmp/verify.zip | grep -q "$SHA" && echo " download sha OK" || echo " ERROR: downloaded zip sha mismatch"+  adom-wiki repo push "$PAGE" --files "$MAN" -m "manifest: $VER as published"+  echo "== 3/7 commit the rewritten manifest"+  git add "$MAN"+  git commit -q -m "manifest: $VER as published (sha256 ${SHA:0:8}…, $SIZE bytes)" \+    -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>" || echo "(manifest already committed)"+  echo "== 4/7 source push (every tracked file, the committed manifest included)"+  # shellcheck disable=SC2046+  adom-wiki repo push "$PAGE" --json -m "$VER source" --files $(git ls-files) >/dev/null+  echo "== 5/7 skills package"+  adom-wiki pkg publish --org adom+  echo "== 6/7 page.json LAST (a pkg publish can unlink the hero, title and brief)"+  adom-wiki repo push "$PAGE" --files page.json -m "page.json: $VER (hero, title, brief, tags)"+  echo "== 7/7 verify"+  python3 tools/verify-wiki.py "$VER" "$SHA" "$SIZE" "$ZIP" || { echo "ERROR: THE WIKI DOES NOT MATCH THIS RELEASE - see above"; exit 1; } fi
tools/sync-page.pyadded+38
@@ -0,0 +1,38 @@+#!/usr/bin/env python3+"""page.json carries what the wiki page shows that package.json owns: tags and+discovery triggers (0.9.12 — the page showed `tags: []`). Run with no argument+to write page.json; `--check` exits 1 if it is out of sync (release.sh runs+that before it publishes anything)."""+import json, os, sys++ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")+FIELDS = ("tags", "discovery_triggers")+++def wanted():+    pkg = json.load(open(os.path.join(ROOT, "package.json")))+    page = json.load(open(os.path.join(ROOT, "page.json")))+    out = dict(page)+    for f in FIELDS:+        if f in pkg:+            out[f] = pkg[f]+    return page, out+++def main():+    page, out = wanted()+    if "--check" in sys.argv:+        drift = [f for f in FIELDS if page.get(f) != out.get(f)]+        if drift:+            print("page.json out of sync with package.json: %s" % ", ".join(drift))+            return 1+        return 0+    with open(os.path.join(ROOT, "page.json"), "w") as fh:+        json.dump(out, fh, indent=2, ensure_ascii=False)+        fh.write("\n")+    print("page.json synced: %s" % ", ".join(FIELDS))+    return 0+++if __name__ == "__main__":+    sys.exit(main())
tools/verify-wiki.pyadded+61
@@ -0,0 +1,61 @@+#!/usr/bin/env python3+"""Verify the published page against this release — every mismatch is printed+and the exit code is non-zero (0.9.12, run as the last step of+`tools/release.sh --publish`). Read-only.++Usage: verify-wiki.py VERSION SHA256 SIZE ZIPNAME+"""+import hashlib, json, os, subprocess, sys, urllib.request++ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")+SLUG, OWNER = "solidworks-bridge", "adom"+MAN = "adom-bridge-solidworks-manifest.json"+API = "https://wiki.adom.inc/api/v1/pages/%s" % SLUG+++def get(url, raw=False):+    with urllib.request.urlopen(url, timeout=120) as r:+        b = r.read()+    return b if raw else json.loads(b)+++def main():+    ver, sha, size, zipname = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]+    bad = []++    def check(ok, claim, detail=""):+        print(("  OK    " if ok else "  FAIL  ") + claim + (" [%s]" % detail if detail else ""))+        if not ok:+            bad.append(claim)++    pub = get(API + "/files/" + MAN)+    local = json.load(open(os.path.join(ROOT, MAN)))+    check(pub.get("version") == ver and pub.get("sha256") == sha and pub.get("size") == size,+          "the published manifest names this release", json.dumps({k: pub.get(k) for k in ("version", "size", "sha256")}))+    check(pub == local, "the committed manifest is byte-for-byte the published one")+    blob = get("https://wiki.adom.inc/download/%s/%s/%s/%s" % (OWNER, SLUG, ver, zipname), raw=True)+    check(hashlib.sha256(blob).hexdigest() == sha and len(blob) == size, "the downloadable zip hashes to the manifest",+          "%d bytes" % len(blob))+    page = get(API)["page"]+    want = json.load(open(os.path.join(ROOT, "page.json")))+    pkg = json.load(open(os.path.join(ROOT, "package.json")))+    check(page.get("hero_path") == want["hero"]["path"], "the hero is linked", str(page.get("hero_path")))+    check(page.get("title") == want.get("title"), "the title", str(page.get("title")))+    check(page.get("brief") == want.get("brief"), "the brief")+    tags = page.get("tags")+    tags = json.loads(tags) if isinstance(tags, str) else tags+    check(sorted(tags or []) == sorted(pkg.get("tags", [])), "the tags", str(tags))+    check(page.get("version") == ver, "the page's package version", str(page.get("version")))+    bv = get(API + "/files/BRIDGE_VERSION", raw=True).decode().strip()+    check(bv == ver, "the page's BRIDGE_VERSION", bv)+    head = subprocess.run(["git", "-C", ROOT, "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()+    print("  page commit %s, local HEAD %s" % (page.get("last_commit_hash"), head))+    if bad:+        print("VERIFY FAILED — %d mismatch(es): %s" % (len(bad), "; ".join(bad)))+        return 1+    print("VERIFY PASSED — manifest, zip, hero, title, brief, tags, package version, BRIDGE_VERSION.")+    return 0+++if __name__ == "__main__":+    sys.exit(main())
uninstall.sh