Skip to content

Commit cda552e

Browse files
committed
session: one canonical 3.12 hook, generated into every checked-out repo
The hook that landed in ae0a440 fixed the interpreter for two repos by hand. Promote it to a policy artifact with the same shape as the never-rewrite-history text: one source (policy/session_start_hook.sh), N generated copies, a drift check so they cannot diverge. repos_sync.py --write installs it (and its settings.json registration) into every checked-out repo; --check fails on a missing, non-executable, edited or unregistered copy. The hook now covers the whole container rather than PATH alone: * a 3.12 venv first on PATH (python, python3, pip, pytest); * /usr/local/bin/python{,3} repointed at 3.12, so a subprocess with a scrubbed environment gets 3.12 too; * the uv-managed tools (black, flake8, mypy, poetry, pyright, ruff) rebuilt on 3.12 at their EXISTING versions — an unpinned rebuild moved mypy 1.19 -> 2.3, a lint change riding in on a Python upgrade. The update-alternatives links under /usr/bin are deliberately left on 3.11: some image tools (conan) are installed only there, and the three surfaces above already cover what a session types. A repo needing more than pytest + PyYAML declares it in .claude/session-python.txt, which the hook installs additively — so the hook itself stays byte-identical everywhere and the drift check stays meaningful. Measured in this container: cold 16.7s, warm 0.6s, every surface 3.12 (python/python3/pip/pytest/black/flake8/mypy/poetry/pyright/ruff), conan still working on 3.11, Mind 197 passed, Brain 494 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NcfSv6NpEuQcApGCZME3uW
1 parent e883c72 commit cda552e

4 files changed

Lines changed: 586 additions & 37 deletions

File tree

.claude/hooks/session-start.sh

Lines changed: 127 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,52 @@
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.
2143
set -euo pipefail
2244

2345
[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0
2446

2547
VENV="${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

policy/session_start_hook.sh

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#!/usr/bin/env bash
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.
6+
#
7+
# ---------------------------------------------------------------------------
8+
# Python 3.12 is the default in Claude Code web/mobile sessions.
9+
#
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.
32+
#
33+
# Remote-only (a local checkout keeps whatever the developer's shell provides),
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.
43+
set -euo pipefail
44+
45+
[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0
46+
47+
VENV="${PYAUTO_SESSION_VENV:-$HOME/.pyauto/session-py312}"
48+
BASE_DEPS=(pytest PyYAML)
49+
EXTRAS_FILE="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/session-python.txt"
50+
51+
# stderr, not stdout: a SessionStart hook's stdout is fed to the agent as
52+
# session context.
53+
log() { printf '[session-start] %s\n' "$*" >&2; }
54+
55+
is_py312() {
56+
[ -x "$1" ] && "$1" -c 'import sys; raise SystemExit(sys.version_info[:2] != (3, 12))' >/dev/null 2>&1
57+
}
58+
59+
find_base_python() {
60+
local candidate
61+
for candidate in /usr/bin/python3.12 /usr/local/bin/python3.12 \
62+
"$(command -v python3.12 2>/dev/null || true)"; do
63+
if [ -n "$candidate" ] && is_py312 "$candidate"; then
64+
printf '%s\n' "$candidate"
65+
return 0
66+
fi
67+
done
68+
# No system 3.12 (a future base image could drop it) — uv can fetch one.
69+
if command -v uv >/dev/null 2>&1; then
70+
log "no system python3.12; asking uv to install one"
71+
uv python install 3.12 >&2 || return 1
72+
candidate="$(uv python find 3.12 2>/dev/null || true)"
73+
if [ -n "$candidate" ] && is_py312 "$candidate"; then
74+
printf '%s\n' "$candidate"
75+
return 0
76+
fi
77+
fi
78+
return 1
79+
}
80+
81+
venv_ready() {
82+
is_py312 "$VENV/bin/python" \
83+
&& [ -x "$VENV/bin/pip" ] \
84+
&& "$VENV/bin/python" -c 'import pytest, yaml' >/dev/null 2>&1
85+
}
86+
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
94+
if ! base_python="$(find_base_python)"; then
95+
log "WARNING: no Python 3.12 in this container; PATH left unchanged"
96+
return 1
97+
fi
98+
log "building $VENV on $base_python"
99+
rm -rf "$VENV"
100+
mkdir -p "$(dirname "$VENV")"
101+
if command -v uv >/dev/null 2>&1; then
102+
# --seed puts pip inside the venv too, so `pip install` targets 3.12
103+
# rather than falling through to the container's 3.11 /usr/bin/pip.
104+
uv venv --seed --python "$base_python" "$VENV" >&2
105+
uv pip install --python "$VENV/bin/python" --quiet "${BASE_DEPS[@]}" >&2
106+
else
107+
"$base_python" -m venv "$VENV" >&2
108+
"$VENV/bin/python" -m pip install --quiet --upgrade pip >&2
109+
"$VENV/bin/python" -m pip install --quiet "${BASE_DEPS[@]}" >&2
110+
fi
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"
132+
fi
133+
}
134+
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+
}
175+
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

Comments
 (0)