docs(governance): D-263 — one CLI named reach, and Q-124 answered

Q-124 asked whether the 123-file Python tooling should be retooled into a
Rust CLI. The answer is no, and it is a costing rather than a preference.
All three frictions it names — per-script permission prompts, the venv/PATH
split between interactive and non-interactive shells, and interpreter
startup paid four times per push — are packaging problems, and one bare
command on PATH with lazy subcommand loading fixes all three. Rust would
additionally owe a numerical-equivalence proof on the planet-gen path,
whose heightmaps are committed build artefacts with goldens standing on
them: a large one-time cost to avoid a small recurring one, paid in the
currency the project can least afford to spend.

D-263 fixes the shape. tooling/ becomes an installable package behind the
`reach` command: a routing-only main.py, every domain under domains/<name>/
split router/service/schemas/helpers, a core/ bounded on day one to what
has no domain, logging and error handling attached as decorators rather
than call-site discipline, and pydantic confined to domain schemas —
measured at 87 ms against a whole gate check of 20-46 ms, which is why it
must never reach the push path. Failures carry the command that fixes them
and keep their exit code; a tool that explains itself and exits 0 silently
disables its own gate.

R-014 records the Rust option as costed down, not argued down, with the
condition under which it is worth reopening. T-1247 files the work as
eight dependency-ordered epics; only the skeleton is unblocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 02:32:21 +02:00
co-authored by Claude Opus 5
parent 284ce847c3
commit bbd64307ab
8 changed files with 150 additions and 3 deletions
+2
View File
@@ -328,6 +328,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it.
- [D-260: Generator scope — Sol is authored, not generated](decisions/architecture.md#d-260-generator-scope--sol-is-authored-not-generated) — _architecture_
- [D-261: River rendering — a stroke, not a scaled feature](decisions/architecture.md#d-261-river-rendering--a-stroke-not-a-scaled-feature) — _architecture_
- [D-262: The wiki↔generator data flow has one canonical map, and it is a diagram](decisions/architecture.md#d-262-the-wikigenerator-data-flow-has-one-canonical-map-and-it-is-a-diagram) — _architecture_
- [D-263: Tooling consolidates into one Python CLI named `reach` — not a Rust rewrite](decisions/architecture.md#d-263-tooling-consolidates-into-one-python-cli-named-reach--not-a-rust-rewrite) — _architecture_
## Open questions
@@ -473,3 +474,4 @@ line in place — keep the Q-record for the audit trail rather than deleting it.
- [R-011: Single currency for Phase 2 economics](rejected/economics.md#r-011-single-currency-for-phase-2-economics) — _economics_
- [R-012: Overheard NPC conversation system (D-078) — scrapped](rejected/perception.md#r-012-overheard-npc-conversation-system-d-078--scrapped) — _perception_
- [R-013: Localization / i18n](rejected/scope.md#r-013-localization--i18n) — _scope_
- [R-014: Rust rewrite of the Python tooling](rejected/architecture.md#r-014-rust-rewrite-of-the-python-tooling) — _architecture_
+88 -1
View File
@@ -2533,4 +2533,91 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*116 decisions (D-001 through D-262, excluding gaps). Last updated: 2026-08-20 (D-262 — the wiki↔generator data flow has one canonical map, `docs/diagrams/data-flow/wiki-generator-flow.d2`; a path checker runs on push, but edge MEANING stays a human check against the tool's source).*
### D-263: Tooling consolidates into one Python CLI named `reach` — not a Rust rewrite
- **Date:** 2026-08-20
- **Resolves:** [Q-124](../questions/architecture.md#q-124-should-the-python-tooling-be-retooled-into-a-single-rust-cli). The Rust option is recorded as [R-014](../rejected/architecture.md#r-014-rust-rewrite-of-the-python-tooling).
- **Decision:** `tooling/` becomes **one installable Python package with one console entrypoint, `reach`**, structured into domain subcommands, installed as a bare name on PATH. It stays Python. Nothing is rewritten — the code is **relocated and re-fronted**.
**The four calls, fixed here so no ticket has to re-litigate them:**
| call | decision |
|---|---|
| language | **Python.** The friction is packaging, not language — see below. |
| command name | **`reach`.** Free on PATH; `Bash(reach *)` becomes the single allowlist entry. |
| package home | **`tooling/` itself is the package** (`tooling/__init__.py`, imported as `tooling.*`), with every domain under `tooling/domains/`. Fewest path rewrites across the 160 markdown files that name `tooling/…`. |
| dependencies added | **`typer`** (transport) and **`pydantic`** (data shapes) join `pyproject.toml`. Both are placement-constrained by the startup budget below — pydantic in particular. |
| numerics | **They move too, and stay Python.** `planet-gen`, `garment-fit`, `economy-db` and `blender` all come inside. No numerical-equivalence problem is created because no numerical code is rewritten. |
**Why not Rust — the friction Q-124 names is packaging, and Rust is not the cheapest fix for any of it.**
| Q-124's friction | what actually fixes it | needs Rust? |
|---|---|---|
| permission prompts (10 hand-written `Bash(tooling/…)` entries, one per script) | one bare command → one allowlist entry | no |
| venv split — agents and git hooks never activate `.venv`, which `VENV_PY` in the Makefile already papers over | `uv tool install` into `~/.local/bin` (an isolated venv, resolved by PATH, no activation) | no |
| interpreter start ×4 per push | lazy subcommand registration | no |
Rust would buy those three at the price of proving numerical equivalence for the numpy/scipy/PIL planet-gen path — whose heightmaps are **committed build artefacts with goldens standing on them** — and of making one-off analysis expensive. That is a large one-time cost to avoid a small recurring one, paid in the currency (numerical trust) the project can least afford to spend.
- **The load-bearing requirement is PATH, not the framework.** A `[project.scripts]` entrypoint lands in `.venv/bin/`, which is on PATH only when the venv is activated — and non-interactive shells never activate it. That is the same split `VENV_PY` works around, and the same scar `tea` left: an absolute path breaks the `Bash(tea *)` rule and prompts every time; the fix was a bare name on PATH. **So the deliverable is "one bare command reliably on PATH".** `uv` is already installed at `~/.local/bin/uv` and `~/.local/bin` is already on PATH, so `uv tool install --editable .` is the whole mechanism. Its isolated environment also means the generic import name `tooling` cannot collide with anything else in the user environment. **Typer is chosen second and is replaceable; the PATH guarantee is not.**
- **`make` stays, and stops holding logic.** `Bash(make *)` is *already* blanket-allowed, so the 84 make targets are frictionless today — this decision does not claim to improve them. Make remains the door for zero-argument repo verbs (`make regen-db`, `make diagrams`); `reach` is the door for anything taking arguments, and for asking *what exists*. Targets become **thin wrappers over `reach`**. One implementation, two doors, and the door holding the implementation is `reach`.
- **The domain split is discovered, not invented.** The domains are already encoded as filename prefixes — `blender` ×14, `atlas` ×8, `generate` ×7, `check` ×7, `visual`/`validate`/`test` ×3, then `godot`/`garment`/`pql`/`install` ×2 — so the groups fall out of the existing names (`reach atlas verify`, `reach check canvas-version`, `reach blender process-bodies`). That is the strongest evidence the consolidation is mechanical enough to be safe. **Naming is normalised on the way in:** modules `snake_case`, CLI verbs `kebab-case`. The four hyphenated directories (`planet-gen`, `economy-db`, `garment-fit`, `pql-migrate`) are not importable and must be renamed; `tooling/econ-sim` is a Rust crate and is excluded from package discovery, not moved.
**The internal architecture is layered, and the layering is the point.** A domain split alone would leave 123 scripts in twelve folders instead of one. This is *one codebase that shares*, and it will get more complex, so every domain gets its own directory under `domains/`, split by **role** — controller, logic, data shapes, helpers — over a deliberately small shared `core/`, with `main.py` doing nothing but routing and the cross-cutting concerns (logging, error handling) attached as **decorators**. **A Typer sub-app is a router**: the transport is a CLI today, and the layering is what survives it changing.
```
tooling/
__init__.py
main.py # THE ROUTER, and nothing else. Registers domain
# routers LAZILY. No logic, no I/O, no pydantic.
# [project.scripts] reach = "tooling.main:app"
core/ # shared substrate — only what has no domain
logging.py # the shared logger + the @logged decorator
errors.py # ReachError + the @handle_errors decorator
console.py # all output; nothing else prints
config.py # repo paths, project.yaml, endpoint config
process.py # subprocess + the Blender launcher
domains/ # every domain lives here, one directory each
atlas/ check/ generate/ validate/ planet/ db/ wiki/ visual/
garment/ godot/ blender/ dev/
router.py # CONTROLLER — args in, delegate, format out. No logic.
service.py # LOGIC — transport-agnostic, importable by anything.
schemas.py # pydantic models for this domain's data
helpers.py # domain-local pure helpers
dependencies.py # resolved collaborators (DB handle, paths, launchers)
scripts/blender/ # payload files — executed, never imported (see below)
```
- **The invariant that makes it shareable: `router.py` holds no logic, and `service.py` holds no Typer.** A service must not know it was called from a CLI. That is what lets one domain's service call another's, lets tests call services directly without a CLI round-trip, and leaves a second surface (an HTTP or MCP front end) possible without a rewrite. It is also what keeps lazy loading achievable — routers are cheap, services are not, and only the invoked domain's service is ever imported.
- **`main.py` is a router and only a router.** It registers domain routers and does nothing else — no logic, no I/O, no pydantic, no domain imports at module level. It is the file most likely to accumulate "just one small thing", and the only defence is that it is short enough that an addition is obvious in review.
- **Cross-cutting concerns are decorators, not call-site discipline.** Logging and error handling attach to a command; they are never re-implemented inside one:
- `@handle_errors` (`core/errors.py`) catches `ReachError(message, fix="…")` and renders it as the instructional failure below — message and remedy to stderr, **non-zero exit preserved**. An uncaught exception it does not recognise still exits non-zero, with the traceback behind `--verbose`.
- `@logged` (`core/logging.py`) emits one structured line per invocation — command, arguments, duration, outcome — through the shared logger. **To stderr, never stdout**, so machine-readable output stays parseable, and quiet by default so hooks are not spammed.
- They compose into a single `@command` decorator so no command can carry one without the other, and **the conformance test asserts every registered command carries it.** A cross-cutting concern applied by hand is a cross-cutting concern applied to 90% of cases.
- **Pydantic is the data-shape vocabulary, and it is confined to `domains/*/schemas.py`.** Measured 2026-08-20: `import pydantic` costs **87 ms**, against a *whole current gate check* of 20–46 ms (`check-client-version` 20 ms, `check-dataflow-graph` 38 ms, `check-canvas-version` 46 ms — ~104 ms for the three unconditional ones). Put pydantic on the import path of `main.py` or `core/` and the push gate goes to ~365 ms, a 3.5× regression bought for nothing, four times per push. **So: `main.py` and `core/` are pydantic-free; a domain's `schemas.py` is imported by that domain's service, never by its router; and `domains/check/` — the push-gate domain — carries no pydantic at all.** This is the concrete reason lazy registration is load-bearing rather than tidy.
- **Enforced, not asked for:** no `typer`/`click` import outside `main.py` and `router.py`; no bare `print` outside `core/console.py`; no `pydantic` import reachable from `main.py`; every command carries `@command`. All four are grep-shaped or import-graph-shaped, and belong in the conformance test alongside the help/failure checks — plus a wall-clock assertion on `reach check …` so the budget is a test, not an intention.
- **Not every domain needs every file.** `schemas.py`/`dependencies.py` appear when a domain has data shapes or collaborators worth naming; `check/` may be a router and a service and nothing else. The layering is a vocabulary, not a quota — a folder of five empty modules is worse than a folder of two full ones.
- **`core/` is bounded on day one, because its failure mode is gradual and invisible.** It holds **only what has no domain**: config, paths, errors, console output, process launching. **The moment something in `core/` grows a service — its own logic, its own data store, its own verbs — it is a domain and it moves out.** A `core/` that accumulates services becomes a package every other package imports and nobody can change, which is the worst possible shape for the one directory that is supposed to be stable. There is no gate that catches this; it is a review question, asked every time a file is added to `core/`.
- **The Blender scripts are a physically-enforced exception.** `tooling/blender` is a bash wrapper resolving flatpak/native/brew installs, and the 35 `blender_*.py` / `blender_author_*.py` files run **under Blender's own bundled interpreter** via `--background --python`, which cannot import this package. They stay standalone payload files. **`reach` fronts them; it does not absorb them** — `reach blender process-bodies` builds and executes the Blender command line. Any claim that "everything is one package" must carry this exception or it is false.
**A failure returns the next command — and keeps its exit code.**
- **Every non-zero exit prints the command that would fix it.** The repo already does this where it matters (`check-canvas-version` fails with *"Run `make regen-db`"*; `check-dataflow-graph` names the path that stopped resolving and says whether to fix the diagram or the path). Generalised, it is the contract.
- **Whenever the accepted set is closed and known, print it.** This is the specific bar Q-124 measured `pql` against and found uneven: unknown-subcommand is solved there, invalid-value is not (`invalid status "nonsense"` without naming the six valid statuses). Invalid-value is the more common failure precisely *because* the set is enumerable.
- **Exit code AND message, never either/or.** Four of these run in the pre-push hook, which fails a push **only** by non-zero exit. A tool that explains itself and exits 0 silently disables its own gate — observed first-hand in clide on 2026-08-20, where `unsupported image format`, `no such file` and unknown-subsystem all returned 0, making every error indistinguishable from success to anything reading `$?`.
- **Never literally interactive by default.** Any prompt is TTY-gated and suppressible with `--no-input`, which hooks pass unconditionally. `tea`'s interactive prompts *"crash in Claude Code (no TTY)"*; a helpful prompt that hangs a hook is worse than a terse exit code.
- **This is enforced by a conformance test, not by discipline** — every registered command must have help at its own level, and every declared failure path must name a next command. A contract nothing checks is a style guide.
**Constraints on execution (these are why the work is sequenced, not why it is hard):**
- **The push-gate total may not regress, and the baseline is already measured.** 2026-08-20, this machine: `check-client-version` **20 ms**, `check-dataflow-graph` **38 ms**, `check-canvas-version` **46 ms** — **~104 ms** for the three unconditional checks (`check-systems-db-stamp` runs only when `systems.db` is in the push). That is the number the ported gates must not exceed. Lazy registration is mandatory, not an optimisation: a single entrypoint that eagerly imported 123 modules — or merely imported pydantic — would multiply this several-fold, four times per push, forever.
- **The `systems.db` stamp survives the move or the move does not land.** `tooling/generator_sources.py` SHAs the concatenated bytes of the generator's sources **sorted by path**, so renaming a file changes the stamp even when its content is byte-identical. The generator relocation must therefore land as **one commit** — registry paths updated, `make regen-db` run, stamp verified — never split across pushes, or the pre-push gate rejects an intermediate state that is in fact correct.
- **Old paths are retired through a deprecation window, not deleted under the callers.** 160 markdown files under `.claude/` and `docs/`, 84 make targets, the pre-push hook and the skills all name `tooling/…` paths. Each retired path leaves a shim that prints the new command and exits non-zero — the failure contract applied to the migration itself — before the shims are removed.
- **What this decision does not claim.** It does not make make-target invocation cheaper (already free), and it does not make the tooling faster to *run* — only to start, find, and be allowed to call. The wins are: ad-hoc and direct invocation stop prompting, `--help` answers "what tooling exists" without an `ls`, arguments become expressible where make could not express them, the venv split disappears, and failures carry their own remedy.
- **Raised by:** Jeroen, 2026-08-20 — *"those python files are a pain… maybe make it into an actual cli of the quality level of pql"*, then the shape: *"moving all python into a separate dir with proper domain split so one door answers all options we have with help and instructions/help when there is an error: a new prompt not an error code"*, and the principle behind it: *"I have this in pql as well: errors become instructions."*
- **Cross-reference:** [Q-124](../questions/architecture.md#q-124-should-the-python-tooling-be-retooled-into-a-single-rust-cli) (the question, and the costing that got here), [R-014](../rejected/architecture.md#r-014-rust-rewrite-of-the-python-tooling) (the Rust option), [D-223](#d-223) + `.claude/rules/asset-pipeline.md` (the stamp contract the move must preserve), [D-262](#d-262) (`check-dataflow-graph`, the newest member of the per-push Python set), `.claude/rules/ticket-cli.md` (`pql` as the quality bar), T-1247 (the initiative implementing this, eight epics).
- **Dissent:** None recorded. The Rust option was preferred by the raiser at filing time and was costed down rather than argued down — see [R-014](../rejected/architecture.md#r-014-rust-rewrite-of-the-python-tooling).
---
*117 decisions (D-001 through D-263, excluding gaps). Last updated: 2026-08-20 (D-263 — `tooling/` becomes one installable Python package behind the `reach` command: a routing-only `main.py`, `domains/<name>/{router,service,schemas,helpers}.py`, a bounded `core/`, logging + error handling as decorators, pydantic kept off the gate path; Rust rejected as R-014 because the friction is packaging, not language).*
+2 -2
View File
@@ -494,7 +494,7 @@ Technical foundation questions: engine, protocols, data structures, performance,
### Q-124: Should the Python tooling be retooled into a single Rust CLI?
- **Date:** 2026-08-20
- **Status:** Open
- **Status:** **RESOLVED 2026-08-20 → [D-263](../decisions/architecture.md#d-263)** — **no.** `tooling/` becomes one installable **Python** package behind a single console command, **`reach`**, layered `router.py`/`service.py`/`schemas.py` per domain over a deliberately bounded `core/`, installed via `uv tool install` so it is a bare name on PATH. The Rust option is [R-014](../rejected/architecture.md#r-014-rust-rewrite-of-the-python-tooling) — **costed down, not argued down**: all three frictions below are packaging problems that one-bare-command-plus-lazy-loading solves completely, while Rust would additionally owe a numerical-equivalence proof on the planet-gen path. The cheap alternative this record demanded be priced first *was* priced first, and it won. The question text below is preserved as the costing that got there.
- **Question:** `tooling/` is **123 Python files** plus 36 extensionless executables. Should it become one Rust CLI of the same calibre as `pql` — a single binary, subcommand-structured, no interpreter and no virtualenv — or should it stay Python and have its friction fixed in place?
- **The friction is concrete, recurring, and mostly not about Python the language:**
1. **Permission prompts.** Every invocation is `python3 tooling/<script>`, and the permission gate prefix-matches whole command strings. A blanket `Bash(python3 *)` grant is explicitly forbidden as "an unbounded write grant" (`docs/agent-operation.md`), so each tool prompts more or less individually. A single binary with subcommands (`pql`-style) is one allowlist entry covering the whole surface — this is the same reason `pql` is frictionless today.
@@ -537,4 +537,4 @@ Technical foundation questions: engine, protocols, data structures, performance,
---
*66 questions (13 resolved, 1 partially resolved, 52 open). Last updated: 2026-08-20 (Q-124 — retool the 123-file Python tooling into a Rust CLI, or fix the friction in place with a single dispatcher).*
*66 questions (14 resolved, 1 partially resolved, 51 open). Last updated: 2026-08-20 (Q-124 RESOLVED → D-263 — not Rust: `tooling/` becomes one layered Python package behind the `reach` command; Rust recorded as R-014).*
+5
View File
@@ -41,3 +41,8 @@ Rejected proposals in the **architecture** domain, rationale preserved for the a
### R-010: protobuf for client-server serialization
- **Rejected:** 2026-02-09
- **Reason:** Schema evolution across independent deployments is a problem we don't have (one developer, client and server ship together). Poor GDScript support. Rigid schema fights dynamic HUD composition driven by perception modes ([D-017](../decisions/perception.md#d-017-perception-modes-as-character-build-system)). MessagePack's schema-optional nature fits better.
### R-014: Rust rewrite of the Python tooling
- **Rejected:** 2026-08-20 — superseded by [D-263](../decisions/architecture.md#d-263) (one Python CLI, `reach`), which answers [Q-124](../questions/architecture.md#q-124-should-the-python-tooling-be-retooled-into-a-single-rust-cli).
- **Reason:** **Costed down, not argued down.** All three frictions that motivated it — per-script permission prompts, the venv/PATH split between interactive and non-interactive shells, and interpreter startup paid four times per push — are **packaging** problems, and each is fully solved by one bare command on PATH with lazy subcommand loading. None of them requires a different language. Against that, Rust would have to prove **numerical equivalence** for the numpy/scipy/PIL planet-gen path, whose heightmaps are committed build artefacts with goldens standing on them — a genuine porting problem, not a transliteration. It would also make one-off analysis expensive, trading a small recurring cost for a large occasional one in the place the project can least afford it. A hybrid (Rust for the four push-gate checks only, Python for the rest) was also rejected: it reintroduces the two-doors problem the whole exercise exists to remove.
- **Not rejected on principle.** The quality bar this proposal was reaching for — `pql`-calibre, one binary, no interpreter — is the right bar, and D-263 adopts it wholesale minus the language. If the check/gate family ever becomes the dominant push cost *after* lazy loading is measured, this is worth reopening for that family alone, with the measurement in hand.