Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
59 changes: 39 additions & 20 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,33 +38,52 @@ See [`docs/internals.md`](docs/internals.md) for the build pipeline, workspace
folder structure, config files, and `release.yml` details. Read it when
changing the pipeline itself, not by default.

## Remote sessions: knock on the door first
<!-- repos_sync:remote:begin -->
## 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.

- **This repo declares extra deps.** `.claude/session-python.txt` names
`ipynb-py-convert` and `Pillow` — the same set `tests.yml` installs. The
bootstrap installs them; without it 14 tests fail on a missing module or a
missing binary while CI on the same commit is green.

- **Run the suite in parallel.** `pytest-xdist` is a base dep of that bootstrap:
`python3 -m pytest -q -n auto`. This repo's 406 tests are 27s on one
core and 13s on four.
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.

- **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.
<!-- repos_sync:remote:end -->

<!-- repos_sync:history:begin -->
## Never rewrite history
Expand Down
Loading