WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s

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.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
+899
View File
@@ -0,0 +1,899 @@
# pyrevit-dev — you are wired to a live Revit session
You were launched from a button inside Revit. **Revit is running right now**,
with a model open, and you can read and modify it directly. Use that — do not
write Revit code you have not run.
Engine: **IronPython 2.7.12**. The Revit and pyRevit versions differ between
the machines this is developed on (2025.4 / pyRevit 6.5.3 on one, 2024.3 /
pyRevit 5.0.1 on the other), so ask rather than assume — `rvt.py status`
answers it — and guard any API name that moved between versions. Two already
known: `BrowserOrganization.GetFolderFields()` does not exist on the older
binding, and `Dimension.Curve` throws on drafting views.
---
## 1. Talking to the running Revit
```bash
python tools/rvt.py status # which model is open
python tools/rvt.py exec -c "print(doc.Title)" # run a line inside Revit
python tools/rvt.py exec somefile.py # run a file inside Revit
python tools/rvt.py exec somefile.py --tx "名字" # wrap in a transaction
python tools/rvt.py sel # what the user has selected
python tools/rvt.py collect OST_Walls --limit 20
python tools/rvt.py params 123456 # every parameter of one element
```
`exec` returns `{ok, stdout, result, error, traceback}` — real stack traces, so
iterate until it works. Snippets get `doc`, `uidoc`, `uiapp`, `view`, `DB`,
`UI`, `vibe`, and the common Revit types pre-imported. Assign to `result` (or
make the snippet a single expression) to send a value back.
If `status` cannot connect: Revit is closed, or a **modal dialog** is open in
Revit — handlers run in Revit's API context and cannot fire behind a dialog.
Say so rather than guessing.
### Every answer is checked against the request that asked for it
Since 2026-09-01 `rvt.py` stamps every request with an id (in the JSON body,
GET routes included — request headers never reach handler code either),
`startup.py` echoes it back, and an answer carrying a **different** id is
refused outright: both ids named, nothing returned. The reason is that pyRevit
serves every route through **one shared response slot** —
`routes/server/server.py`'s module-level `REQUEST_HNDLR` / `EVENT_HNDLR`, whose
single `_response`/`_done` pair every connection thread resets, waits on and
reads — so a client-side timeout cancels nothing, and the abandoned request's
late answer is handed to whoever asks next. Measured that day, twice: one sweep
call timed out at 300s, and the `doc.Title` calls after it came back with the
sweep's progress string, which the sweep printed as the document title and an
agent read as the model having changed underneath it. The files on disk were
right; only the attribution was wrong, and **a response from the wrong request
is indistinguishable from a valid answer**. So a timeout is now an explicit
could-not-judge (exit 1, `COULD NOT JUDGE`, no value) that latches the channel
in `.bridge-timeout-<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:
```bash
cd WillTools.extension && python -m pytest
python -m pytest # from the repo root: this suite + panellift
```
**Run it; do not quote a count from here.** The count in this paragraph has
been wrong every time anybody has checked it — the suite grows daily, and on
2026-08-31 the root total moved twice in one afternoon while several agents
were working. For scale only, and only that: it was roughly 1,800 under
`WillTools.extension` and roughly 2,100 from the repo root that day. The root
run also sweeps `_scratch/`, which is gitignored agent working space, so its
total moves for reasons that have nothing to do with the tool.
**One test fails on a configured machine and it is the test that is wrong.**
`tests/test_reinforceoffline.py::test_no_index_and_no_jobs_root_says_all_three_ways_to_give_one`
calls `resolve_index(None, None, None)` expecting the "no jobs root" refusal,
but `resolve_index` falls back to the ambient `WILLTOOLS_JOBS`, so on any
machine that actually has the tool set up it gets the "which model?" refusal
instead. It passes under `env -u WILLTOOLS_JOBS`. Nothing in the tool is
broken; the test needs to clear the variable. Measured 2026-08-31, not yet
fixed — so a green suite here is everything passing **except that one**, and
one failure is not automatically your change.
`WillTools.extension/tests/conftest.py` is how: it loads a module by path
instead of `import vibe.whatever`, because `vibe/__init__.py` pulls in the
Revit API the moment it runs. Read the docstring there before adding a test
for a new module — it explains exactly which shape of code this can and
cannot reach, and `ticketqc.py` / `ticketchecks.py` are the standing example
of code that cannot: they import `Autodesk.Revit.DB` at module scope, not
just inside a function, so freeing them needs more than moving an import.
Ticket-QC data lives beside the **model**, not here:
`<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.debug`s it, so you get an empty ribbon slot and no message anywhere.
`python tools/make_icons.py` draws both of the tab's pulldown icons; run it
and **read its output**, because it prints "skipped, no such component" for a
path that has moved rather than failing.
**Unfinished tools never sit on a panel with finished ones.** Standing owner
instruction, 2026-08-30. `Design.panel` is where they go — a panel of its own
whose single button is the `DesignHub` pulldown, so nothing half-built is on
screen until somebody opens it; the `(WIP)` suffix on the title is what names
them once they do. (Until 2026-08-30 this was the slideout on `Lab.panel`.
Same rule, better door: a slideout arrow is easy to miss and shares its panel
with a working button, which is the thing the rule is against.) `DesignHub`
also holds two FINISHED rows today, PanelLiftCheck and PanelAnalysis — the
owner grouped them here on 2026-09-05 because he wants the analysis tools
together, not because either stopped working; see that pulldown's own bundle
comment.
A button that is finished but not wanted on screen used to move to
`WillTools.extension/parked/`, outside every `*.tab`. **That folder is gone**
as of 2026-08-30 — JobRecon and Takeoff, its only residents, came back onto
the ribbon inside `Config.pulldown`. A pulldown row costs no panel space, so
there is now somewhere on the ribbon to put a button nobody wants underfoot,
and parking has no job left. Do not recreate `parked/` without asking; if a
button really must leave the ribbon, taking it out of the parent's `layout:`
already stops it being built.
Titles are **short and two lines**: a large ribbon button is as wide as its
widest line, which is how "Reinforcing Suggestion" came to dwarf everything
next to it. `title: "Ticket\nQC"` — double quotes, because YAML only honours
`\n` inside them. Keep each line under ~9 characters. Rows inside a pulldown
are the opposite case — text beside a 16px icon, in a dropdown that grows to
fit its longest row — so those stay **single-line and may say what they
mean** (`Project Config`, `Design Extract`). A `\n` in a pulldown row has
nowhere to go.
**No button carries hover text.** The owner asked for it twice, so it is now
a test, not a convention: `bundle.yaml` gets no `tooltip:` and no `author:`,
and `script.py` gets **no module docstring at all**. Document a button in `#`
comments under the coding cookie instead — pyRevit cannot render those, and
nothing is lost. `tests/test_button_tooltips.py` fails and names the button
if any of the three comes back. The trap it exists for: pyRevit reads the
docstring only `if not self.meta` (no `bundle.yaml`), so a docstring can sit
harmless for months and become hover text the day a bundle file is trimmed
or a button ships without one.
pyRevit still appends `Bundle Name:\n<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.
```python
from vibe import tx
with tx('Name the user will see in Undo'):
...
```
No nested plain `Transaction` (use `SubTransaction` / `TransactionGroup`).
**Internal units are decimal FEET.** Always — areas in square feet, angles in
radians. Convert at the boundary with `vibe.ft(mm)` / `vibe.mm(feet)`. Use
`UnitTypeId.*`; `DisplayUnitType` was removed in Revit 2022+.
**`ElementId` in 2024+**: `.IntegerValue` is deprecated for `.Value`. Read it
defensively (`try IntegerValue / except .Value`) as the existing code does.
**Don't block Revit's UI thread.** No sleeps or polling loops inside handlers.
**"Could not judge" and "judged, found nothing" are different answers, and
nothing here may collapse them.** This is the discipline the whole tool is
built on, and it shows up in three places that each look like a local detail:
- **A knowledge lookup that cannot resolve returns `None`, never `False`.**
`knowledge.same_face()` returns `None` when either token is an abbreviation
it does not know — because "I cannot read that" and "those are different
faces" are opposite answers, and `False` would assert the second.
`rebar_stock()` carries `basis = None` for "nothing knows a stock length"
alongside `'by_size'` and `'generic'`, and `stocked` is a separate `None`
again. `rebar_fy()` returns `basis = None` and `fy = None` when no grade is
defensible — A615 names no strength, so its `default_fy_ksi` is deliberately
null. Each of them also returns a `why` sentence: standing down without
saying why is the same failure one step later. Follow this for any new
lookup.
- **An unrecognised state word renders red, never grey.**
`ticketpage.state_color()` matches GREEN / YELLOW / GREY states by name and
everything else falls through to red. That fallthrough is load-bearing, not
a default: it is what makes it safe to add a state like `MUTED` at all,
because a typo in a state word becomes loud instead of quietly grey. Grey is
reachable only through a word the table knows — a *declared* mute. Do not
"tidy" the fallthrough into grey or into an exception.
- **A check that returns nothing owes an explanation.** See §5 below; this is
the same rule, and it is the one a newcomer trips first.
**Never run an unfiltered collector, and never guess at an API enum.** This
crashed Revit on 2026-08-18 — a single exploratory snippet that walked the
whole document with `FilteredElementCollector(doc).WhereElementIsNotElementType()`,
touched `.Symbol` on every result, *and* set `Options.DetailLevel` from a value
reached by `__import__('Autodesk').Revit.DB...`. Revit died with no exception
and no journal entry, because a bad value handed to a native API is not a
Python error. So:
- Always scope a collector — `OfClass`, `OfCategory`, a view id, or just
`asm.GetMemberIds()`. This model has ~377 assemblies and six figures of
elements.
- Import enums by name (`from Autodesk.Revit.DB import ViewDetailLevel`).
If the import fails you get a traceback; if a bogus object reaches the
native layer you get a dead Revit.
- One narrow question per `exec`. Geometry extraction goes in its own request,
never bundled with a model-wide scan.
**A contradiction hit mid-refactor is the owner's to settle, not yours.** A
restructure on 2026-08-31 (private repo `pyrevit-knowledge`, commit
`767281e`, "总表三层化、厂层收割落库") walked into `lifters.capacity_lb` in a
plant's `knowledge.yaml` and found an override the owner had entered by hand —
`P76 = 16,000 lb`, the correct ALP with-tension-bar rating — disagreeing with
`knowledge/catalogue.yaml`'s published 7,690 lb for the same part. The
restructure emitted the key EMPTY and wrote a new comment reading "Direct
override, checked BEFORE the catalogue. One-offs only," phrased as though the
key had always been blank. `lifter_capacity()` checks that override before the
catalogue, so every export after that quietly fell back to 7,690. Five real
panels (WP242, WP283, WP318, WP322, WP326) each carried a `FAIL / PLUMB`
finding — 8,660 lb each of a 34,638 lb piece against a 7,690 lb tension limit,
113% — before the owner asked on 2026-09-01 where 7,690 came from, and
answering that took a git-history dig across two repositories. The sibling key
in the same block, `lifters.min_edge_distance_in: 9`, survived the identical
restructure untouched, which is what makes the dropped key a mistake and not a
policy. Deleting the losing side of a contradiction, silently preferring the
"published" value, and writing a comment that makes the surviving value look
uncontested are all the same move: each retires the question instead of
asking it. When a refactor meets a contradiction, stop and surface it to him
instead.
**No client or job information in the repo. Ever.**
Not in code, not in comments, not in docstrings, not in a commit message, not
in an example. When you need to refer to one, write the placeholder rather
than deleting the sentence:
| | |
| --- | --- |
| a client / owner | `sample_client` |
| a job / project / model | `sample_job` |
Product codes are **not** client information and stay as they are: design
numbers (`PNL1.0`), piece marks (`PNL101`), hardware part numbers
(`ANCHOR_FACE_4T`) are naming conventions, not identities.
`python tools/scrub.py` checks the working tree **and the whole git history**
against `.scrub-terms` (gitignored, so the list of names is not itself the
leak) plus structural patterns — model filenames, absolute user paths, email
addresses. It runs as a pre-commit hook and inside `tools/package.py`.
The working-tree scan covers **untracked files too**. A scratch script nobody
has added is exactly where a model path gets pasted, and it is one `git add -A`
from being in the commit. `--worktree` is the audit and a hit anywhere fails;
`--staged` is what the hook runs, where only staged files block and untracked
hits are printed as a warning — a gate that blocks work unrelated to the commit
is a gate that gets passed with `--no-verify`.
**But every scan above honours `.gitignore`, so none of them has ever opened
`QNA/`.** Nor `knowledge/plant/`, `knowledge/job/` or `_scratch/`. That is the
largest body of prose in the checkout and the only part of it whose job is to
quote source material verbatim, so a name is not merely possible there, it is
certain. `--private` is the mode that looks at it, and it is **an inventory,
not a gate: it always exits 0** and the hook never runs it. It asks a
different question, because "does this contain a name" is answered yes by
design — it asks whether a name is sitting in a shape that **travels**: inside
a fenced block, a proposed `key: value`, a quoted literal, a path, or glued
into a longer identifier (`<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
```python
from vibe import q, p, set_p, tx, selected, ft, mm, out, table
```
| Instead of | Write |
| --- | --- |
| `FilteredElementCollector(doc).OfCategory(BuiltInCategory.OST_Walls).WhereElementIsNotElementType().ToElements()` | `q('OST_Walls')` |
| `StorageType` branching | `p(el, 'Comments')` |
| manual Transaction start/commit/rollback | `with tx('name'):` |
| `uidoc.Selection.GetElementIds()` + `GetElement` | `selected()` |
Signatures: `WillTools.extension/lib/vibe/helpers.py`.
API patterns: `docs/revit-api-cheatsheet.md`.
**Before touching anything that measures geometry, read
`docs/interference.md`.** Two working-looking versions of the clash check
reported no interference anywhere because a cylinder's edges are circles whose
end points coincide; a third was two thirds false positives because a bent
bar's bounding box is mostly the hole in the middle.
Inside a button script, use pyRevit forms rather than raw WPF: `forms.alert`,
`forms.SelectFromList.show`, `forms.ask_for_string`, `forms.select_levels`,
and `script.get_output()` for `print_md` / `print_table` / `linkify`.
---
## 5. Building a new tool for the user
1. Prototype with `rvt.py exec` against the live model until it is correct.
2. Write it into `WillTools.extension/WillTools.tab/<Panel>.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
`Finding`s, and **an empty return is ambiguous** — it can mean *ran and found
nothing wrong* or *never ran, for want of data*. `run_all` cannot tell, so it
does not guess:
| what happened | what `run_all` logs | what `checklist_states` shows |
| --- | --- | --- |
| returned findings | `RAN` | the verdict |
| raised | `FAILED` | NOT RUN, plus a `CHECK-ERROR` warning naming the check |
| empty, probe returns a reason | `SKIPPED` | NOT RUN, *with that reason* |
| empty, probe returns `None` | `RAN` | OK — ran, found nothing |
| empty, **no probe registered** | `RAN-EMPTY` | **NOT RUN, "no probe declared"** |
So a check with no probe that ever legitimately finds nothing renders as a red
NOT RUN row for a piece that is fine. That is deliberate — silence that looks
like "fine" is the exact defect the checklist exists to surface — but it means
**a new check with no probe is a defect you have just shipped**, not a check
that works.
And **a checklist row may name more than one check.** It is answered only when
every one of them spoke; any quiet half makes the whole row NOT RUN and the
note carries each quiet half's own reason. Under-claiming is the safe
direction, because a green row is a claim — a row that asks two questions and
prints OK because the half that could still fail it was never asked is exactly
the silent pass the table exists to prevent.
Register one beside the check:
```python
@_register_skip_probe('check_your_thing')
def _probe_your_thing(t, intent, know, spec):
if not t.get('the_input_it_needs'):
return 'no <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:
```bash
PYTHONIOENCODING=utf-8 python -c "
import json,glob,os,sys
q=sys.argv[1]
for f in glob.glob(os.path.expanduser('~/.claude/projects/*/*.jsonl')):
for n,l in enumerate(open(f,encoding='utf-8'),1):
if q not in l: continue
r=json.loads(l)
if r.get('type')!='user': continue
c=r['message']['content']
if not isinstance(c,str): c=' '.join(p.get('text','') for p in c if isinstance(p,dict))
if q in c: print(os.path.basename(f)[:8],n,r.get('timestamp',''),c[:300],'\n')
" "7.5sqrt"
```
That exact command is what found the ruling the rule was written for. **It
reads all 65 MB in 0.2 seconds**, so run it three or four times with different
terms rather than once — and search **Chinese as well as English**, because he
writes in both and often mixes them inside one sentence. Search the value
(`7.5`), the concept (`应力极限`), the part number, the parameter name. Prefer
a `git log -S` / `git grep` pass too: a ruling that already landed is usually
quoted in the comment beside the value.
Two ways to get this wrong, and the second is the expensive one:
- **A hit is not automatically the answer.** Check the timestamp and read the
surrounding lines. Two rulings months apart may be about different questions
— one about *what the number is*, one about *where the setting lives* — and
neither retracts the other. §3.8 has the case.
- **No hit is not proof he never said it.** These are one machine's history,
unindexed and bilingual, and the word you searched is very often not the word
he used. Report ***"I searched `X`, `Y`, `Z` — nothing"***, listing the terms,
and never *"he has never ruled on this"*. Then ask him. Asking is not the
failure; asking **first** is.