Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

53 KiB
Raw Permalink Blame History

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

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-<port>.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-<ver>-<date>.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:

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: <model folder>/_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
<model folder>/_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: <doc> p.N, verified <date>. 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 <model>/_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.debugs 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<Name> (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 <script.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 <session-id> 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.

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 (<name>_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

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>.panel/<Name>.pushbutton/ as script.py + bundle.yaml (+ optional 96×96 icon.png).
  3. Add <Name> 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 Findings, 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:

@_register_skip_probe('check_your_thing')
def _probe_your_thing(t, intent, know, spec):
    if not t.get('the_input_it_needs'):
        return 'no <input> on this piece -- <what> 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 <id> 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.
  • https://www.revitapidocs.com/2025/ · https://pyrevitlabs.notion.site/

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/<project-slug>/*.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/<project-slug>/*.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:

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.