WillTools 2026-09-09 -- clean snapshot of the development repo for review
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:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
+709
View File
@@ -0,0 +1,709 @@
# WillTools
This is the document to read before opening a file here. It assumes you know precast and
Python and nothing about this codebase. Four parts, in the order they matter: **what the
tool is**, **how it is built**, **the discipline**, **where everything stands**.
Read part 3 properly. Parts 1 and 2 orient you; part 3 is what stops you doing damage.
Every rule in it was bought with a failure and carries the incident that bought it,
because a rule whose reason has been lost gets "simplified" away by the next reader.
One standing convention, and it is the project's premise: **this repository does not state
things it has not checked.** Anything unverified is marked unverified; anything measured
says when. Where a number rots — line counts, test counts, timings — you get the command
instead, because those have each been wrong more than once in a single week. Where a
document and the code disagree, **the code wins**; §4.6 lists the disagreements known on
2026-08-30.
---
## 1. What it is
### 1.1 The product
WillTools is one engineer's QC instrument, not a plant production system. The owner is
an **independent precast specialty consultant** — not the plant's employee — and on any
job his responsibility is **cladding and wall panels only**. Other engineers hold the
beams, slabs and columns, and the plant knowledge every check runs on was validated
against panels and nothing else.
`lib/vibe/scope.py` exists because of that, and insists that **out of scope is a third
state** — not a pass, not a failure, not an absence. Every report names how much work it
left alone and of what kind. The scope is *declared* in the job knowledge layer
(`scope.mark_prefixes`), never inferred. The project already paid for the alternative:
one unauthorised exclusion hid forty tickets and every check that ran came back clean.
That is the first face of the rule the whole tree is organised around — **silence is
never a pass**. Its full statement is §3.1, and it is the single most important idea
here.
### 1.2 The ribbon
One tab, four panels: **Tools**, **Setup**, **Design**, **Lab**. Folder names *are* the
UI, and `layout:` in a container's `bundle.yaml` decides what gets built at all — a child
not listed is absent, not misordered. No button carries hover text (no `tooltip:`, no
`author:`, no module docstring); `tests/test_button_tooltips.py` fails by name if one
returns.
**Reinforcing Suggestion** asks which already-reinforced panels, *between them*, account
for every feature of this one — a set-cover jigsaw, not a similarity ranking — capped at
four donors, with the shortfall named rather than relaxed to a fifth. Search in
`reinforcematch`, index in `reinforceindex`/`reinforcecore`, join in `reinforcebind`,
wording in `reinforcereport`. **It opens no Transaction, by hard rule**: a wall panel's
middle vertical band is per-design engineering, and copying a donor's cage in
automatically would be the most dangerous thing this repo could do. An ordinary press
rebuilds only pieces whose cheap signature moved; shift-click forces a full rebuild,
because the signature cannot see an opening moved or resized in place.
**Panel Lift Check** is a PCI Chapter 8 handling check over stripping, transportation and
erection. The piece is measured from the model, then **you click the inserts carrying each
stage, on the piece itself** — the rules supply highlighted defaults and no longer get the
last word. It refuses rather than guesses: fewer than two support lines, a stage naming an
insert the panel has not got, a stage with no multiplier and no `pci_case`, short/long
above 0.75 (two-way spanning, which a beam idealisation cannot represent). Multi-select
runs a background batch whose worker lives in `vibe/batchrun.py` and never in the button —
a thread reading the command module's scope after pyRevit tears it down raises on a
non-main thread and kills Revit. Batches skip the position search — 93% of a run, 12.6 s
of a 13.6 s median piece on the reference sweep.
**Ticket QC** is the flagship. It checks every shop ticket of a design against the
extracted spec plus the knowledge layers: concrete strengths, jacking force, strand
count/grade/diameter/position, W.W.F., unit weight, embeds in both directions, bar marks,
note views, size envelope, interference. **27 check functions** in `ticketchecks.CHECKS`
feed **34 numbered checklist rows** in `ticketpage.CHECKLIST_ITEMS` — both counted
2026-08-30, both countable with `len()`. Every check is arithmetic; the one exception is
quarantined (§3.1). A row's status comes from `run_all`'s disposition log, never from an
absence of findings. And a ticket is not one sheet: it is the union of every sheet named
for the mark.
**Snapshot & Close** is the daily close-out. The model is 300+ MB on disk and 10+ GB open,
so there is never a second copy in memory: phase A indexes the live document, phase B
closes Revit (the press *is* the authorisation), phase C optionally reopens it detached in
a deferred host process once the memory is free. The request is journalled before the close
and the deferred process journals its outcome, so a day it silently did not run is reported
in capitals on the next press.
Behind the **Config** pulldown on the Setup panel: *Project Config*, *Design Extract*
(scrapes a design packet into a reviewable `spec.json`, with whatever the parser could not
place landing verbatim in `unresolved` rather than dropped), *Self Check*, *Job Recon*
(reconciles the issued ticket set against the whole job — the question no per-ticket check
can answer, because a ticket that is not there cannot be checked) and *Takeoff*.
The **Design** panel is a quarantine: everything on it sits behind the `DesignHub`
pulldown, on a panel of its own, so nothing half-built ever sits beside a finished tool.
On the **Lab** panel, **GOD_MOD** opens a terminal running `claude` at the repo root with
default permissions.
### 1.3 What a run produces
**Ticket QC.** The checklist table *is* the page — first, unfolded, one per ticket that
has anything to say, in loudness order. `ticketpage.state_color`: OK green; WARN and FOR
ENGINEER yellow; **NOT RUN and FAIL both red**. An all-green ticket gets one line, not a
table.
Everything else is a `foldreport.Section`, and the folding is a contract: *the answer is
the headline and is never folded; everything else starts closed; a section carrying a
problem opens itself, and its `problem` is a **sentence** naming what is wrong, not a
boolean; folded is not gone — a closed header still says what is inside and how much.*
The reader's side of that contract is **if nothing below the tables is open, nothing needs
me.** A problem section renders open as plain HTML needing no script, because pyRevit's
output panel is a WinForms `WebBrowser` in IE7 document mode where `<details>` does not
exist and fails *silently*.
Three files land in `_ticketqc/` on every press: `last-ticketqc.txt` (the whole report,
nothing folded, long notes wrapped rather than cut, so a dead fold costs a file open and
never a sentence); `qc-run.json` (`findings` and `model_read` as two keys, never one list,
each entry carrying `source` and a boolean `computed`; muting never touches it, because a
muted row is a rendering decision and a muted check that failed still fails the ticket —
but it does not yet carry the run log, §4.4); and the `ticketqc/2` evidence file of §2.2
(`qc-run.json` itself carries `ticketvision.RUN_SCHEMA = 'qc-run/3'` — read the constants,
both have moved since this paragraph was written).
**Panel Lift Check** produces a self-contained HTML file with the `.job.json` beside it as
the complete input, re-runnable where there is no Revit: a stage table, an "Inserts picked"
table recording the one part of the run that came from a human, recommended moves in dx/dy
inches, and inline SVG free-body, shear, moment and stress plots that load **nothing
external**. Two habits in it are worth copying. A stage that was not analysed prints
*words*, never a dash, because a dash among numbers reads as a small number. And the
station tables under each plot thin ~2000 stations by stride but never thin what matters —
both ends, every opening edge, every pick or dunnage line, the peak and trough of V and M,
the worst compression, and the governing tension station, tinted and bold and saying so.
Read Δx as "about here", not as a dimension, until §4.4's solver work is done.
---
## 2. How it is built
### 2.1 Two Pythons, and the line between them
`WillTools.extension/` is **IronPython 2.7.12** on Revit's UI thread: no f-strings, no
`pathlib`, no `dataclasses`, no typing syntax. `panellift/` and `tools/` are **host
CPython** (`panellift` declares `>=3.9`).
**`panellift` is deliberately unimportable from Revit** — it uses dataclasses and typing
syntax on purpose. A calculation whose output reaches a plant must be verifiable, and
verifiable means a suite that needs no licence, no model and no Windows. That is the
whole reason the line is where it is.
`vibe/liftexport.py` is the entire Revit side of it: it measures the piece, writes the
job file, shells out with a timeout and tick callback (`find_python`, `run`), and reads
the summary back. **Mechanics live on the far side of that line; facts about *this
model* live on the near side.**
### 2.2 The contract is the file, not the function
Modules hand each other versioned JSON carrying `schema`, `written_at`, `written_by`,
`doc_key` and `knowledge_sources` — not objects. Four instances today:
`liftexport.SCHEMA = 'panellift/1'`, `reinforcecore.SCHEMA = 6`,
`specformat.SCHEMA_VERSION = 1`, and `ticketqc.SCHEMA = 'ticketqc/2'` (bumped from `/1` on
2026-08-31; read the constant, it will move again), written to
`<model>/_ticketqc/evidence/run-<stamp>.json` on every Ticket QC press. **Nothing reads
the last one yet** — that is step 2 of the migration (§4.5), and precisely why the writer
could ship alone. The four rules, from `docs/architecture-migration.md` §2:
1. **Discard whole on mismatch.** `reinforcecore.load()` and `ticketqc.load_evidence()`
throw a stale file away entirely — never partially, never migrated.
2. **No live objects.** The plan named two holdouts; the build found four (`t['doc']`,
`t['legends']`, `t['sheets']`/`t['sheet']`, and live `XYZ` in `t['strand_points']` on
the no-framing early return) — so the serialiser gates on **type**, not on a list of
known offenders.
3. **Absence is not a value.** A gather that could not read something writes an explicit
`null` *and* a reason, through one `put()`. A merely missing key reads downstream as
"nothing to check" — the same failure mode `ticketchecks._SKIP_PROBES` exists to
prevent (§3.1).
4. **Provenance travels** — which layers were in force, which spec source (`spec` or
`regex`), which build. `intent` and `spec_source` ride with each ticket. Findings do
not: `qc-run.json` owns verdicts and the evidence file owns inputs, and step 2 needs
them apart so it can compare one against the other.
### 2.3 The five knowledge layers
Each layer overrides the one before it. `knowledge.load(doc)` merges them;
`knowledge.sources(doc)` lists them, and Self Check prints them.
| layer | where it lives |
| --- | --- |
| `knowledge.DEFAULTS` | this repo — what the code assumes |
| `knowledge/catalogue.yaml` | this repo — published hardware ratings, identifies nobody |
| plant `knowledge.yaml` | `%APPDATA%\WillTools\` or `WILLTOOLS_KNOWLEDGE`, per-plant folder |
| job `knowledge.yaml` | `<model folder>/_ticketqc/` |
| the project config file | wherever the engineer put it; `_ticketqc/active-config.txt` points at it |
**Maps deep-merge, lists replace.** Two consequences bite: a list in the job file
*removes* the plant's entries rather than adding to them, and `_deep_update` simply
assigns, so **an override and a typo are indistinguishable today**.
Two refusals guard the edges. A plant named in `job.plant` whose file is missing sets
`_plant_missing` rather than silently falling back to the machine-wide file; a pointer to
a vanished config sets `_config_missing`. `knowledge.config_problems()` turns either into
a sentence the buttons refuse on.
Note the deliberate two-pass in `load()`: `job.plant` chooses the plant file but lives in
a layer that merges *after* it, so `plant_id()` reads the job and config files once,
alone, first. **Delete that pre-pass and the tool reads the wrong plant while every report
looks normal.**
Which layer a fact belongs in is not a preference; it is an argument, and it is part of
the discipline — §3.2.
### 2.4 The bridge
`tools/rvt.py` is a host client for a pyRevit routes endpoint registered by `startup.py`.
`rvt.py exec` runs a snippet *inside* the live session and returns
`{ok, stdout, result, error, traceback}`. It is how anything is prototyped against a real
model, and it is the most dangerous thing in the repo. Its one absolute rule is §3.5.
### 2.5 Two repos
This repository can be shown to anybody. The other one cannot. `knowledge/plant/`,
`knowledge/job/` and `knowledge/_archive/` sit in this working tree for convenience and
are tracked by the **other** repository, never by this one; `.gitignore` says so at the
point of exclusion. `knowledge/catalogue.yaml` is the one deliberate exception, being
published manufacturer data, and it says so in its own header. The gate that keeps the
split honest is §3.4.
---
## 3. The discipline
### 3.1 The honesty invariants
**NOT RUN is never a pass. Silence that looks like success is the cardinal sin.** NOT RUN
renders **red**, with FAIL, and `ticketpage.py` carries a comment forbidding the next
reader from greying it out: a check that did not run tells you nothing about the piece, so
it must never look calmer than one that failed. One unauthorised exclusion hid forty
tickets and every check that ran came back clean (`scope.py`, §1.1); seven `die()` calls
let refusals leave through a dialog, so a batch where every panel refused produced a blank
report. Every refusal now goes through `not_run()`.
**`None` ("could not judge") is not `[]` ("judged, found nothing").** `ticketchecks.run_all`
disambiguates an empty return with a **skip probe**: `_SKIP_PROBES[check_name]` gives
either a reason or `None` (ran, found nothing). A check with *no* probe logs `RAN-EMPTY`
and renders NOT RUN reading "no probe declared" — unexplained silence is the defect, not
agreement. A probe's sentence must name a verifiable place: row 5.4 now separates *no
spec*, *a spec predating the field*, and *a design recording no mesh*. Every registered
check has a probe now, and it is test-enforced (§4.4) rather than a count to keep
re-auditing by hand.
**A declared out-of-scope row is a third state, not a silence.** The scope is declared in
the job layer, never inferred. No declaration means judge everything, and a mark whose
prefix cannot be read is never set aside — "I could not tell" may only resolve towards
judging.
**A model-read (`AI-*`) finding is always WARN**, never OK and never FAIL. With
`vision_escalation` on, grade-D items no comparison can settle go to a small model reading
one rendered view; the answer lands in `model_read`, never in `findings`, and **can never
set a checklist row**. `ticketvision` carries that provenance four independent ways (the
`source` field, an `AI-` code owning no row, its own list, its own report section), on the
assumption that an edit will drop one and the other three still have to hold. A model that
saw nothing wrong in one view is a reason to look, not to stop looking.
**Muting never silences a FAIL**, and a muted check still *runs*: `apply_muting` repaints
it grey rather than removing it, and MUTED gets its own tally bucket. "31 OK" when four of
them were nobody's judgement is the quiet lie the table exists to stop.
**A check may not claim what the tool is in no position to claim.** Rows 5.6 and 5.7 both
printed *"no reinforcing found on this piece"*; this plant models bars outside any rebar
category, so with `drawings.reinforcing_families` empty a **301-member** piece produced the
same words as a piece with no steel in it. They now say the tool could not **recognise**
any, name the key that would turn it on, and **stay red** — *a better sentence is not
permission to soften the state*. Two siblings landed the same day: 5.1 printed OK off one
of its two questions, so a check slot now takes a tuple and **any quiet half makes the row
NOT RUN**; and 5.3, a person's job by design, printed red because a hand-kept
`FOR ENGINEER` tuple omitted it — that list is now derived from the table.
### 3.2 The knowledge layer law
The mechanism is §2.3. The law is this: **job beats plant beats what everybody in the
trade knows** — not a tie-break, but a statement about where truth lives. The trade knows
a bar mark is a recipe; the plant knows its own hardware; the job knows what was drawn.
So **placement is an argument, not a preference**. Three examples from a single day:
- **Rebar stock lengths → the plant.** One number for every size let a 30 ft `#4` pass
where the plant stocks `#4` in 20 ft — a false OK on a real defect class. It became a
**map keyed by printed size, in integer inches** (`catalogue.rebar.stock_by_size`, values
supplied by the plant layer). As a *list*, a job adding one size would silently delete
the plant's whole table.
- **A job's odd-grade bar → the job.** The manual files every stock length under a
designation the owner says is retired; if it reappears it is an exception to this job,
so it is job knowledge.
- **Which end is up → public.** The plant layer carried `top_is_higher_global_z`, and both
obvious repairs were wrong. An assembly origin is placed when the assembly is made, and
**placing it is the statement**: that is how precast is built, not a house habit, so it
belongs in the repo catalogue. A key asking a plant to declare it is not an unread key,
it is the wrong question — and it named a *mechanism* rather than an answer, which a
layer must never be able to swap.
**Dormant and loud.** A key not yet wired ships **null**, with `*_verified: false` and a
written reason — never a guess. The reinforcing rule shipped that way and proved the right
*shape* around the wrong *field*; the flag did its job, because the rule never ran and the
report named the reason, where a guess would have left every bar in the job silently
un-recognised. Turning it on was two YAML fields and no code change. §4.3 lists what is
dormant now.
### 3.3 Never guess a plant fact
Parameter names, categories, family tokens and casting conventions are not code's to know.
They are reached through **roles** declared in the knowledge layers (`PARAMETER_ROLES`,
`knowledge.param_names`). Two incidents from one day:
- **Three guesses, three wrong.** An agent needed one plant parameter name and guessed it
three ways; none of the three existed in the model. `rvt.py params <id>` answers it in
one request. The precedent: the mark parameter name left the code entirely and became
the role `member.mark`.
- **Types read where the values were on instances.** A census of the reinforcing parameter
read family *types* only, but this plant writes that value on the **instance** — so the
census saw nothing and the agent told the owner the wall panels carried no reinforcing
at all. Measured properly, one piece had 112 bars and another 84. The rule now carries
`read_from: [instance, type]`: the value sits on one or the other, mutually exclusively
per element, and a reader taking only one finds nothing on half the model **and returns
a result that looks perfectly clean**.
**Measure it, ask the owner, or stand down with a written reason — never assume.** When a
bar-size parameter could not be measured, nobody guessed; it is still an open question
(§4.3). *"Ask the owner" is the third resort, not the second:* **search the transcripts
first** — §3.8, one-liner in `CLAUDE.md` §7.
### 3.4 Secrecy and the scrub gate
**No client, job or plant information anywhere** — code, comments, docstrings, commit
messages, examples. Where you need to refer to one, write the `CLAUDE.md` §3 placeholder
rather than deleting the sentence. Product codes are not identities and stay as they are.
`tools/scrub.py` is the gate, in two modes on purpose. `--worktree` is the audit: every
file git can see, tracked *and* untracked, and a hit anywhere is a hit. `--staged` is the
pre-commit hook: only staged files can block a commit, while untracked hits print loudly
without stopping unrelated work — because a gate that blocks unrelated work is one people
learn to pass with `--no-verify`. It also runs inside `tools/package.py`. The term list
lives in gitignored `.scrub-terms`, so the list of names is never itself the leak;
structural patterns (model filenames, absolute user paths) are checked whether or not
anyone remembered to add a term.
**The cautionary tale (2026-08-30).** The gate built each term as `\b<term>\b`. In regex
`_` and digits are word characters, so `\bTerm\b` never matched `PREFIX_TERM` — and
`PREFIX_ROLE` was the dominant naming convention here before the roles refactor. **The
terms most likely to be a real leak were written in the one shape the gate was blind to.**
The fix is letter-only lookarounds, and the sanctioned placeholders are now read out of
`CLAUDE.md`'s own table so the gate cannot drift from the rule. The rescan: working tree 0
hits → **4**; history 19 hits in 3 commits → **22 in 6**, all six already pushed.
**Assume every scan before that fix under-reported.** Editing a file does not remove a
name from the commit that introduced it — §4.4 has the decision still outstanding.
### 3.5 Revit safety
- **Never reload pyRevit. Restart Revit.** `reload_pyrevit()` tears down the engine that
owns the routes handler while the HTTP server still points at it, so the next bridge
request runs against a dead engine and takes Revit down — on a non-main thread, where
Revit cannot catch it. Measured 2026-08-14: 11 clean requests, one reload, the 12th
fatal. It happened twice. Our code never gets to run, so the guard sits outside Revit:
`startup.py` appends a line to `.bridge-sessions.log` on every pyRevit load, and `rvt.py`
reads that log before *every* request and hard-refuses on two loads under one process id.
There is no `/reload` endpoint. If `rvt.py` says the bridge is unsafe, do not work around
it and do not curl the endpoint — ask for a restart, which picks up new buttons just as
well.
- **Never run an unfiltered collector; never guess an API enum.** 2026-08-18: one snippet
walked the whole document with `WhereElementIsNotElementType()`, touched `.Symbol` on
every result, and set `Options.DetailLevel` from a value reached by `__import__`. Revit
died with no exception and no journal entry, because **a bad value handed to a native
API is not a Python error**. Scope every collector; import enums by name, so a bad name
is a traceback rather than a dead process.
- **Transactions for writes; reads never need one** (`with tx('name')`). The production
model is read-only to agents: prototype read-only, show counts and a sample, and get an
explicit go-ahead before any mutation. **Nothing in the extension writes document data or
geometry, on purpose** — a wrong report can be disbelieved, but wrong geometry gets built.
The one exception is view state: Panel Lift Check opens a `tx('Show assembly')` to
isolate the piece temporarily and put an 85% transparency override on its non-insert
members, and writes both back off when it is done.
### 3.6 How the work is organised
The owner works through sub-agents. **One dedicated long-lived agent holds the bridge**,
and all live-Revit work routes through it. Everything else is split into **non-overlapping
file sets** and run in parallel, each landing committed **by explicit path** and pushed.
**`git add -A` is prohibited**, and it has cost twice. `cb1587d` swept in a
**deliberately broken** button that the tooltip agent had planted to prove its guard
fires, so HEAD briefly shipped a fake tooltip (repair and rule in `14de0d3`). And
`109d7df` — titled *"Today is the 30th, not the 31st"* — swept in the first half of
another agent's unfinished up-end work; the follow-up, `cf9e164`, opens by saying so.
A glob is the same mistake in a different coat: `git add .`, `git commit -a`,
`git add lib/` while another agent is mid-edit. **Name the files.**
### 3.7 Testing
**Host-testable by design.** The whole suite runs on plain CPython with no Revit and no
licence:
```bash
python -m pytest WillTools.extension/tests -q # the extension
cd panellift && python -m pytest -q # the solver
```
**Run them; do not quote them.** Two readings on 2026-08-30, hours apart on the same
machine: `panellift` 272 passed both times, while the extension went from 1807 passed / 1
failed to 1860 passed / 1 failed, because another agent was adding tests in between. That
is the normal condition here, and it is why every count in this document is date-stamped
or replaced by a command. The one thing that held across both readings is the single
failure — a real one, environment-coupled, described in §4.4. Several documents in this
tree still quote counts that were true when written and are not now (§4.6).
`tests/conftest.py` is what makes it possible: it registers a bare module object as
`sys.modules['vibe']` with `__path__` pointed at the real package, so `vibe/__init__.py` —
which imports the Revit API the moment it runs — never executes. That reaches any module
touching Revit only *inside* functions needing a live `doc`, and cannot reach one
importing the Revit API at **module scope**. `ticketqc.py` / `ticketchecks.py` are the
standing example: `ticketqc` imports eight `Autodesk.Revit.DB` names at the top and
`ticketchecks` imports `Finding` from it, so freeing them takes more than moving an
import. `test_ticketchecks_host.py` reaches a few pure decisions through a deliberately
**inert** stub of those names: if an edit makes one really call the API, the test raises
instead of quietly passing.
**Pin against a written-down golden, not a recomputation.** The takeoff count is pinned to
numbers typed into the test, because *a pin that recomputes both sides pins nothing* — it
agrees with any refactor, including a wrong one.
**Tombstone tests** keep a deleted thing deleted. `test_no_cap_survives_anywhere_in_the_module`
asserts that `ticketpage` has no `capped_note` and no `NOTE_CAP`: that 180-character cap
had truncated a debond run mid-number, in both the panel and the text file.
### 3.8 Search the transcripts before you ask him
**A question he has already answered is worse than a question nobody asked.** It costs him
time, it tells him nobody looked, and — the real damage — it invites him to answer a second
time. Two rulings then sit on the record with nothing to say which governs, and the next
agent gets to pick. The rule, in his words, 2026-08-31: *"开发纪律里面再加一条,有问题先搜索
聊天记录。搜不到再来问我。"*
**The incident that bought it (2026-08-31).** An overnight audit put
`handling.tension_limit_sqrt_fc` at the top of the owner's list as **A0**, under a heading
saying it was *"the only open item that changes a live engineering answer"* — reasoning
that he had made the stress limit a per-job choice, that the job layer held nothing, and
that the job was therefore running on 7.5 inherited from a layer he never said should
decide it. Every one of those facts was true. The conclusion was still wrong: he had ruled
the value twelve days earlier, **2026-08-19: *"就用7.5sqrt。"*** The plant layer holds
exactly 7.5, the tool had been doing the right thing the whole time, and A0 existed only
because nobody searched. Escalating it would have spent his morning re-deciding a decided
thing.
Note what the audit actually did wrong, because it is the subtle half: it found a **later**
quote (2026-08-30, *"应力极限写入 projectconfig … 让用户自己填写。I can see it both ways"*)
and read a ruling about **where the knob lives** as if it vacated the ruling about **what
the number is**. Those are different questions and both answers stand. A mechanism ruling
does not retract a value ruling — and a search that returns two hits is not finished until
you have worked out whether they even disagree.
**Searching is cheap. `CLAUDE.md` §7 has the one-liner; the measured cost over the whole
65 MB of transcripts is 0.2 seconds.** "It is expensive to look" is not available as a
reason, and neither is "I did not know where they were".
**But a search that finds nothing is not proof he never said it.** The transcripts are one
machine's history, they are not indexed, they hold his messages in two languages, and the
term he used is very often not the term you searched. So the honest report is
***"I searched X, Y and Z and found nothing"* — never *"he has never ruled on this"***.
State the terms; the next reader can then extend the search instead of repeating it. This
is §3.1's invariant wearing different clothes: not-found is a NOT RUN, not a pass.
When you do find it, **write it down where the value lives** — beside the number, with the
date and his words verbatim — so the next agent finds it by reading rather than by
searching. A ruling recorded only in a transcript will be asked again.
### 3.9 Three tiers: the coordinator decides, Sonnet explores, DSV4F labours
**The coordinating agent (Fable / Opus class) is the most expensive process on this
machine, and it spends its budget on four things only: planning and splitting work, judging
results, deciding anything irreversible or anything that touches live Revit, and talking to
the owner.** Below it sit two cheaper tiers, and they are not interchangeable:
- **Exploration — reading a codebase to answer "how does this work / what would it take" —
goes to a Sonnet sub-agent** (the harness's `Agent` tool with `model: sonnet`; or Opus
driving Sonnet), never to DSV4F. Exploration needs judgement about what matters, and the
owner's ruling is that DSV4F does not get to make it. Sonnet returns a note with
`file:line` citations; the coordinator reads the note, not the files, and goes to the
source only for the fact a decision hinges on.
- **Labour — extracting tables from documents, mining PDFs into notes and fixtures,
translating, classifying, reformatting, and writing code to a settled, pre-split spec —
goes to DSV4F (DeepSeek V4 Flash)**, with V4 Pro only where Flash has demonstrably failed
on the same material. The owner, 2026-09-05: *"探索你带着sub sonnet或者让opus带sonnet去做。
DSV4F别做探索。DSV4F就干点儿体力活儿"*.
**The incident that bought it (2026-09-05).** `CLAUDE.md` carried *EXPLORE (me) → WRITE
(DSV4F) → REVIEW (me)* with the sentence *"exploration never goes to DSV4F"*, and no rule
said the exploring had to be cheap. Under that, the coordinator spent an afternoon on
`docs/pa-web-spec.md`, `tools/pa_web/static/app.js`, `draw.js` and every key
`beamcalc/model.py` reads from a job file — twelve consecutive file reads on the most
expensive model, to produce a note a Sonnet sub-agent writes as well. The owner's words:
*"用DSV4F。这些东西开发纪律里面没有?"* — and until that day this section did not exist. The
first draft of the fix then over-corrected and dispatched that same reconnaissance to DSV4F;
he stopped it within minutes with the ruling quoted above. Both halves are the rule: the
coordinator does not do the reading, **and** the reading is Sonnet's, not Flash's.
**Which DSV4F route.** Two exist and they do not share a balance. `cheap` (in `PATH`) is
the DeepSeek platform direct; on 2026-09-05 that balance was **negative** (`402 Insufficient
Balance`) after the `sample_job` document mining ran V4 Pro at 65k output tokens with parallel retries, and
every `cheap` call and mining script died with it. **Command Code (`cmd -p "<brief>" --model
deepseek/deepseek-v4-flash --yolo --no-session`) is a separate account with its own quota**,
and the owner's instruction that day was to use it. Check the balance before dispatching a
batch, not after it has failed; a 402 on the first call means every call.
**Detach anything that outlives a turn.** The coordinator runs as a systemd service under a
watchdog that restarts it — three times on 2026-09-05: 13:11 and 14:11 for a mis-detected
gateway, 15:09 for a stall. A service restart kills the whole cgroup, and each time it took
the background workers with it: seven `cmd` mining jobs launched from the coordinator's
shell, four dead mid-write, output files 0 bytes or absent. A worker launched with
**`systemd-run --user --unit <name> …`** is its own transient unit and survives; one
launched with `&`, `nohup` or `setsid` from the coordinator's shell does not. Write the
worker's result to a file and its status to a log (§3.1 applies: an empty log is a NOT RUN,
not a pass), so a coordinator that has lost its memory can read what finished instead of
asking.
**Split before you send, and size to one reply.** A whole-feature brief truncates at
max-tokens and reads as "it skipped the hard parts"; one function, one section, one file's
change per task (`CLAUDE.md` § delegation, owner 2026-09-03: *"确认了边界的活儿,拆细了再让
DS 干"*). The gates — pytest, scrub, review — run on the coordinator's side, never inside the
worker.
---
## 4. Where it stands
Written 2026-08-30 against `ff10870`. The working tree was clean when this survey was
taken; it is edited concurrently by several agents, so check `git status` yourself rather
than trusting that. `docs/checklist-code-map.md` was rebuilt 08-30 17:19 and commits have
landed since.
### 4.1 The checklist, row by row
Authority is `CHECKLIST_ITEMS` in `lib/vibe/ticketpage.py`.
| # | grade | state | note |
| --- | --- | --- | --- |
| 1.1 sheet name = mark | A | running | no skip probe (§4.4) |
| 1.2 sheet design reduces to piece design | A | running | judged in `ticketqc.check_ticket`; no named check fn |
| 1.3 one ticket set per piece | A | running | no skip probe |
| 1.4 view names refer to this design | B | running; **dormant out of the box** | needs `nomenclature.design_number_re` — plant layer, not `DEFAULTS`. Re-graded A→B 08-30 |
| 1.5 call-outs do not overlap | C | running, built 08-30 | rotated annotations set aside under `CALLOUT-OVERLAP-UNREAD`; no sample, untested |
| 2.1 printed size vs modelled | B | running | |
| 2.2 weight three ways | A | running | the *measured* leg needs run option `expensive_geometry` (default ON); off, the row is NOT RUN naming it |
| 2.3 unit weight | A | running | |
| 2.4 cast extras in the weight | A | running, built 08-30 | verified end-to-end on a real ticket |
| 3.1 f'c · 3.2 f'ci | A | running | |
| 3.3 release ratio | B | **muted on this job**, still runs | owner's own dated sentence: no panel here has the thing it asks about |
| 4.1 strand count · 4.2 diameter & grade | B/A | running | |
| 4.3 jacking force | A | running | `tolerances.it_lb` wired 08-30 to **100 lb**; the job layer's `1.0` deleted, not corrected |
| 4.4 strand positions vs chain from rail | B | running | stands down with a reason where there is no strand geometry |
| 4.5 debond call-out | C | running, built 08-30 | consumes 4.7; where 4.7 could not judge, 4.5 is NOT RUN with 4.7's reason |
| 4.6 rows & spacing | A | running | same check as 4.4 |
| 4.7 strand debonded where concrete runs short | B | running | |
| 5.1 BED PLAN call-out embed exists | A | **half running by construction** | `check_bom_members` runs; `check_detail_callouts` reports NOT RUN *naming the sheets nobody read* wherever the plan cites a bubble. Only a plan citing no detail can be OK |
| 5.2 embed in model appears on ticket | A | running | |
| 5.3 face designations vs model side | C | **FOR ENGINEER by design** | printed a red NOT RUN on every ticket until 08-30 — missing from a hand-kept tuple (§3.1) |
| 5.4 mesh / WWF designation | A | **muted on this job**, still runs | no mesh on this job |
| 5.5 bar marks legible | D | **FOR ENGINEER by design** | a property of the picture |
| 5.6 bar marks unique | B | running, built 08-30 | split out of the old 5.5; runs where the reinforcing rule resolves (§4.3) |
| 5.7 no bar longer than its stock | B | running, **against one generic number** | the per-size stock table is built, populated and unreachable (§4.3) |
| 6.1 insert capacity · 6.2 edge distance | A | running | no skip probe |
| 6.3 handling result | A | running | no skip probe; three silent `return []` paths |
| 6.4 dunnage not under an opening | A | running, probe added 08-30 | open ruling on the all-blind-embed path: NOT RUN (today) or OK |
| 6.5 note views say something | B | running; **dormant out of the box** | needs `notes.instruction_view_sets`; `DEFAULTS` ships it empty |
| 7.1 interference | B | running | WARN-only by ruling, over 157 findings on 57 archived tickets. Do not re-infer that the rest are noise — that was overruled once |
| 8.1 unresolved design-summary lines | C | running | the one true FAIL of the 08-30 live run ("no probe declared"); probe added |
| 8.2 no invisible characters | A | running | no skip probe |
Nothing on the list is unbuilt. Two rows are a person's job on purpose, two are muted with
a written dated reason, the rest compute. What varies is how much of its question each row
actually reaches — **5.1 and 5.7 claim least**.
**The table above is the 34-row census this section was written against and is now stale
by count** — `checklist.master` in `knowledge/catalogue.yaml` carries **46** entries as of
2026-08-31 (verified by reading `len(ticketpage.CHECKLIST_ITEMS)` on a fresh load; run it
rather than trusting this number later). The rows above through 8.2 are still real; twelve
more (`4.8`, `5.8`–`5.15`, `6.6`–`6.8`) have landed in the master since and are not
individually described here. Extend the table from the catalogue, not from memory of it.
### 4.2 The other tools
| tool | state |
| --- | --- |
| **Panel Lift Check** | Finished, in daily use, no licence needed to test. Numerics open (§4.4). The per-stage input control aligned to the reference tool's `HandlingData` is a **mapping document only** (`docs/panellift-ewf-alignment.md`); nothing is built from it |
| **Reinforcing Suggestion** | Finished. End-to-end 08-30: cold 10 s, warm ~1 s, offline byte-identical to the bridge. Position recommendation defaults **off**; one invariant test still owed |
| **Ticket QC** | Finished, the flagship. Six in-process `vibe/ticket*.py` modules — `wc -l WillTools.extension/lib/vibe/ticket*.py`, and expect the number to have moved since you last looked; it is what §4.5 is about |
| **Snapshot & Close** | Finished. Phases A and B measured green; phase C (the deferred detached copy) is off unless the job layer enables it, and journals its own silence |
| **Takeoff** | Finished. `nomenclature.ticket_unit` wired 08-30, so the tool stops deciding for itself what one ticket covers |
| **Job Recon** | Finished. Self-loading, scope-aware, 9.3 s on the real job |
| **Design Extract** | Finished, with its caveat printed on it: every value it writes is machine-read and unverified |
| **Self Check** | Finished |
| **Gravity (WIP)** | Neither this nor Lateral writes to the Revit model — both are extractors: read the model, write a file elsewhere. Gravity is meant to extract information and write **Excel**. Today it does none of that: **a button with nothing behind it, and it says so.** ~200 lines of placeholder printing the open questions and assuming nothing. Blocked on the owner supplying old jobs and a control spreadsheet |
| **Lateral (WIP)** | Meant to extract lateral-resisting members and write **RISA** — a structural analysis package — its input format. Half-built, and it does real work: it extracts marked members and writes a JSON intermediate model, two CSVs, and a `.r3d.DRAFT.txt` (`.r3d` is RISA-3D's own file extension) — the `.DRAFT` suffix deliberate, so it cannot be loaded into RISA by accident. What the RISA format needs beyond that is not established |
### 4.3 Dormant, and what would wake it
| dormant | what wakes it | who |
| --- | --- | --- |
| `drawings.reinforcing_rule.scopes` — empty, so 5.6 and 5.7 see one population | a ruling on three edge cases: bar-formed hardware carrying the token (one is a strand loop, with neither mark nor stock length), and reinforcement carrying none | owner, A1 |
| `catalogue.rebar.stock_by_size` — populated, read by `knowledge.rebar_stock()`, **never called by `check_bar_stock_length`** | which parameter carries a bar's size | owner, A3 |
| 5.3 as a computed check | what makes an elevation line dashed — line style, subcategory, or nested detail items. The view's 17 filters were read; none carries it | owner, A5 |
| the up-end rule | woken 08-30 on evidence over 282 assemblies. Still open: the vertical-panel case, and the colour→axis mapping, which is inference not measurement | owner, A4 |
| `tolerances.psi` (inert, muted not deleted) and `details`, the 70-entry job catalogue nothing reads | one word each: wire or delete | owner, D1 / D7 |
| `drawings.rebar_must_appear_on` / `section_a` / `section_b` | a dated promise queued behind the current phase — not drift | queued |
| `check_detail_callouts` as a verdict | something that reads a detail sheet's *contents*. Not built; needs the model | build |
| vision escalation (`AI-` codes) | off by default; owns no checklist row and cannot flip one (§3.1) | config |
### 4.4 Known defects and unfinished business
- **`qc-run.json` carries findings, not the run log.** `ticketvision.qc_json` takes
`findings` + `model_read` and nothing else — checklist rows, mutes and `run_all`'s log
stay behind. A NOT RUN row's *reason* reaches the HTML page and `last-ticketqc.txt` but
never the machine-readable file, so anything reading the JSON cannot tell a row that
passed from a row nobody asked.
- **A failing test, environment-coupled.** `test_reinforceoffline.py`'s no-index case fails
here because `cli.resolve_index` finds the real, gitignored `knowledge/job/` and answers
with a job list instead of the three-ways message. Green on a clean checkout, red on the
owner's machine.
- **Closed, 2026-08-31: every registered check now has a skip probe, and it is
test-enforced rather than a hand count that can silently rot again.**
`tests/test_ticketchecks_host.py::test_every_registered_check_has_a_skip_probe` walks
`ticketchecks.CHECKS` against `_SKIP_PROBES` and fails, naming the orphan, the day a new
check ships without one. A silent `return []` from an unprobed check still logs
RAN-EMPTY and prints a red NOT RUN reading "no probe declared" — the mechanism is
unchanged — but the gap this bullet used to track (eight bare entries, then seven) is
gone; run the test rather than trusting a count copied in here.
- **Client and plant names are in pushed history** (§3.4). **Untaken decision:** redact the
working tree only, or rewrite history and force-push.
- **The lifting solver's numerics.** `docs/lift-check.md`, written up, unstarted. Not a
speed question: today's recommendations are not determined to better than several inches
and on some pieces are not the best layout available. It gates any further
insert-placement work.
- **`families.wwf_prefix` has two defaults** — `takeoffgather` falls back to empty (no mesh
detection), `ticketqc` to `'WWF'`. Reachable only when a layer sets the key null, which is
a plant saying it has no welded wire fabric. If that is what null means, `ticketqc` is
wrong.
- **Smaller, recorded:** `read_ticket` costs 37.6 s on a large piece, entirely outside the
checking wall and with no code-level cure; and `catalogue.rebar.stock_exempt_marks` is
empty pending the 60-ft continuous-bar ruling.
### 4.5 The roadmap, as it actually exists
**`docs/architecture-migration.md` — nine steps, approved 08-30.** In-process IronPython
keeps only the gatherers; judgement, matching and rendering move to host CPython, fed by
the versioned JSON of §2.2.
*Step 1 landed 08-31*: Ticket QC writes `evidence/run-<stamp>.json` on every press and
**nothing reads it**. As built it corrected its own plan — four live-object holdouts, not
two, so the serialiser gates on type. **§5.1, parity on real data, is not met and could
not be**: it needs a press, and the bridge was held.
*Step 2* buys a parity proof and nothing else: a host runner judging the 13 Revit-free
checks offline against the twelve-piece audit set. Cost M, no visible change. *Step 3* is
where it pays — the button calls that runner (reusing `liftexport.find_python`/`run`),
making it the first press where a `ticketchecks` judgement edit is visible **without a
Revit restart**. Cost S.
**§8.2, honestly:** `engine: clean` is landed on three buttons and buys nothing today. A
clean engine is already the default here, because `extension.json` declares no `url`, so
pyRevit's package lookup drops the extension and rocket mode never takes the clean engine
away — verified two ways offline. It is a **pin, not a lever**, against the day somebody
adds a `url`. **The one-minute behavioural check — press, edit a rendered string, press
again without restarting — has still never been run**, and no bridge call answers it.
**`QNA/eriksson-gap-inventory.md` — the commercial-parity list.** 46 quantities: 10 at
parity, 2 needing no build, **34 to build** (17 S · 5 S-after-dependency · 5 M · the rest
larger). **Mcr is the cheapest first win** — S, P, A and e are held at every station and
`modulus_of_rupture_psi` is written. The load-bearing finding: the reference tool is a **1D
single-member analysis, not 2D FEA** — every plot is a function of x, with no mesh
reference and no Mxy/Wood-Armer column in 18 pages. That reframes tier B entirely. The list
needs the owner's tick before anything on it is built.
Beyond those two, the only genuine plans are the reinforcing-suggestion brief's open
granularity questions and §4.3's queued coverage rule. The rest of `QNA/` is record, not
plan.
### 4.6 Where the docs and the code disagree
Several documents were accurate when written. Where they are not, the code wins.
- **Every test count quoted in this tree is stale.** `CLAUDE.md` says `panellift` has 76
tests and the extension suite is `49 passed`; `README.md` says `34 passed`. Run the
commands in §3.7.
- **Every line count quoted in this tree is stale, recent ones included.**
`architecture-migration.md` §3 warns it will drift and has drifted again; on 2026-08-30
`ticketqc.py` moved between two measurements minutes apart, because other agents were
editing. Use `wc -l`, and read any line count as a date-stamp rather than a fact. §3.3's
"26 registered checks" is now 27. **Verdicts hold; numbers do not.**
- **Design Extract calls no AI**, despite `README.md` ("hands the scrape to Claude Code")
and `CLAUDE.md`. The script says "no AI call, no subprocess", and no button routes
through `vibe/aiextract.py` any more.
- **`configdump.py` and `CLAUDE.md` say four knowledge layers**; `layer_values()` handles
five, with config behind `include_config=False`.
- **`knowledge.load()`'s docstring drops a folder** — it gives the job layer as
`<model folder>/knowledge.yaml`, where `designspec.knowledge_path()` returns
`<model folder>/_ticketqc/knowledge.yaml`.
- **`docs/handoff.md` calls Panel Lift Check "the one under development"**; it is one of
the four finished large buttons, and the same file's "Never migrate" section calls it
*already migrated*.
- **Batch speeds disagree across three files** — `panellift/cli.py` 41 min / 40 s,
`docs/lift-check.md` ~40 min / 2 min, `PanelLiftCheck/script.py` 38 min / 2.5 min. The
last is the most specific and names its path.
- **`rails.left_rail_is_fixed` is a dead flag** (`docs/knowledge-override.md` says so
correctly): one reader, `aiextract`, which puts it in a prompt. No check consumes it.