docs(governance): D-263 — output parity over timing, and commands that stream

Three amendments, all from pressure-testing the record against how the CLI
will actually be used.

The ~104 ms push-gate ceiling is withdrawn. It was the summed cost of three
single-sample timings, imported as a requirement without asking who pays —
and who pays is the pre-push hook, which already runs cargo test or the
gdUnit4 suite on any code push. A few hundred milliseconds is invisible
there, and on a governance-only push the whole hook is about a second. The
criterion is OUTPUT parity: a ported check must produce the same output and
the same exit code as the script it replaces, and is not required to be as
fast. What replaces the ratchet is a ceiling with headroom — under ~250 ms to
feel instant. Lazy registration stays mandatory, justified by the real
threat rather than by parity: scipy.ndimage alone is 275 ms, and an eager
entrypoint would pay ~460 ms before executing a line of its own.

That budget change removed the only argument for keeping pydantic out of the
gate domain, so the carve-out goes with it. One fewer exception, and the
reference implementation is now the normal pattern rather than a footnote.

Commands also stream. The gates are milliseconds but the generators are
minutes, and an agent Bash call gives up at two and sends nothing. Detaching
alone would fix the timeout and keep the silence; streaming fixes the part
that costs real time — you learn a generator is wedged at minute one instead
of minute nine. JSONL events on stderr, stdout reserved for actual output,
rendering at the sink so a job log and a live terminal are one artefact in
two presentations. Reattach is a byte offset into an append-only file, which
is why there is deliberately no daemon.

The trap, recorded because it would quietly undo the thing this record cares
most about: streaming is ADDITIVE to the failure contract. A remedy emitted
at line 400 of 900 is printed and invisible, so the verdict still prints
once, last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:53:29 +02:00
co-authored by Claude Opus 5
parent bbd64307ab
commit 5df8afedb9
5 changed files with 253 additions and 8 deletions
+6
View File
@@ -8,3 +8,9 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat
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;
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', '06G1S7NVJR0GT9KWS9QVYNNFMM', '2026-08-20 00:42:42.114', '2026-08-20 00:42:42.114', NULL, 'a486bea3e2cb04fd03cb71e364417bd9', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', '06G1S67EQ8EQTT2E9E3CGTXS7W', '2026-08-20 00:42:42.642', '2026-08-20 00:42:42.642', NULL, '8c28924c4eb7ac75e2a22942940f8d95', 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 ('06G1S67EQ8EQTT2E9E3CGTXS7W', '06G1S685APSPZRXSM9F62DTKGM', '2026-08-20 00:42:45.139', '2026-08-20 00:42:45.139', NULL, '8e97125389f637adefc4de43a7f0212c', 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 ('06G1S67EQ8EQTT2E9E3CGTXS7W', '06G1S6986SDTTQNCYCAQGKKCAW', '2026-08-20 00:42:45.655', '2026-08-20 00:42:45.655', NULL, 'b1d531df73152d05f989ff97aa5eef04', 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 ('06G1S685APSPZRXSM9F62DTKGM', '06G1S6A2D22PZVQJ0S0HT91E7W', '2026-08-20 00:42:48.615', '2026-08-20 00:42:48.615', NULL, 'e02c4cc126a066706fbd79ab65351104', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', '06G1S7EZ0HZSZT5HYHJ5HPBQ2G', '2026-08-20 00:42:48.741', '2026-08-20 00:42:48.741', NULL, 'a72569a21eac1ed513d718965c545f76', 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;
+107
View File
@@ -1254,3 +1254,110 @@ CONFIRMED 2026-08-20 by Jeroen: your read is correct for the tooling. The rule i
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;
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', 'status', 'backlog', 'in_progress', NULL, '2026-08-20 00:33:11', '2026-08-20 00:33:11.395', '2026-08-20 00:33:11.395', NULL, '6ed2abbbb436a85fddf16a796ef6b57b', 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 ('06G1S392DEG41MRGEFSYWJWP2W', 'status', 'backlog', 'in_progress', NULL, '2026-08-20 00:34:08', '2026-08-20 00:34:08.839', '2026-08-20 00:34:08.839', NULL, '7534faa8dc1964118f80712d65852c98', 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 ('06G1S3DTRHSBYJS55NDT9YR4R0', 'description', '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.', '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.
AMENDED 2026-08-20 — the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second — so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion — a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.', NULL, '2026-08-20 00:41:07', '2026-08-20 00:41:07.767', '2026-08-20 00:41:07.767', NULL, 'ffffcd28d67d933cb7afe7006ab4abb1', 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 ('06G1S685APSPZRXSM9F62DTKGM', 'description', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 — the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn — see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms — about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.', NULL, '2026-08-20 00:41:10', '2026-08-20 00:41:10.589', '2026-08-20 00:41:10.589', NULL, '5dce3a2bb22cae2198c3123a29788e5b', 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 ('06G1S3DTRHSBYJS55NDT9YR4R0', 'title', 'E4 — The gate family and the push hook, inside the 104 ms budget', 'E4 — The gate family and the push hook, at output parity', NULL, '2026-08-20 00:41:16', '2026-08-20 00:41:16.769', '2026-08-20 00:41:16.769', NULL, '9c3b75da7d34e93cb0c6cf6523bccfbb', 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 ('06G1S685APSPZRXSM9F62DTKGM', 'title', 'Lazy domain registration — the mechanism the gate budget depends on', 'Lazy domain registration — --help must not import the tree', NULL, '2026-08-20 00:41:21', '2026-08-20 00:41:21.127', '2026-08-20 00:41:21.127', NULL, 'a2b2a9c27d9e9e56fb17e881ea6dcebe', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'description', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.', NULL, '2026-08-20 00:41:24', '2026-08-20 00:41:24.521', '2026-08-20 00:41:24.521', NULL, 'fdb63ac76cdbe30045a02ec8e03b66c5', 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 ('06G1S392DEG41MRGEFSYWJWP2W', 'description', '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.', '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.
AMENDED 2026-08-20 — two corrections to the description above. (a) Item (4) calls lazy registration the mechanism the 104 ms gate budget depends on; that budget is WITHDRAWN (D-263 amendment, T-1251). Lazy registration is still mandatory, justified instead by what an eager entrypoint would really cost: scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms, about 460 ms before executing a line of its own. (b) Item (2) names the entrypoint tooling.main:app; it is tooling.main:cli — the root is a click Group subclass, not a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and keeping typer off the --help path is worth having. Refined into six tasks: T-1258 package skeleton, T-1259 entrypoint and deps, T-1260 lazy registration, T-1261 PATH guarantee, T-1262 reference domain, T-1263 permission entry.', NULL, '2026-08-20 00:41:30', '2026-08-20 00:41:30.963', '2026-08-20 00:41:30.963', NULL, 'ab6d141573cac4a801a543881074c24a', 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 ('06G1S40293BXWK39D9HAXHM8Q4', 'description', '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.', '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.
AMENDED 2026-08-20 — item (5), the wall-clock budget assertion from E4, is WITHDRAWN along with the 104 ms timing-parity budget it enforced (D-263 amendment, T-1251). It is replaced by the import-graph assertion: after reach --help, no domain service module and no heavy dependency (numpy, scipy, PIL, pydantic) may appear in sys.modules. That test is not flaky on a loaded machine and it names the offending import rather than reporting that something got slower.', NULL, '2026-08-20 00:41:33', '2026-08-20 00:41:33.421', '2026-08-20 00:41:33.421', NULL, 'a58efc4e760bdcd1ee3bba9b9f5daf68', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'description', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 — core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR — stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL — a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict — outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.', NULL, '2026-08-20 00:47:37', '2026-08-20 00:47:37.519', '2026-08-20 00:47:37.519', NULL, 'e652f920921f349bb132d4f6697bd1b6', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'title', 'E9 — Detach mode and the jobs domain: long runs that outlive the caller', 'E9 — Streaming output, job logs, and reattach without a daemon', NULL, '2026-08-20 00:47:37', '2026-08-20 00:47:37.950', '2026-08-20 00:47:37.950', NULL, '82babe54ea8f7a06b31e973d0babc309', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'description', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are 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. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are 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. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
AMENDED 2026-08-20 — scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time — you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter — JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive — detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.', NULL, '2026-08-20 00:47:48', '2026-08-20 00:47:48.269', '2026-08-20 00:47:48.269', NULL, '932f6c8adbc828e767dfc9ab2419eb5a', 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 ('06G1S39T92PW9JNBPWD0F4JFR0', 'description', '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.', '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.
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.', NULL, '2026-08-20 00:47:55', '2026-08-20 00:47:55.366', '2026-08-20 00:47:55.366', NULL, '3af1760723d2bf8e4d8083a1303f7e47', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'description', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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. Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed. VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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. Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed. VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
ADDED 2026-08-20 — the NEGATIVE acceptance criterion, which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or via the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, python -m tooling, .venv/bin/reach, or any file path. The only supported form is the bare word reach. Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. Highest-risk site is E6: 84 make targets become reach wrappers, and one -m tooling among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.', NULL, '2026-08-20 00:49:03', '2026-08-20 00:49:03.925', '2026-08-20 00:49:03.925', NULL, 'e71eefbc727c3c964a9e5946c2a6392f', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'description', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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. Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed. VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
ADDED 2026-08-20 — the NEGATIVE acceptance criterion, which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or via the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, python -m tooling, .venv/bin/reach, or any file path. The only supported form is the bare word reach. Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. Highest-risk site is E6: 84 make targets become reach wrappers, and one -m tooling among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.', NULL, '2026-08-20 00:49:27', '2026-08-20 00:49:27.688', '2026-08-20 00:49:27.688', NULL, 'feb5a6f4f73a6ec722b133ff4ef34e4b', 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.
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.', '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.
Amended again 2026-08-20: NINE epics. T-1264 was added after a session finding that the gates are milliseconds but the generators are minutes, and an agent Bash call gives up at two minutes sending nothing — so reach streams JSONL progress events and detached runs append to a tailable per-job log, with no daemon. Epic ids, since the E-prefixes in the titles are a reading aid and not handles: T-1248 the door, T-1249 decorators, T-1250 the domain move, T-1251 the gate family, T-1252 generators and numerics, T-1253 retiring old paths, T-1256 adoption, T-1257 the test surface, T-1264 streaming and jobs. Also amended this session: the 104 ms timing-parity budget was withdrawn in favour of output parity plus a 250 ms feels-instant ceiling, which in turn dropped the no-pydantic-in-check carve-out.', NULL, '2026-08-20 00:51:13', '2026-08-20 00:51:13.519', '2026-08-20 00:51:13.519', NULL, 'f2eab2ed5df1238f570fd38a3d12bbc9', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'status', 'backlog', 'in_progress', NULL, '2026-08-20 00:51:31', '2026-08-20 00:51:31.670', '2026-08-20 00:51:31.670', NULL, 'b38b1a2db4df8206145d8860c64d62f9', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'status', 'in_progress', 'in_progress', NULL, '2026-08-20 00:52:06', '2026-08-20 00:52:06.891', '2026-08-20 00:52:06.891', NULL, 'f83279ebf627b5f91e846e1525c7de3a', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'description', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 — core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR — stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL — a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict — outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 — core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR — stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL — a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict — outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.
DONE 2026-08-20. Package skeleton landed and verified. Findings and deviations below, since several affect later tickets.
VERIFIED (each check could have failed, and the failure paths were exercised):
- uv pip install -e . succeeds; dist-info top_level.txt contains exactly "tooling" — discovery did NOT pull in client/, server/, docs/, wiki/, db/ or tests/, which was the risk that made explicit config necessary.
- import tooling.core.config, tooling.core.console, tooling.domains all succeed.
- console non-TTY: JSONL on stderr, command output alone on stdout (proved by redirecting stderr to /dev/null and seeing only DATA).
- console SR_OUTPUT_FORMAT=text: human rendering, phase prefix, percentage, and the verdict printing its Fix line last.
- debug-level events suppressed at the default threshold; verdict never suppressed.
- config sentinel REJECTS a bad override: SR_REPO_ROOT=/tmp raises and names both remedies.
- ruff check tooling/ clean (full tree, as the push gate runs it).
- No regression to the existing scripts: check-client-version, check-canvas-version and check-dataflow-graph all still pass with tooling/ now a package.
- Import cost: tooling.core.config 0.4 ms, tooling.core.console 3.6 ms — of which 1.9 ms is typing, which typer and click both import anyway, so it is free in any real invocation.
DEVIATION FROM THE WRITTEN ACCEPTANCE — pip is not available.
The acceptance says "pip install -e . succeeds". There is no pip in .venv: pyvenv.cfg records uv = 0.10.2, so the venv was made by uv, which does not install pip. Acceptance was met with `uv pip install -e . --python .venv/bin/python` instead. This is the direction of travel per D-263 anyway.
FINDING FOR T-1261 (the make target) — make setup-venv is STALE.
It runs `python3 -m venv .venv` then `.venv/bin/pip install -e ".[dev]" --quiet`. Neither matches the venv on disk, and the pip line cannot work against it. This is precisely the venv split Q-124 named: the recorded procedure and the actual state have already diverged. T-1261 owns the make target and should replace it with the uv path rather than patch pip back in.
FINDING — the project had never actually been installed.
Before this ticket site-packages held the DEPENDENCIES but no settled-reach-tooling dist-info and no .pth for it. That is consistent: with no __init__.py anywhere, an editable install had no package to expose. So this is the first commit where `import tooling` means anything.
DELIBERATE OMISSION — err() was dropped.
The original description asked for out() to stdout and err() to stderr. The amendment made console the event emitter, and a raw-text err() would then be a SECOND output path competing with the event stream — exactly the drift D-263 forbids by naming console the single one. event(level="error") and verdict(ok=False) cover every case err() would have. Flagging it because the word appears in the description and its absence is intentional, not an oversight.
CARRIED TO T-1249 — config raises RuntimeError, not ReachError.
core/errors.py does not exist yet, so _validated raises RuntimeError with an instructional message that already follows the contract (it names the fix). One call site to convert when T-1249 lands; there is a comment at the raise saying so.
INCIDENTAL — .gitignore.
Added *.egg-info/ (build output the install creates). Also added .venv/, which was only ignored via .git/info/exclude — machine-local, so a fresh clone or a new worktree would not have ignored it.', NULL, '2026-08-20 16:53:05', '2026-08-20 16:53:05.531', '2026-08-20 16:53:05.531', NULL, '6260d1d5d4bc05097ce85b96f091b896', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'description', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.
FINDING from T-1258 (2026-08-20): make setup-venv is already stale and this ticket owns the fix. It runs python3 -m venv .venv then .venv/bin/pip install -e .[dev] --quiet, but the venv on disk was created by uv (pyvenv.cfg records uv = 0.10.2) and therefore has NO pip at all, so that second line cannot work against it. Replace the target with the uv path rather than patching pip back in — uv is the direction of travel for this initiative anyway, and installing pip into a uv venv just to satisfy a stale recipe recreates the interpreter fork the ticket exists to remove. Related: the project itself had never actually been installed before T-1258 — site-packages held the dependencies but no settled-reach-tooling dist-info, which is consistent with there being no __init__.py to expose.', NULL, '2026-08-20 16:53:12', '2026-08-20 16:53:12.179', '2026-08-20 16:53:12.179', NULL, '9c22d5535327a13116653673fd38dd38', 2) ON CONFLICT(hash) DO NOTHING;
+7
View File
@@ -17,3 +17,10 @@ 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 ('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;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'T-1258', '2026-08-20 00:36:12.033', '2026-08-20 00:36:12.033', NULL, 'f481e448888534797529fd0c583d909d', 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 ('06G1S67EQ8EQTT2E9E3CGTXS7W', 'T-1259', '2026-08-20 00:36:19.770', '2026-08-20 00:36:19.770', NULL, '2301d64986860055fc8761011923e939', 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 ('06G1S685APSPZRXSM9F62DTKGM', 'T-1260', '2026-08-20 00:36:25.557', '2026-08-20 00:36:25.557', NULL, '906a9ca9b4fe6db3a03bd67dfc667ef2', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'T-1261', '2026-08-20 00:36:34.486', '2026-08-20 00:36:34.486', NULL, 'cf599099010d5687326e00773b472280', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'T-1262', '2026-08-20 00:36:41.192', '2026-08-20 00:36:41.192', NULL, 'ad11263fdf961eb2d9561e7422533346', 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 ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'T-1263', '2026-08-20 00:41:43.428', '2026-08-20 00:41:43.428', NULL, 'b642d87918935c2bdeb9e4f1d9b8032f', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'T-1264', '2026-08-20 00:42:39.895', '2026-08-20 00:42:39.895', NULL, 'da9af4d41ef21abb8a09513e4f4344d0', 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;
+110
View File
@@ -1224,3 +1224,113 @@ CONFIRMED 2026-08-20 by Jeroen: your read is correct for the tooling. The rule i
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;
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.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:17.258', '2026-08-20 00:33:11.395', NULL, '4511becb743553860e9c685e6c29891d', 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.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:26.571', '2026-08-20 00:34:08.839', NULL, '7e67500e72784b0e6c7454247c5854bd', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 00:36:12.032', NULL, '2f496f6b45f1e81169d577f168307312', 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 ('06G1S67EQ8EQTT2E9E3CGTXS7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The reach entrypoint: [project.scripts] plus typer and pydantic', 'pyproject.toml currently has NO [project.scripts] at all — no console entrypoint is defined anywhere. Adding one plus the two dependencies is the whole delta. Deliverables: [project.scripts] reach = tooling.main:cli; typer and pydantic added to [project] dependencies, both version-pinned and CVE-checked against NVD plus OSV the way scipy and Pillow already are in this file, with the check date in a comment since that is the established convention here. NOTE the entrypoint target is main:cli not main:app — see the lazy-registration task; the root is a click Group subclass rather than a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and because keeping typer off the --help path is worth having. Acceptance: .venv/bin/reach --help runs. This is the venv-local proof only; the bare-name-on-PATH guarantee is a separate task and is the one that actually matters.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:19.770', '2026-08-20 00:36:19.770', NULL, 'de8d6892a3b2a55b08846450349a801a', 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 ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — the mechanism the gate budget depends on', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-20 00:36:25.557', NULL, '2f29bb49be355f9ed08a401ebbe72f9b', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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. Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed. VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-20 00:36:34.486', NULL, 'c7d96f13996f3e8d48713482c4ee0ef1', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-20 00:36:41.192', NULL, '37a44aedee96ec2fdae9b2d30a973974', 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.
AMENDED 2026-08-20 the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:24:05.572', '2026-08-20 00:41:07.767', NULL, '3f25a3d2abace7b875bfcb4345fb3ed6', 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 ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — the mechanism the gate budget depends on', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-20 00:41:10.589', NULL, '26d59ae3fb7c386ad6c9f566bc5dabaa', 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, at output parity', '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.
AMENDED 2026-08-20 the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:24:05.572', '2026-08-20 00:41:16.768', NULL, 'f0edcbda3612efe3f7cb94fdba9864bd', 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 ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — --help must not import the tree', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-20 00:41:21.127', NULL, '797a573d0900ff331fd6da90ff026e82', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
AMENDED 2026-08-20 the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-20 00:41:24.521', NULL, '55d192c18a2a86bbceb65f7bf22a971b', 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.
AMENDED 2026-08-20 two corrections to the description above. (a) Item (4) calls lazy registration the mechanism the 104 ms gate budget depends on; that budget is WITHDRAWN (D-263 amendment, T-1251). Lazy registration is still mandatory, justified instead by what an eager entrypoint would really cost: scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms, about 460 ms before executing a line of its own. (b) Item (2) names the entrypoint tooling.main:app; it is tooling.main:cli the root is a click Group subclass, not a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and keeping typer off the --help path is worth having. Refined into six tasks: T-1258 package skeleton, T-1259 entrypoint and deps, T-1260 lazy registration, T-1261 PATH guarantee, T-1262 reference domain, T-1263 permission entry.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:26.571', '2026-08-20 00:41:30.963', NULL, 'f3f7ea151cf854053a5f7a08f4a36be3', 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.
AMENDED 2026-08-20 item (5), the wall-clock budget assertion from E4, is WITHDRAWN along with the 104 ms timing-parity budget it enforced (D-263 amendment, T-1251). It is replaced by the import-graph assertion: after reach --help, no domain service module and no heavy dependency (numpy, scipy, PIL, pydantic) may appear in sys.modules. That test is not flaky on a loaded machine and it names the offending import rather than reporting that something got slower.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:34.952', '2026-08-20 00:41:33.421', NULL, 'c908934cd550d418cadeddd4ea0ed028', 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 ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-20 00:41:43.428', NULL, 'b75a9ad1310d58b1299aa8325e032735', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Detach mode and the jobs domain: long runs that outlive the caller', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are 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. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-20 00:42:39.894', NULL, 'f9e89128d4cb7cd808cef0ee601f2be7', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 00:47:37.518', NULL, '098878cce987cc1a6ae43b33c16cfde0', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are 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. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-20 00:47:37.950', NULL, '1ddb7a7200e1d6f24b80ada09940740f', 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 ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are 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. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
AMENDED 2026-08-20 scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-20 00:47:48.268', NULL, '47c90408026cfde9d412830a8e03a44c', 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.
AMENDED 2026-08-20 the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-20 00:47:55.366', NULL, '091136a4ca712cf2cb7f118759c5b396', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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. Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed. VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
ADDED 2026-08-20 the NEGATIVE acceptance criterion, which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or via the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, python -m tooling, .venv/bin/reach, or any file path. The only supported form is the bare word reach. Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time the exact failure already recorded for tea, where the fix was a bare name on PATH and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. Highest-risk site is E6: 84 make targets become reach wrappers, and one -m tooling among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-20 00:49:03.924', NULL, '4a841f89b5ecaf04ceb0f65b20662aea', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time the exact failure already recorded for tea, where the fix was a bare name on PATH and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-20 00:49:27.688', NULL, '888e7dde02f2da1ac01c66dd50039870', 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.
Amended again 2026-08-20: NINE epics. T-1264 was added after a session finding that the gates are milliseconds but the generators are minutes, and an agent Bash call gives up at two minutes sending nothing so reach streams JSONL progress events and detached runs append to a tailable per-job log, with no daemon. Epic ids, since the E-prefixes in the titles are a reading aid and not handles: T-1248 the door, T-1249 decorators, T-1250 the domain move, T-1251 the gate family, T-1252 generators and numerics, T-1253 retiring old paths, T-1256 adoption, T-1257 the test surface, T-1264 streaming and jobs. Also amended this session: the 104 ms timing-parity budget was withdrawn in favour of output parity plus a 250 ms feels-instant ceiling, which in turn dropped the no-pydantic-in-check carve-out.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:17.258', '2026-08-20 00:51:13.519', NULL, 'db66dd46fad193cb77e4a88b6230c419', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 00:51:31.670', NULL, '33f8f175d37e326c042a023b515a2769', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 00:52:06.891', NULL, 'd6ac17453435122bcfee990719d36c61', 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 ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.
DONE 2026-08-20. Package skeleton landed and verified. Findings and deviations below, since several affect later tickets.
VERIFIED (each check could have failed, and the failure paths were exercised):
- uv pip install -e . succeeds; dist-info top_level.txt contains exactly "tooling" discovery did NOT pull in client/, server/, docs/, wiki/, db/ or tests/, which was the risk that made explicit config necessary.
- import tooling.core.config, tooling.core.console, tooling.domains all succeed.
- console non-TTY: JSONL on stderr, command output alone on stdout (proved by redirecting stderr to /dev/null and seeing only DATA).
- console SR_OUTPUT_FORMAT=text: human rendering, phase prefix, percentage, and the verdict printing its Fix line last.
- debug-level events suppressed at the default threshold; verdict never suppressed.
- config sentinel REJECTS a bad override: SR_REPO_ROOT=/tmp raises and names both remedies.
- ruff check tooling/ clean (full tree, as the push gate runs it).
- No regression to the existing scripts: check-client-version, check-canvas-version and check-dataflow-graph all still pass with tooling/ now a package.
- Import cost: tooling.core.config 0.4 ms, tooling.core.console 3.6 ms of which 1.9 ms is typing, which typer and click both import anyway, so it is free in any real invocation.
DEVIATION FROM THE WRITTEN ACCEPTANCE pip is not available.
The acceptance says "pip install -e . succeeds". There is no pip in .venv: pyvenv.cfg records uv = 0.10.2, so the venv was made by uv, which does not install pip. Acceptance was met with `uv pip install -e . --python .venv/bin/python` instead. This is the direction of travel per D-263 anyway.
FINDING FOR T-1261 (the make target) make setup-venv is STALE.
It runs `python3 -m venv .venv` then `.venv/bin/pip install -e ".[dev]" --quiet`. Neither matches the venv on disk, and the pip line cannot work against it. This is precisely the venv split Q-124 named: the recorded procedure and the actual state have already diverged. T-1261 owns the make target and should replace it with the uv path rather than patch pip back in.
FINDING the project had never actually been installed.
Before this ticket site-packages held the DEPENDENCIES but no settled-reach-tooling dist-info and no .pth for it. That is consistent: with no __init__.py anywhere, an editable install had no package to expose. So this is the first commit where `import tooling` means anything.
DELIBERATE OMISSION err() was dropped.
The original description asked for out() to stdout and err() to stderr. The amendment made console the event emitter, and a raw-text err() would then be a SECOND output path competing with the event stream exactly the drift D-263 forbids by naming console the single one. event(level="error") and verdict(ok=False) cover every case err() would have. Flagging it because the word appears in the description and its absence is intentional, not an oversight.
CARRIED TO T-1249 config raises RuntimeError, not ReachError.
core/errors.py does not exist yet, so _validated raises RuntimeError with an instructional message that already follows the contract (it names the fix). One call site to convert when T-1249 lands; there is a comment at the raise saying so.
INCIDENTAL .gitignore.
Added *.egg-info/ (build output the install creates). Also added .venv/, which was only ignored via .git/info/exclude machine-local, so a fresh clone or a new worktree would not have ignored it.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 16:53:05.530', NULL, 'c7fc59092f1f13d14deab7207adf662b', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, 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.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time the exact failure already recorded for tea, where the fix was a bare name on PATH and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.
FINDING from T-1258 (2026-08-20): make setup-venv is already stale and this ticket owns the fix. It runs python3 -m venv .venv then .venv/bin/pip install -e .[dev] --quiet, but the venv on disk was created by uv (pyvenv.cfg records uv = 0.10.2) and therefore has NO pip at all, so that second line cannot work against it. Replace the target with the uv path rather than patching pip back in — uv is the direction of travel for this initiative anyway, and installing pip into a uv venv just to satisfy a stale recipe recreates the interpreter fork the ticket exists to remove. Related: the project itself had never actually been installed before T-1258 — site-packages held the dependencies but no settled-reach-tooling dist-info, which is consistent with there being no __init__.py to expose.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-20 16:53:12.179', NULL, 'fe96cd641763396858e412c5af53b982', 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;
+23 -8
View File
@@ -2546,7 +2546,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
| 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. |
| dependencies added | **`typer`** (transport) and **`pydantic`** (data shapes) join `pyproject.toml`. Neither may be imported at module level in `main.py`. |
| 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.**
@@ -2574,12 +2574,13 @@ tooling/
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
console.py # the event emitter — all output; nothing else
# prints. JSONL to stderr, rendered at the sink.
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/
garment/ godot/ blender/ jobs/ 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
@@ -2594,8 +2595,9 @@ tooling/
- `@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.
- **Pydantic is the data-shape vocabulary, and it lives in `domains/*/schemas.py`.** Available to every domain, including the gate domain. `main.py` and `core/` stay pydantic-free — not for the milliseconds, but because `main.py` is a router and `core/` is a substrate, and neither has data shapes of its own.
- **The import rule that actually matters: nothing heavy at module level in `main.py` or any `router.py`.** Measured 2026-08-20 in the repo venv: `scipy.ndimage` **275 ms**, `pydantic` 87 ms, `numpy` 72 ms, `PIL.Image` 29 ms. An entrypoint that eagerly imported the tree would pay **~460 ms before executing a line of its own** — and *that*, not pydantic, is what lazy registration exists to prevent. Heavy imports belong inside a service, or inside the function that needs them.
- **Enforced, not asked for:** no `typer`/`click` import outside `main.py` and `router.py`; no bare `print` outside `core/console.py`; no heavy import (numpy, scipy, PIL, pydantic) 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. The **import-graph** assertion is the one worth writing carefully — a wall-clock assertion is flaky on a loaded machine and tells you *that* something got slow rather than *what*, whereas asserting `sys.modules` after `reach --help` names the offender directly.
- **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.
@@ -2608,16 +2610,29 @@ 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.
**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.
- **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.
- **Detached runs append to a per-job log; tailing it is how you attach.** `.cache/reach/jobs/<id>.jsonl` (gitignored), `reach jobs log --follow` to tail, and reattach is **reading from a byte offset** — a caller can attach, drop off, and come back without losing anything. **Deliberately no daemon:** a session process that survives between calls is state that can be stale, orphaned, or wrong, and an append-only file buys ~90% of the value with no lifecycle to get wrong. If that proves insufficient, a session layer is a follow-on — to be *found* necessary, not assumed.
- **Streaming is additive to the failure contract, never a replacement for it.** A stream has no single moment of truth: a remedy emitted at line 400 of 900 is technically printed and practically invisible. **The verdict — outcome, exit code, and the command that fixes it — is still printed once, last, where it cannot be missed.** A stream that dissolved the summary would quietly undo the requirement this record cares most about.
- **The split follows the `core/` bound, and this is its first real test.** The primitives — emit, spawn, detach, redirect, record — are substrate and live in `core/`. The verbs `list`, `status`, `log`, `wait` have logic and state of their own, so they are a **domain**: `reach jobs …`. A job store in `core/` would be exactly the drift this record warns about.
- **Non-negotiable: a detached job's exit code must survive.** A runner that reports "started" and loses the failure is the exit-0 trap from the top of this record, relocated somewhere nothing is watching — which is worse. `reach jobs wait` exits with the job's code, and an unwaited failed job is visible in `reach jobs list`.
- **Where this overlaps the harness, prefer the harness.** Claude Code's `Bash` tool already has a background mode that solves the timeout *for agents*. What `reach` adds is for the callers with no such escape — a human terminal, a Makefile, a git hook — plus durable logs and job history. Scope it there rather than rebuilding what one caller already provides.
**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 acceptance criterion is output parity, not timing parity** *(amended 2026-08-20, same day: the original text set the ported gates a hard ceiling of ~104 ms — the summed single-sample cost of the three unconditional checks — and that was wrong in kind. It imported "do not regress" as a requirement without asking who pays.)* **Who pays is the pre-push hook, and almost nobody else.** On a push touching `server/` or `client/` the hook runs `cargo test` or the gdUnit4 suite — minutes — so a few hundred milliseconds is invisible. On a governance-only push the whole hook is about a second. No human and no loop consumes these often enough for 100 ms versus 400 ms to register. **So a ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast.**
**The budget that replaces it is a ceiling with headroom, not a ratchet:** a `reach` invocation should feel instant to a human — **under ~250 ms** — and the unconditional gate set should stay **comfortably under a second**. That is loose enough that pydantic, a subprocess, or a DB open are all affordable, and tight enough that nobody imports scipy at module level. **Lazy registration stays mandatory**, justified by the real threat rather than by parity: an eager entrypoint would pay ~460 ms of numpy + scipy + PIL + pydantic before executing a line of its own, and would grow every time a domain was added.
- **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).
- **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) — epics T-1248 the door, T-1249 decorators, T-1250 the domain move, T-1251 the gate family, T-1252 generators and numerics, T-1253 retiring old paths, T-1256 adoption, T-1257 the test surface, T-1264 streaming and jobs.
- **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).*
*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; amended same day twice — the timing-parity budget dropped for output parity plus a ~250 ms feels-instant ceiling, and a streaming execution model added: JSONL progress events to stderr, per-job logs tailed for reattach, no daemon; Rust rejected as R-014 because the friction is packaging, not language).*