diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 521f222..f624d3e 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -22,7 +22,9 @@ # resolving PATH without this session's env (a subprocess with a scrubbed # environment, a `#!/usr/bin/env python3` script) also gets 3.12; # 3. the uv-managed tools rebuilt on 3.12 — mypy and flake8 read the -# interpreter's version, so on 3.11 they judged code against 3.11 rules. +# interpreter's version, so on 3.11 they judged code against 3.11 rules — +# and then repaired, because (2) is what breaks each tool env's own +# `bin/python`, and a rebuild cannot fix a link it is handed. # # What it deliberately does NOT touch: the update-alternatives links under # /usr/bin. Scripts with a literal `#!/usr/bin/python3` shebang follow those, @@ -344,6 +346,69 @@ retool_uv_tools() { done } +# 3b. The link uv's rebuild cannot fix from inside. +# +# uv creates each tool env with `bin/python` as a SYMLINK to whatever `python3` +# was at install time — here `/usr/local/bin/python3`, which leg 2 has already +# replaced with a wrapper that `exec`s the session venv. Every tool env's python +# then resolves its prefix to the VENV: `sys.prefix` is the venv, the tool's own +# site-packages never reaches `sys.path`, and the console script dies with +# `ModuleNotFoundError: No module named 'flake8'` — with flake8 sitting +# installed two directories away. +# +# TWO paths reach that state, which is why this runs after leg 3 rather than +# only on the envs leg 3 rebuilt: leg 3 rebuilds a 3.11 tool and hands the new +# env the hijacked path, and leg 2 separately breaks every PRE-EXISTING tool env +# that already pointed there and that leg 3 skips (`is_py312 … && continue`). +# Running here covers both, because leg 2 runs before leg 3. +# +# Measured 2026-08-27, post-bootstrap: mypy, flake8, black, poetry and pyright +# all dead this way; `ruff` survived (a native binary) and `pytest` survived +# (leg 2b points its shim straight at the venv, which has pytest). The +# bootstrap's `--check` called every one of them "3.12 OK", because the +# interpreter they reach IS 3.12 — it is simply the wrong one. A session then +# lints clean by not linting at all, and CI is the thing that finds out. +# +# The fix is one link: a venv's `bin/python` must resolve to a BASE interpreter, +# never to a path this hook hijacks. +# +# This lived in `scripts/session_bootstrap.sh` for one pass, which is the wrong +# home. The bootstrap is what a MULTI-repo session runs; a SINGLE-repo session +# registers this hook and never calls the bootstrap, so it got leg 2's breakage +# and none of the repair. +repair_uv_tools() { + local tools_dir base tool link prefix + base="$("$VENV/bin/python" -c 'import sys, os; print(os.path.join(sys.base_prefix, "bin", "python3.12"))' 2>/dev/null)" || base="" + [ -x "$base" ] || base="$(command -v python3.12 2>/dev/null)" || base="" + [ -x "$base" ] || return 0 + tools_dir="${PYAUTO_UV_TOOLS_DIR:-$(uv tool dir 2>/dev/null || echo "$HOME/.local/share/uv/tools")}" + [ -d "$tools_dir" ] || return 0 + for tool in "$tools_dir"/*/; do + link="${tool}bin/python" + [ -L "$link" ] || continue + # Ask the interpreter where it thinks it lives, rather than tracing the + # link: `/usr/local/bin/python3` is a WRAPPER SCRIPT (a symlink there + # would lose the venv — leg 2's whole note), so `readlink -f` stops at + # the wrapper and reports nothing about the venv behind it. sys.prefix + # is the outcome; anything else is the mechanism. + prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" || continue + [ -n "$prefix" ] || continue + [ "$prefix" = "${tool%/}" ] && continue # resolves to its own env: correct + # Non-fatal like every other leg: an unwritable tools dir is a warning, + # never a failed session start. + ln -sfn "$base" "$link" || { + log "WARNING: could not repoint $(basename "${tool%/}") at $base" + continue + } + prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" || prefix="" + if [ "$prefix" = "${tool%/}" ]; then + log "repointed $(basename "${tool%/}") at $base (it resolved into $VENV, not its own env)" + else + log "WARNING: $(basename "${tool%/}") still resolves to ${prefix:-nothing} — it will not run" + fi + done +} + # 4. Honest git history. # # A remote session clones shallow. `git merge-base --is-ancestor` then LIES @@ -461,6 +526,15 @@ if [ "${PYAUTO_SESSION_DEFINE_ONLY:-}" = "1" ]; then return 0 2>/dev/null || exit 0 fi +# Run leg 3b on its own, without a session start. The door +# `scripts/session_bootstrap.sh` knocks on after it has run every repo's hook — +# a subprocess rather than a source, so this script's `set -euo pipefail` never +# leaks into a caller that is contractually "a bootstrap, never a gate". +if [ "${1:-}" = "--repair-uv-tools" ]; then + repair_uv_tools + exit 0 +fi + ensure_full_clone install_workspace_settings @@ -475,6 +549,7 @@ if ensure_venv; then ensure_repo_extras point_system_default retool_uv_tools + repair_uv_tools point_pytest_at_venv point_venv_scripts_at_venv # Every repo in the session registers this hook, so the second copy must not diff --git a/AGENTS.md b/AGENTS.md index 58ef724..6db4519 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,28 +46,52 @@ framework, the <30s tick budget, how to add a check, and the hard rules (observer-only, colour coding, atomic state writes). Read it when changing Heart's own code, not by default. -## Remote sessions: knock on the door first + +## Remote sessions (Claude Code on web and mobile) -Measured in a web/mobile container, where this file is loaded and little else is. +Three facts, all measured in a web/mobile container, where this file is loaded +and little else is. They ride in every organ because a session may hold any +subset of them — and the session that needs this most is the one holding +several, which is exactly the session no hook fires in. -- **A session holding several organs registers no SessionStart hook.** Claude - Code reads project hooks from the project directory, which in that layout is - the repos' *parent*, not a repo — so none of the Python-3.12 setup runs and - the session uses the container's 3.11. Knock on the door yourself in the first - turn: +- **Bootstrap in the first turn, unconditionally** — before the first test + command, not as a remedy once something looks wrong: ``` - bash PyAutoMind/scripts/session_bootstrap.sh # fix it + bash PyAutoMind/scripts/session_bootstrap.sh # ~10s cold, ~1s warm bash PyAutoMind/scripts/session_bootstrap.sh --check # report only ``` - The symptom of skipping it: collection `ImportError`s naming `yaml`, or - `No module named pytest`. Both are the session resolving a pytest that is not - this workspace's — never a broken test module. + A session holding several organs registers no SessionStart hook — Claude Code + reads project hooks from the project directory, which in that layout is the + repos' *parent*, not a repo — so nothing has set this session up. It was once + phrased as a remedy keyed to `No module named pytest` or collection + `ImportError`s naming `yaml`; that symptom stopped appearing when the + container image moved to Python 3.12, while the environment is still wrong in + ways that read like a bad command rather than a stale session (`pytest -n + auto` → `unrecognized arguments: -n`). The bootstrap also **unshallows the + clones**: a remote session clones shallow, and `git merge-base --is-ancestor` + then answers "not an ancestor" for a commit whose ancestry is merely absent — + the answer the ship and close-out procedures act on when proving a branch + merged. -- **Run the suite in parallel.** `pytest-xdist` is a base dep of that bootstrap: - `python3 -m pytest -q -n auto`. This repo's 641 tests are 7.6s on one - core and 2.7s on four. +- **Then run the suite in parallel.** 4 cores, subprocess-heavy suites, no + single slow test: about 3.5x. `python3 -m pytest -q -n auto`, with + `pytest-xdist` supplied by the bootstrap above. + +- **There is no `gh`, and installing one does not help.** A remote session + reaches GitHub through the `mcp__github__*` tools, already scoped to the + session's repos. `gh` installs in two seconds and is a trap: it authenticates, + then 403s every repo-scoped call, because the egress proxy serves neither the + REST repo paths nor GraphQL beyond a pinned set of PR-review operations — a + binary that looks healthy and fails everything that matters. It also defeats + the surface probe, which keys off `gh auth status`. Read + `PyAutoBrain/skills/GITHUB_ACCESS.md` at the top of any run that touches + GitHub; it maps each `gh` operation onto its MCP tool. Spell that path from + the workspace root, as written: a multi-organ session is cwd'd at the repos' + *parent*, so a bare `skills/…` reads as a missing file rather than a missing + repo prefix. + ## Never rewrite history