Files
settled-reach/tooling/main.py
T
jpmschweitzerandClaude Opus 5.5 ddce4441a9 refactor(tooling): T-1290 — the assets domain, where OFF is the normal case
tooling/db/ (a misnamed directory: connectors, not database work),
trellis-batch.sh and synth_ui_sounds.py become `reach assets`:
audio {health,generate,batch,post {convert,normalize,trim,pipeline}},
image {health,generate}, trellis {health,generate,batch}, and synth-ui.
The four audio bash wrappers are retired, and tooling/db/ is gone.

Parity, from baselines taken before anything moved:

- the four UI-sound WAVs and the harmonic-synth WAVs (exponential and linear
  decay) are byte-identical
- the ffmpeg pipeline's decoded PCM is identical. Its .ogg bytes are not,
  even between two runs of the OLD code: Ogg picks a random stream serial,
  so the encoded file was never the right thing to compare
- the network success paths can't be run in a gate (Stable Audio and Trellis
  are kept off, Gemini costs money), so tooling/test_assets.py stands up a
  fake Gradio and pins every payload: the audio submit, Trellis's six-call
  session sequence with its 9-input image_to_3d, and the Gemini body. It
  failed when one Trellis value was mutated (7.5 → 7.0)

Failure classification, in endpoints.py, is the point of the port. The
services are OFF by design (VRAM on tower-of-joy, D-17), and the topology doc
warns against "fixing" one by restarting it. So a refused connection says OFF
and asks for the service to be turned on rather than restarted; a 4xx/5xx says
the request was rejected; 401/403 says credentials; 429 says quota; and an
unreachable Gemini blames the network, not VRAM.

Behaviour changes, each a failure that used to read as success or crash:

- audio batch and trellis batch exited 0 with failures in their summaries;
  they now print the summary and exit 1
- trellis generate on a missing image crashed with a TypeError
  (print(..., indent=2)); it now names the file, and checks it before the
  service so a typo is not reported as an outage
- the ffmpeg pipeline left its intermediates behind when a step failed

Structure: the connectors called each other as subprocesses (batch spawned
the connector, which spawned audio_post) and parsed each other's stdout. They
are now function calls, and ffmpeg is the only exec, through core/process.
ensure_venv() is removed: it os.execv'd into .venv, which D-263's exec rule
forbids, and reach declares the dependencies itself. config.json moved into
the domain deliberately, and the local-services rule follows it.

Output contract: results are still JSON on stdout with the same keys, so skill
readers keep working. Failures are an exit status with a Fix line, never
{"ok": false}. The audio-gen, glb-gen and image-gen skills, Araminta's agent
file and the allow-list are updated to match. glb-gen's "trellis-batch.sh is
hardcoded to one category" caveat is gone: batch takes --input-dir or --names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:49:20 +02:00

254 lines
10 KiB
Python

"""`reach` — one command for every repo tool (D-263).
**This file is a router and 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 stays short enough that an
addition is obvious in review.
The root is a `typer.Typer`, and the reason is worth recording because the
original plan was different. T-1259 specified a `click.Group` root, on the
theory that it would keep typer off the `reach --help` path. **That is no longer
possible: typer vendors click as of 0.26.0** — there is no top-level `click`
package to import, and the docs are explicit that "extracting the internal Click
app" is unsupported. Mixing a real `click.Group` root with typer sub-apps would
mean two different Click implementations in one process.
So the customisation surface is `typer.Typer(cls=...)` with a `TyperGroup`
subclass, which is the supported path and is what `LazyDomainGroup` below uses
to register domains lazily.
`rich_markup_mode=None` is not a style preference — it is worth 94 ms of the
168 ms an empty `--help` otherwise costs, and it keeps `rich` and `pygments`
off the import path entirely (verified absent from `sys.modules`). It also
stops typer drawing box-art help, which it does **even when stdout is a pipe**,
so hook logs and agent output stay readable. One line to revert if the boxes
are ever wanted more than the milliseconds.
The callback below is not decoration. A `typer.Typer` with **no commands and no
callback** raises `RuntimeError: Could not get a command for this Typer
instance` at build time; with a callback it builds fine. Since every domain is
registered lazily and none is eager, the callback is what makes the root legal.
"""
from __future__ import annotations
import importlib
import typer
from tooling.core.cli import ReachGroup
# The domain registry: name -> (import target, one-line help).
#
# This table is the ONLY thing `reach --help` reads. The short help lives here
# as a literal string rather than being pulled off the loaded command, because
# reading it off the command is precisely what would import the world — see
# LazyDomainGroup.format_commands.
#
# Adding a domain is adding a line here plus a `router.py` that exposes `app`.
DOMAINS: dict[str, tuple[str, str]] = {
"check": (
"tooling.domains.check.router:app",
"Consistency gates — the checks the push hook runs",
),
"validate": (
"tooling.domains.validate.router:app",
"Content, checklists and RON against their schemas",
),
"atlas": (
"tooling.domains.atlas.router:app",
"The spatial ladder — authoring, inspection and the DB",
),
"ledger": (
"tooling.domains.ledger.router:app",
"The economics pipeline — the import that builds systems.db",
),
"wiki": (
"tooling.domains.wiki.router:app",
"The wiki seed — fill rates and the GTTR hook",
),
"assets": (
"tooling.domains.assets.router:app",
"Asset generators — Stable Audio, Gemini images, Trellis 3D",
),
"godot": (
"tooling.domains.godot.router:app",
"Does the client parse, and does it parse cold",
),
"visual": (
"tooling.domains.visual.router:app",
"Compare captures against goldens, and catch blank ones",
),
"generate": (
"tooling.domains.generate.router:app",
"Producers — brands, corporations, manifests and stubs",
),
"blender": (
"tooling.domains.blender.router:app",
"Run the Blender payloads — bodies, garments, inspection",
),
"pr": (
"tooling.domains.pr.router:app",
"The review loop — comments, watchlist and the board",
),
"jobs": (
"tooling.domains.jobs.router:app",
"Detached runs — status, logs and outcomes",
),
"dev": (
"tooling.domains.dev.router:app",
"Developer environment and self-diagnosis",
),
}
class LazyDomainGroup(ReachGroup):
"""Lists domains without importing them; imports exactly the one invoked.
Extends `ReachGroup`, so bare `reach` prints the domain list and exits 0
like every domain group does. Only the laziness and the domain-specific
wording live here.
Three overrides, and the third is the one that matters. `TyperGroup`'s own
`format_commands` loops over `list_commands` calling `get_command` on each,
just to read a short help string off the loaded command — which, with lazy
loading underneath, imports every domain in the registry to render `--help`.
That would defeat the whole mechanism silently, while looking correct.
So `format_commands` is overridden to read help from `DOMAINS` and never
touch `get_command`. The cost of `reach --help` is then flat no matter how
many domains exist, which is the property that has to hold as this grows
from one domain to a dozen.
"""
def list_commands(self, ctx: typer.Context) -> list[str]:
return sorted({*super().list_commands(ctx), *DOMAINS})
def get_command(self, ctx: typer.Context, cmd_name: str):
if cmd_name in DOMAINS:
return _load_domain(cmd_name)
return super().get_command(ctx, cmd_name)
def resolve_command(self, ctx: typer.Context, args: list[str]):
# Closed-set enumeration (D-263). Click's default is "No such command
# 'x'" — which tells you that you are wrong without telling you what
# would be right, the exact gap measured in pql. The accepted set is
# sitting in DOMAINS, already loaded for --help, so naming it is free.
if args and args[0] not in DOMAINS and super().get_command(ctx, args[0]) is None:
listed = ", ".join(sorted({*DOMAINS, *super().list_commands(ctx)}))
ctx.fail(f"unknown domain {args[0]!r}\n\nChoose one of: {listed}")
return super().resolve_command(ctx, args)
def format_commands(self, ctx: typer.Context, formatter) -> None:
# Deliberately does NOT call get_command. See the class docstring.
rows = [(name, short) for name, (_target, short) in sorted(DOMAINS.items())]
for name in sorted(super().list_commands(ctx)):
command = super().get_command(ctx, name)
if command is not None and not command.hidden:
rows.append((name, command.get_short_help_str(80)))
if rows:
with formatter.section("Domains"):
formatter.write_dl(sorted(rows))
def _load_domain(name: str):
"""Import one domain's router and convert its Typer app to a command."""
target, _short = DOMAINS[name]
module_name, _, attr = target.partition(":")
module = importlib.import_module(module_name)
return typer.main.get_command(getattr(module, attr))
cli = typer.Typer(
name="reach",
cls=LazyDomainGroup,
help="Repo tooling for The Settled Reach.\n\nRun `reach <domain> --help` to see what a domain can do.",
no_args_is_help=True,
add_completion=False,
rich_markup_mode=None,
# See core/cli.py — a second rich path that renders unhandled exceptions as
# box-art, arriving through a different door than rich_markup_mode.
pretty_exceptions_enable=False,
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
)
def main() -> None:
"""Console entry point — `reach`.
Exists so a detached child records its completion at the PROCESS's exit
rather than at a command's. Recording it inside `@command` looked right and
was subtly wrong: a child that fails before any command runs — bad
arguments, an unknown verb, an import error — never reaches that decorator,
so its metadata said `running` forever. A failed job that looks busy is the
exit-0 trap wearing a new disguise, and worse than the original because
nothing is watching a background job.
Here, every exit path passes through one `finally`.
"""
exit_code = 0
try:
cli()
except SystemExit as exc:
exit_code = exc.code if isinstance(exc.code, int) else 1
raise
except BaseException:
exit_code = 1
raise
finally:
# Imported lazily and only when detached, so `reach --help` never pays
# for it — the laziness T-1260 protects applies here too.
import os
from tooling.core import jobs
if os.environ.get(jobs.ENV_JOB_ID):
from tooling.core import process
process.finish_if_detached(exit_code)
@cli.callback()
def root(
verbose: bool = typer.Option(
False, "--verbose", "-v", help="Show progress events and full tracebacks."
),
no_input: bool = typer.Option(
False, "--no-input", help="Never prompt. Hooks and agents should always pass this."
),
detach: bool = typer.Option(
False, "--detach", help="Run in the background; print a job id and return at once."
),
) -> None:
"""Global options, declared once here so every domain inherits them.
A per-domain copy of these is how the two would drift apart — one router
growing a `--verbose` that sets a different level, or forgetting `--no-input`
entirely.
Note `--no-input` is about PROMPTING, not output format. Console already
chooses JSONL versus rendered text from `isatty` with an `SR_OUTPUT_FORMAT`
override; making this flag a second, conflicting way to say the same thing
would leave nobody sure which one wins.
"""
from tooling.core import console, runtime
if verbose:
console.set_level("debug")
runtime.set_no_input(no_input)
if detach:
# Handled here, before any domain loads, because detaching is a property
# of the INVOCATION rather than of the verb — every command gets it and
# no command implements it. The child re-runs this same argv with
# --detach stripped, so it does the work instead of forking again.
from tooling.core import process
job_id = process.spawn_detached(process.current_argv())
console.out(job_id)
console.verdict(
f"started job {job_id} — this exit status means STARTED, not succeeded",
fix=None,
)
raise typer.Exit(0)