# pyrevit-dev — you are wired to a live Revit session You were launched from a button inside Revit. **Revit is running right now**, with a model open, and you can read and modify it directly. Use that — do not write Revit code you have not run. Engine: **IronPython 2.7.12**. The Revit and pyRevit versions differ between the machines this is developed on (2025.4 / pyRevit 6.5.3 on one, 2024.3 / pyRevit 5.0.1 on the other), so ask rather than assume — `rvt.py status` answers it — and guard any API name that moved between versions. Two already known: `BrowserOrganization.GetFolderFields()` does not exist on the older binding, and `Dimension.Curve` throws on drafting views. --- ## 1. Talking to the running Revit ```bash python tools/rvt.py status # which model is open python tools/rvt.py exec -c "print(doc.Title)" # run a line inside Revit python tools/rvt.py exec somefile.py # run a file inside Revit python tools/rvt.py exec somefile.py --tx "名字" # wrap in a transaction python tools/rvt.py sel # what the user has selected python tools/rvt.py collect OST_Walls --limit 20 python tools/rvt.py params 123456 # every parameter of one element ``` `exec` returns `{ok, stdout, result, error, traceback}` — real stack traces, so iterate until it works. Snippets get `doc`, `uidoc`, `uiapp`, `view`, `DB`, `UI`, `vibe`, and the common Revit types pre-imported. Assign to `result` (or make the snippet a single expression) to send a value back. If `status` cannot connect: Revit is closed, or a **modal dialog** is open in Revit — handlers run in Revit's API context and cannot fire behind a dialog. Say so rather than guessing. ### Every answer is checked against the request that asked for it Since 2026-09-01 `rvt.py` stamps every request with an id (in the JSON body, GET routes included — request headers never reach handler code either), `startup.py` echoes it back, and an answer carrying a **different** id is refused outright: both ids named, nothing returned. The reason is that pyRevit serves every route through **one shared response slot** — `routes/server/server.py`'s module-level `REQUEST_HNDLR` / `EVENT_HNDLR`, whose single `_response`/`_done` pair every connection thread resets, waits on and reads — so a client-side timeout cancels nothing, and the abandoned request's late answer is handed to whoever asks next. Measured that day, twice: one sweep call timed out at 300s, and the `doc.Title` calls after it came back with the sweep's progress string, which the sweep printed as the document title and an agent read as the model having changed underneath it. The files on disk were right; only the attribution was wrong, and **a response from the wrong request is indistinguishable from a valid answer**. So a timeout is now an explicit could-not-judge (exit 1, `COULD NOT JUDGE`, no value) that latches the channel in `.bridge-timeout-.json`; the next call drains the stale answer out of that slot with a self-identifying probe before it sends anything real, and refuses to send at all if the channel cannot be proven clean. An in-Revit half that echoes **no** id — which is every session until Revit is restarted after this lands — is warned about on stderr and flagged `_unverified` in the body rather than refused, because a restart is the only way to load the new half and refusing would take the bridge down for every tool in the meantime; pass `--strict` (or set `WILLTOOLS_BRIDGE_STRICT=1`) to refuse instead. ### The bridge can now REFUSE, and a refusal is not a fault Since 2026-09-01 every route — `/status` included — refuses outright when the open document's title is on `bridge.refuse_titles` in the job or plant layer. That is the owner's *不要碰我的live* written as code. The answer comes back `{"ok": false, "refused": true, "doc_title": ..., "pid": ..., "error": "REFUSED: …"}` and it means **you reached the live session, not the sandbox**. Do not retry it, do not work around it, and do not go looking for another way in. **The owner reversed that policy the same day, for the model it protects.** A title on `bridge.readonly_titles` instead is served normally through the bridge — including `/exec` — but only for reads: the iron rule is *铁律是在 live里面只读不写*, and `/exec` refuses outright when the request carries a transaction or the snippet matches known write-capable Revit API surface, coming back the same `refused: true` shape as above with a one-line reason naming the title and the rule, never a silent no-op. Why it can happen when you did nothing wrong: pyRevit walks the routes port **up** from the configured one past every Revit already holding one, so 48884 is "whichever Revit started first" rather than the session you meant. With two sessions open the sandbox is usually on **48885** — and its `doc.Title` is identical to the live model's, because the snapshot deliberately keeps the title so the job folder does not move. `rvt.py` reads `WILLTOOLS_BRIDGE_PORT`; set it, or let `tools/snapshot_cycle.py` find the right session for you. The whole mechanism, including how a snapshot proves it is a snapshot, is in `docs/snapshot-cycle.md`. ### Never reload pyRevit. Restart Revit instead. This is the single most important rule here, and it is not a style preference — it kills Revit outright. pyRevit's routes request handler is an external event handler owned by the scripting engine that first loaded it. `reload_pyrevit()` tears that engine down but leaves the handler and the HTTP server pointing at it. **The next bridge request after any reload executes against a dead engine and takes Revit down** — an unhandled exception on a non-main thread, which Revit cannot catch. Measured on 2026-08-14: 11 clean requests, one reload, the 12th request fatal. It happened twice. Nothing inside Revit can defend against this — our code never gets to run. So: - There is no `/reload` endpoint, and `rvt.py reload` refuses. - `startup.py` appends a line to `.bridge-sessions.log` on every pyRevit load. - `rvt.py` reads that log before **every** request and hard-refuses if it sees two loads under the same Revit process. If `rvt.py` tells you the bridge is unsafe, do not work around it, do not curl the endpoint directly. Ask the user to **restart Revit**. A restart picks up new buttons exactly as well as a reload does, and a fresh process is safe. If `rvt.py` warns that there is no session log while Revit is clearly running, the tripwire itself is broken — say so and do not reload. --- ## 2. Layout ``` pyrevit-dev/ ├── CLAUDE.md ← you are here ├── INSTALL.md deploying to another machine / Revit version ├── tools/rvt.py client for the live bridge ├── tools/package.py builds dist/willtools--.zip ├── tools/sweep.py check every WP assembly in the open model ├── tools/snapshot_cycle.py make a timestamped detached copy, cycle a │ Revit onto it, run a batch, close it ├── docs/snapshot-cycle.md …and why the detach is not done in-session ├── docs/revit-api-cheatsheet.md working Revit 2025 patterns — read when stuck ├── docs/interference.md why the clash check is shaped the way it is ├── docs/lift-check.md the handling check, and what it will not answer ├── docs/handoff.md open engineering questions, the last sweep, │ what to pick up first ├── knowledge/catalogue.yaml published hardware ratings (ships with the tool) ├── panellift/ PCI Ch.8 handling checks — CPython, own suite │ ├── src/panellift/cli.py `python -m panellift job.json -o report.html` │ └── src/panellift/layout.py where the inserts should go instead └── WillTools.extension/ ├── startup.py registers the bridge (no UI) ├── lib/vibe/ │ ├── helpers.py q() p() set_p() tx() ft() mm() selected() ... │ ├── compat.py text_type / string_types — py2+py3 shim │ ├── designspec.py evidence scrape, spec schema, spec io │ ├── aiextract.py the AI reader — NOT the extractor any more │ ├── knowledge.py knowledge.yaml → defaults, family rules │ ├── configdump.py all five layers flattened into one file │ ├── configmodel.py what may be edited, its provenance, the splice │ ├── liftexport.py assembly → panellift job file, and runs it │ ├── ticketqc.py reads tickets, the comparison checks │ ├── ticketchecks.py the checks that need arithmetic │ └── ticketqc_run.py the shared engine behind TicketQC and │ ReinforcingQC (a `mode` parameter apart) └── WillTools.tab/ the ribbon — "Folder names are the UI" │ below is the whole of how it is built ├── Tools.panel/ Modeling.pulldown/ — ReinforceSuggest, │ ReinforcingQC, TicketQC — plus │ SnapshotClose as the one remaining large │ icon (reorganised 2026-09-05; this panel │ used to hold all four as large icons) ├── Setup.panel/ Config.pulldown/ — one large button with a │ dropdown arrow: ProjectConfig, │ ExtractDesign, SelfCheck, JobRecon, │ Takeoff ├── Design.panel/ DesignHub.pulldown/ — PanelLiftCheck and │ PanelAnalysis (both finished, both grouped │ here on the owner's own instruction), then │ BeamAnalysis, ConnectionAnalysis, │ GravityLoadChase and LateralExtract, all │ four unfinished and titled "(WIP)" └── Lab.panel/ GodMod — the button that launched you. ``` **`aiextract` no longer extracts anything.** Design Extract was rewritten to read the scrape in-process with `ticketqc`'s regex/geometry parser — its own header says "no AI call, no subprocess, nothing that leaves this process" — and it imports `designspec`, `ticketqc`, `knowledge` and `specsave`, not this. The module is kept, and its only live callers are `find_claude()`: Self Check probes with it to say whether the CLI is on this machine, and the GOD_MOD button uses it to find the CLI it launches. The prompt, the `claude -p` spawn and the cross-check are all still there and nothing calls them. Spec files written before the rewrite can still carry `source.extractor: 'opus'` (or the older `'claude-code'`); the mechanical reader stamps `'regex'`. Nothing branches on which of the three it finds — the field is displayed, in Design Extract's own spec table, and never acted on — so anything touching it has to tolerate all three. Do not describe this module as the extractor. **`panellift` is not importable from Revit and that is deliberate.** It is CPython — dataclasses, typing syntax — and the extension is IronPython 2.7. The two talk through a JSON job file, so the engineering can be tested without a licence, a model, or Windows. `vibe.liftexport` is the whole Revit side of that boundary: it measures the piece, writes the job, shells out to CPython, and reads the summary back. Anything about *mechanics* belongs on the far side of that line; anything about *this model* belongs on this side. Most of `vibe` does not touch Revit at module scope either — only at the top of individual functions that need a live `doc` (`knowledge.load()`, `liftexport.export()`, and anything that reaches for `vibe.designspec` / `vibe.ticketqc`, which import the Revit API directly). That leaves real logic testable with no model and no licence, the same way `panellift` is. `WillTools.extension/tests/` is 34 files covering about thirty of the `vibe` modules — `ticketpage`, `knowledge`, the host-side half of `ticketchecks`, the `configmodel` / `configform` / `configpage` set, the `reinforce*` and `takeoff*` sets, the part of `liftexport.py` below `export()`, and more — run straight on host CPython: ```bash cd WillTools.extension && python -m pytest python -m pytest # from the repo root: this suite + panellift ``` **Run it; do not quote a count from here.** The count in this paragraph has been wrong every time anybody has checked it — the suite grows daily, and on 2026-08-31 the root total moved twice in one afternoon while several agents were working. For scale only, and only that: it was roughly 1,800 under `WillTools.extension` and roughly 2,100 from the repo root that day. The root run also sweeps `_scratch/`, which is gitignored agent working space, so its total moves for reasons that have nothing to do with the tool. **One test fails on a configured machine and it is the test that is wrong.** `tests/test_reinforceoffline.py::test_no_index_and_no_jobs_root_says_all_three_ways_to_give_one` calls `resolve_index(None, None, None)` expecting the "no jobs root" refusal, but `resolve_index` falls back to the ambient `WILLTOOLS_JOBS`, so on any machine that actually has the tool set up it gets the "which model?" refusal instead. It passes under `env -u WILLTOOLS_JOBS`. Nothing in the tool is broken; the test needs to clear the variable. Measured 2026-08-31, not yet fixed — so a green suite here is everything passing **except that one**, and one failure is not automatically your change. `WillTools.extension/tests/conftest.py` is how: it loads a module by path instead of `import vibe.whatever`, because `vibe/__init__.py` pulls in the Revit API the moment it runs. Read the docstring there before adding a test for a new module — it explains exactly which shape of code this can and cannot reach, and `ticketqc.py` / `ticketchecks.py` are the standing example of code that cannot: they import `Autodesk.Revit.DB` at module scope, not just inside a function, so freeing them needs more than moving an import. Ticket-QC data lives beside the **model**, not here: `/_ticketqc/{evidence,specs,log}/` + `knowledge.yaml`. Knowledge is read in **five layers**, each overriding the one before: | | | | --- | --- | | `knowledge.DEFAULTS` | what the code assumes — in this repo | | `knowledge/catalogue.yaml` | published hardware ratings — in this repo, identifies nobody | | `%APPDATA%\WillTools\knowledge.yaml` | what this **plant** always does — naming conventions, casting-bed rails | | `/_ticketqc/knowledge.yaml` | what this **job** does — parameter names, tolerances, per-job overrides | | the **project config file** | what the engineer set, wherever they put it — opt-in, and it wins | Only the first two are in the repository, and that is the point: between them the other three are a full description of one office's template. Set `WILLTOOLS_KNOWLEDGE` to put the plant layer on a share. `knowledge.sources(doc)` lists the layers; Self Check prints them. **Where the layers get their facts from: `knowledge/library/`.** It is a drop folder for source PDFs — manuals, manufacturer catalogues, datasheets — plus a ledger with one row per document, keyed by content hash, recording what was extracted from it and **which file and key path each fact landed in**. The folder is gitignored as a folder (not merely by `*.pdf`), because the ledger and the extraction notes quote their sources verbatim and `scrub.py` cannot read a PDF; like `knowledge/plant/` it is the other repository's territory, and `scrub.py --private` is what reads it. **Idle capacity drains it by `knowledge/library/RUNBOOK.md`** — inventory, extract with a page cite and a confidence mark on every fact, **land**, verify, report. The landing step is the point and it is the one that gets skipped: a 929-page manual was once mined into ten notes and a 25-fact audit of which almost nothing became YAML, and the owner ruled on it — *"提取出来以后没有写入。今天不要再犯这个错误"*. A document read but not landed is a debt, not progress, and the ledger says so in the status column. `python tools/library.py` lists the folder against the ledger. **Extraction notes are clues, never sources — a number lands only off the page itself.** Measured 2026-08-31: a cheap-model extraction pass over one document drop carried a **42% page-error rate** on independently sampled pages — inverted inequalities, misidentified parts, specs blurred into vagueness — and the landing pass then caught three more errors the audit itself had missed, including a one-row table shift and a column read under the wrong heading. Three stages, none may collapse into another: 1. **Extract** with a page cite on every fact (cheap model is fine — speed is what it buys). 2. **Audit independently** — a different agent samples pages against the note. A sub checking its own family's work reported zero errors on the same material where the independent auditor found the 42%; self-audit is not audit. 3. **Land off the page** — any NUMBER moving from a note into a YAML layer is re-read against the rendered source page by the landing agent, cited `# SOURCE: p.N, verified `. A number that cannot be confirmed lands as `null` with the reason and an open question — never as the note's value, never as a plausible reconstruction. **Then run `python tools/knowledgelint.py`.** Landing is not finished until it passes. pyRevit's YAML reader is strict — one mapping key stated twice raises `Duplicate key` and it returns *nothing* — and a layer that fails to load looks exactly like a layer that agrees with the defaults. Measured 2026-09-01: one nested duplicate in a plant file discarded that whole 255KB layer inside Revit, every parameter role came back None, the sweep enumerated 0 assemblies, and every report rendered normally. The lint strict-parses every layer on this machine and exits 1 on any that will not load; `knowledge.config_problems()` and Self Check now say so too, but only after the file has already been written. Where a table carries its own internal identity (a recommended value derived from two measured columns, a series that must be monotonic), assert the identity across the whole table at landing — it is the cheapest error detector there is, and it is what turned "one value off by one" into "the whole block is shifted a row". **`knowledge.load()` reads the job file twice, and that is not a bug.** The job layer holds `job.plant`, which names *which plant folder* the plant layer is read from — but the plant layer merges **before** the job layer, so by the time the name is in the merged tree the plant file has already been read from the wrong path. The dependency runs backwards through the merge order and no reordering fixes it. So `plant_id()` reads the job file (and the config file above it, which can override the same key) once on its own, before anything merges, purely to answer "which plant" — then the ordinary five-layer merge runs. **Do not collapse this into one pass.** Deleting the pre-pass makes the tool read the machine-wide plant file while the job file plainly says which plant it is for, and every report still looks completely normal. `_plant_layer_stale` is the tripwire for exactly that, comparing the plant the file was chosen by against the plant the merged tree ends up naming. Same discipline as `_config_missing`: a named plant whose file is absent sets `_plant_missing` and never falls back to the machine-wide file. The last layer is what the **Project Config** button writes. A model has one only once somebody has pressed that button, and the file goes wherever they choose — so a one-line pointer at `/_ticketqc/active-config.txt` records where it is. It is deliberately last: it is the layer a human edited most recently and by hand. Two rules around it, both learned the hard way in other tools: - A pointer to a file that is **gone** must never fall back quietly. The run would use a configuration nobody chose and look completely normal. `load()` marks it `_config_missing` and `knowledge.config_problems(doc, know)` turns that into a sentence a button can refuse on. - The file opens with `decisions:` — every setting that is the engineer's to make, gathered from wherever it is really read and put above everything else, each under its own comment. Where the reports go, whether they open, the prestress loss, the transfer length, and the per-stage rigging mode. `knowledge._merge_decisions` maps every one of them back on load. **A file where editing the obvious place does nothing is a trap**, so that mapping has to exist in both directions or the section is decoration. **Write for both Pythons.** pyRevit picks the engine and this extension ships to machines whose engine we do not control, so on top of the IronPython 2.7 rules below: no `unicode` / `basestring` (use `vibe.compat`), and guard any Revit API name that moved between versions. `python tools/package.py` and `INSTALL.md` cover the rest. Folder names are the UI: `Foo.tab / Bar.panel / Baz.pushbutton / script.py`, plus `Bar.panel / Thing.pulldown / Baz.pushbutton` for a pulldown. Copy `GodMod.pushbutton` as a structural template (not its contents). `*.stack` also exists and this tab used two of them until 2026-08-30. Both are gone: a stack is up to three *small* buttons in one panel slot, and the owner wanted the set-up commands off the panel entirely rather than shrunk on it. Prefer a `.pulldown` — one large icon plus an arrow, unlimited rows, and the rows are free to carry readable titles because a row is text beside a 16px icon rather than a ribbon slot. **Which name renders, and from where** — this is not uniform and guessing it wastes a Revit restart: | | | | --- | --- | | tab | the **folder** name. `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)`; a `title:` in a tab bundle is ignored. | | panel | the **folder** name, same reason (`create_ribbon_panel(panel.name)`). Rename the folder to rename the panel — spaces are fine, pyRevit ships panels with them. | | stack | nothing renders. The folder name is only what the panel's `layout:` refers to it by. | | pulldown | `bundle.yaml`'s **`title:`** — unlike a stack, a pulldown container really does read one. `genericcomps.GenericUIComponent._read_bundle_metadata` sets `_ui_title` from it and `uimaker._produce_ui_pulldown` passes that to `create_pulldown_button`. With no `title:` it falls back to the folder name. | | button | `bundle.yaml`'s **`title:`**, and nothing else. `__title__` in the script is the fallback pyRevit reads only `if not self.meta`, i.e. only for a button with no `bundle.yaml` at all — keep it in step anyway, or the old name comes back the day a bundle file is trimmed. | **Order is `layout:` in the container's own `bundle.yaml`**, a list of child folder names (minus their `.panel` / `.stack` / `.pushbutton` suffix), read by `genericcomps.GenericUIContainer.parse_layout_metadata`. The old `_layout` file is gone in pyRevit 5.0.1 — do not write one, nothing reads it. Two things about that list are worth knowing before it bites: - **A child not listed is not created.** Not misordered — absent. Add a button, add it to the layout, or it silently never appears. - `"---"` inserts a separator and `">>>"` a **slideout** (everything after it hides behind the arrow at the panel's bottom-right). Both must be quoted — a bare `>` starts a folded block scalar and the panel fails to parse — and neither may be the last entry, because `__iter__` only honours a marker whose index is below the last one. **A `.pulldown` needs its own `icon.png` or it renders blank.** A pulldown is a component like any other — `genericcomps` resolves an `icon.png` for it, and `_produce_ui_pulldown` hands that to `create_pulldown_button`. When there isn't one, `ribbon._create_button_group` catches the icon error and only `mlogger.debug`s it, so you get an empty ribbon slot and no message anywhere. `python tools/make_icons.py` draws both of the tab's pulldown icons; run it and **read its output**, because it prints "skipped, no such component" for a path that has moved rather than failing. **Unfinished tools never sit on a panel with finished ones.** Standing owner instruction, 2026-08-30. `Design.panel` is where they go — a panel of its own whose single button is the `DesignHub` pulldown, so nothing half-built is on screen until somebody opens it; the `(WIP)` suffix on the title is what names them once they do. (Until 2026-08-30 this was the slideout on `Lab.panel`. Same rule, better door: a slideout arrow is easy to miss and shares its panel with a working button, which is the thing the rule is against.) `DesignHub` also holds two FINISHED rows today, PanelLiftCheck and PanelAnalysis — the owner grouped them here on 2026-09-05 because he wants the analysis tools together, not because either stopped working; see that pulldown's own bundle comment. A button that is finished but not wanted on screen used to move to `WillTools.extension/parked/`, outside every `*.tab`. **That folder is gone** as of 2026-08-30 — JobRecon and Takeoff, its only residents, came back onto the ribbon inside `Config.pulldown`. A pulldown row costs no panel space, so there is now somewhere on the ribbon to put a button nobody wants underfoot, and parking has no job left. Do not recreate `parked/` without asking; if a button really must leave the ribbon, taking it out of the parent's `layout:` already stops it being built. Titles are **short and two lines**: a large ribbon button is as wide as its widest line, which is how "Reinforcing Suggestion" came to dwarf everything next to it. `title: "Ticket\nQC"` — double quotes, because YAML only honours `\n` inside them. Keep each line under ~9 characters. Rows inside a pulldown are the opposite case — text beside a 16px icon, in a dropdown that grows to fit its longest row — so those stay **single-line and may say what they mean** (`Project Config`, `Design Extract`). A `\n` in a pulldown row has nowhere to go. **No button carries hover text.** The owner asked for it twice, so it is now a test, not a convention: `bundle.yaml` gets no `tooltip:` and no `author:`, and `script.py` gets **no module docstring at all**. Document a button in `#` comments under the coding cookie instead — pyRevit cannot render those, and nothing is lost. `tests/test_button_tooltips.py` fails and names the button if any of the three comes back. The trap it exists for: pyRevit reads the docstring only `if not self.meta` (no `bundle.yaml`), so a docstring can sit harmless for months and become hover text the day a bundle file is trimmed or a button ships without one. pyRevit still appends `Bundle Name:\n (pushbutton)` to every tooltip in `uimaker._make_button_tooltip`, with no setting to suppress it. Those two lines are the floor; do not try to beat them by editing the pyRevit install. **Editing an existing `script.py` needs no reload** — pyRevit re-reads it on every click, so most iteration costs nothing. **Adding or renaming** a button needs a **Revit restart** (see the rule above — not a pyRevit reload). --- ## 3. Hard rules **Dry-run and self-test before the owner touches it.** Owner, 2026-09-02: "以后写完代码先测试,做dry-run,能修的bug修完以后再让我来测试." Finishing a change is not "the code is written" -- it is "I ran it, on a real input, saw no garbage, and fixed every bug I could before handing it over." For a tool that reads the live model, that means: export a real assembly to a deck through the bridge (read-only), run the offline solver on it (`panellift`), read the output, and fix what breaks -- BEFORE asking the owner to press the button. His time is the last check, not the first. A handoff that says "try it" without a clean dry-run behind it wastes the one reviewer who cannot be replaced. This sits beside "report outcomes faithfully": a dry-run that still errors is reported as such, not smoothed over. **A green host suite is not a live dry-run, and a clean merge is not one either.** Added 2026-09-03, after Panel Analysis reached the owner broken twice in a day. The form passed all 127 `panelanalysis` host tests and pre-filled *nothing* live: it read mark and f'c only through plant `knowledge` roles, and on that job the plant mapped those roles to nothing, so every field came up blank, the header said "unnamed panel", and the project / client / date fields the owner expected did not exist at all. Host tests check the schema; they cannot see that a form reads no real value from a real model. So anything that READS A MODEL or renders a FORM is dry-run **through the live bridge, read-only**, with the actual values it recovered confirmed present, before it is handed over -- the button press is the owner's last check, never the first. The same holds for code that arrives by MERGE from the other machine: a merge that applied cleanly is UNVERIFIED, not validated. The `\xe2` crash and the blank form both shipped in merged front-end code and both first failed when the owner ran them -- run every gate AND a live dry-run on merged code exactly as on your own. **A WPF button gets a bridge smoke-import before it reaches the owner.** The host cannot import `System.Windows` at all, so a wrong WPF namespace is invisible to every host gate -- 2026-09-03, `StretchDirection` imported from `System.Windows.Media` (it lives in `System.Windows.Controls`) and the button died at import the first time the owner clicked it, the day after the rule above was written. The bridge runs IronPython INSIDE Revit, so the check is cheap: exec the button's `from System.Windows...` import block through it (`python tools/wpf_smoke.py `) and require zero import errors. Owner, the same day: "写的东西测试过了再给我,别老让我报错." A button handed over without that run is a button the owner is testing for you. **A failure class that is invisible on the host earns a guard test.** The Panel Analysis crash on 2026-09-03 was a non-ASCII byte in an IronPython 2.7 file with no `# -*- coding: utf-8 -*-` cookie: a hard `SyntaxError` inside Revit, and completely silent on host CPython 3, which defaults to UTF-8. A bug that only bites in the engine we cannot run here, and passes every host check, recurs unless a host test reproduces its shape. `tests/test_coding_cookie.py` now walks every extension `.py` and fails on a non-ASCII byte with no cookie, the way `test_button_tooltips.py` guards hover text. When a class of failure is invisible on the host but real in Revit, add the guard rather than trusting the next person to remember. **Reading a model must be GENERIC -- geometry and built-ins first, a mapped role only as an accelerator.** Owner, 2026-09-03: "很多的预填充应该是通用的代码,避免 换一个活儿就得重来." A pre-fill or scrape keyed to one job's parameter names is rework waiting for the next job. Dimensions and weight come from GEOMETRY (bounding box, solid volume) -- job-independent, the floor. Names come from Revit BUILT-INS (`Mark`; ProjectInformation's Project Name / Client Name). Strengths and the like, when the plant role is unmapped, fall back to case-insensitive parameter-NAME PATTERN matching (f'c: "f'c" / "fc" / "28 day"; release: "fci" / "release"; unit weight: "density" / "pcf"), never one job's literal name. The plant `knowledge` role is the accelerator where it exists, never the only path. This is "no client/job information in the repo" seen from the code side, and the same law as the five knowledge layers: switching to another job must pre-fill out of the box with no edit. **Delegation is EXPLORE (me) -> WRITE (DSV4F, to a settled spec) -> REVIEW (me), and the tooling is what makes it safe.** DSV4F's code quality is not the risk -- on 2026-09-03 its `tools/rvt.py` work was correct and passed review plus 16 new tests; every DSV4F failure that day was INFRASTRUCTURE, and each has a standing fix. (a) The dsh headless CLI times out and the dsh sandbox denies pytest's `tmp_path`, so GATES RUN OUTSIDE the sandbox: I run pytest / scrub on my side, DSV4F only writes. (b) A dsh sub that stalls or hits an approval prompt does not report back, so a dispatched dsh sub is WATCHED with `tools/dsh_watch.py ` in the background -- its exit is the wake signal, instead of the owner having to tell me it stopped. And do NOT `fork` for execution work: a fork inherits the conversation and on 2026-09-03 continued in reply-to-the-owner mode with zero tool calls instead of doing the job. For "go build / change X", spawn a FRESH agent with an imperative, self-contained brief -- the JUDGEMENT about what to build is still mine; the reading that informs it goes to a Sonnet sub-agent, not to me and not to DSV4F (PROJECT.md §3.9). And SPLIT the settled spec before it reaches DSV4F (owner, 2026-09-03: "确认了边界的活儿,拆细了再让 DS 干"): one bounded piece per task -- a function, a section, one file's change -- each sized to finish inside a SINGLE reply. A whole-feature spec handed to DSV4F in one turn truncates at max-tokens (the PanelAnalysis write stopped after the easy schema edits, `stopReason: length`), which reads as "it skipped the hard parts" when it simply ran out of output. Exploration never goes to DSV4F -- but it does not stay with the expensive coordinator either: it goes to a Sonnet sub-agent (owner, 2026-09-05: "探索你带着sub sonnet…DSV4F别做探索。DSV4F就干点儿体力活儿"). DSV4F gets labour only: extraction, mining, translation, bounded pre-split writes. PROJECT.md §3.9 carries the incident, the `cmd -p` Command Code route when the DeepSeek platform balance is empty, and the `systemd-run --user` rule for any worker that outlives a turn. **IronPython 2.7 syntax** for everything under `WillTools.extension/` and every `exec` snippet: - No f-strings. Use `'{0}'.format(x)`. - `print('a:', x)` prints a **tuple** `('a:', 5)`. Build one string instead. - No `pathlib`, `dataclasses`, typing syntax, or walrus. - Only `tools/rvt.py` runs on host CPython 3.11 — modern syntax is fine there. **Any change to the model needs a Transaction.** Reading never does. ```python from vibe import tx with tx('Name the user will see in Undo'): ... ``` No nested plain `Transaction` (use `SubTransaction` / `TransactionGroup`). **Internal units are decimal FEET.** Always — areas in square feet, angles in radians. Convert at the boundary with `vibe.ft(mm)` / `vibe.mm(feet)`. Use `UnitTypeId.*`; `DisplayUnitType` was removed in Revit 2022+. **`ElementId` in 2024+**: `.IntegerValue` is deprecated for `.Value`. Read it defensively (`try IntegerValue / except .Value`) as the existing code does. **Don't block Revit's UI thread.** No sleeps or polling loops inside handlers. **"Could not judge" and "judged, found nothing" are different answers, and nothing here may collapse them.** This is the discipline the whole tool is built on, and it shows up in three places that each look like a local detail: - **A knowledge lookup that cannot resolve returns `None`, never `False`.** `knowledge.same_face()` returns `None` when either token is an abbreviation it does not know — because "I cannot read that" and "those are different faces" are opposite answers, and `False` would assert the second. `rebar_stock()` carries `basis = None` for "nothing knows a stock length" alongside `'by_size'` and `'generic'`, and `stocked` is a separate `None` again. `rebar_fy()` returns `basis = None` and `fy = None` when no grade is defensible — A615 names no strength, so its `default_fy_ksi` is deliberately null. Each of them also returns a `why` sentence: standing down without saying why is the same failure one step later. Follow this for any new lookup. - **An unrecognised state word renders red, never grey.** `ticketpage.state_color()` matches GREEN / YELLOW / GREY states by name and everything else falls through to red. That fallthrough is load-bearing, not a default: it is what makes it safe to add a state like `MUTED` at all, because a typo in a state word becomes loud instead of quietly grey. Grey is reachable only through a word the table knows — a *declared* mute. Do not "tidy" the fallthrough into grey or into an exception. - **A check that returns nothing owes an explanation.** See §5 below; this is the same rule, and it is the one a newcomer trips first. **Never run an unfiltered collector, and never guess at an API enum.** This crashed Revit on 2026-08-18 — a single exploratory snippet that walked the whole document with `FilteredElementCollector(doc).WhereElementIsNotElementType()`, touched `.Symbol` on every result, *and* set `Options.DetailLevel` from a value reached by `__import__('Autodesk').Revit.DB...`. Revit died with no exception and no journal entry, because a bad value handed to a native API is not a Python error. So: - Always scope a collector — `OfClass`, `OfCategory`, a view id, or just `asm.GetMemberIds()`. This model has ~377 assemblies and six figures of elements. - Import enums by name (`from Autodesk.Revit.DB import ViewDetailLevel`). If the import fails you get a traceback; if a bogus object reaches the native layer you get a dead Revit. - One narrow question per `exec`. Geometry extraction goes in its own request, never bundled with a model-wide scan. **A contradiction hit mid-refactor is the owner's to settle, not yours.** A restructure on 2026-08-31 (private repo `pyrevit-knowledge`, commit `767281e`, "总表三层化、厂层收割落库") walked into `lifters.capacity_lb` in a plant's `knowledge.yaml` and found an override the owner had entered by hand — `P76 = 16,000 lb`, the correct ALP with-tension-bar rating — disagreeing with `knowledge/catalogue.yaml`'s published 7,690 lb for the same part. The restructure emitted the key EMPTY and wrote a new comment reading "Direct override, checked BEFORE the catalogue. One-offs only," phrased as though the key had always been blank. `lifter_capacity()` checks that override before the catalogue, so every export after that quietly fell back to 7,690. Five real panels (WP242, WP283, WP318, WP322, WP326) each carried a `FAIL / PLUMB` finding — 8,660 lb each of a 34,638 lb piece against a 7,690 lb tension limit, 113% — before the owner asked on 2026-09-01 where 7,690 came from, and answering that took a git-history dig across two repositories. The sibling key in the same block, `lifters.min_edge_distance_in: 9`, survived the identical restructure untouched, which is what makes the dropped key a mistake and not a policy. Deleting the losing side of a contradiction, silently preferring the "published" value, and writing a comment that makes the surviving value look uncontested are all the same move: each retires the question instead of asking it. When a refactor meets a contradiction, stop and surface it to him instead. **No client or job information in the repo. Ever.** Not in code, not in comments, not in docstrings, not in a commit message, not in an example. When you need to refer to one, write the placeholder rather than deleting the sentence: | | | | --- | --- | | a client / owner | `sample_client` | | a job / project / model | `sample_job` | Product codes are **not** client information and stay as they are: design numbers (`PNL1.0`), piece marks (`PNL101`), hardware part numbers (`ANCHOR_FACE_4T`) are naming conventions, not identities. `python tools/scrub.py` checks the working tree **and the whole git history** against `.scrub-terms` (gitignored, so the list of names is not itself the leak) plus structural patterns — model filenames, absolute user paths, email addresses. It runs as a pre-commit hook and inside `tools/package.py`. The working-tree scan covers **untracked files too**. A scratch script nobody has added is exactly where a model path gets pasted, and it is one `git add -A` from being in the commit. `--worktree` is the audit and a hit anywhere fails; `--staged` is what the hook runs, where only staged files block and untracked hits are printed as a warning — a gate that blocks work unrelated to the commit is a gate that gets passed with `--no-verify`. **But every scan above honours `.gitignore`, so none of them has ever opened `QNA/`.** Nor `knowledge/plant/`, `knowledge/job/` or `_scratch/`. That is the largest body of prose in the checkout and the only part of it whose job is to quote source material verbatim, so a name is not merely possible there, it is certain. `--private` is the mode that looks at it, and it is **an inventory, not a gate: it always exits 0** and the hook never runs it. It asks a different question, because "does this contain a name" is answered yes by design — it asks whether a name is sitting in a shape that **travels**: inside a fenced block, a proposed `key: value`, a quoted literal, a path, or glued into a longer identifier (`_EMBED_P76`), which is the shape a reader skims past. Those are aggregated by the distinct string and listed, worst first; a name in a sentence, or in a value in a plant file, is the file doing its job and comes back as a number only. Read a row as "has this exact string already been pasted into a tracked file" and check it with `git grep`. It exits 0 because it has no verdict of its own: every string it prints carries a term or a structural pattern, so a copy into a tracked file is already a `--worktree` failure. What it finds is the raw material a leak gets made out of, weeks earlier — and a check that failed on that would fail every run, which is a check somebody wraps in `|| true`. Editing a file does not remove a name from the commit that introduced it, and `git push` sends commits, not the checkout. Before anything is pushed the history can still be rewritten; afterwards it cannot. **The open model is the user's real work.** Before anything that deletes, renames in bulk, purges, or touches a saved model: prototype read-only, show counts and a sample, and get an explicit go-ahead. Never use `--tx` to "just try" a mutation on a model you have not checked with the user. --- ## 4. Idioms ```python from vibe import q, p, set_p, tx, selected, ft, mm, out, table ``` | Instead of | Write | | --- | --- | | `FilteredElementCollector(doc).OfCategory(BuiltInCategory.OST_Walls).WhereElementIsNotElementType().ToElements()` | `q('OST_Walls')` | | `StorageType` branching | `p(el, 'Comments')` | | manual Transaction start/commit/rollback | `with tx('name'):` | | `uidoc.Selection.GetElementIds()` + `GetElement` | `selected()` | Signatures: `WillTools.extension/lib/vibe/helpers.py`. API patterns: `docs/revit-api-cheatsheet.md`. **Before touching anything that measures geometry, read `docs/interference.md`.** Two working-looking versions of the clash check reported no interference anywhere because a cylinder's edges are circles whose end points coincide; a third was two thirds false positives because a bent bar's bounding box is mostly the hole in the middle. Inside a button script, use pyRevit forms rather than raw WPF: `forms.alert`, `forms.SelectFromList.show`, `forms.ask_for_string`, `forms.select_levels`, and `script.get_output()` for `print_md` / `print_table` / `linkify`. --- ## 5. Building a new tool for the user 1. Prototype with `rvt.py exec` against the live model until it is correct. 2. Write it into `WillTools.extension/WillTools.tab/.panel/.pushbutton/` as `script.py` + `bundle.yaml` (+ optional 96×96 `icon.png`). 3. **Add `` to its parent container's `bundle.yaml` `layout:` list** — the panel's, or the pulldown's if it is a row inside one. A child that is not listed is not created — the button will simply not be there, and nothing anywhere will say why. 4. A tool that is not finished goes behind the `Design.panel` pulldown with `(WIP)` in its title, never onto a panel beside the working tools. 5. Ask the user to **restart Revit** (never "pyRevit → Reload"), then try it. Do not declare a tool finished before step 1 has actually run clean. ### Adding a check to Ticket QC — a new check owes a skip probe This is the rule most likely to bite you, because adding a check is the first thing anyone does in `ticketchecks.py` and the failure is silent at write time and loud on the page. Write the check, add it to `CHECKS`, and it runs. But a check returns a list of `Finding`s, and **an empty return is ambiguous** — it can mean *ran and found nothing wrong* or *never ran, for want of data*. `run_all` cannot tell, so it does not guess: | what happened | what `run_all` logs | what `checklist_states` shows | | --- | --- | --- | | returned findings | `RAN` | the verdict | | raised | `FAILED` | NOT RUN, plus a `CHECK-ERROR` warning naming the check | | empty, probe returns a reason | `SKIPPED` | NOT RUN, *with that reason* | | empty, probe returns `None` | `RAN` | OK — ran, found nothing | | empty, **no probe registered** | `RAN-EMPTY` | **NOT RUN, "no probe declared"** | So a check with no probe that ever legitimately finds nothing renders as a red NOT RUN row for a piece that is fine. That is deliberate — silence that looks like "fine" is the exact defect the checklist exists to surface — but it means **a new check with no probe is a defect you have just shipped**, not a check that works. And **a checklist row may name more than one check.** It is answered only when every one of them spoke; any quiet half makes the whole row NOT RUN and the note carries each quiet half's own reason. Under-claiming is the safe direction, because a green row is a claim — a row that asks two questions and prints OK because the half that could still fail it was never asked is exactly the silent pass the table exists to prevent. Register one beside the check: ```python @_register_skip_probe('check_your_thing') def _probe_your_thing(t, intent, know, spec): if not t.get('the_input_it_needs'): return 'no on this piece -- could not be judged' return None # None means: it ran, and found nothing. Never ''. ``` The probe's return is the *reason*, and it is printed. `None` is the "everything was there, it just found nothing" answer — do not return an empty string for that, and do not write a probe that can never return a reason. This is now test-enforced rather than a count somebody has to keep re-auditing: `tests/test_ticketchecks_host.py::test_every_registered_check_has_a_skip_probe` walks `CHECKS` and fails, naming names, the day a new check ships without a probe registered for it in `_SKIP_PROBES` — the same failure the 2026-08-31 backlog of seven bare `CHECKS` entries used to sit as silently. Run the test rather than trusting a number here; a count copied into this paragraph is exactly the kind of fact this file has gone stale on before. ### Tests must not write into the working directory `vibe.batchrun.make_worker` takes an `io` for exactly this reason. A test that actually **calls** the worker must pass `io=RecorderIO()`; the sibling tests all do, and the one that did not created a file literally named `s` — twice, once at the repo root and once under `WillTools.extension/` — which reached a commit (`68adc27`) before anyone noticed and had to be deleted in `9504c05`. Anything that only inspects the worker's code object can pass the real one, because nothing runs. **This is a convention with nothing enforcing it.** There is no fixture and no guard; `pytest` will happily let a test write anywhere. Use `tmp_path` or a recorder, and be aware that the next stray file is one `git add -A` from the history — which is the same reason `tools/scrub.py --worktree` scans untracked files. A guard would be better than this paragraph; that is a separate job and nobody has asked for it yet. --- ## 6. When stuck - `rvt.py params ` beats guessing a parameter name. It and `/collect` send their arguments as a JSON body, not a query string, because pyRevit 5.0.1's routes `Request` exposes no parsed query string under any name. **Confirmed live 2026-09-01 on Revit 2024.3 / pyRevit 5.0.1: both endpoints return `ok: true` over the JSON body.** Getting that confirmation took two tries, and the lesson is worth more than the fix: **`status` returning ok is not proof of a fresh engine.** A second Revit can hold the bridge port while serving code older than the file on disk, and the right model open plus a session-log line dated today both look exactly like a restart. Compare `System.Diagnostics.Process.GetCurrentProcess().StartTime` (through `/exec`) against `startup.py`'s mtime before believing new code is loaded. - **Two things `rvt.py` can now say about an answer, and neither is a fault to work around.** *"REFUSING this answer - it belongs to a different request"* is the shared-response-slot desync of §1 caught in the act; the channel is latched and the next call drains it, so retry rather than reaching for curl. *"this answer could not be attributed"* means the in-Revit half is older than the client and echoes no id — a **Revit restart** (never a reload) is what fixes it, and until then any answer taken shortly after a timeout is unattributed, which is exactly how a sweep's progress string once got printed as a document title. - `rvt.py exec -c "print([m for m in dir(el) if 'Type' in m])"` — reflection on the live object beats searching docs. - pyRevit's library is readable source at `%APPDATA%\pyRevit-Master\pyrevitlib\pyrevit\` — grep it. - · --- ## 7. Search the transcripts before you ask the owner **`PROJECT.md` §3.8 is the rule; this is how to run it.** Before you put a question to him, search what he has already said. He has answered far more than any document records, and asking twice gets you a second ruling that does not match the first. The transcripts are `~/.claude/projects//*.jsonl` on the machine the conversation happened on. **The owner's machine's transcripts travel in the private KNOWLEDGE repo under `sessions/`** (the Discord agent's host has them at `~/projects/KNOWLEDGE/sessions/**/*.jsonl`), so a search on a different machine must include that tree or it is not a search of what he said (2026-09-05: two issues asked him questions answered there days before). Locally they are `~/.claude/projects//*.jsonl` — newline-delimited JSON, **one record per line**, no enclosing array, so read them line by line and never `json.load()` the file. His messages are the records with `type == "user"`; `message.content` is either a plain string or a list of blocks, where the text lives in the blocks with `type == "text"`. Skip what is not his: a `type: "user"` record can also carry a tool result, and the long "This session is being continued…" blocks are a **summary written by an agent, not by him** — quote one as his words and you have invented a ruling. **The encoding trap.** The files are UTF-8 and Windows stdout is cp1252, so printing a Chinese message raises `UnicodeEncodeError` and the search looks like it crashed rather than like it found something. `PYTHONIOENCODING=utf-8` is the fix, and it belongs on every one of these commands: ```bash PYTHONIOENCODING=utf-8 python -c " import json,glob,os,sys q=sys.argv[1] for f in glob.glob(os.path.expanduser('~/.claude/projects/*/*.jsonl')): for n,l in enumerate(open(f,encoding='utf-8'),1): if q not in l: continue r=json.loads(l) if r.get('type')!='user': continue c=r['message']['content'] if not isinstance(c,str): c=' '.join(p.get('text','') for p in c if isinstance(p,dict)) if q in c: print(os.path.basename(f)[:8],n,r.get('timestamp',''),c[:300],'\n') " "7.5sqrt" ``` That exact command is what found the ruling the rule was written for. **It reads all 65 MB in 0.2 seconds**, so run it three or four times with different terms rather than once — and search **Chinese as well as English**, because he writes in both and often mixes them inside one sentence. Search the value (`7.5`), the concept (`应力极限`), the part number, the parameter name. Prefer a `git log -S` / `git grep` pass too: a ruling that already landed is usually quoted in the comment beside the value. Two ways to get this wrong, and the second is the expensive one: - **A hit is not automatically the answer.** Check the timestamp and read the surrounding lines. Two rulings months apart may be about different questions — one about *what the number is*, one about *where the setting lives* — and neither retracts the other. §3.8 has the case. - **No hit is not proof he never said it.** These are one machine's history, unindexed and bilingual, and the word you searched is very often not the word he used. Report ***"I searched `X`, `Y`, `Z` — nothing"***, listing the terms, and never *"he has never ruled on this"*. Then ask him. Asking is not the failure; asking **first** is.