WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user