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.
53 KiB
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
/reloadendpoint, andrvt.py reloadrefuses. startup.pyappends a line to.bridge-sessions.logon every pyRevit load.rvt.pyreads 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:
- Extract with a page cite on every fact (cheap model is fine — speed is what it buys).
- 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.
- 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 asnullwith 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_missingandknowledge.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_decisionsmaps 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.pyruns 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, neverFalse.knowledge.same_face()returnsNonewhen either token is an abbreviation it does not know — because "I cannot read that" and "those are different faces" are opposite answers, andFalsewould assert the second.rebar_stock()carriesbasis = Nonefor "nothing knows a stock length" alongside'by_size'and'generic', andstockedis a separateNoneagain.rebar_fy()returnsbasis = Noneandfy = Nonewhen no grade is defensible — A615 names no strength, so itsdefault_fy_ksiis deliberately null. Each of them also returns awhysentence: 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 likeMUTEDat 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 justasm.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
- Prototype with
rvt.py execagainst the live model until it is correct. - Write it into
WillTools.extension/WillTools.tab/<Panel>.panel/<Name>.pushbutton/asscript.py+bundle.yaml(+ optional 96×96icon.png). - Add
<Name>to its parent container'sbundle.yamllayout: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. - A tool that is not finished goes behind the
Design.panelpulldown with(WIP)in its title, never onto a panel beside the working tools. - 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/collectsend their arguments as a JSON body, not a query string, because pyRevit 5.0.1's routesRequestexposes no parsed query string under any name. Confirmed live 2026-09-01 on Revit 2024.3 / pyRevit 5.0.1: both endpoints returnok: trueover the JSON body. Getting that confirmation took two tries, and the lesson is worth more than the fix:statusreturning 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. CompareSystem.Diagnostics.Process.GetCurrentProcess().StartTime(through/exec) againststartup.py's mtime before believing new code is loaded.- Two things
rvt.pycan 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.