# Migration — thin in-process gatherer, fat host CPython Approved by the owner 2026-08-30. This is the order of work, the size of each step, and what each step has to prove before the next one starts. It is not an argument for the direction; the direction is settled. **The rule it implements.** In-process IronPython keeps only *gatherers* — code that must touch the Revit API: views, schedules, geometry, selection, dialogs, export. Everything that churns — check judgement, matching, rendering, config logic — runs on host CPython and is fed by files. **The contract is the file, not the function.** A versioned JSON document with a `SCHEMA` int (or string), discarded whole on mismatch, carrying provenance. The pattern is already proven here three times: `liftexport` → `panellift/1`, `reinforceindex.extract` → `reinforcecore.SCHEMA = 6`, `designspec` → `evidence/.json` + `specformat.SCHEMA_VERSION`. Nothing below invents a fourth style. --- ## 1. What the restarts are actually for The direction is right; the stated reason for it is only two thirds right, and the third that is wrong changes which steps pay off first. Measured 2026-08-30, from the pyRevit 5.0.1 source in `%APPDATA%\pyRevit-Master\` and from this machine's own configuration: | what needs a restart | why | can this plan fix it | | --- | --- | --- | | adding, renaming or re-laying-out a button | the ribbon and its command assembly are built once, at pyRevit load | **no.** Nothing can. | | a change to a module the **bridge session** has already imported | `startup.py` registers the routes handler on the engine that first loaded it; that engine's `sys.modules` lives as long as Revit. `docs/revit-bridge-context.md` measured this on 2026-08-30 | **partly** — host CPython is re-read on every spawn, so anything that moves across the file boundary stops being cacheable at all | | a change to a `lib/vibe` module used by a **button press** | this is the one that is probably already false — see §8 | already free, if §8's reading is right | So the payoff of this migration is not mainly "presses see lib edits". It is: - **the bridge stops lying.** A judgement layer on the far side of a file is re-read every spawn; no session can hold a stale copy of it, and the trap in `revit-bridge-context.md` ("a module read early and edited later is stuck for the rest of the session") cannot apply to it. - **the suite can reach it.** `tests/conftest.py` can only import a module that does not touch Revit at module scope. Every line moved across the boundary becomes testable without a licence, a model or Windows — which is how `panellift` came to have its own suite and `ticketpage` its 250-odd tests, all of them running on host CPython. - **the restart-needing surface shrinks.** TicketQC's six modules (`ticketqc`, `ticketchecks`, `ticketpage`, `ticketvision`, `ticketdelta`, `ticketextract`) are all in-process today, and §4 ends with roughly 1,700 lines of them in-process and the rest on the host. Their combined total was measured six times in two days and came out 12,139 → 12,232 → 13,179 → 13,538 → 13,635 → 14,215, the last two both on 2026-08-31 a few hours apart. The argument does not depend on which of those it is, which is why §3 no longer prints per-module counts. Re-measure with §3's command if you need today's. Write that down honestly in any status note. The plan reduces the surface that needs a restart. It never reaches zero, because a gatherer is still a module in Revit's engine. --- ## 2. The contract One shape, reused. Anything new copies `liftexport.SCHEMA` / `reinforcecore` rather than inventing: ``` { "schema": "ticketqc/1", # int or "name/N"; bumped on ANY key change "written_at": "", # provenance "written_by": "TicketQC ", # which button, which build "doc_key": "", # reinforcecore.doc_key idiom "knowledge_sources": [ ... ], # knowledge.sources(doc), verbatim ... payload ... } ``` Four rules, all of them already load-bearing somewhere in this repo: 1. **Discard whole on mismatch.** `reinforcecore.load()` throws away a file whose `schema` is not the current int — never partially, never migrated. A half-read evidence file is a run against a configuration nobody chose. 2. **No live objects.** Element ids as ints, marks as strings, view names and flattened text as strings. `read_ticket` currently puts the live `Document` on `t['doc']` and live `View` objects in `t['legends']`. **Corrected while building step 1: those are not the whole list.** `t['sheets']` is a list of live `ViewSheet` and `t['sheet']` is one of them, read in thirteen places in `ticketchecks`; and on the no-panel early return `t['strand_points']` is still live `XYZ`. Four holdouts, not two — which is why the serialiser gates on TYPE rather than on a list of known offenders, and why the test for it walks the whole structure. 3. **Absence is not a value.** A gather that could not read something writes the key with an explicit `null` *and* a reason beside it. A key that is simply missing reads as "nothing to check" downstream, which is the exact silence-looks-like-a-pass failure `ticketchecks._SKIP_PROBES` exists to prevent. The probe idiom moves across the boundary with the check. 4. **Provenance travels.** Which knowledge layers were in force, which spec source (`spec` / `regex`), which build. A finding whose provenance was left behind on the Revit side cannot be re-judged from the file. --- ## 3. Module inventory, with verdicts Every module under `WillTools.extension/lib/vibe/`. "Revit at module scope" comes from an AST walk, not from reading; "heat" is commits touching the file in the seven days to 2026-08-30. **The line counts are gone from these tables, deliberately.** They were walked three times in three days and were stale all three times. Two measurements twenty minutes apart on 2026-08-30 disagreed by 479 lines on `knowledge.py`, 173 on `ticketpage.py` and −71 on `designspec.py`, because two agents were writing to the tree between them. The 20:52 walk that replaced those was itself wrong by the next morning: re-walked 2026-08-31, `yamlkit.py` had gone 346 → 791, `configform.py` 612 → 985, `takeoffcount.py` 299 → 470, and ten more had moved — `knowledge.py` by nearly a thousand lines (2,477 → 3,442), `ticketpage.py` by three hundred. Both of those moved *again* between two walks a few hours apart the same day, while agents were writing to the tree. This section has said "the verdicts do not move, the numbers do" since it was written, and then printed the numbers anyway. **The format now matches the note.** Get them fresh instead — one line, and it is the same walk the table used: ```bash cd WillTools.extension/lib/vibe && wc -l *.py | sort -rn ``` Rows below are ordered largest-first **as of 2026-08-31**, so the ordering is a snapshot too. The verdict in each row is not, and that is the column this table exists for. **Counts: 4 gather-bound · 28 already-pure · 7 mixed, needs a split line** — 39 modules, re-checked 2026-08-31: none added, none removed since the 20:52 walk. `plantpath.py` remains the most recent addition and is already in §3.2. The one growth worth knowing about is not churn: **`ticketqc` grew by roughly 590 lines when step 1 added the evidence writer to it**, which is this plan's own work. The `defs` and pure/Revit splits quoted inside §3.3 are **as first measured and not re-walked**, except where a cell says otherwise. The original counted a def as Revit-bound by what it reaches for, which an import-based AST walk cannot reproduce, so re-deriving them means redoing the judgement, not rerunning a script. They are marked in place rather than deleted; treat them as unverified-since. ### 3.1 Gather-bound — stays in-process, permanently (4) | module | heat | note | | --- | --- | --- | | `latextract.py` | 1 | `Autodesk.Revit.DB` at module scope; pure model reading. Feeds `latmodel` (already pure). | | `jobreconscan.py` | 2 | ditto; the scan half of JobRecon, whose judgement half (`jobrecon.py`) is already pure. | | `helpers.py` | 0 | `q p set_p tx selected ft mm`. This *is* the Revit surface. Never migrates. | | `__init__.py` | 0 | re-exports `helpers`; the reason `conftest.py` has to stub the package. | ### 3.2 Already pure — no migration, only a runner and a feed (28) These import no Revit API at module scope and none inside their functions. Every one of them could run on the host today given its inputs as a file. | module | heat | what it is | | --- | --- | --- | | `ticketpage.py` | 26 | the whole TicketQC page. **Hottest pure module in the repo, and the proof the target works.** | | `knowledge.py` | 12 | five-layer merge. Reads YAML from paths; already file-fed by construction. | | `configmodel.py` | 4 | what may be edited, provenance, the splice. | | `reinforcereport.py` | 2 | | | `jobrecon.py` | 3 | | | `reinforcebind.py` | 3 | | | `configform.py` | new | untracked when this table was first written; tracked since. | | `ticketdelta.py` | 1 | | | `yamlkit.py` | 1 | | | `takeoffreport.py` | 2 | | | `reinforcematch.py` | 1 | | | `ticketextract.py` | 1 | | | `reinforcecore.py` | 2 | owns `SCHEMA = 6` — the reference implementation of rule 1. | | `configdump.py` | 3 | | | `takeoffcore.py` | 1 | | | `configpage.py` | 2 | | | `snapshotmeta.py` | 3 | | | `takeoffcount.py` | 1 | | | `specformat.py` | 2 | owns `SCHEMA_VERSION`. | | `foldreport.py` | 3 | | | `latmodel.py` | 1 | | | `takeoffgather.py` | 1 | pure despite the name — it gathers *counts*, not elements. | | `scope.py` | 1 | | | `specsave.py` | 1 | | | `latrisa.py` | 1 | | | `plantpath.py` | 0 | **new since the 19:30 walk** — the plant-layer path resolution `designspec` re-exports. Pure, and already file-fed. | | `compat.py` | 0 | py2/py3 shim; stays in-process as a shim, irrelevant on the host. | | `batchrun.py` | 3 | **pure of Revit but in-process by nature** — the queue, the lock and the thread factory must be created on the UI thread while the defining scope is alive (`docs/lift-check.md`). Do not "migrate" it; it is the thing that drives a migration. Listed last regardless of size, because that note is the reason it is here. | ### 3.3 Mixed — the split line, named (7) | module | heat | the split line | | --- | --- | --- | | `ticketchecks.py` | **40 — hottest file in the repo** | Top-level defs went 128 (19:30 walk) → 131 (20:52) → 138 (midday 2026-08-31); `grep -c '^def ' ticketchecks.py` for today's, and note that it moved three times in two days. The pure/Revit split is *as first measured, not re-walked*: **97 pure (1967 lines), 31 Revit-touching (~2000)**. **27 registered checks** in `CHECKS` — the 26 this table used to say predates `check_callout_overlap` (checklist row 1.5); re-count by reading the `CHECKS` tuple, which is one name per line. **20 of the 27 carry a skip probe and 7 do not** (see `CLAUDE.md` §5 for why that matters when you add one). About 21 top-level defs reach `t['doc']` or `t['sheets']` — that is the real size of step 4's surface. The older "13 of the checks reach `t['doc']`" figure counted reads through helpers as well as direct ones; only 9 do it in their own body, so the two are not the same measurement and neither is wrong. Split **per check**: each becomes `gather_(doc, t) → dict` (in-process, into the evidence file) plus `check_(t, intent, know, spec)` reading only that dict (host). `_SKIP_PROBES` moves with the judgement half. | | `ticketqc.py` | 17 | Top-level defs plus classes, not defs alone: the "76" this table used to give was 74 defs + 2 classes, and the def count has since moved again. The half-and-half below is *as first measured, and excludes the evidence writer*: **31 Revit-bound (991 lines)** — `read_ticket`, `member_boxes`, `collect_context`, `_read_bom_schedules`, `parse_design_summary`, `strand_layout_of`, `concrete_extents`, `element_solids`, `run`, `run_for_assemblies`; **31 pure (689 lines)** — `Finding`, `check_ticket`, every `_check_*`, `spec_to_intent`, and all the parsers/formatters (`parse_feet`, `fmt_feet`, `parse_strand_groups`, `strand_signature`, `parse_concrete`, `parse_wwf`, `_compare`). The pure half is what `ticketchecks` imports at module scope, which is the single reason `ticketchecks` cannot be imported on the host without a stub. **Splitting `ticketqc` into `ticketqc` (gather) + `ticketrules` (pure) deletes the stub in `test_ticketchecks_host.py`.** | | `liftexport.py` | 10 | **already split, correctly.** `export()` and below-`export()` are separated by design; the file boundary is `panellift/1`. Precedent, not work. Only new gathers go in above the line. | | `ticketvision.py` | 4 | gather = rendering a view to an image and shelling out; pure = the ladder, `report_block`, `qc_json`, `write_run_json`. The pure half is where the NOT RUN / WARN invariant lives and it must cross with the judgement. | | `designspec.py` | 4 | **half already across.** `gather_evidence` → `evidence/.json` exists. `build_spec`, `new_spec`, `validate_spec`, `get_path` and the path helpers are pure and can move as soon as a runner exists. Module-scope `Autodesk.Revit.DB` is what pins the rest. | | `reinforceindex.py` | 2 | `extract` gathers → the index file; everything else already reads the file. Same shape as `liftexport`. Small residue. | --- ## 4. Migration order Sizes: **S** = one working session · **M** = two or three · **L** = a week of sessions. Every step ships alone and leaves the tree working; no step depends on the next one existing. ### Step 1 — TicketQC evidence file, written and ignored — **M** — **DONE 2026-08-30** `ticketqc` grows `ticket_to_json(t)` and the button writes `/_ticketqc/evidence/run-.json` on every press, schema `ticketqc/1`. Nothing reads it. `t` itself does not change shape, and no judgement moves. The two holdouts named in §2 rule 2 are the work: `t['doc']` becomes `doc_key` + the model's path stem, `t['legends']` becomes `[{id, name, text}]` from `_flat_text`. Everything else in `read_ticket` is already strings, numbers and dicts. Ships alone because a file nobody reads cannot break a run — wrap the write the way `write_run_json` is wrapped in the button today, so a failure prints a line and does not lose the report. **AS BUILT. There is a THIRD live holdout, and the paragraph above is wrong about it.** `t['sheets']` is a list of live `ViewSheet` objects and `t['sheet']` is one of them; `ticketchecks` reads `.Name`, `.Id` and the sheet number off them in thirteen places, and 20 of its top-level defs reach `t['doc']` or `t['sheets']`. They serialise the same way the legends do — `[{id, number, name}]`. A fourth exists only on a failure branch: when `read_ticket` finds no framing member it returns early, and `t['strand_points']` is still a list of live `XYZ` at that point rather than the `None` the success path leaves. Both are handled; neither was in the plan. Also as built, and deliberately beyond the paragraph above: - **`load_evidence` ships now, unused.** Rule 1 is a property of the format, not of the writer, and a discard rule that has never been executed is a comment. It is copied from `reinforcecore.load()` down to the shape of the failure, and its test asserts that a schema-mismatched file comes back with **no** tickets — not merely with `_load_error` set. - **Rule 3 is enforced by construction, not by discipline.** Every field goes through one `put()` that will not write a null without writing a sentence beside it, and the test walks the produced document rather than naming keys. - **The writer names what it did not serialise.** `unserialised_keys` lists every ticket key this writer does not know about — today the caches `ticketchecks` leaves behind (`frame`, `member_boxes`, `_debond_requirement`, ...). That list is step 4's worklist, and it is there so a gather added later cannot fall out of the file in silence. - **`intent` and `spec_source` ride with each ticket**, because §2 rule 4 says a finding whose provenance stayed on the Revit side cannot be re-judged. Findings do NOT: `qc-run.json` owns the verdicts, this file owns the inputs, and step 2 needs them apart to compare one against the other. Gates: §5.2 / §5.3 / §5.4 / §5.5 met (39 new tests, suite green, scrub clean, one implementation). **§5.1 is NOT met and could not be**: parity needs a real press and the bridge was held exclusively by another session. What is unproven is only what a press can show — that the write costs nothing visible, that the file is well-formed on real geometry, and its size. The budget in §6 is still a budget; a synthetic single-piece document is 3.7 KB, which says nothing about a real one carrying a full BOM and flattened note text. ### Step 2 — the host runner, judging from the file — **M** `WillTools.extension/runners/ticket_judge.py`, host CPython, no Revit anywhere: reads the evidence file + `knowledge.yaml` layers + specs, calls `check_ticket` and `ticketchecks.run_all` for the 13 checks that never touch `doc`, writes `findings.json`. The button does not call it yet. This step's whole output is a parity proof (§5) run offline against the twelve-piece audit set in `docs/ticket-audit-wp1-12.md`. ### Step 3 — the button calls the runner for the pure checks — **S** `liftexport.find_python` + `liftexport.run` already know how to find an interpreter and shell out with a timeout and a tick callback. Reuse them; do not write a second spawner. The in-process path stays in the file, behind a knowledge switch, until step 5. This is the first press where a `ticketchecks` judgement edit is visible without a restart. ### Step 4 — the 13 doc-touching checks get gather halves — **L, but 13 ships** One check per commit, in churn order. For each: `gather_` writes its sub-document into the evidence file under its own key; the judgement half moves to the host and reads only that key; the skip probe moves with it. Order by heat and by how much geometry each drags along: | # | check | why here | | --- | --- | --- | | 1 | `check_bom_strand_count` | smallest gather (a schedule read), and 56 lines | | 2 | `check_jacking_force` | 58 lines, already has a probe | | 3 | `check_wwf_designation` | 44 lines | | 4 | `check_ticket_set` | 58 lines | | 5 | `check_handling_result` | reads a file `liftexport` already writes | | 6 | `check_bom_members` | 104 lines | | 7 | `check_picking_match` | 90 lines | | 8 | `check_note_views` | 80 lines | | 9 | `check_cast_extras` | 78 lines | | 10 | `check_strand_debond` | via `_strand_debond_openings` | | 11 | `check_dunnage_openings` | 227 lines, drags `liftexport` | | 12 | `check_bed_plan_reverse` | 232 lines | | 13 | `check_member_interference` | **last.** 300 lines, and the one place where two working-looking versions were both wrong (`docs/interference.md`). Move it only when the other twelve have proved the gather shape. | ### Step 5 — `ticketqc` splits; the stub dies — **M** `ticketqc` keeps the 31 Revit-bound defs. The 31 pure ones move to `ticketrules.py`. `ticketchecks` imports `Finding`, `fmt_feet` and `_fmt_num` from there instead, and `test_ticketchecks_host.py`'s `_stub_revit()` — with its careful comment about putting `sys.modules` back — is deleted, not rewritten. Delete the in-process duplicate path from step 3 in the same commit; double maintenance ends here. ### Step 6 — `ticketpage` renders on the host — **S** It is already pure and already the hottest pure file. The runner returns markdown; the button prints it. Small because the work was done in advance. ### Step 7 — ProjectConfig — **S/M** Five modules, all already pure (`configmodel`, `configpage`, `configform`, `configdump`, `knowledge`), 4,801 lines, 14 commits in seven days and second only to TicketQC in churn. The gather is thin: `knowledge.load(doc)` and the model's paths. Highest ratio of churn relieved to work done in the whole plan — it may deserve to run before step 4 if config keeps churning. ### Step 8 — ExtractDesign — **S** `gather_evidence` already writes the file. Move `build_spec` and validation to a runner. ### Step 9 — ReinforceSuggest — **M** `reinforceindex.extract` already writes the index; `reinforcematch`, `reinforcebind` and `reinforcereport` are already pure. Low heat (3 commits in seven days) — do it when the churn justifies it, not before. ### Never — and say so out loud | | why | | --- | --- | | **SelfCheck** | its entire output is *what this session can see*: engine, knowledge layers, paths, live document. Moving it off-process would make it report a different process's answer. Inherently in-process. | | **SnapshotClose** | detaches and saves a document. It is a Revit transaction with a progress dialog. Nothing to migrate. | | **PanelLiftCheck** | already migrated. `panellift` is the model this plan copies. | | **`batchrun`** | see §3.2 — UI-thread coordination, not logic. | | **`helpers`, `__init__`** | the Revit surface itself. | | **LateralExtract, GravityLoadChase** | unfinished, behind the `DesignHub` pulldown on `Design.panel` (the `Lab.panel` slideout until 2026-08-30). Do not migrate a tool that has not settled what it does. | --- ## 5. Acceptance gates Every step, all five. A step that cannot show all five is not shipped, it is in progress. ### 5.1 Parity, on real data, with the clock frozen The standard the `enabled` work set and the `--no-recommend` work met: **the old path and the new path produce byte-identical output on the reference data.** `panellift/tests/test_no_recommend.py::test_a_default_run_is_byte_for_byte_what_it_was` is the shape to copy — assert it in the suite on one case, and run it out of band across the whole reference set with the clock frozen. For a check moving in step 4 the unit is the finding, not the byte: the same `(severity, code, message)` tuples in the same order, for all twelve pieces of the WP1 audit set, in-process vs host. A message that differs by so much as a rounded digit is a failed step, not a cosmetic difference. Freeze the clock. Every document in §2 carries `written_at`, and a parity diff that is nothing but timestamps teaches nothing and hides everything. ### 5.2 The honesty invariants, re-asserted after the move These are the reason the tool is trusted and they are the easiest thing to lose in a refactor. Each needs a test on the **host** side after the move: - **NOT RUN is never a pass.** A check whose inputs did not arrive reports NOT RUN with a reason. The `_SKIP_PROBES` disambiguation — probe returns text → SKIPPED with the why; probe absent → NOT RUN, reason "no probe" — must survive the split intact. An empty gather key is exactly the case it catches, and it is the case a file boundary makes more likely, not less. - **Could-not-judge stays distinct from judged-and-fine.** The `computed` / `model-read` separation in `ticketvision` never merges into one list; `qc_json` keeps two keys. - **Provenance stamps survive.** Knowledge layers, spec source, build. §2 rule 4. ### 5.3 Suite green `cd WillTools.extension && python -m pytest` — 1440 at time of writing — and `cd panellift && python -m pytest` — 272. **Mind the root**: run from the repo root instead and pytest also collects `panellift/` and the gitignored `_scratch/`, which is a different and larger number (1,847 green at 2026-08-30 21:05, all suites, after step 1's 39). Quote which root a count came from or two people will compare numbers that were never the same measurement. Both green, and the count goes **up** at every step: a step that moves 500 lines to the host and adds no tests has not banked the reason for moving them. ### 5.4 Scrub clean `python tools/scrub.py --worktree`. New attention needed: evidence files carry element ids, marks and sheet names. Ids and marks are naming conventions and stay; a model filename or an absolute user path in a *committed sample* is a leak. Evidence files live beside the model and are never committed — keep it that way, and keep the fixture set synthetic. ### 5.5 The gate that is specific to this migration **No step may leave two live implementations across a Revit restart.** During a step the old path may stay behind a switch; by the end of the step it is deleted. Two paths that both run are two paths that drift, and the drift is invisible until someone reads a finding that only one of them produces. --- ## 6. Costs, measured | cost | measured | note | | --- | --- | --- | | interpreter spawn, bare | **0.026 s** median of 7, host CPython 3.12 | floor | | spawn + the stdlib a runner imports (`json os io re argparse datetime html`) | **0.040 s** | | | spawn + `import panellift.cli` — a real package | **0.066 s** | closest analogue to a judgement runner | | a Panel Lift press, end to end | **~0.1 s** of overhead | includes .NET `Process.Start`, writing the job and reading the summary back | | a reference job file | **8.6 KB** median (368 files, 3.0 MB total) | the proven `panellift/1` payload | | a ticket evidence file | **budget 30–80 KB** per piece, STILL A BUDGET | bigger than a job file: BOM rows, member boxes, flattened note text. Step 1 shipped without the real number — it needs a press, and the bridge was held. A synthetic single-piece document is 3.7 KB, which measures the envelope and nothing else. Measure it on the first real press and replace this row. | | a 184-piece batch | **6–15 MB** on that budget | one file per run, not per piece | **The spawn is per press, not per piece.** A batch of 184 pieces pays it once. It is noise next to a 12.6 s position search and next to the geometry reads the gather does anyway. It is *not* noise on a button someone presses to see one number — if such a button appears, it keeps its logic in-process. **Double maintenance** runs from step 3 to step 5 for TicketQC, and for the duration of each later step. The gate in §5.5 bounds it: within a step, not across one. **The residual truth.** Steps 1, 4 and every future gather still live in Revit's engine. Editing `read_ticket` or a `gather_*` still needs a restart if the bridge has imported it. The plan shrinks that surface from 12,139 lines to roughly 1,700; it does not remove it, and nothing can. --- ## 7. What not to do - **No big-bang rewrite.** Nine steps, each shippable, each with a parity proof. A branch that moves TicketQC wholesale cannot be verified against anything, because the thing it would be verified against no longer runs. - **Never migrate a module another agent is refactoring this week.** `ticketchecks` took 38 commits in seven days. A split landing on top of an in-flight change to the same file loses one of them silently. Check `git log --since` before starting a step; if the file moved today, pick a different step. - **The bridge and fork workflow are untouched.** No `/reload`, no second session log under one process, and `rvt.py`'s refusal is never worked around. Prototype read-only before anything that writes. - **The read-only rule is untouched.** A gatherer reads. A migration is not a reason to open a transaction on the owner's model. - **Do not invent a fourth file format.** Three exist and agree. Copy one. - **Do not move a check and its probe separately.** The probe is the honesty invariant; a check that arrives on the host without one renders NOT RUN with reason "no probe" — which is correct behaviour reporting a botched migration, and will be read as a tool bug. --- ## 8. The quick win, landed: `engine: clean` on the three churn-heaviest buttons ### 8.1 The option is real, and this is exactly what it does Verified against the installed pyRevit 5.0.1 at `%APPDATA%\pyRevit-Master\`, not against folklore: | | | | --- | --- | | key | `engine:` → `clean:` — `pyrevit/extensions/__init__.py` lines 101–102, `MDATA_ENGINE = 'engine'`, `MDATA_ENGINE_CLEAN = 'clean'` | | read by | `GenericUICommand._read_bundle_metadata`, `pyrevit/extensions/genericcomps.py` ~590 | | siblings | `full_frame`, `persistent`, `mainthread` (+ Dynamo-only keys) | | value type | **string, always.** `coreutils/yaml.py` wraps YamlDotNet's representation model and returns `YamlScalarNode.Value`, so `clean: true` arrives as `"true"` and the `.lower() == 'true'` test in genericcomps is safe. Unquoted `true` is correct and is what pyRevit's own bundles write (`pyRevitCore/.../Reload.pushbutton/bundle.yaml`). | | effect | `runtime/pythontypemaker._does_need_clean_engine` → `runtime.create_ipyengine_configs(clean=…)` → a fresh IronPython engine per press instead of the cached one. Fresh engine, fresh `sys.modules`, `lib/vibe` re-imported from disk. | | cost | engine construction on every press instead of engine reuse | Our guard test tolerates it already: `test_button_tooltips.py` reads only `tooltip` / `author` / `authors`, and `vibe.yamlkit.parse` handles the nested mapping (checked: returns `{'engine': {'clean': True}}` and leaves `tooltip` `None`). **No test change was needed.** ### 8.2 The caveat that changes what this is Read `_does_need_clean_engine` in full: ```python use_clean_engine = True if user_config.rocket_mode \ and _is_rocketmode_compat(extension.name) \ and not cmd_component.requires_clean_engine: use_clean_engine = False ``` A clean engine is the **default**. Rocket mode is what takes it away — and only for an extension that `extpackages.get_ext_package_by_name()` can find. That lookup drops any package failing `ExtensionPackage.is_valid()`, which is: ```python return self.name is not None and self.url is not None ``` `WillTools.extension/extension.json` declares `name` and `rocket_mode_compatible: true` but **no `url`**. So the package is never registered, `_is_rocketmode_compat('WillTools')` returns `False`, and every WillTools button already gets a clean engine — even though `pyrevit configs rocketmode` reports **Rocket Mode is Enabled** on this machine. That was checked two ways. `pyrevit.exe configs rocketmode` for the setting; and the package-discovery and clean-engine decision replayed on host CPython against the real `pyRevit_config.ini` and the real manifests — WillTools comes back `is_valid=False`, every shipped pyRevit extension `is_valid=True`. **So this is a pin, not a lever.** It buys no restart-freedom today, because the behaviour is already in force. What it buys is that the behaviour stops depending on a field being absent from a JSON file. Adding a `url` to `extension.json` — the obvious thing to do the day this ships from its repository — silently switches every button to the shared engine and brings back exactly the pain this plan was called for. `engine: clean: true` makes the three buttons immune to that. It is landed on that basis and nothing more. It is not the reason the restarts happened today; §1 is. ### 8.3 Where it was applied Churn over the seven days to 2026-08-30, by distinct commits touching a button's modules: | button | commits, 7d | today | in-process lines (2026-08-30) | | --- | --- | --- | --- | | **TicketQC** | 51 | 7 | 13,179 | | **ProjectConfig** | 14 | 6 | 5,357 | | **PanelLiftCheck** | 13 | 5 | 2,078 | | ExtractDesign | 4 | 2 | 1,793 | | ReinforceSuggest | 3 | 3 | 4,126 | The top three carry the key. The other seven buttons do not — and that ranking comes from the commit columns, which are a closed seven-day window and do not drift. **The line column is dated and `unverified` as of 2026-08-31.** It has moved (TicketQC's six modules alone are 13,635 today), but it cannot simply be re-run: whoever measured it did not record which modules each button's total covers, and the obvious guesses do not reproduce the 2026-08-30 figures. Re-deriving it means defining the walk first — per button, its `script.py` plus the `vibe` modules it imports transitively — and that is a piece of work, not a `wc -l`. Left in place, stamped, rather than deleted: it is the right order of magnitude and it is what the decision below was taken on. ### 8.4 What the first press after a restart should show Nothing measured live: the ribbon is built at load, so this cannot be checked without a restart, and no bridge call can answer it. **The cheap check, no code.** pyRevit caches the parsed extension at `%APPDATA%\pyRevit\2024\pyRevit_2024_cache_WillTools.pickle`, rebuilt on every load. Before this change, all ten ribbon buttons cached `requires_clean_engine = False` (verified 2026-08-30 19:17). After a restart it must read `True` for TicketQC, ProjectConfig and PanelLiftCheck and `False` for the other seven. It is a Python-2 pickle of `pyrevit.extensions.components` types; unpickle it with an `Unpickler` whose `find_class` returns a stub, and read `requires_clean_engine` off each component. **The behavioural check, one minute.** Press TicketQC. Change a literal string that renders — a section heading in `ticketpage.py` will do. Press it again **without restarting**. If the new heading appears, the engine is re-importing `lib/vibe` on every press and §8.2's reading is confirmed on this machine. If the old heading persists, §8.2 is wrong, `engine: clean` is doing real work after all, and §1's table needs its third row rewritten. Either answer is worth having. Record it here. ### 8.4a Recorded — the offline check, run 2026-08-30 20:45 **The cache still describes the OLD ribbon, so its flags are not a fact about today.** That is the finding, and it is more useful than the flags would have been. | | | | --- | --- | | file | `%APPDATA%\pyRevit\2024\pyRevit_2024_cache_WillTools.pickle` | | mtime | **2026-08-30 19:17:43**, 14,304 bytes — the same moment §8.4 above records its "verified 19:17" reading. It has not been rewritten since. | | components carrying the flag | 10 | | `requires_clean_engine = True` | **0 of 10** | The ten it lists are `ExtractDesign`, `GravityLoadChase`, `LateralExtract`, `PanelLiftCheck`, `ProjectConfig`, `ReinforceSuggest`, `SelfCheck`, `SnapshotClose`, `TicketQC` plus one more no longer on the ribbon. That set is the ribbon as it stood **before** 2026-08-30's restructure: `JobRecon` and `Takeoff` have come back onto the ribbon out of the deleted `parked/`, and the two `.pulldown` containers (`Config`, `DesignHub`) do not appear at all. Meanwhile `engine: clean: true` is on disk right now in `TicketQC`, `ProjectConfig` and `PanelLiftCheck`'s `bundle.yaml`. **So the cache is rebuilt at pyRevit load, not on a bundle edit, and Revit has not been restarted since either change landed.** Reporting these `False` flags as current state would be reporting a file that predates the thing it is being asked about. What the check does establish: 1. the unpickle-with-a-stub technique works, and is the right way to read this — no `pyrevit` import needed; 2. the flags are a **post-restart** measurement and nothing else. Re-run the probe after the next Revit restart; §8.2 predicts `True` for exactly TicketQC, ProjectConfig and PanelLiftCheck, `False` for the rest, and the component list should show the new ribbon; 3. §8.2's reading is not tested by this either way. The cache said `False` for all ten before the `engine: clean` keys were written too, which is what the 19:17 reading already said. **The behavioural check (press, edit a heading, press again) is still not run** — it needs a person at the keyboard in Revit, and no bridge call can answer it. It stays open. ### 8.5 When to take it back out **Per button, as that button stabilises.** The comment sits in each `bundle.yaml`; the whole of the instruction is "delete these three lines". A button whose modules have not been touched in a fortnight does not need the pin — and once §4 and §7 have moved the churn to the host, the pin protects nothing worth protecting. It is not a permanent setting and it should not outlive step 6. --- ## 9. Where this starts Step 1, and read `docs/plan-ticketqc-refactor.md` first — it owns the shape of the ticket data this plan serialises, and its phases are not finished. Nothing here reorders that plan; this one starts where a ticket has already been read correctly.