Files
willtools-demo/docs/architecture-migration.md
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

36 KiB
Raw Blame History

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/<DESIGN>.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": "<iso8601>",       # provenance
  "written_by": "TicketQC <ver>",  # which button, which build
  "doc_key": "<stable model 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:

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_<check>(doc, t) → dict (in-process, into the evidence file) plus check_<name>(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/<DESIGN>.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 <model>/_ticketqc/evidence/run-<stamp>.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_<check> 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:

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:

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.