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
+9
View File
@@ -1 +1,10 @@
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TYBD74TQNVKTJMKA8D9KM', '06FT0TZC9MJV6KZTSRVRYQ327M', '2026-07-26 21:56:27.160', '2026-08-06 15:59:23.500', '2026-08-06 15:59:23.500', 'd5c447e9898bbc6d9ef3556b0ed91213', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S392DEG41MRGEFSYWJWP2W', '06G1S3D0M1TQW0GMFBBPQZG3ZM', '2026-08-20 00:24:26.247', '2026-08-20 00:24:26.247', NULL, '39bf5e421900c58b404ab0e13ec3a271', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S392DEG41MRGEFSYWJWP2W', '06G1S39T92PW9JNBPWD0F4JFR0', '2026-08-20 00:24:26.779', '2026-08-20 00:24:26.779', NULL, 'b9a45c52e1102952f2178eda43926904', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', '06G1S3DTRHSBYJS55NDT9YR4R0', '2026-08-20 00:24:29.509', '2026-08-20 00:24:29.509', NULL, '39cb42f26d460fb08103e2e4dbcd31f5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', '06G1S3DTRHSBYJS55NDT9YR4R0', '2026-08-20 00:24:30.055', '2026-08-20 00:24:30.055', NULL, '35605f0847f8a0c6ac3c70d136aa1e03', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', '06G1S3F6SKZ6EQQ7TY0RKT2970', '2026-08-20 00:24:32.315', '2026-08-20 00:24:32.315', NULL, 'c360233f027f49cad717466f683a8069', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3DTRHSBYJS55NDT9YR4R0', '06G1S3G1D2828RX2F3ZFNWRJGC', '2026-08-20 00:24:32.847', '2026-08-20 00:24:32.847', NULL, '87e729265fef30c452c56e72f612aa37', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3F6SKZ6EQQ7TY0RKT2970', '06G1S3G1D2828RX2F3ZFNWRJGC', '2026-08-20 00:24:35.166', '2026-08-20 00:24:35.166', NULL, '4139189a17dc9c58d5c4fd3fa96b236d', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', '06G1S3Z1TJHNK1A3YS4HGMBPE4', '2026-08-20 00:26:47.809', '2026-08-20 00:26:47.809', NULL, 'd84cb85d8ed39b4fb6caf70708368031', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', '06G1S40293BXWK39D9HAXHM8Q4', '2026-08-20 00:26:48.361', '2026-08-20 00:26:48.361', NULL, 'c23fd8b1b991a81c164aa7408406b9a8', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
+11
View File
@@ -1243,3 +1243,14 @@ Start here, not with Rust. Measured state: pyproject.toml already exists and mak
The real deliverable is ''one bare command reliably on PATH'', not ''a CLI framework''. A [project.scripts] entrypoint lands in .venv/bin/, which is on PATH only when activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same failure recorded for tea (absolute path breaks the Bash(tea *) rule and prompts every time). Install via uv tool/pipx into ~/.local/bin, or symlink. A Typer app behind an absolute venv path solves nothing.
Watch startup cost: a single entrypoint importing every subcommand eagerly pays all 123 modules on every invocation, four times per push. Lazy subcommand registration is mandatory, not an optimisation — measure before and after.', NULL, '2026-08-20 00:00:11', '2026-08-20 00:00:11.592', '2026-08-20 00:00:11.592', NULL, '45f6dd41a5c643a730209c654bd57c76', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3RFJ739EWN4JDG8ZEX89W', 'parent_id', 'T-1253', 'T-1256', NULL, '2026-08-20 00:26:44', '2026-08-20 00:26:44.635', '2026-08-20 00:26:44.635', NULL, 'f71860962dd3296261dca624ae915282', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'parent_id', 'T-1253', 'T-1256', NULL, '2026-08-20 00:26:44', '2026-08-20 00:26:44.768', '2026-08-20 00:26:44.768', NULL, '7d3de7291e2e58a06c7c7b199f082df0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S37Y1ARMV68RJT802Z3VPG', 'description', 'Consolidate the 123-file tooling/ tree into one installable Python package behind a single console command, reach. Resolves Q-124; Rust rejected as R-014 because the friction is packaging, not language. Shape per D-263: a routing-only main.py, every domain under tooling/domains/<name>/ split router/service/schemas/helpers, a bounded core/ holding only what has no domain, shared logging and error handling attached as decorators, pydantic for data shapes but kept off the push-gate import path, installed via uv tool install so reach is a bare name on PATH. Deliberately parentless — this is phase-independent tooling, not cascade work. Six epics; each unfolds into tickets when refined.', 'Consolidate the 123-file tooling/ tree into one installable Python package behind a single console command, reach. Resolves Q-124; Rust rejected as R-014 because the friction is packaging, not language. Shape per D-263: a routing-only main.py, every domain under tooling/domains/<name>/ split router/service/schemas/helpers, a bounded core/ holding only what has no domain, shared logging and error handling attached as decorators, pydantic for data shapes but kept off the push-gate import path, installed via uv tool install so reach is a bare name on PATH. Deliberately parentless — this is phase-independent tooling, not cascade work. Six epics; each unfolds into tickets when refined.
Amended 2026-08-20 during the planning session: EIGHT epics, not six. E7 (Adoption) was split out of E6 because retiring old paths and establishing the new way as the only way are different jobs with different failure modes. E8 (Test surface) was added because make test-tooling runs five suites that all move during E3/E5 and nothing else owned rewiring them.', NULL, '2026-08-20 00:26:54', '2026-08-20 00:26:54.553', '2026-08-20 00:26:54.553', NULL, '487f9e6791c4d32c8f01911ee746ac5d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'description', 'Add the standing rule to settled-reach/CLAUDE.md that all tooling is developed IN the reach CLI and nowhere else — no new loose scripts under tooling/, no new extensionless executables, no new python3 tooling/<script>.py entrypoints. A new tool is a verb on an existing domain, or a new domain under tooling/domains/. Rationale to state in one line: the tree fragmented into 123 files and four naming conventions precisely because there was never a rule against adding one more, and a consolidation with no standing rule re-fragments. Name the two legitimate exceptions so they are not litigated later: (1) the Blender payload scripts under tooling/scripts/blender/, which run under Blender bundled interpreter and physically cannot import the package; (2) tooling/econ-sim, a Rust crate. Also update .claude/rules/project-structure.md tooling/ entry to describe the package layout. NOTE ON WORDING — Jeroen phrased this 2026-08-20 as all tooling should not be developed in that new cli; read as no tooling developed OUTSIDE it, since the opposite reading contradicts D-263 entirely. Confirm before writing the rule.', 'Add the standing rule to settled-reach/CLAUDE.md that all tooling is developed IN the reach CLI and nowhere else — no new loose scripts under tooling/, no new extensionless executables, no new python3 tooling/<script>.py entrypoints. A new tool is a verb on an existing domain, or a new domain under tooling/domains/. Rationale to state in one line: the tree fragmented into 123 files and four naming conventions precisely because there was never a rule against adding one more, and a consolidation with no standing rule re-fragments. Name the two legitimate exceptions so they are not litigated later: (1) the Blender payload scripts under tooling/scripts/blender/, which run under Blender bundled interpreter and physically cannot import the package; (2) tooling/econ-sim, a Rust crate. Also update .claude/rules/project-structure.md tooling/ entry to describe the package layout. NOTE ON WORDING — Jeroen phrased this 2026-08-20 as all tooling should not be developed in that new cli; read as no tooling developed OUTSIDE it, since the opposite reading contradicts D-263 entirely. Confirm before writing the rule.
CONFIRMED 2026-08-20 by Jeroen: your read is correct for the tooling. The rule is NO TOOLING DEVELOPED OUTSIDE THE CLI. No further confirmation needed before writing it.', NULL, '2026-08-20 00:28:43', '2026-08-20 00:28:43.014', '2026-08-20 00:28:43.014', NULL, 'af249a8b124a6ac49493694024bdf32f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3Z1TJHNK1A3YS4HGMBPE4', 'description', 'A consolidation with no standing rule re-fragments. The tree reached 123 files and four naming conventions precisely because there was never a rule against adding one more, so the documentation half of this initiative is not decoration — it is the part that makes the other six stick. Distinct from E6, which retires OLD paths; this establishes the NEW way as the only way. Pieces that become tickets: (1) the reach skill — usage and authoring (T-1254); (2) the CLAUDE.md standing rule that no tooling is developed outside the CLI (T-1255); (3) .claude/rules/project-structure.md — the tooling/ entry rewritten to describe the package layout rather than a file list; (4) the D-263 diagram at docs/diagrams/architecture/reach-cli.d2 — the door, the layers, the domains and the Blender carve-out; D-263 already cross-references this path, so until it exists the record names a file that is not there; (5) a short authoring-contract section wherever a reviewer will look — the invariants that make the layering real (no logic in routers, no Typer in services, every command decorated, pydantic off the gate path) are easy to state and easy to skip. Sequence AFTER E1 and E2 so the docs describe a CLI that exists.', 'A consolidation with no standing rule re-fragments. The tree reached 123 files and four naming conventions precisely because there was never a rule against adding one more, so the documentation half of this initiative is not decoration — it is the part that makes the other six stick. Distinct from E6, which retires OLD paths; this establishes the NEW way as the only way. Pieces that become tickets: (1) the reach skill — usage and authoring (T-1254); (2) the CLAUDE.md standing rule that no tooling is developed outside the CLI (T-1255); (3) .claude/rules/project-structure.md — the tooling/ entry rewritten to describe the package layout rather than a file list; (4) the D-263 diagram at docs/diagrams/architecture/reach-cli.d2 — the door, the layers, the domains and the Blender carve-out; D-263 already cross-references this path, so until it exists the record names a file that is not there; (5) a short authoring-contract section wherever a reviewer will look — the invariants that make the layering real (no logic in routers, no Typer in services, every command decorated, pydantic off the gate path) are easy to state and easy to skip. Sequence AFTER E1 and E2 so the docs describe a CLI that exists.
Amended 2026-08-20: the forward reference to docs/diagrams/architecture/reach-cli.d2 was REMOVED from D-263 rather than left pointing at a file that does not exist. So the diagram ticket now carries a second half — when the diagram lands, ADD the cross-reference back into D-263. Per .claude/rules/diagrams.md a diagram not referenced from its D-record is unreachable from the governance tree, which is the failure this avoids in the other direction.', NULL, '2026-08-20 00:28:49', '2026-08-20 00:28:49.216', '2026-08-20 00:28:49.216', NULL, '3d520074d4905545282a1e0f84c9ffe2', 2) ON CONFLICT(hash) DO NOTHING;
+11
View File
@@ -6,3 +6,14 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1R8WZ987YCP1ARANSA70R0W', 'T-1244', '2026-08-19 22:28:11.726', '2026-08-19 22:28:11.726', NULL, 'fd1f9d38036692125ba7c7de0198bfc8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1RB7GSJA4379EAQDZPQQDN8', 'T-1245', '2026-08-19 22:38:22.413', '2026-08-19 22:38:22.413', NULL, '34a0757e14a868c4d9227c372c013e90', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1RWR7M1H1PC3TY2RT7AC174', 'T-1246', '2026-08-19 23:54:55.776', '2026-08-19 23:54:55.776', NULL, 'f3663ab4236d777635d78a5cf33e65e1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S37Y1ARMV68RJT802Z3VPG', 'T-1247', '2026-08-20 00:23:17.258', '2026-08-20 00:23:17.258', NULL, 'cbf9b4b16420425b79b5decb6db7c4f1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S392DEG41MRGEFSYWJWP2W', 'T-1248', '2026-08-20 00:23:26.571', '2026-08-20 00:23:26.571', NULL, 'a5735013adb4baa7cb7c47782e42faec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', 'T-1249', '2026-08-20 00:23:32.680', '2026-08-20 00:23:32.680', NULL, 'b24dbef6bf676c3b7ec7ecd0da96e4d4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'T-1250', '2026-08-20 00:23:58.881', '2026-08-20 00:23:58.881', NULL, 'c560071746ff0654b44a04da53981ce1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3DTRHSBYJS55NDT9YR4R0', 'T-1251', '2026-08-20 00:24:05.572', '2026-08-20 00:24:05.572', NULL, '0dca314daf74d6e3b98fa8d5a33bf10a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3F6SKZ6EQQ7TY0RKT2970', 'T-1252', '2026-08-20 00:24:16.844', '2026-08-20 00:24:16.844', NULL, '46d8c49209e07b13dca2e9fc48682600', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3G1D2828RX2F3ZFNWRJGC', 'T-1253', '2026-08-20 00:24:23.657', '2026-08-20 00:24:23.657', NULL, '170413bf137ee1f555d95739463092a4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3RFJ739EWN4JDG8ZEX89W', 'T-1254', '2026-08-20 00:25:32.817', '2026-08-20 00:25:32.817', NULL, 'b782e3ed2a2427ce6cb4f18ab070bba4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'T-1255', '2026-08-20 00:25:41.820', '2026-08-20 00:25:41.820', NULL, '585e4b1d8b9002e7b4628492053424f7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3Z1TJHNK1A3YS4HGMBPE4', 'T-1256', '2026-08-20 00:26:26.644', '2026-08-20 00:26:26.644', NULL, 'a19ac46dc49884889880b51126fa0797', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S40293BXWK39D9HAXHM8Q4', 'T-1257', '2026-08-20 00:26:34.952', '2026-08-20 00:26:34.952', NULL, 'd28cc4d45adf44eb817cd91b9ffd7144', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
+22
View File
@@ -1202,3 +1202,25 @@ Start here, not with Rust. Measured state: pyproject.toml already exists and mak
The real deliverable is ''one bare command reliably on PATH'', not ''a CLI framework''. A [project.scripts] entrypoint lands in .venv/bin/, which is on PATH only when activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same failure recorded for tea (absolute path breaks the Bash(tea *) rule and prompts every time). Install via uv tool/pipx into ~/.local/bin, or symlink. A Typer app behind an absolute venv path solves nothing.
Watch startup cost: a single entrypoint importing every subcommand eagerly pays all 123 modules on every invocation, four times per push. Lazy subcommand registration is mandatory, not an optimisation — measure before and after.', 'backlog', 'low', NULL, 'tooling', 'Q-124', '2026-08-19 23:54:55.776', '2026-08-20 00:00:11.592', NULL, 'b48a1523bdfbddb038c083b365414e9f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S37Y1ARMV68RJT802Z3VPG', 'initiative', NULL, 'reach — one CLI for all repo tooling (D-263)', 'Consolidate the 123-file tooling/ tree into one installable Python package behind a single console command, reach. Resolves Q-124; Rust rejected as R-014 because the friction is packaging, not language. Shape per D-263: a routing-only main.py, every domain under tooling/domains/<name>/ split router/service/schemas/helpers, a bounded core/ holding only what has no domain, shared logging and error handling attached as decorators, pydantic for data shapes but kept off the push-gate import path, installed via uv tool install so reach is a bare name on PATH. Deliberately parentless — this is phase-independent tooling, not cascade work. Six epics; each unfolds into tickets when refined.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:17.258', '2026-08-20 00:23:17.258', NULL, 'bac7a14e2e36595a5348ea47f6c5dea8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S392DEG41MRGEFSYWJWP2W', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E1 — The door: reach on PATH, routing-only main.py, lazy domains', 'The walking skeleton, and the epic every other one depends on. Deliverable: typing reach in any shell — interactive, agent, or git hook — prints the domain list, and one domain is ported end to end through router/service as the reference every later port copies. Pieces that become tickets: (1) package skeleton — tooling/__init__.py, main.py, core/, domains/, setuptools discovery configured to exclude the econ-sim Rust crate; (2) pyproject [project.scripts] reach = tooling.main:app, plus typer and pydantic added to dependencies; (3) the PATH guarantee — uv tool install --editable, verified from a NON-INTERACTIVE context (git hook, agent Bash) and not just a login shell, since that split is the whole point; (4) lazy router registration — main.py must list domains in --help WITHOUT importing their services, which is the mechanism the 104 ms gate budget depends on; (5) help at every level: reach --help enumerates domains, reach <domain> --help enumerates verbs; (6) one domain ported end to end as the reference implementation; (7) Bash(reach *) added to .claude/settings.json. Done when: reach runs from a git hook with no venv activation, --help answers what tooling exists without an ls, and the reference domain has zero logic in its router.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:26.571', '2026-08-20 00:23:26.571', NULL, '1d9add2650ae88a9b96bd96df97577ab', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E2 — Decorators: shared logging, robust error handling, failures that teach', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-20 00:23:32.680', NULL, '4e7715d95d89a3030c6e82c5dce8b5cc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E3 — domains/: move the 123-file tree into the layered package', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-20 00:23:58.880', NULL, '60b3389274f65fb923e5444ffaf631e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3DTRHSBYJS55NDT9YR4R0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E4 — The gate family and the push hook, inside the 104 ms budget', 'The highest-frequency, highest-friction surface, and the one with a hard number attached. Baseline measured 2026-08-20: check-client-version 20 ms, check-dataflow-graph 38 ms, check-canvas-version 46 ms — about 104 ms for the three unconditional checks; check-systems-db-stamp runs only when systems.db is in the push. The ported gates must not exceed that. Pieces that become tickets: (1) port the four checks into domains/check/ — client-version, canvas-version, dataflow-graph, systems-db-stamp; (2) port validate-content, validate-checklist, validate-ron, check-fact-ids into domains/validate/; (3) rewire .config/hooks/pre-push to call reach, keeping the fail_check by-name reporting that hook deliberately has; (4) prove each gate still FAILS — a check that explains itself and exits 0 silently disables its own gate, which is exactly the clide failure recorded in D-263; test each one against a deliberately broken tree, not just a clean one; (5) the wall-clock assertion that turns the budget into a test; (6) confirm domains/check/ imports no pydantic — 87 ms against a 20 ms check is a 3.5x regression bought for nothing, four times per push. Depends on E1 (lazy registration) and E2 (the error contract) being real first.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:24:05.572', '2026-08-20 00:24:05.572', NULL, 'ff8b3ac61202a3e1facc4d723bd3d6e0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3F6SKZ6EQQ7TY0RKT2970', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E5 — Generators and numerics, without breaking the systems.db stamp', 'The riskiest epic, and the one with a hard sequencing constraint. tooling/generator_sources.py SHAs the concatenated bytes of the generator sources SORTED BY PATH, so renaming a file changes the stamp even when its content is byte-identical. The 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. Pieces that become tickets: (1) move import_economics.py and the economy_import package into domains/db/, update generator_sources.py in the same change, regen and verify the stamp; (2) the generate_brands Rust binary is a SUBROUTINE of the Python importer, not an independent generator — keep that relationship intact; (3) canvas_sources.py and schema_version.py move with their consumers, and check-canvas-version must keep resolving the registry; (4) planet-gen into domains/planet/ — import_heightmaps, import_province_boundaries, planet_simulation, scaffold_bodies, render_heightmap; these are one-time build imports baked into the committed DB, intentionally unstamped, and they lean on numpy/scipy/PIL; (5) garment-fit into domains/garment/ with its 23 blender_author_* files going to scripts/blender/ per the carve-out; (6) db/wiki_sync.py and the audio/image/trellis connectors, which resolve a venv explicitly today in tooling/db/common.py and should stop needing to; (7) test_sim_determinism and test_oasis_ring_scaling keep passing — they are the guard that the numerics did not move under us. NOTHING here is rewritten. Code is relocated and re-fronted; no numerical behaviour changes, which is precisely why Rust was rejected.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:24:16.844', '2026-08-20 00:24:16.844', NULL, '796fd7edeb556309549bbff444046d1c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3G1D2828RX2F3ZFNWRJGC', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E6 — Retire the old paths: make targets, docs, skills, permissions', 'The migration is not done when reach works; it is done when nothing calls the old paths. Surface: 84 Makefile targets, 160 markdown files under .claude/ and docs/ that name tooling/ paths, the pre-push hook, and the skills. Pieces that become tickets: (1) make targets become THIN WRAPPERS over reach — make stays the door for zero-argument repo verbs, reach is the door for anything taking arguments; one implementation, and the door holding it is reach; note Bash(make *) is already blanket-allowed so this epic does not claim to make make cheaper; (2) the 10 hand-written Bash(tooling/...) entries in .claude/settings.json are deleted, replaced by the single Bash(reach *); (3) deprecation shims — each retired path leaves a stub that prints the new command and exits NON-ZERO, the failure contract applied to the migration itself, removed after a window rather than deleted under the callers; (4) sweep the 160 markdown callers, including .claude/rules/*.md and every skill that shells out to a tooling path; (5) the reach skill documenting usage AND how to add a domain or command; (6) the CLAUDE.md rule that no new tooling is written outside the CLI, so the tree cannot re-fragment; (7) delete the shims and confirm no tooling/ script path survives outside scripts/blender/.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:24:23.657', '2026-08-20 00:24:23.657', NULL, '46e71123daa96f64882085b936674e92', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3RFJ739EWN4JDG8ZEX89W', 'story', '06G1S3G1D2828RX2F3ZFNWRJGC', 'reach skill — usage, and how to add a domain or command', 'Project-scope skill at .claude/skills/reach/SKILL.md, user-invocable, covering two audiences. USAGE: the domain list, how to discover verbs (reach --help then reach <domain> --help), the flags that matter to agents and hooks (--no-input, --verbose), and the rule that a non-zero exit always carries the command that fixes it. AUTHORING — the part that keeps the tree from re-fragmenting: how to add a verb to an existing domain, how to add a whole domain, and the invariants from D-263 that a reviewer will check — router.py holds no logic, service.py holds no Typer, every command carries the composed @command decorator (logging plus error handling), all output goes through core/console.py, pydantic models live in domains/<d>/schemas.py and must not be reachable from main.py, and core/ holds only what has no domain. Include the failure-message contract with a worked example (raise ReachError with a fix= remedy) because that is the requirement most likely to be skipped under time pressure. Write this AFTER E1 and E2 land so the skill documents a CLI that exists rather than one that is planned.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:32.817', '2026-08-20 00:25:32.817', NULL, 'f65a35978c2117e381ce77fa4afc1394', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'story', '06G1S3G1D2828RX2F3ZFNWRJGC', 'CLAUDE.md rule — no new tooling outside the reach CLI', 'Add the standing rule to settled-reach/CLAUDE.md that all tooling is developed IN the reach CLI and nowhere else — no new loose scripts under tooling/, no new extensionless executables, no new python3 tooling/<script>.py entrypoints. A new tool is a verb on an existing domain, or a new domain under tooling/domains/. Rationale to state in one line: the tree fragmented into 123 files and four naming conventions precisely because there was never a rule against adding one more, and a consolidation with no standing rule re-fragments. Name the two legitimate exceptions so they are not litigated later: (1) the Blender payload scripts under tooling/scripts/blender/, which run under Blender bundled interpreter and physically cannot import the package; (2) tooling/econ-sim, a Rust crate. Also update .claude/rules/project-structure.md tooling/ entry to describe the package layout. NOTE ON WORDING — Jeroen phrased this 2026-08-20 as all tooling should not be developed in that new cli; read as no tooling developed OUTSIDE it, since the opposite reading contradicts D-263 entirely. Confirm before writing the rule.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:41.820', '2026-08-20 00:25:41.820', NULL, '32d39b03d15628da8f810ec710b28f37', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3Z1TJHNK1A3YS4HGMBPE4', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E7 — Adoption: the skill, the rule, and the docs that stop re-fragmentation', 'A consolidation with no standing rule re-fragments. The tree reached 123 files and four naming conventions precisely because there was never a rule against adding one more, so the documentation half of this initiative is not decoration — it is the part that makes the other six stick. Distinct from E6, which retires OLD paths; this establishes the NEW way as the only way. Pieces that become tickets: (1) the reach skill — usage and authoring (T-1254); (2) the CLAUDE.md standing rule that no tooling is developed outside the CLI (T-1255); (3) .claude/rules/project-structure.md — the tooling/ entry rewritten to describe the package layout rather than a file list; (4) the D-263 diagram at docs/diagrams/architecture/reach-cli.d2 — the door, the layers, the domains and the Blender carve-out; D-263 already cross-references this path, so until it exists the record names a file that is not there; (5) a short authoring-contract section wherever a reviewer will look — the invariants that make the layering real (no logic in routers, no Typer in services, every command decorated, pydantic off the gate path) are easy to state and easy to skip. Sequence AFTER E1 and E2 so the docs describe a CLI that exists.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:26.644', '2026-08-20 00:26:26.644', NULL, '26d362ab165b2fc7868a977a214875c7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S40293BXWK39D9HAXHM8Q4', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E8 — The tooling test surface, rewired', 'make test-tooling is the tooling half of the push gate and it runs five suites that ALL move during this initiative: planet-gen test_sim_determinism.py and test_oasis_ring_scaling.py (via VENV_PY), test_canvas_version_check.py, economy-db test_traits.py and test_atlas_idempotency.py, plus an import_economics --dry-run against the committed DB. Nobody owns rewiring them, so this epic does. Pieces that become tickets: (1) the five suites move with their domains and keep passing — they are the guard that the numerics did not shift under the relocation, so a green run before and after the move is the actual acceptance evidence; (2) VENV_PY disappears from the Makefile — the whole point of uv tool install is that there is no interpreter fork between a human shell and a hook, and every surviving VENV_PY is a place where that promise is not yet kept; (3) a home for service-level tests — the layering exists so services can be called directly without a CLI round trip, and if no test ever does that, the layering is unverified decoration; (4) the conformance test from E2 joins this gate rather than living alone; (5) the wall-clock budget assertion from E4 joins it too; (6) confirm the gate still FAILS on a broken tree — a test gate that only passes has never been tested. Watch: ruff config currently ignores E402, E702 and F841; the E402 debt is script-style sys.path.insert before imports and should largely vanish with a real package, so re-enable it here rather than carrying the ignore forever.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:34.952', '2026-08-20 00:26:34.952', NULL, '46c63386f34fbb52f8abf946afa854bd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3RFJ739EWN4JDG8ZEX89W', 'story', '06G1S3Z1TJHNK1A3YS4HGMBPE4', 'reach skill — usage, and how to add a domain or command', 'Project-scope skill at .claude/skills/reach/SKILL.md, user-invocable, covering two audiences. USAGE: the domain list, how to discover verbs (reach --help then reach <domain> --help), the flags that matter to agents and hooks (--no-input, --verbose), and the rule that a non-zero exit always carries the command that fixes it. AUTHORING — the part that keeps the tree from re-fragmenting: how to add a verb to an existing domain, how to add a whole domain, and the invariants from D-263 that a reviewer will check — router.py holds no logic, service.py holds no Typer, every command carries the composed @command decorator (logging plus error handling), all output goes through core/console.py, pydantic models live in domains/<d>/schemas.py and must not be reachable from main.py, and core/ holds only what has no domain. Include the failure-message contract with a worked example (raise ReachError with a fix= remedy) because that is the requirement most likely to be skipped under time pressure. Write this AFTER E1 and E2 land so the skill documents a CLI that exists rather than one that is planned.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:32.817', '2026-08-20 00:26:44.635', NULL, '9435f993de52370214847529ddb30bdf', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'story', '06G1S3Z1TJHNK1A3YS4HGMBPE4', 'CLAUDE.md rule — no new tooling outside the reach CLI', 'Add the standing rule to settled-reach/CLAUDE.md that all tooling is developed IN the reach CLI and nowhere else — no new loose scripts under tooling/, no new extensionless executables, no new python3 tooling/<script>.py entrypoints. A new tool is a verb on an existing domain, or a new domain under tooling/domains/. Rationale to state in one line: the tree fragmented into 123 files and four naming conventions precisely because there was never a rule against adding one more, and a consolidation with no standing rule re-fragments. Name the two legitimate exceptions so they are not litigated later: (1) the Blender payload scripts under tooling/scripts/blender/, which run under Blender bundled interpreter and physically cannot import the package; (2) tooling/econ-sim, a Rust crate. Also update .claude/rules/project-structure.md tooling/ entry to describe the package layout. NOTE ON WORDING — Jeroen phrased this 2026-08-20 as all tooling should not be developed in that new cli; read as no tooling developed OUTSIDE it, since the opposite reading contradicts D-263 entirely. Confirm before writing the rule.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:41.820', '2026-08-20 00:26:44.768', NULL, '784a21074f9cb5f4a89094cd715455c9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S37Y1ARMV68RJT802Z3VPG', 'initiative', NULL, 'reach — one CLI for all repo tooling (D-263)', 'Consolidate the 123-file tooling/ tree into one installable Python package behind a single console command, reach. Resolves Q-124; Rust rejected as R-014 because the friction is packaging, not language. Shape per D-263: a routing-only main.py, every domain under tooling/domains/<name>/ split router/service/schemas/helpers, a bounded core/ holding only what has no domain, shared logging and error handling attached as decorators, pydantic for data shapes but kept off the push-gate import path, installed via uv tool install so reach is a bare name on PATH. Deliberately parentless — this is phase-independent tooling, not cascade work. Six epics; each unfolds into tickets when refined.
Amended 2026-08-20 during the planning session: EIGHT epics, not six. E7 (Adoption) was split out of E6 because retiring old paths and establishing the new way as the only way are different jobs with different failure modes. E8 (Test surface) was added because make test-tooling runs five suites that all move during E3/E5 and nothing else owned rewiring them.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:17.258', '2026-08-20 00:26:54.553', NULL, 'd8a1d79223020984e9a9d65d4bf0ed04', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'story', '06G1S3Z1TJHNK1A3YS4HGMBPE4', 'CLAUDE.md rule — no new tooling outside the reach CLI', 'Add the standing rule to settled-reach/CLAUDE.md that all tooling is developed IN the reach CLI and nowhere else — no new loose scripts under tooling/, no new extensionless executables, no new python3 tooling/<script>.py entrypoints. A new tool is a verb on an existing domain, or a new domain under tooling/domains/. Rationale to state in one line: the tree fragmented into 123 files and four naming conventions precisely because there was never a rule against adding one more, and a consolidation with no standing rule re-fragments. Name the two legitimate exceptions so they are not litigated later: (1) the Blender payload scripts under tooling/scripts/blender/, which run under Blender bundled interpreter and physically cannot import the package; (2) tooling/econ-sim, a Rust crate. Also update .claude/rules/project-structure.md tooling/ entry to describe the package layout. NOTE ON WORDING — Jeroen phrased this 2026-08-20 as all tooling should not be developed in that new cli; read as no tooling developed OUTSIDE it, since the opposite reading contradicts D-263 entirely. Confirm before writing the rule.
CONFIRMED 2026-08-20 by Jeroen: your read is correct for the tooling. The rule is NO TOOLING DEVELOPED OUTSIDE THE CLI. No further confirmation needed before writing it.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:41.820', '2026-08-20 00:28:43.014', NULL, '11bef4568939ff71fdf9f4e9f5b6e6fb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3Z1TJHNK1A3YS4HGMBPE4', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E7 — Adoption: the skill, the rule, and the docs that stop re-fragmentation', 'A consolidation with no standing rule re-fragments. The tree reached 123 files and four naming conventions precisely because there was never a rule against adding one more, so the documentation half of this initiative is not decoration — it is the part that makes the other six stick. Distinct from E6, which retires OLD paths; this establishes the NEW way as the only way. Pieces that become tickets: (1) the reach skill — usage and authoring (T-1254); (2) the CLAUDE.md standing rule that no tooling is developed outside the CLI (T-1255); (3) .claude/rules/project-structure.md — the tooling/ entry rewritten to describe the package layout rather than a file list; (4) the D-263 diagram at docs/diagrams/architecture/reach-cli.d2 — the door, the layers, the domains and the Blender carve-out; D-263 already cross-references this path, so until it exists the record names a file that is not there; (5) a short authoring-contract section wherever a reviewer will look — the invariants that make the layering real (no logic in routers, no Typer in services, every command decorated, pydantic off the gate path) are easy to state and easy to skip. Sequence AFTER E1 and E2 so the docs describe a CLI that exists.
Amended 2026-08-20: the forward reference to docs/diagrams/architecture/reach-cli.d2 was REMOVED from D-263 rather than left pointing at a file that does not exist. So the diagram ticket now carries a second half — when the diagram lands, ADD the cross-reference back into D-263. Per .claude/rules/diagrams.md a diagram not referenced from its D-record is unreachable from the governance tree, which is the failure this avoids in the other direction.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:26.644', '2026-08-20 00:28:49.216', NULL, '06d68cc8318313dd746a0f55e5834dc0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+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 2046 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.