chore(config): T-1258 — tooling/ becomes an importable package

The skeleton the reach CLI hangs off. Nothing moves yet: this adds the
package, the bounded core/, and explicit setuptools discovery.

core/console.py is the single output path, and the split it enforces is the
whole design — stdout carries the command's actual output so `reach ... | jq`
keeps working, stderr carries the event stream as JSONL. Rendering happens at
the sink: a terminal gets human text, anything else gets raw JSONL, so a live
view and a job log are one artefact in two presentations. Emitting is
optional — the gates emit nothing — and verdict() prints once, last, carrying
its remedy as a structured field.

core/config.py resolves the repo root from __file__ against a project.yaml
sentinel, with an SR_REPO_ROOT override. No subprocess and no git call: this
is on the gate path, and cwd is not a reliable signal anyway since a hook runs
from the root and an agent call may not. Both paths are validated, because a
silent fallback is how you end up editing one checkout and checking another.

Discovery is configured explicitly rather than left to flat-layout
auto-discovery, which would have had to choose between erroring on the
ambiguity and quietly shipping client/ or docs/. Verified: top_level.txt
contains exactly "tooling".

Verified beyond the happy path — the sentinel rejects SR_REPO_ROOT=/tmp and
names both remedies; debug events are suppressed at the default threshold
while the verdict is not; stdout stays clean with stderr redirected away; and
the three unconditional push-gate checks still pass now that tooling/ is a
package, which was the real regression risk.

Two findings recorded on the tickets. make setup-venv is stale — it calls
.venv/bin/pip, but the venv was created by uv and has no pip, so the recorded
procedure and the actual state have already diverged (T-1261 owns the fix).
And settled-reach-tooling had never actually been installed: site-packages
held the dependencies but no dist-info, which follows from there being no
__init__.py to expose. This is the first commit where `import tooling` means
anything.

.venv/ was only ignored via .git/info/exclude, which is machine-local, so a
fresh clone or a new worktree did not ignore it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:53:48 +02:00
co-authored by Claude Opus 5
parent 5df8afedb9
commit 559f3d82dc
7 changed files with 267 additions and 0 deletions
+5
View File
@@ -83,6 +83,11 @@ vendor/
__pycache__/
*.pyc
*.pyo
# Editable-install metadata, regenerated by every `uv pip install -e .` (T-1258)
*.egg-info/
# Was only in .git/info/exclude, which is machine-local — so a fresh clone or a
# new worktree did not ignore it at all (T-1258)
.venv/
# OS
.DS_Store
+12
View File
@@ -13,6 +13,18 @@ dependencies = [
"Pillow==12.2.0",
]
[tool.setuptools.packages.find]
# Explicit, not flat-layout auto-discovery. The repo root holds client/, server/,
# docs/, wiki/, db/ and tests/ alongside tooling/, and auto-discovery either
# errors on the ambiguity or quietly ships something unintended (T-1258).
#
# Nothing needs excluding yet: the hyphenated directories (planet-gen,
# economy-db, garment-fit, pql-migrate) are invisible to package discovery
# because a hyphen is not a valid Python identifier, and tooling/econ-sim is a
# Rust crate with no __init__.py. That changes in T-1250, which renames them —
# at which point they become real packages and this include starts matching them.
include = ["tooling*"]
[project.optional-dependencies]
dev = [
# ruff 0.15.9 — checked clean against NVD + OSV, no CVEs on record (2026-04-05)
+7
View File
@@ -0,0 +1,7 @@
"""Repo tooling, as one installable package behind the `reach` command (D-263).
Deliberately empty. Every import here is paid by every invocation of `reach`,
including the four that run in the pre-push hook, so this file holds no imports
and no logic. Put things in `tooling.core` (shared substrate) or
`tooling.domains.<name>` (everything else).
"""
+10
View File
@@ -0,0 +1,10 @@
"""Shared substrate — only what has no domain (D-263).
config, paths, errors, console output, process launching. Nothing else.
The bound is deliberate and it is the rule most likely to erode invisibly: the
moment a module in here grows a service — its own logic, its own data store, its
own verbs — it is a domain and it moves to `tooling.domains`. A `core/` that
accumulates services becomes a package everything imports and nobody can change,
which is the worst possible shape for the one directory meant to be stable.
"""
+63
View File
@@ -0,0 +1,63 @@
"""Repo-root and path resolution. No domain logic lives here (D-263).
This module is on the push-gate path, so it does no subprocess work: no
`git rev-parse`, no shelling out, nothing beyond the stdlib. Resolution is
`__file__`-relative and validated against a sentinel file. That is not only
faster than asking git — it is *more correct*, because the current working
directory is not a reliable signal. A git hook runs from the repo root, an agent
`Bash` call may not, and `reach` is meant to work from anywhere.
"""
from __future__ import annotations
import os
from pathlib import Path
# The file whose presence proves a directory is the repo root. project.yaml is
# the version source of truth (CLAUDE.md), so it is the honest sentinel: if it
# is absent, everything downstream was going to fail anyway — better to say so
# here, by name, than to hand out a plausible wrong path.
SENTINEL = "project.yaml"
ENV_OVERRIDE = "SR_REPO_ROOT"
def repo_root() -> Path:
"""Absolute path to the repo root.
`SR_REPO_ROOT` wins if set; otherwise the package's own location is used.
Both are validated, deliberately: an override pointing somewhere useless
should fail loudly rather than fall back to a default that happens to work,
because a silent fallback is how you end up editing one checkout and
checking another.
Not cached. The two syscalls are microseconds, and a cache would make the
override untestable for the sake of nothing measurable.
"""
override = os.environ.get(ENV_OVERRIDE)
if override:
return _validated(Path(override).expanduser().resolve(), f"{ENV_OVERRIDE}={override}")
# tooling/core/config.py -> tooling/core -> tooling -> repo root
return _validated(Path(__file__).resolve().parents[2], "the installed package location")
def path(*parts: str) -> Path:
"""Join `parts` onto the repo root, so callers do not each re-resolve it."""
return repo_root().joinpath(*parts)
def _validated(root: Path, source: str) -> Path:
if (root / SENTINEL).is_file():
return root
# Raised as RuntimeError only because core/errors.py does not exist yet;
# T-1249 converts this to ReachError(message, fix=...). The message already
# follows the contract — it names the command that fixes it.
raise RuntimeError(
f"cannot locate the repo root: {root} contains no {SENTINEL} "
f"(resolved from {source}).\n"
f"If reach was installed from a different checkout than the one you are "
f"working in, re-point it:\n"
f" uv tool install --editable <path-to-repo>\n"
f"To override for a single command:\n"
f" {ENV_OVERRIDE}=<path-to-repo> reach ..."
)
+147
View File
@@ -0,0 +1,147 @@
"""The single output path (D-263). Nothing else in `reach` prints.
Two channels, and keeping them apart is the whole design:
stdout the command's ACTUAL OUTPUT — the data the caller asked for.
Nothing else ever goes here, so `reach ... | jq` keeps working.
stderr the EVENT STREAM — progress, and the final verdict — as JSONL,
one object per line.
**Rendering happens at the sink, not at the emit site.** When stderr is a
terminal the events are rendered for a human; otherwise they are written as raw
JSONL. A live terminal and a job log are then the same artefact in two
presentations, which is what lets `reach jobs log` render for a person while a
conformance test asserts on the same bytes.
**Emitting is optional.** A command that never calls `event()` works normally,
and the push-gate checks deliberately emit nothing. This is a channel, not an
obligation — the point is that a long command *has somewhere to speak*, not that
every command must.
**The stream never replaces the verdict.** A remedy emitted at line 400 of 900
is technically printed and practically invisible, so `verdict()` prints once,
last, and is what a caller reads when it reads only one thing.
Stdlib only, and cheap to import: this module is on the gate path.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from typing import Any, TextIO
LEVELS: dict[str, int] = {"debug": 10, "info": 20, "warn": 30, "error": 40}
ENV_LEVEL = "SR_LOG_LEVEL"
ENV_FORMAT = "SR_OUTPUT_FORMAT" # "json" | "text" — overrides TTY detection
# Quiet by default so hooks are not spammed. T-1249's global --verbose lowers it.
_threshold = LEVELS.get(os.environ.get(ENV_LEVEL, "info").lower(), LEVELS["info"])
def set_level(name: str) -> None:
"""Set the minimum level that reaches the stream. Unknown names are a no-op."""
global _threshold
if name.lower() in LEVELS:
_threshold = LEVELS[name.lower()]
def out(text: str = "") -> None:
"""Write to stdout — the command's actual output, never commentary."""
print(text, file=sys.stdout, flush=True)
def event(
message: str,
*,
level: str = "info",
phase: str | None = None,
progress: float | None = None,
**extra: Any,
) -> None:
"""Emit one progress event onto the stream.
Suppressed entirely if below the current level, so a debug-chatty service
costs nothing in a hook.
"""
if LEVELS.get(level, LEVELS["info"]) < _threshold:
return
_write(
{
"ts": _now(),
"level": level,
"phase": phase,
"message": message,
"progress": progress,
**extra,
}
)
def verdict(message: str, *, ok: bool = True, fix: str | None = None) -> None:
"""Print the final summary: once, last, and never suppressed by the level.
`fix` is the command that would resolve a failure — the contract at the top
of D-263. It is carried as a structured field so a caller can extract it
without parsing prose.
"""
_write(
{
"ts": _now(),
"level": "info" if ok else "error",
"kind": "verdict",
"ok": ok,
"message": message,
"fix": fix,
}
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
def _write(payload: dict[str, Any]) -> None:
stream: TextIO = sys.stderr
if _render_as_text(stream):
stream.write(_render(payload))
else:
# Drop None-valued optionals so a log line carries what happened, not a
# census of the fields that did not apply.
compact = {k: v for k, v in payload.items() if v is not None}
stream.write(json.dumps(compact, ensure_ascii=False) + "\n")
stream.flush()
def _render_as_text(stream: TextIO) -> str | bool:
override = os.environ.get(ENV_FORMAT, "").lower()
if override == "text":
return True
if override == "json":
return False
try:
return stream.isatty()
except (AttributeError, ValueError):
# A closed or substituted stream: prefer the machine format, which is
# the one that stays parseable when nobody is watching.
return False
def _render(payload: dict[str, Any]) -> str:
message = payload.get("message", "")
if payload.get("kind") == "verdict":
if payload.get("ok"):
return f"{message}\n"
fix = payload.get("fix")
tail = f"\n\nFix: {fix}\n" if fix else "\n"
return f"{message}{tail}"
prefix = {"warn": "warning: ", "error": "error: "}.get(payload.get("level", ""), "")
phase = payload.get("phase")
scope = f"[{phase}] " if phase else ""
progress = payload.get("progress")
pct = f" ({progress * 100:.0f}%)" if isinstance(progress, (int, float)) else ""
return f"{scope}{prefix}{message}{pct}\n"
+23
View File
@@ -0,0 +1,23 @@
"""One directory per domain (D-263).
Each domain is split by role, and the split is what makes this a codebase that
shares rather than 123 scripts in twelve folders:
router.py CONTROLLER — args in, delegate, format out. No logic.
service.py LOGIC — transport-agnostic, importable by anything.
schemas.py this domain's data shapes (pydantic).
helpers.py domain-local pure helpers.
dependencies.py resolved collaborators (DB handle, paths, launchers).
The invariant that makes it work: `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 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.
Not every domain needs every file. `schemas.py` and `dependencies.py` appear
when a domain has data shapes or collaborators worth naming. The layering is a
vocabulary, not a quota — a folder of five empty modules is worse than a folder
of two full ones.
"""