11#! /usr/bin/env bash
2- # SessionStart hook — Python 3.12 is the default interpreter in Claude Code
3- # web/mobile sessions.
2+ # GENERATED — canonical source: PyAutoMind/policy/session_start_hook.sh
3+ # Installed into every checked-out repo as .claude/hooks/session-start.sh by
4+ # `python3 PyAutoMind/scripts/repos_sync.py --write`, and drift-checked by
5+ # `--check`. Edit the canonical file, never a copy.
46#
5- # The remote container ships `/usr/local/bin/python3 -> python3.11` and a
6- # uv-managed `pytest` built on 3.11 with no PyYAML, so a mobile session ran on
7- # an interpreter BELOW the floor the organism set for itself in the Python
8- # 3.12 floor campaign, and `pytest tests/` died at import on `yaml` before a
9- # single test ran.
7+ # ---------------------------------------------------------------------------
8+ # Python 3.12 is the default in Claude Code web/mobile sessions.
109#
11- # This hook builds one small 3.12 virtualenv per container and puts it first on
12- # PATH, so `python`, `python3`, `pip` and `pytest` all mean 3.12 with exactly
13- # the dependency set CI installs (pytest + PyYAML) — a mobile session and the
14- # `pytest (3.12)` CI leg then run the same interpreter and the same packages.
15- # 3.13 stays reachable as `/usr/bin/python3.13` for the two-version checks.
10+ # The remote container ships /usr/local/bin/python{,3} -> /usr/bin/python3.11,
11+ # a pip whose shebang is #!/usr/bin/python3, and a uv-managed tool set (pytest,
12+ # ruff, black, mypy, pyright, flake8, poetry) every one of which was built on
13+ # 3.11 — one minor version below the floor the organism set for itself in the
14+ # Python 3.12 floor campaign, and below every CI leg it runs. The image's own
15+ # `use-python 3.12` does not fix it: it moves the update-alternatives links
16+ # under /usr/bin, which /usr/local/bin/python{,3} then shadow.
17+ #
18+ # This hook makes a session 3.12 on three surfaces:
19+ #
20+ # 1. a 3.12 virtualenv first on PATH — python, python3, pip, pytest;
21+ # 2. the /usr/local/bin/python{,3} symlinks repointed at 3.12, so anything
22+ # resolving PATH without this session's env (a subprocess with a scrubbed
23+ # environment, a `#!/usr/bin/env python3` script) also gets 3.12;
24+ # 3. the uv-managed tools rebuilt on 3.12 — mypy and flake8 read the
25+ # interpreter's version, so on 3.11 they judged code against 3.11 rules.
26+ #
27+ # What it deliberately does NOT touch: the update-alternatives links under
28+ # /usr/bin. Scripts with a literal `#!/usr/bin/python3` shebang follow those,
29+ # and some of the image's own tools (conan) are installed for 3.11 only — a
30+ # flip there breaks them for no gain the three surfaces above don't already
31+ # give.
1632#
1733# Remote-only (a local checkout keeps whatever the developer's shell provides),
18- # idempotent (the venv is reused once the container image is cached), and
19- # non-blocking: if no 3.12 can be found the hook logs why and leaves PATH alone
20- # rather than failing the session start.
34+ # idempotent (everything is skipped once it already reads 3.12, so the second
35+ # repo's copy in the same session costs ~0.2s), and non-blocking: every step
36+ # degrades to a logged warning rather than failing the session start.
37+ #
38+ # Per-repo dependencies: a repo that needs more than pytest + PyYAML declares it
39+ # in .claude/session-python.txt — one pip argument per line (`-e .`, a package
40+ # spec, `-r requirements.txt`; `#` comments ignored). Installed additively into
41+ # the shared venv, so the file stays out of this generated hook and the hook
42+ # stays byte-identical in every repo.
2143set -euo pipefail
2244
2345[ " ${CLAUDE_CODE_REMOTE:- } " = " true" ] || exit 0
2446
2547VENV=" ${PYAUTO_SESSION_VENV:- $HOME / .pyauto/ session-py312} "
26- DEPS=(pytest PyYAML)
48+ BASE_DEPS=(pytest PyYAML)
49+ EXTRAS_FILE=" ${CLAUDE_PROJECT_DIR:- $PWD } /.claude/session-python.txt"
2750
2851# stderr, not stdout: a SessionStart hook's stdout is fed to the agent as
2952# session context.
@@ -61,12 +84,16 @@ venv_ready() {
6184 && " $VENV /bin/python" -c ' import pytest, yaml' > /dev/null 2>&1
6285}
6386
64- if venv_ready; then
65- log " reusing $VENV ($( " $VENV /bin/python" -V 2>&1 ) )"
66- else
87+ # 1. The interpreter a session types: python, python3, pip, pytest.
88+ ensure_venv () {
89+ local base_python
90+ if venv_ready; then
91+ log " reusing $VENV ($( " $VENV /bin/python" -V 2>&1 ) )"
92+ return 0
93+ fi
6794 if ! base_python=" $( find_base_python) " ; then
68- log " ERROR : no Python 3.12 available in this container; PATH left unchanged"
69- exit 0
95+ log " WARNING : no Python 3.12 in this container; PATH left unchanged"
96+ return 1
7097 fi
7198 log " building $VENV on $base_python "
7299 rm -rf " $VENV "
@@ -75,26 +102,89 @@ else
75102 # --seed puts pip inside the venv too, so `pip install` targets 3.12
76103 # rather than falling through to the container's 3.11 /usr/bin/pip.
77104 uv venv --seed --python " $base_python " " $VENV " >&2
78- uv pip install --python " $VENV /bin/python" --quiet " ${DEPS [@]} " >&2
105+ uv pip install --python " $VENV /bin/python" --quiet " ${BASE_DEPS [@]} " >&2
79106 else
80107 " $base_python " -m venv " $VENV " >&2
81108 " $VENV /bin/python" -m pip install --quiet --upgrade pip >&2
82- " $VENV /bin/python" -m pip install --quiet " ${DEPS [@]} " >&2
109+ " $VENV /bin/python" -m pip install --quiet " ${BASE_DEPS [@]} " >&2
83110 fi
84- if ! venv_ready; then
85- log " ERROR: could not build a working 3.12 venv at $VENV ; PATH left unchanged"
86- exit 0
111+ venv_ready || { log " WARNING: could not build a 3.12 venv at $VENV " ; return 1; }
112+ }
113+
114+ # This repo's own dependencies, if it declares any. Additive and marked, so a
115+ # session holding several repos installs each repo's set exactly once.
116+ ensure_repo_extras () {
117+ [ -r " $EXTRAS_FILE " ] || return 0
118+ local marker args=()
119+ marker=" $VENV /.extras-$( cksum < " $EXTRAS_FILE " | tr -d ' /' ) "
120+ [ -e " $marker " ] && return 0
121+ while IFS= read -r line; do
122+ line=" ${line%%#* } "
123+ line=" $( printf ' %s' " $line " | tr -d ' \r' | sed -e ' s/^[[:space:]]*//' -e ' s/[[:space:]]*$//' ) "
124+ [ -n " $line " ] && args+=(" $line " )
125+ done < " $EXTRAS_FILE "
126+ [ ${# args[@]} -gt 0 ] || return 0
127+ log " installing this repo's declared deps: ${args[*]} "
128+ if (cd " $( dirname " $( dirname " $EXTRAS_FILE " ) " ) " && " $VENV /bin/python" -m pip install --quiet " ${args[@]} " >&2 ); then
129+ : > " $marker "
130+ else
131+ log " WARNING: $EXTRAS_FILE install failed; continuing without it"
87132 fi
88- fi
133+ }
89134
90- # Both PyAutoMind and PyAutoBrain register this hook and a session usually has
91- # both checked out, so the second run must not prepend the venv twice.
92- if [ -n " ${CLAUDE_ENV_FILE:- } " ] && ! grep -qs ' PYAUTO_SESSION_PY312=' " $CLAUDE_ENV_FILE " ; then
93- {
94- echo " export PYAUTO_SESSION_PY312=\" $VENV \" "
95- echo " export VIRTUAL_ENV=\" $VENV \" "
96- echo " export PATH=\" $VENV /bin:\$ PATH\" "
97- } >> " $CLAUDE_ENV_FILE "
98- fi
135+ # 2. What PATH means without this session's env file.
136+ point_system_default () {
137+ local base_python=" $1 " link
138+ for link in /usr/local/bin/python /usr/local/bin/python3; do
139+ is_py312 " $link " && continue
140+ [ -w " $( dirname " $link " ) " ] || { log " WARNING: cannot rewrite $link (not writable)" ; continue ; }
141+ ln -sfn " $base_python " " $link "
142+ done
143+ is_py312 /usr/local/bin/python3 \
144+ && log " /usr/local/bin/python{,3} -> $base_python " \
145+ || log " WARNING: /usr/local/bin/python3 is still $( /usr/local/bin/python3 -V 2>&1 ) "
146+ }
147+
148+ # 3. The uv-managed tools — rebuilt on 3.12 only where they are not already.
149+ #
150+ # Pinned to the version already installed: this hook changes the INTERPRETER a
151+ # tool runs on, and nothing else. Unpinned, `uv tool install --force` fetches
152+ # the latest release, which quietly moved mypy across a major version (1.19 ->
153+ # 2.3) the first time this ran — a lint-result change nobody asked for, riding
154+ # in on a Python upgrade. Falls back to unpinned only if the pin cannot be
155+ # resolved for 3.12.
156+ retool_uv_tools () {
157+ command -v uv > /dev/null 2>&1 || return 0
158+ local tools_dir tool name version spec
159+ tools_dir=" $( uv tool dir 2> /dev/null || echo " $HOME /.local/share/uv/tools" ) "
160+ [ -d " $tools_dir " ] || return 0
161+ for tool in " $tools_dir " /* /; do
162+ name=" $( basename " $tool " ) "
163+ is_py312 " $tool /bin/python" && continue
164+ version=" $( uv tool list 2> /dev/null | awk -v n=" $name " ' $1 == n {print substr($2, 2); exit}' ) "
165+ spec=" $name "
166+ [ -n " $version " ] && spec=" $name ==$version "
167+ if uv tool install --python 3.12 --force " $spec " > /dev/null 2>&1 \
168+ || uv tool install --python 3.12 --force " $name " > /dev/null 2>&1 ; then
169+ log " rebuilt ${spec} on 3.12"
170+ else
171+ log " WARNING: could not rebuild $name on 3.12; it stays on $( " $tool /bin/python" -V 2>&1 ) "
172+ fi
173+ done
174+ }
99175
100- log " default python is now $( " $VENV /bin/python" -V 2>&1 ) ($VENV /bin)"
176+ if ensure_venv; then
177+ ensure_repo_extras
178+ point_system_default " $( readlink -f " $VENV /bin/python" ) "
179+ retool_uv_tools
180+ # Every repo in the session registers this hook, so the second copy must not
181+ # prepend the venv a second time.
182+ if [ -n " ${CLAUDE_ENV_FILE:- } " ] && ! grep -qs ' PYAUTO_SESSION_PY312=' " $CLAUDE_ENV_FILE " ; then
183+ {
184+ echo " export PYAUTO_SESSION_PY312=\" $VENV \" "
185+ echo " export VIRTUAL_ENV=\" $VENV \" "
186+ echo " export PATH=\" $VENV /bin:\$ PATH\" "
187+ } >> " $CLAUDE_ENV_FILE "
188+ fi
189+ log " default python is now $( " $VENV /bin/python" -V 2>&1 ) ($VENV /bin)"
190+ fi
0 commit comments