diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index e9b8843ab..a836bfc58 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -1491,3 +1491,58 @@ AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above 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. RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.', NULL, '2026-08-25 05:31:01', '2026-08-25 05:31:01.531', '2026-08-25 05:31:01.531', NULL, '541b2744645b4c59cf155673bc9bd56c', 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', 'status', 'in_progress', 'done', NULL, '2026-08-25 05:31:18', '2026-08-25 05:31:18.128', '2026-08-25 05:31:18.128', NULL, 'a4023a6ba8146bebe9544ffa67c5c4c5', 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', 'status', 'backlog', 'in_progress', NULL, '2026-08-25 18:21:38', '2026-08-25 18:21:38.902', '2026-08-25 18:21:38.902', NULL, 'fb2c5ccaced37c745b4c1f485c36ba5f', 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', 'status', 'in_progress', 'in_progress', NULL, '2026-08-25 18:21:50', '2026-08-25 18:21:50.604', '2026-08-25 18:21:50.604', NULL, '772b04e032574beb900babd322fa4edf', 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. + +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.', '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. + +DONE 2026-08-30. `reach` is a bare name on PATH, verified in all three contexts with a negative control that actually fails. + +VERIFICATION — the part this ticket said was the ticket. +Baseline first, so the checks could fail: VIRTUAL_ENV was unset in agent Bash calls and `reach` was NOT on PATH. Then after `uv tool install --python 3.14 --editable .`: +(a) Stripped non-interactive shell — `env -i HOME=$HOME PATH=$HOME/.local/bin:/usr/bin:/bin bash -c ''reach check client-version''` — resolves and runs. No profile sourcing, no venv. +(b) REAL git hook, not a simulation — wrote a scratch hook and ran `git -c core.hooksPath=.cache/hooktest hook run pre-push`. Inside the hook process: VIRTUAL_ENV unset, `command -v reach` -> ~/.local/bin/reach, check ran, exit 0. Scratch hook deleted afterwards. +(c) Agent Bash call — resolves at ~/.local/bin/reach with VIRTUAL_ENV unset. +NEGATIVE CONTROL: `env -i PATH=/usr/bin:/bin bash -c ''command -v reach''` -> NOT-FOUND. So the three passes above are discriminating, not vacuous, and confirm reach is not merely resolving because a venv happens to be active. + +FOUND AND FIXED — a silent interpreter fork, which is this initiative''s own failure mode. +`uv tool install` without --python chose CPython 3.11 for the tool environment while .venv and system python3 are both 3.14.6. uv picks the lowest interpreter satisfying requires-python (">=3.11"). That is precisely the human-shell-versus-hook divergence D-263 exists to remove, just relocated: `reach` would have run on one interpreter and the test scripts on another, with different wheels for numpy/scipy/PIL, and any future 3.12+ syntax would break the tool while the venv stayed green. Reinstalled with --python 3.14 and introduced PYTHON_VERSION ?= 3.14 in the Makefile so setup-venv and install-reach share one pin. Verified the tool env is now lib/python3.14. + +make setup-venv REBUILT (the T-1258 finding) — and the first fix was wrong. +Replaced `python3 -m venv` + `.venv/bin/pip` with the uv path. First attempt used plain `uv venv`, which FAILS on an existing venv ("A virtual environment already exists at .venv. Use --clear to replace it") — so the target was not idempotent, while the python3 -m venv version it replaced had been. Caught by actually running it twice rather than dry-running it, which is how the old one rotted unnoticed in the first place. Fixed with --allow-existing. Ran it twice, both clean, and test_lazy_domains still passes against the rebuilt venv. + +DELIVERED +- make install-reach — wraps uv tool install with the pin and --editable, and SELF-CHECKS: fails loudly if `reach` is not on PATH afterwards, pointing at `uv tool update-shell`. Added to `make setup`. +- make reach-repoint — same command, but the situation has a name now. uv records the source path at install time, so an install made from a worktree keeps resolving there after the worktree is gone: `reach` still runs, edits in the main checkout do nothing, and there is NO error message. That silent-wrong-source failure is the reason this deserves its own target rather than a doc line. +- docs/DEVOPS.md — a Setup subsection covering why ~/.local/bin rather than .venv/bin, the bare-name rule, what --editable and --python buy, and the re-pointing trap. +- make test-tooling passes end to end (test-tooling: PASS). + +NEGATIVE ACCEPTANCE CRITERION — verified manually, NOT yet automated. +`grep -rn "python -m tooling\|python3 -m tooling\|\.venv/bin/reach" Makefile .config/hooks .claude docs` returns nothing. The automated version belongs to T-1257''s conformance suite and has been recorded there with the exact pattern; deliberately not written here to avoid taking that ticket''s work.', NULL, '2026-08-30 12:36:09', '2026-08-30 12:36:09.500', '2026-08-30 12:36:09.500', NULL, '330b54a59c258b39ea2c6e95519b4ddd', 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. + +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.', '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. + +FROM T-1261 (2026-08-30) — the negative-invocation check is yours, and it is currently unguarded. T-1261 verified by hand that nothing invokes the CLI through an interpreter or a path, but wrote no automated check, deliberately, so as not to take this ticket''s work. The exact command that passed: grep -rn for the patterns ''python -m tooling'', ''python3 -m tooling'' and ''.venv/bin/reach'' across Makefile, .config/hooks, .claude and docs — currently returns nothing. Automate that in the conformance suite. It matters most at T-1253, where 84 make targets become reach wrappers and one interpreter-prefixed invocation among them silently undoes the PATH guarantee. Also add PYTHON_VERSION drift detection while you are there: T-1261 found uv had installed the reach tool on CPython 3.11 while .venv and system python were 3.14, because uv picks the lowest interpreter satisfying requires-python. The Makefile now pins PYTHON_VERSION for both, but nothing detects the two environments diverging again — and that divergence is invisible until a version-specific bug appears in one and not the other.', NULL, '2026-08-30 12:36:17', '2026-08-30 12:36:17.515', '2026-08-30 12:36:17.515', NULL, '8371c59b1d68bce09567852d4ceb190e', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 1c769a9b2..2bbb5caf7 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -1538,3 +1538,112 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat 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. RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-25 05:31:01.531', NULL, '22e7c3529424eda485dc84e15561343a', 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..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. + +AMENDED 2026-08-23 from T-1259 — the base class in the description is WRONG and this ticket cannot be built as written. + +The description specifies LazyDomainGroup(click.Group). There is no top-level click package: typer VENDORS click as of 0.26.0 (as typer._click), and the docs are explicit that extracting the internal Click app is unsupported. Adding click as a direct dependency would be worse, not better — two Click implementations in one process, with typer.main.get_command returning a typer._click Command that a real click.Group cannot host. + +CORRECTED DESIGN, verified working in T-1259: +- The root is `cli = typer.Typer(cls=LazyDomainGroup, ...)` in tooling/main.py — already in place. +- LazyDomainGroup subclasses **typer.core.TyperGroup**, not click.Group. Confirmed TyperGroup''s MRO is (TyperGroup, typer._click.core.Command, ABC) and that cls= composes. +- Override list_commands, get_command and format_commands on that class. The format_commands trap is UNCHANGED and is still the heart of this ticket: the base implementation calls get_command for every subcommand to render short help, which would import every domain just to print --help. It must read short help from the DOMAINS declaration table instead. +- get_command resolves the invoked domain via importlib and converts the domain''s Typer sub-app with typer.main.get_command. +- The sys.modules acceptance test is unchanged and is still the point: after `reach --help`, no domain service module may appear in sys.modules. Extend it to assert `rich` and `pygments` are absent too — that is currently true and is worth pinning, since it is one careless kwarg away from regressing. + +ALREADY DONE IN T-1259, do not redo: +- main.py exists with the Typer root and a callback. The callback is REQUIRED: a Typer root with no commands and no callback raises RuntimeError("Could not get a command for this Typer instance") at build time, and lazy registration means no command is ever eager. +- rich_markup_mode=None is set and is load-bearing — worth 94 ms of a 168 ms empty --help, and it keeps rich and pygments off the import path. Do not remove it while adding the lazy group. + +OPEN QUESTION carried here: bare `reach` currently exits 2 (Click''s usage-error convention via no_args_is_help). Once domains are registered and a bare `reach` prints a useful domain list, decide whether that should exit 0 instead. It is not a failure, and D-263''s exit-code contract concerns failures carrying a remedy. + +BUDGET NOTE: the D-263 ceiling was raised to ~500 ms on 2026-08-23. Current cost of `reach --help` with zero domains is 80 ms. Stay smart about what loads; do not contort for tightness. + +DONE 2026-08-25. Lazy registration works and is guarded by a test that has been proven to fail. + +THE TRAP IS REAL — confirmed in the vendored source, not assumed from upstream Click. +typer.core.TyperGroup.format_commands loops over list_commands calling get_command on each, purely to read get_short_help_str off the loaded command. With lazy loading underneath that imports every domain in the registry to render --help, while the output looks entirely correct. Overridden to read short help from the DOMAINS table and never touch get_command. + +Note on the base class: TyperGroup''s MRO is (TyperGroup, Command, ABC, object) — typer''s vendored click has NO separate Group/MultiCommand class, everything is collapsed into Command, and TyperGroup defines list_commands/get_command/format_commands itself. So the overrides sit directly on TyperGroup as the amendment said. + +DELIVERED +- tooling/main.py: DOMAINS registry (name -> import target, short help) and LazyDomainGroup(TyperGroup) overriding list_commands, get_command, format_commands. _load_domain resolves via importlib and typer.main.get_command only for the domain actually invoked. +- tooling/domains/check/: __init__.py, router.py, service.py, schemas.py — the check domain, registered so the mechanism has a subject. +- tooling/test_lazy_domains.py, wired into make test-tooling. + +THE TEST WAS PROVEN TO FAIL, which is the only reason its pass means anything. Disabled the format_commands override and re-ran: it failed and named the cause, listing tooling.domains, .check, .check.router, .check.schemas and .check.service as leaked. Restored, green again. Three properties: (1) nothing under tooling.domains in sys.modules after --help; (2) no heavy third-party module (rich, pygments, numpy, scipy, pydantic, PIL); (3) POSITIVE CONTROL — invoking check client-version DOES import the service, without which properties 1 and 2 would also pass for a loader that is simply broken and imports nothing ever. The test also fails on an empty DOMAINS registry, which would otherwise satisfy everything vacuously. Probes run in a FRESH subprocess, since the assertion is about a module graph and an in-process check would inherit whatever the harness already imported. + +SCOPE OVERLAP WITH T-1262 — READ BEFORE PICKING THAT UP. +The acceptance test needs a real domain or it asserts nothing, so this ticket created the check domain rather than a stub (stub code that raises NotImplementedError would have been committed dead code). check/service.py now implements client_version for real, ported from tooling/check-client-version, and router.py has the client-version verb. What T-1262 STILL OWNS, unchanged: converting schemas.py from the stdlib dataclass to pydantic; byte-for-byte output-parity verification against the old script on both the OK and drift paths including exit codes; and the failure-path tests. T-1262 should be re-read as a hardening ticket rather than a port. + +TYPER GOTCHA worth knowing before every future domain: Typer COLLAPSES a single-command app into a bare command. reach check client-version failed with "Got unexpected extra argument(s) (client-version)" until the check router got an @app.callback(). Every domain router needs that callback until it has two or more verbs, and keeping it afterwards costs nothing and stops the shape changing under you. Same mechanism as the root callback from T-1259, different symptom. + +HELP AT EVERY LEVEL now works, which closes item (5) of the parent epic T-1248: reach --help lists domains under a "Domains" heading, reach check --help lists verbs under "Commands". + +TIMING: reach --help is 98 ms with one domain registered, against 80 ms with none. The registry itself is the only growth; the point of the override is that this stays flat as domains are added. D-263 ceiling is 500 ms. + +STILL OPEN, carried forward: bare `reach` exits 2 via no_args_is_help (Click''s usage-error convention). It now prints a genuinely useful domain list, so exit 0 is arguably more honest — it is not a failure. Left as-is rather than changed silently; belongs with T-1249''s error contract work.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-25 05:31:18.127', NULL, '0447ecc7be9154fb5b53cde3b97a39fc', 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.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-25 18:21:38.902', NULL, '728d72f471f44ecc6ab1d591fdefb577', 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.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-25 18:21:50.604', NULL, '3649abd33f7c0aaddd173c33b83dba0f', 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. + +DONE 2026-08-30. `reach` is a bare name on PATH, verified in all three contexts with a negative control that actually fails. + +VERIFICATION — the part this ticket said was the ticket. +Baseline first, so the checks could fail: VIRTUAL_ENV was unset in agent Bash calls and `reach` was NOT on PATH. Then after `uv tool install --python 3.14 --editable .`: +(a) Stripped non-interactive shell — `env -i HOME=$HOME PATH=$HOME/.local/bin:/usr/bin:/bin bash -c ''reach check client-version''` — resolves and runs. No profile sourcing, no venv. +(b) REAL git hook, not a simulation — wrote a scratch hook and ran `git -c core.hooksPath=.cache/hooktest hook run pre-push`. Inside the hook process: VIRTUAL_ENV unset, `command -v reach` -> ~/.local/bin/reach, check ran, exit 0. Scratch hook deleted afterwards. +(c) Agent Bash call — resolves at ~/.local/bin/reach with VIRTUAL_ENV unset. +NEGATIVE CONTROL: `env -i PATH=/usr/bin:/bin bash -c ''command -v reach''` -> NOT-FOUND. So the three passes above are discriminating, not vacuous, and confirm reach is not merely resolving because a venv happens to be active. + +FOUND AND FIXED — a silent interpreter fork, which is this initiative''s own failure mode. +`uv tool install` without --python chose CPython 3.11 for the tool environment while .venv and system python3 are both 3.14.6. uv picks the lowest interpreter satisfying requires-python (">=3.11"). That is precisely the human-shell-versus-hook divergence D-263 exists to remove, just relocated: `reach` would have run on one interpreter and the test scripts on another, with different wheels for numpy/scipy/PIL, and any future 3.12+ syntax would break the tool while the venv stayed green. Reinstalled with --python 3.14 and introduced PYTHON_VERSION ?= 3.14 in the Makefile so setup-venv and install-reach share one pin. Verified the tool env is now lib/python3.14. + +make setup-venv REBUILT (the T-1258 finding) — and the first fix was wrong. +Replaced `python3 -m venv` + `.venv/bin/pip` with the uv path. First attempt used plain `uv venv`, which FAILS on an existing venv ("A virtual environment already exists at .venv. Use --clear to replace it") — so the target was not idempotent, while the python3 -m venv version it replaced had been. Caught by actually running it twice rather than dry-running it, which is how the old one rotted unnoticed in the first place. Fixed with --allow-existing. Ran it twice, both clean, and test_lazy_domains still passes against the rebuilt venv. + +DELIVERED +- make install-reach — wraps uv tool install with the pin and --editable, and SELF-CHECKS: fails loudly if `reach` is not on PATH afterwards, pointing at `uv tool update-shell`. Added to `make setup`. +- make reach-repoint — same command, but the situation has a name now. uv records the source path at install time, so an install made from a worktree keeps resolving there after the worktree is gone: `reach` still runs, edits in the main checkout do nothing, and there is NO error message. That silent-wrong-source failure is the reason this deserves its own target rather than a doc line. +- docs/DEVOPS.md — a Setup subsection covering why ~/.local/bin rather than .venv/bin, the bare-name rule, what --editable and --python buy, and the re-pointing trap. +- make test-tooling passes end to end (test-tooling: PASS). + +NEGATIVE ACCEPTANCE CRITERION — verified manually, NOT yet automated. +`grep -rn "python -m tooling\|python3 -m tooling\|\.venv/bin/reach" Makefile .config/hooks .claude docs` returns nothing. The automated version belongs to T-1257''s conformance suite and has been recorded there with the exact pattern; deliberately not written here to avoid taking that ticket''s work.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-30 12:36:09.500', NULL, '4064e418e6602016571d63e8a0396d55', 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. + +FROM T-1261 (2026-08-30) — the negative-invocation check is yours, and it is currently unguarded. T-1261 verified by hand that nothing invokes the CLI through an interpreter or a path, but wrote no automated check, deliberately, so as not to take this ticket''s work. The exact command that passed: grep -rn for the patterns ''python -m tooling'', ''python3 -m tooling'' and ''.venv/bin/reach'' across Makefile, .config/hooks, .claude and docs — currently returns nothing. Automate that in the conformance suite. It matters most at T-1253, where 84 make targets become reach wrappers and one interpreter-prefixed invocation among them silently undoes the PATH guarantee. Also add PYTHON_VERSION drift detection while you are there: T-1261 found uv had installed the reach tool on CPython 3.11 while .venv and system python were 3.14, because uv picks the lowest interpreter satisfying requires-python. The Makefile now pins PYTHON_VERSION for both, but nothing detects the two environments diverging again — and that divergence is invisible until a version-specific bug appears in one and not the other.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:34.952', '2026-08-30 12:36:17.515', NULL, 'fd68c72105e921c0a7d1fefd4bd69240', 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; diff --git a/Makefile b/Makefile index 5e3e54e6f..8ca0c91aa 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build client server game atlas stop test test-tooling lint lint-python setup-venv ci ci-client ci-server clean \ + install-reach reach-repoint \ decisions-sync decisions-active decisions-validate \ validate-content check-fact-ids setup-hooks install-hooks \ audit deny atlas-verify economy-db regen-db check-systems-db check-client-version check-canvas-version \ @@ -17,6 +18,10 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) # --- Configuration --- GODOT_VERSION ?= 4.6 +# One interpreter for both .venv and the installed `reach` tool. Pinned rather +# than inferred: uv otherwise picks the lowest version satisfying +# requires-python, which forks the two environments silently (T-1261). +PYTHON_VERSION ?= 3.14 # Default help: @@ -24,6 +29,8 @@ help: @echo "" @echo " make setup Install dev dependencies (Rust, Godot, tooling, venv)" @echo " make setup-venv Create .venv and install Python tooling deps" + @echo " make install-reach Install the 'reach' CLI onto PATH (bare name, no venv needed)" + @echo " make reach-repoint Re-point 'reach' at THIS checkout (after worktree work)" @echo " make build Build client and server" @echo " make game Build and run the full game (server + client)" @echo " make atlas Standalone Atlas companion app — attach or spawn, read-only (D-254)" @@ -90,7 +97,7 @@ help: # --- Setup --- -setup: setup-rust setup-godot setup-tooling setup-venv setup-hooks decisions-sync +setup: setup-rust setup-godot setup-tooling setup-venv install-reach setup-hooks decisions-sync @echo "Dev environment ready." setup-rust: @@ -117,9 +124,37 @@ install-hooks: setup-hooks @echo "Hooks installed — pre-commit, pre-push, post-merge, post-checkout, post-rewrite are active." setup-venv: - @python3 -m venv .venv - @.venv/bin/pip install -e ".[dev]" --quiet + @command -v uv >/dev/null 2>&1 || { echo "Install uv: https://docs.astral.sh/uv/"; exit 1; } + @uv venv --python $(PYTHON_VERSION) --allow-existing .venv + @uv pip install --python .venv/bin/python -e ".[dev]" --quiet @echo "Venv ready at .venv — activate with: source .venv/bin/activate" + @echo "NOTE: activating is for running tests. The reach CLI does not need it — see 'make install-reach'." + +# The PATH guarantee (T-1261, D-263). A [project.scripts] entrypoint alone lands +# in .venv/bin, which is on PATH only while the venv is activated — and agents +# and git hooks never activate it. `uv tool install` puts the executable in +# ~/.local/bin instead, so `reach` is a bare name everywhere: interactive shell, +# git hook, agent Bash call. +# +# --editable so the checkout IS the source: edit tooling/, run reach, no reinstall. +# --python pinned so the tool and .venv run the SAME interpreter; left to itself +# uv picks the lowest version satisfying requires-python (it chose 3.11 while the +# venv was on 3.14), which is a silent interpreter fork of exactly the kind this +# initiative exists to remove. +install-reach: + @command -v uv >/dev/null 2>&1 || { echo "Install uv: https://docs.astral.sh/uv/"; exit 1; } + @uv tool install --python $(PYTHON_VERSION) --editable . --force + @command -v reach >/dev/null 2>&1 || { \ + echo "FAIL: 'reach' is not on PATH. Add ~/.local/bin to PATH (uv tool update-shell)."; exit 1; } + @echo "reach installed — verify with: reach --help" + +# Re-point reach at THIS checkout. uv records the source path at install time, so +# an install made from a worktree keeps resolving there after the worktree is +# removed, and edits in the main checkout then have no effect on a working `reach` +# — a confusing failure with no error message. Same command; the alias exists so +# the situation has a name. +reach-repoint: install-reach + @echo "reach now resolves to: $(CURDIR)" # --- Build --- diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 05c2344db..29f1474fa 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -50,6 +50,41 @@ GODOT_VERSION=4.4 make setup # Pin a specific Godot version Downloads and installs Godot to `~/bin/godot4`, installs Rust clippy + rustfmt, and verifies Python/curl/unzip. Skips the download if the correct version is already installed. The `GODOT_VERSION` variable defaults to `4.6` and can be overridden. +#### The `reach` CLI and PATH (T-1261, D-263) + +```bash +make install-reach # Put `reach` on PATH (run by `make setup`) +make reach-repoint # Re-point `reach` at THIS checkout +``` + +`reach` is installed with `uv tool install --editable`, which puts the +executable in `~/.local/bin` rather than `.venv/bin`. **That distinction is the +whole point.** A `[project.scripts]` entrypoint alone lands in `.venv/bin`, +which is on PATH only while the venv is activated — and agents and git hooks +never activate it. Installing as a uv tool makes `reach` a bare name in every +context: interactive shell, git hook, agent `Bash` call. + +**Always invoke it as the bare word `reach`.** Never `python -m tooling`, never +`.venv/bin/reach`, never an absolute path. Any of those breaks the +`Bash(reach *)` permission rule and prompts every time — the same failure `tea` +had, where the fix was a bare name on PATH — and reintroduces the interpreter +fork between a human shell and a hook. + +`--editable` means the checkout *is* the source: edit `tooling/`, run `reach`, +no reinstall. `--python` is pinned to `PYTHON_VERSION` so the tool and `.venv` +share one interpreter; left to itself uv picks the lowest version satisfying +`requires-python`, which silently forks the two environments. + +**Re-pointing.** uv records the source path at install time. An install made +from a worktree keeps resolving there after the worktree is deleted — `reach` +then still runs, but from a path that no longer exists or, worse, from a stale +copy, so edits in the main checkout appear to do nothing. There is no error +message for this. Run `make reach-repoint` from the checkout you want it to +follow. + +Activating `.venv` is still needed for running the test scripts directly; it is +not needed for `reach`. + ### Build ```bash