History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
47 KiB
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:
- Discard whole on mismatch.
reinforcecore.load()andticketqc.load_evidence()throw a stale file away entirely — never partially, never migrated. - No live objects. The plan named two holdouts; the build found four (
t['doc'],t['legends'],t['sheets']/t['sheet'], and liveXYZint['strand_points']on the no-framing early return) — so the serialiser gates on type, not on a list of known offenders. - Absence is not a value. A gather that could not read something writes an explicit
nulland a reason, through oneput(). A merely missing key reads downstream as "nothing to check" — the same failure modeticketchecks._SKIP_PROBESexists to prevent (§3.1). - Provenance travels — which layers were in force, which spec source (
specorregex), which build.intentandspec_sourceride with each ticket. Findings do not:qc-run.jsonowns 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
#4pass where the plant stocks#4in 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 rolemember.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.pyappends a line to.bridge-sessions.logon every pyRevit load, andrvt.pyreads that log before every request and hard-refuses on two loads under one process id. There is no/reloadendpoint. Ifrvt.pysays 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.Symbolon every result, and setOptions.DetailLevelfrom 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 atx('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:
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
Agenttool withmodel: 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 withfile:linecitations; 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.jsoncarries findings, not the run log.ticketvision.qc_jsontakesfindings+model_readand nothing else — checklist rows, mutes andrun_all's log stay behind. A NOT RUN row's reason reaches the HTML page andlast-ticketqc.txtbut 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 becausecli.resolve_indexfinds the real, gitignoredknowledge/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_probewalksticketchecks.CHECKSagainst_SKIP_PROBESand fails, naming the orphan, the day a new check ships without one. A silentreturn []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_prefixhas two defaults —takeoffgatherfalls back to empty (no mesh detection),ticketqcto'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,ticketqcis wrong.- Smaller, recorded:
read_ticketcosts 37.6 s on a large piece, entirely outside the checking wall and with no code-level cure; andcatalogue.rebar.stock_exempt_marksis 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.mdsayspanellifthas 76 tests and the extension suite is49 passed;README.mdsays34 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-30ticketqc.pymoved between two measurements minutes apart, because other agents were editing. Usewc -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") andCLAUDE.md. The script says "no AI call, no subprocess", and no button routes throughvibe/aiextract.pyany more. configdump.pyandCLAUDE.mdsay four knowledge layers;layer_values()handles five, with config behindinclude_config=False.knowledge.load()'s docstring drops a folder — it gives the job layer as<model folder>/knowledge.yaml, wheredesignspec.knowledge_path()returns<model folder>/_ticketqc/knowledge.yaml.docs/handoff.mdcalls 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.py41 min / 40 s,docs/lift-check.md~40 min / 2 min,PanelLiftCheck/script.py38 min / 2.5 min. The last is the most specific and names its path. rails.left_rail_is_fixedis a dead flag (docs/knowledge-override.mdsays so correctly): one reader,aiextract, which puts it in a prompt. No check consumes it.