Architectural plans (PDF first, DWG when available) to a verified Blender 5.2 building model, as a Claude Code plugin. Named after Ted Mosby, the architect in How I Met Your Mother.
Mosby is a harness, not a magic converter: it gives the model a phase plan with computable
definitions of done, a mosby CLI whose audits write the reports that define "done", hooks that
stop the most expensive mistakes mechanically, and skills that carry the method. The method was
distilled from one real job: a two-level house that took 119 correction iterations, 310 scripts
and three owner-rejected "deliveries" before it was acceptable. Mosby exists so the next job does
not.
Be clear about what is enforced and what is advised. Enforced (hooks, no opinion needed):
generated files (plan*.json, reports/*.json, status.json, ...) cannot be hand-edited; every
Blender snippet is identity-checked against the job's file/scene and auto-checkpointed; headless
builds cannot start while the P0/P1 gates are unpassed; the session cannot end on a "delivered /
complete / verified" claim that status.json does not back, nor after three unobserved mutations.
Advised (skills and the CLI's audits): everything else, which audit to run when, how to read a
sheet, how to seat a fixture, how to word a finding. Advice is followed by a model that has read
it; enforcement is not optional. The design is summarised under "Design" below; the words a
finding or status may use are the claim table in skills/mosby/reference.md (section H).
v0.1.0. Every CLI module listed in tools/mosby/constants.py (CLI_COMMANDS) exists and the 17
test suites pass (10 Python, 7 inside headless Blender). What has been proven end to end on a real
plan set is the front half: the P0 (survey) and P1 (extraction) gates pass on a 17-sheet AutoCAD
PDF with no hand edits to any generated file (numbers under "Quickstart"). The shell, finishes,
interior, review and delivery stages have unit and Blender-side tests with planted defects but have
not yet been driven through a full job under the harness. Known gaps: P2 reports go stale when a
later build changes model.blend (whole-file hash binding), camera and lighting finding classes do
not block, and mosby build finishes is a stub. Expect to iterate; that is what the repo is for.
Requirements: Python 3.11+, Blender 5.2 LTS on PATH (or set MOSBY_BLENDER to the executable),
the Blender MCP add-on running in the Blender you intend to drive, Claude Code.
git clone https://github.com/elliottshort/mosby.git
pip install -e mosby/tools # installs the `mosby` CLI and library
claude --plugin-dir mosby # loads hooks, skills and agents for this session
blender --version # must resolve; or set MOSBY_BLENDER
Blender MCP: one MCP-enabled Blender per job. If two Blenders run on one machine they must not
share a port; record the port in job.json (blender.port). The house run edited the wrong
instance twice because both listened on 9876.
Optional: pip install -e "mosby/tools[dwg]" for the DWG front end (needs the dwg-mcp server),
[dev] for pytest.
The sequence below is the one that took the house PDF (17 sheets, no DWG) through the P0 and P1 gates with no hand edits to any generated file. Replace the page numbers, rows and values with your own; every number here was read off the sheets or measured by the commands, never assumed.
mosby init jobs/myhouse --pdf C:/plans/HOUSE.pdf --levels L1
mosby pdf probe C:/plans/HOUSE.pdf --job jobs/myhouse # census per page -> reports/pdf_probe.json (route: layered_vector)
mosby pdf sheets C:/plans/HOUSE.pdf --job jobs/myhouse # sheet kinds + regions -> reports/sheet_index.json
mosby pdf to-entities C:/plans/HOUSE.pdf --page 4 --job jobs/myhouse --level L1 --scale "1/4in=1ft"
mosby scale-check jobs/myhouse --printed-in 1484 --axis x # wall extent vs the printed overall dimension (persisted in sources.json)
mosby pdf crop C:/plans/HOUSE.pdf --page 4 --job jobs/myhouse --bbox-pt 1280 1890 1720 2000 --reason "north arrow" --png evidence/pdf/north_arrow.png
mosby survey north jobs/myhouse --axis Y- --evidence evidence/pdf/north_arrow.png --status verified --sheet A2.1
mosby pdf datums C:/plans/HOUSE.pdf --page 10 --job jobs/myhouse --scale "1/4in=1ft" --region-pt 150 150 2900 1000 --reference-y 793.96 --names '{"793.96": "L1", "613.96": "bearing_plate", "576.31": "L2"}' --levels '[{"name": "L1", "printed": "0'''-0\""}, {"name": "bearing_plate", "printed": "10'''-0\""}, {"name": "L2", "printed": "12'''-1\""}]'
# -> copy the verified heights into job.json levels[] (ceiling_height_m, datum_source)
# -> fill decisions.md (every D-xx row gets an adopted default), then:
mosby survey conflicts jobs/myhouse
mosby audit gate p0_survey jobs/myhouse # host-only gate: prints the writer for anything missing/stale
mosby pdf blobs C:/plans/HOUSE.pdf --page 4 --job jobs/myhouse --min-height-pt 4 --out jobs/myhouse/evidence/pdf/blobs_p4 # blobs + contact sheets + template
# -> transcribe the contact sheets into transcriptions.json (Read the PNGs; "?" for illegible), then:
mosby pdf inject-text jobs/myhouse/entities/L1 evidence/pdf/blobs_p4/transcriptions.json evidence/pdf/blobs_p4/text_blobs.json
mosby extract jobs/myhouse --level L1 # plan_L1.json + debug png + plan_validate/plan_lint/source_completion
mosby plan validate jobs/myhouse --level L1 # + reports/openings_complete.json
mosby registration jobs/myhouse
mosby audit gate p1_extraction jobs/myhouse
# corrections ONLY through plan_flags.yaml (labels, openings, gap/cell/room dispositions), then re-run extract
mosby build shell jobs/myhouse # headless blender -b: walls, headers, jambs, slabs, ceilings, stairs, cameras
mosby audit gate p2_shell jobs/myhouse # mesh_validity, section_vs_source, coplanar_faces, protrusion, headers_present, ...
mosby render batch jobs/myhouse && mosby render inspect jobs/myhouse
mosby status jobs/myhouse # phase, gates, stale reports, next step
What the house run produced at the P1 gate: scale delta 0.0 in on 1484 in, four datums within 0.2 in of their printed values, 204 labels injected (104 multi-word merges), 194 walls, 24 doors, 14 windows, 3 cased openings, 25 rooms, 0 leaks, 41/41 openings with resolved widths and heights.
Inside Claude Code you rarely type these: the mosby router skill detects the phase from
status.json and calls the phase skill, which runs the commands. The job argument may be omitted
when the current directory is, or contains, exactly one job.
Five root causes accounted for most of the house run's cost, and each has a mechanism in the plugin that removes it rather than a rule that asks the model to remember it:
| Root cause | Share | Mechanism |
|---|---|---|
| Geometry built from the wrong reference: finishes, floors, trim, fixtures and cameras placed from semantic room polygons and nominal datums instead of the evaluated faces of the model (103 floor gaps, 110 detached baseboards, 105 floating plates) | ~40 % | Every finish, fixture and camera builder takes host faces from the evaluated depsgraph, never from plan.json; coplanar_faces, finish_reach, fixture_seating and protrusion run as pre-render gates |
| Source not exhausted before building: 141 wall polygons omitted by parallel-line pairing, door hinge and swing never extracted, headers absent, PDF layers never probed | ~20 % | source_completion (closed cells of raw lines vs the wall set) is a P1 gate; door records carry hinge, swing, rough and clear width from source arcs and jambs; PDF probe, scale gate and datum protocols are P0 steps |
| Verification narrower than the claim: door-throat rays reported as walkability, eye-level cameras reviewed as whole-house, contact sheets as native inspection | ~20 % | Computable definitions of done per phase in status.json; route and body-sweep audit; camera coverage and line-of-sight validator; native-resolution review receipts; a Stop hook that blocks delivery claims the gates do not back |
| Process without enforcement: a 6-iteration cap in prose, duplicate iteration numbers, fix-one-instance instead of fix-the-class, four full re-render generations | ~15 % | Hooks own the bookkeeping (act log, checkpoints, cap counter); class audits run after every apply; renders happen once after gates pass; freeze generations instead of "pending" markers |
| Infrastructure surprises: two Blenders on one MCP port, an Eevee material-preview crash after mesh replacement, PNG file locks, float32 CSG, boolean modifiers on architecture | ~5 %, but catastrophic | Scene-identity preamble injected into every execute_blender_code; headless-by-default mutations with live reload; render queue with staging files; analytic CSG on a 10 um grid proven on stored float32 coordinates |
Principles that follow: source walls are immutable and finishes are separate objects that touch a
host face; every builder consumes evaluated geometry (plan.json is the plan, not the model);
nothing is done without a passing report bound to the source hashes and, from P2, the model hash
and scene signature; class, not instance; observe once, correctly; defaults over blocking
questions (conflicts get a recorded default in decisions.md and building proceeds); headless
mutations, live viewing. Evidence is one contract: mosby.report.Report writes every audit, gate
and receipt, mosby status computes gates and staleness from input hashes, and the hooks read it.
Rubric scores do not exist; findings carry a class, and blocking classes fail gates.
Claim language is part of the design: built, verified, reviewed and delivered are four different
words with four different evidence requirements, "passes" always names its audit and scope, a
bounded check is PASS_LOCAL(scope), and an anomalous pixel is probed to an object before anyone
calls it a visual limit. The full table is skills/mosby/reference.md section H.
mosby/
.claude-plugin/plugin.json plugin manifest
hooks/hooks.json + *.py enforcement (see "Hooks"); _hooklib.py is the shared I/O contract
skills/<name>/SKILL.md phase skills + `mosby` router (+ reference.md per skill)
agents/ independent-reviewer, source-transcriber, defect-hunter, gate-auditor
tools/mosby/ python package + `mosby` CLI (constants.py is the shared contract)
tools/sync_codex.py generates codex/ (AGENTS.md + .agents/skills) from skills/
templates/job/ job.json, brief.md, decisions.md, plan_flags.yaml, status.json
tests/ hook contract tests (synthetic events), validator tests, Blender-side suites
codex/ (generated) Codex mirror; never edit by hand
tools/mosby/constants.py is the one file every module, hook and skill agrees on: object roles
and custom properties, tolerances, phase names, the audits each gate requires (GATE_AUDITS),
which command writes each host-side audit (HOST_AUDIT_WRITERS), the job folder layout
(JOB_FILES, JOB_DIRS) and the CLI surface (CLI_COMMANDS). Skills refer to CLI commands and
audit names, never to Python function names.
All hooks are registered in hooks/hooks.json and run as python hooks/<name>.py so they behave
identically under PowerShell, Git Bash and POSIX shells. Every hook is a no-op unless a Mosby job
folder (a directory holding job.json, brief.md, plan.json, model.blend or sources.json)
exists under the project, at most three levels deep. A hook that crashes never blocks: the error
is swallowed and logged as a note. Set MOSBY_HOOK_DEBUG=1 to append every decision to
$CLAUDE_PLUGIN_DATA/hook_debug.log.
| Hook | Event (matcher) | What it does when a job exists | Blocks? | How to get past it |
|---|---|---|---|---|
session_start.py |
SessionStart (startup, resume, compact, clear) | Injects the active job, phase, gate states, Blender target (file/scene/port), carried-over unobserved acts, and the 8 global rules. Phase is read from status.json or inferred from files, never from prose. |
no | - |
prompt_context.py |
UserPromptSubmit | Injects a one-line status only when status.json changed, or when a check-in is due: 6 mutating acts since the last passing class audit. |
no | Run the class audit (mosby audit <name>); a passing mosby audit/status/review command resets the counter. |
guard_write.py |
PreToolUse Write, Edit, MultiEdit, NotebookEdit | Refuses hand edits of generated files: plan*.json, entities/*.json, sources.json, status.json, reports/*.json, receipts.jsonl, anything under .mosby/; refuses any write into sources/ or to a .pdf/.dwg/.dxf. |
yes | The block names the command that regenerates the file (mosby extract, mosby pdf to-entities, mosby status, mosby audit ...). Change the inputs (plan_flags.yaml, decisions.md) and regenerate. If the generator lacks a field, extend tools/mosby. |
guard_blender.py |
PreToolUse mcp__*blender*__execute_blender_code* |
Rewrites the code: prepends a preamble that raises if bpy.data.filepath/scene differ from job.json, or a render job is running; the first mutating snippet of a session and the first after 10 quiet minutes also saves a checkpoint to .mosby/checkpoints/. In a live target: blocks read_homefile, read_factory_settings, quit_blender, and open_mainfile without the marker. Warns (does not block) on MATERIAL/RENDERED viewport shading, Boolean modifiers, object.join. |
yes (live resets) | Mark a read-only probe with # mosby: read-only to skip the checkpoint. Mark the reload-after-headless-apply step with # mosby: reload. Fix job.json (blender.file, blender.scene, blender.port) if the preamble says WRONG FILE/SCENE; do not edit the wrong session. |
guard_bash.py |
PreToolUse Bash | Blocks recursive deletes of sources/, .mosby/checkpoints/, reports/, renders/ inside the job; blocks blender ... --python builds while a gate in GATES_REQUIRED_BEFORE_BUILD (p0, p1) is not passed; adds a note when PowerShell > redirection would write a UTF-16 log. |
yes | Move evidence out of the job by hand instead of deleting. Run the missing audits (mosby status lists them). Log with | Out-File -Encoding utf8 or run through Bash. |
log_acts.py |
PostToolUse Blender exec, Bash (blender/mosby/tool scripts) |
Appends .mosby/acts.jsonl (code sha, mutating?, status, error, checkpoint landed?); counts mutating acts since the last observation and since the last passing audit. |
no | - |
log_observations.py |
PostToolUse Read of an image, Blender render/screenshot tools | Appends .mosby/observations.jsonl and resets the unobserved-mutation counter. |
no | - |
stop_gate.py |
Stop | Blocks once when the last message claims delivery/completion/verification (delivered, final, complete, all checks pass, no remaining defects, 100%, ...) and the matching gate (p6_delivery or p5_review) is not pass and non-stale in status.json; or when 3+ mutating acts are unobserved. |
yes, once | State the evidence scope instead (PASS_LOCAL(kitchen cameras), sampled: 34 door planes, unobserved), or run the missing audits and mosby status. The second stop attempt is always allowed (stop_hook_active). |
Disabling: hooks fire only when the plugin is loaded (claude --plugin-dir); remove a row from
hooks/hooks.json to switch one off for a session. Hook state (checkpoint receipts, counters,
active job) lives in $CLAUDE_PLUGIN_DATA/state/<session_id>.json; deleting it resets counters.
mosby init <path> [--pdf F] [--dwg F] [--target headless|live] [--port N] [--scene NAME] creates
this from templates/job/. Names come from constants.JOB_FILES / JOB_DIRS.
<job>/
job.json name, sources[] (sha256), units, north, blender{target,file,scene,port}, levels[], acceptance{}
brief.md the user's words, deliverable, global decisions table, scope exclusions, progress log
decisions.md conflict table: id, sources, measured values, recommended default, status (default|user), depends
decisions.json machine form of decisions.md
plan_flags.yaml THE correction channel for extraction (per level); never hand-edit plan_*.json
sources/ read-only inputs (copied by init unless --no-copy)
sources.json per sheet: page, title, scale, verified_scale, rotation, layers, kind (generated)
entities/<level>/ header.json, entities.json, layers.json, export_summary.json (generated)
plan_<level>.json plan schema v2 (tools/mosby/schemas/plan_v2.schema.json) (generated)
model.blend current model; model_final.blend + model.manifest.json after freeze
reports/ <audit>.json per audit, p<N>_<phase>.json per gate, freeze.json (generated)
renders/<batch>/ png frames + receipts.jsonl
evidence/ crops with sidecar json, overlays, debug plots
scripts/ every act as a script: <phase>_<subgoal>_<n>.py
ledger.jsonl scoped, receipt-bound findings (replaces the house run's critique log)
status.json computed by `mosby status`; hooks read it (generated)
.mosby/ acts.jsonl, observations.jsonl, transactions.jsonl, checkpoints/, last_session.json
Phase gates, and the audits each one needs (from constants.GATE_AUDITS; a gate passes only
when every audit has a non-stale, whole-house, pass report on the current model sha):
| Gate | Audits |
|---|---|
p0_survey |
pdf_probe, scale_check, north, datums, conflicts_resolved, sheet_index |
p1_extraction |
plan_validate, plan_lint, registration, source_completion, openings_complete, text_transcription |
p2_shell |
mesh_validity, section_vs_source, envelope_closure, coplanar_faces, protrusion, headers_present, stair_routes, diagnostic_cameras |
p3_finishes |
finish_reach, coplanar_faces, trim_coverage, material_slots, protrusion |
p4_interior |
programme_coverage, containment, door_sweeps, fixture_seating, room_connectivity, light_coverage, camera_adequacy |
p5_review |
render_receipts, anomaly_scan, independent_review, edge_coverage |
p6_delivery |
freeze, package, reopen, no_pending_markers |
Audits that run inside Blender are started with mosby audit <name> or mosby audit gate <phase>;
host-side audits (all of P0 and P1, review, delivery) are written by the command named in
HOST_AUDIT_WRITERS, and mosby audit gate p0_survey prints that command for anything missing or
stale instead of launching Blender. A report goes stale when any input hash it recorded (sources,
plan, decisions, model) changes; mosby status shows why. A pending marker is
<!-- PENDING:<id> --> in any job .md; the delivery gate fails while one exists.
python tests/run_all.py # 17 suites: 10 Python, 7 inside blender -b
python tests/run_all.py --no-blender # Python suites only
python -m unittest tests.test_hooks -v # 29 hook contract tests; feeds synthetic events to each hook
python tools/sync_codex.py --check # codex/ mirror is up to date
Two optional fixture sets make more tests run: set MOSBY_HOUSE_DIR to a checkout of the house
experiment (default: a sibling experiments/house folder) and MOSBY_SCRATCH to a folder holding
a pdf_probe/ export; tests that need either skip cleanly when it is absent. The DWG integration
tests need a running dwg-mcp server; set MOSBY_SKIP_DWG_INTEGRATION=1 to skip them. The hook
tests deliberately run with a cp1252 pipe (no PYTHONIOENCODING) because the first end-to-end run
lost the SessionStart banner to a UnicodeEncodeError. No pytest is required; with the [dev]
extra, pytest tests also works.
Hooks, agents and auto-loaded skills are Claude Code features. For Codex CLI (and any client that
reads AGENTS.md and .agents/skills/) run:
python tools/sync_codex.py # writes codex/AGENTS.md and codex/.agents/skills/<name>/...
python tools/sync_codex.py --dest C:/my/workspace # or generate straight into a workspace root
python tools/sync_codex.py --check # CI / pre-commit: exit 1 when the mirror is stale
The generator reads skills/<name>/SKILL.md (+ sibling .md references), rewrites Claude phrasing
to Codex phrasing (image inspection via view_image, skill mentions as $name, CLAUDE.md to
AGENTS.md, references under references/), emits agents/openai.yaml per skill, and writes
AGENTS.md from the mosby router skill plus the global rules and the hook contract (pulled from
hooks/session_start.py and each hook's docstring) so a client without hooks at least reads
what they would have enforced. AGENTS.md never names a job: job state comes from mosby status.
The house run kept two hand-maintained instruction files that pointed at different jobs for 20
hours. Edit skills/, never codex/; rerun the sync.
Everything here is distilled from one job: a two-level residence from a 17-page AutoCAD PDF (all text as stroked outlines, 352 OCG layers) plus its DWG. The first model was "delivered" at 04:11 after 3.5 hours; the owner rejected it on scale, layout, door swing and trim the next afternoon; 7.5 more hours and iterations 57-119 followed, with two further rejections after self-review had reported "39/39 PASS". Counted afterwards: 141 wall polygons the extractor omitted, 37 doors without headers, 103 floor gaps, 110 of 131 baseboards detached, 4811 cuboids with reversed normals, four full re-render generations, a 6-iteration cap blown by 2-10x on every finish sub-goal, and one Blender crash.
The run was analysed into 124 failure classes in eight clusters (source/scale/PDF, extraction,
openings/stairs, envelope/finishes, fixtures/materials/lighting, review/verification,
process/delivery, infrastructure/API), plus 63 classes added by a skeptic review. Every rule in a
skill and every hook behaviour carries its class id in a trailing <!-- FC-... --> comment
(FC-<cluster><nn>; skeptic-added classes are FC-<cluster>M<nn>). The ids are tags, so a rule
can be traced to the defect that motivated it; the catalogue and the experiment they point into
are not part of this repository.
The skills' reference.md files carry the evidence each rule rests on.
- Windows: every CLI and hook forces UTF-8 on stdout/stderr. Do not capture logs with PowerShell
>(UTF-16, wrapped stderr); use Bash or| Out-File -Encoding utf8. - Blender stores mesh coordinates as float32; everything is authored on a 10 um grid and proven on
the stored coordinates (
constants.GRID_M,WELD_M). Validators read the evaluated depsgraph. headlessis the default Blender target: mutations run inblender -bonmodel.blendand the live session reloads.liveruns under the identity check and auto-checkpoint described above.
Issues and pull requests are welcome. A few conventions keep the plugin coherent:
- Commits follow Conventional Commits (
feat:,fix:,docs:,test:,refactor:,chore:), scoped to the area when useful (fix(plan): ...,feat(hooks): ...). - Every new rule, validator or hook behaviour carries a failure-class tag, and prefers a validator
or hook over prose. Every gate audit has a writer: a bpy audit or an entry in
constants.HOST_AUDIT_WRITERS. - Job constants never go in the library; they belong in
job.json,decisions.mdorplan_flags.yaml. - Edit
skills/, then runpython tools/sync_codex.py;codex/is generated. - Run
python tests/run_all.py(or--no-blenderwhen Blender is not installed) before opening a pull request, andclaude plugin validate .after touching the manifest, hooks or skills.
MIT. See LICENSE.