docs(governance): D-263 — make and reach split by kind, streaming as a decorator

Two decisions taken before the 160-file move, because both change what the
move produces.

The Makefile has 84 targets and is today's front door, so "one CLI for all
repo tooling" was not yet true. The split is by what a target DOES: make keeps
genuine build and test orchestration, and targets that are really tooling
wrappers are retired in favour of reach verbs — retired, not wrapped. A
wrapper leaves two ways to invoke every tool, and then reach --help stops
being the answer to "what tooling exists" because the Makefile is still a
competing index. Two doors is the condition this record exists to end, so
keeping both would defeat it while looking like caution.

Streaming becomes a decorator rather than an API commands call. @command
already wraps every invocation, and that is exactly the seam where job
identity, progress correlation and detach belong: the decorator assigns the
job id, tags the events, and forks on --detach. A command must not know that
jobs exist. The alternative — each command opening a job and remembering to
close it — is call-site discipline wearing a different hat, and it fails the
same way the fortieth command into a porting session, with the failure
vanishing from the log and nothing to indicate anything is missing. Logging
and error handling are decorators for this reason; streaming is the third
cross-cutting concern, not a special case.

Consequent resequencing: T-1264 lands before the T-1250 move, so every ported
command arrives already streaming. Old scripts now retire per domain as each
port passes its parity test, rather than in one sweep at the end — a
continuous shrink, instead of months where every tool exists twice and an edit
can land in the dead copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:38:26 +02:00
co-authored by Claude Opus 5
parent 91a57b8304
commit 6b31111cd2
4 changed files with 102 additions and 0 deletions
+7
View File
@@ -2611,9 +2611,16 @@ tooling/
- **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.
**`make` and `reach` split by kind, not by preference** *(settled 2026-08-31)*. The Makefile has 84 targets and is today's front door, so "one CLI for all repo tooling" is not true until that relationship is stated. The boundary is **what the target actually does**, not who calls it:
- **`make` keeps genuine build and test orchestration** — cargo, Godot, the test gate, anything that sequences a build. That is what make is for, and `reach` would be a worse version of it.
- **Targets that are really tooling wrappers are retired in favour of `reach` verbs — retired, not wrapped.** A wrapper leaves two ways to invoke every tool, and then `reach --help` stops being the answer to "what tooling exists" because the Makefile is still a competing index. **Two doors is the condition this record exists to end**, so keeping both would defeat it while looking like caution.
- Where a target must survive for muscle memory, it delegates to `reach` in one line and says so. The test: can a reader tell from the target alone which of the two owns the behaviour?
**Commands stream, they do not go quiet and return a verdict** *(added 2026-08-20)*. The gates are milliseconds; the generators are not. `make regen-db`, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors run for minutes, and the callers that matter have ceilings — an agent `Bash` call gives up at two minutes and **sends nothing**, already a recorded scar here for `git push` under the full-`cargo test` hook. Detaching alone would fix the timeout and keep the silence. Streaming fixes the thing that actually costs time: **you learn a generator is wedged at minute one instead of minute nine.**
- **Every command emits a stream of structured progress events as it runs.** Not a requirement to instrument everything — a command that emits nothing still works, and the gates should emit nothing. It is a requirement that the *channel exists* and is the same channel everywhere, so a long command has somewhere to say what it is doing.
- **Streaming is delivered AS A DECORATOR, not as an API that commands call** *(settled 2026-08-31)*. `@command` already wraps every invocation, and that is exactly the seam where job identity, progress correlation and detach belong: the decorator assigns the job id, tags every event emitted during the invocation with it, and — given `--detach` — forks and returns the id immediately. **A command must not know that jobs exist.** The alternative, where each command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails the same way — the fortieth command of a long porting session forgets, and its failure vanishes from the log with nothing to indicate anything is missing. This is the same reasoning that made logging and error handling decorators; streaming is a third cross-cutting concern, not a special case.
- **The stream is JSONL — one object per line** (`ts`, `level`, `phase`, `message`, optional `progress`), rendered human-readably at the sink. Machine-parseable and human-readable are then the **same artefact** rather than two that drift: `reach jobs log` renders it for a person, and the conformance suite asserts against it directly.
- **The stream goes to stderr; stdout carries the command's actual output.** Same rule already stated for `@logged`, for the same reason — `reach … | jq` must keep working.
- **`core/console.py` is therefore an event emitter, not a print wrapper.** It remains the single output path and stays stdlib-only (`json` is stdlib). This is the module every command depends on, so its shape is fixed here rather than discovered per-domain.