Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

47 KiB
Raw Permalink Blame History

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:

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.