diff --git a/AGENTS.md b/AGENTS.md index e3a2b9e4..f0515aaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,32 +90,52 @@ For the full workflow narrative, conventions, and registry schemas, read loosely-related changes, split into separate prompt files before issuing. 4. **`tmp/` is scratch.** Never commit anything under it. -## Running tests in a remote (web/mobile) session - -Two facts, both measured, both worth one line each: - -1. **Run the suite in parallel.** A remote container has 4 cores and the suites - are subprocess-heavy with no single slow test: PyAutoBrain's 554 tests take - 96s on one core and 28s on four. `pytest-xdist` is installed by the - session-start hook, so the command is just: - - ``` - python3 -m pytest -q -n auto - ``` - -2. **If `python3 -m pytest` or `pytest` misbehaves, the environment is stale, - not the code.** A session holding several organs registers no SessionStart - hook (Claude Code reads hooks from the project directory, which is the - repos' *parent*). Knock on the door directly, once, in the first turn: - - ``` - bash PyAutoMind/scripts/session_bootstrap.sh # fix it - bash PyAutoMind/scripts/session_bootstrap.sh --check # report only - ``` - - The symptom to recognise: 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. + +## Remote sessions (Claude Code on web and mobile) + +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. + +- **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 # ~10s cold, ~1s warm + bash PyAutoMind/scripts/session_bootstrap.sh --check # report only + ``` + + 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. + ## When you are asked to add a new prompt diff --git a/active.md b/active.md index 7e67cf98..a3234376 100644 --- a/active.md +++ b/active.md @@ -1,5 +1,21 @@ # Active Tasks +## board-github-data-seam +- issue: https://github.com/PyAutoLabs/PyAutoBrain/issues/303 (issued 2026-08-27) +- issued: 2026-08-27 +- prompt: active/board_without_gh_phase1_seam.md +- status: library-dev — not started; routed by /start_dev, no worktree claimed yet + (this session is remote: no ~/Code/PyAutoLabs-wt, so /start_library runs on the + machine that picks the task up). +- worktree: ~/Code/PyAutoLabs-wt/board-github-data-seam/ (to be created by /start_library) +- repos: PyAutoBrain +- phase: 1 of 2 — phase 2 is draft/feature/pyautobrain/board_without_gh_phase2_legs.md, + blocked-by this one; parent design + probe evidence in + draft/feature/pyautobrain/board_without_gh.md +- why: the board is the morning door and eleven legs are dark on the surface it is + read from. The 2026-08-27 probe closed the two cheaper options — $GH_TOKEN 403s + every repo path — so the injection seam is the design. + ## transformed-message-factor-gradient-unpack - issue: https://github.com/PyAutoLabs/PyAutoFit/issues/1501 (issued 2026-08-19) - issued: 2026-08-19 diff --git a/active/board_without_gh_phase1_seam.md b/active/board_without_gh_phase1_seam.md new file mode 100644 index 00000000..4b4d6417 --- /dev/null +++ b/active/board_without_gh_phase1_seam.md @@ -0,0 +1,47 @@ +# Board phase 1: the injection seam, proven on the overnight legs + +Type: feature +Target: pyautobrain +Repos: +- PyAutoBrain +Difficulty: medium +Autonomy: supervised +Priority: normal +Status: formalised +Filed: 2026-08-27 +Issued: 2026-08-27 +Parent: draft/feature/pyautobrain/board_without_gh.md + +Phase 1 of the board's gh-less work. The parent prompt holds the design, the +measurements and the 2026-08-27 probe that selected this option; read it first +and do not re-derive them. + +## Scope + +Build the seam and prove it on **one** leg family — the seven `overnight: +could not read /` rows, the largest block of the eleven. + +- `board/_board.py` accepts pre-fetched GitHub JSON (`--github-data `), + and `gh_json()` reads from it when present. The `gh` path is untouched, so a + dev box behaves exactly as today. +- The `/board` skill — which *is* the agent, and so is the only thing that can + call `mcp__github__*` — gathers that JSON when `command -v gh` fails, writes + it, and invokes the renderer with the flag. `_board.py` stays a pure + renderer; a subprocess cannot reach MCP tools and this is the whole reason + the seam exists. +- The file's shape is a documented contract in `board/AGENTS.md` (extend + "Reading the board in a remote session"), because it outlives whoever writes + it. + +## Guards + +- Tests drive the seam from a **fixture file**, never a live call. +- A leg with no injected data still reports `could not read` — the invariant + from the 2026-08-26 work: no leg may substitute an empty answer for an + unasked question. A green badge still requires a render that read everything. + +## Done when + +The overnight rows are live in a remote render, the degraded count drops by +seven, the dev-box path is byte-identical, and `tests/test_board_degraded.py` +plus the new fixture tests pass. diff --git a/dashboard.html b/dashboard.html index 89e4b446..a89f694d 100644 --- a/dashboard.html +++ b/dashboard.html @@ -204,7 +204,7 @@

PyAutoMindDashboard

Intent. Priority. Flow.

Every task the Mind is holding. Tap a task's 📋 and its /start_dev command is on your clipboard — paste it into a Claude Code chat to route Claude straight to that task. Recent is the same work by date — what has been happening rather than what to do next.

-
  • 2In flight
  • 3Parked
  • 5Planned
  • 132Backlog
+
  • 3In flight
  • 3Parked
  • 5Planned
  • 134Backlog

Last updated 2026-08-27. This page is generated from active/, draft/ and the registry files, so it is only as current as they are. dashboard_refresh.yml re-renders it on every push to main — that heals a stale page, but not a stale prompt: a task that shipped without its prompt advancing to complete/ keeps rendering here as pickable backlog. Reconciling those is the refresh below.

@PyAutoFit TransformedMessage.factor_gradient crashes on first callissue #1501 — issued 2026-08-19HOLD — do not start dev. Fix-or-delete hangs off the PyAutoFit#1498 logpdf-contract

+

Board phase 1: the injection seam, proven on the overnight legsissue #303 — issued 2026-08-27library-dev — not started; routed by /start_dev, no worktree claimed yet

Why does XLA CPU's Eigen thread pool wedge on the multi_dataset vmap…issue #1530 — issued 2026-08-27not started — research follow-up to the shipped jax-compile-stall epic

Parked markdown version

@@ -270,14 +271,15 @@

Planned

latent-nan-guard-honest-run — planned 2026-07-22

Backlog markdown version

-

132 filed prompts, not started — sorted most-pickable first (priority, then size). 24 of them belong to an epic and are listed only under Epics below.

+

134 filed prompts, not started — sorted most-pickable first (priority, then size). 24 of them belong to an epic and are listed only under Epics below.

-feature — 28 +feature — 29

Give PyAutoFit searches a seed — today no search can be made…✨ featureautofitmediumsupervisedmedium

+

Board phase 2: the remaining four legs onto the seam✨ featurepyautobrainsmallsupervisednormal

Brain board follow-ups: what real mornings surface✨ featurepyautobrainsmallsupervisednormal

Can create a list of InversionMatrix objects for each dataset✨ featureautoarraymediumsupervisednormal

Tune cluster-scale JOSS benchmarks toward their 5-minute targets✨ featureautolens_workspacemediumsupervisednormal

@@ -321,10 +323,11 @@

Backlog

Cluster-scale gradient-search benchmark (Prodigy vs Nautilus, point-source)🔬 researchautolens_profiling

-maintenance — 20 +maintenance — 21

Untrack the generated FITS test artifacts in autoarray🧹 maintenancelibrariessmallsupervisedmedium

autolens_workspace_developer rectangular experiments — Gut stash + rename🧹 maintenanceautolens_workspace_developersmallsupervisednormal

+

Mirror drifted library config keys into the workspace configs🧹 maintenanceworkspacessmallsupervisednormal

Un-park multi_galaxy/features/scaling_relation/slam once a capped run passes🧹 maintenanceworkspacessmallsupervisednormal

@@ -418,6 +421,12 @@

Backlog 2026-08-27 filed +Two organs are missing this session's fixes, and the hook still ships… + + + +2026-08-27 +filed Should TransformedMessage carry its own support, rather than the… @@ -446,24 +455,36 @@

Backlog +2026-08-27 +filed +Board phase 2: the remaining four legs onto the seam + + + +2026-08-27 +issued +Board phase 1: the injection seam, proven on the overnight legs + + + 2026-08-26 filed jax_profiling/gradient/imaging/pixelization.py: 3.2% of its pin move… - + 2026-08-26 filed The Brain board should work in a session that has no gh - + 2026-08-24 filed wiki-currency's --check-version gate rots on every library main merge - + 2026-08-24 filed interferometer/jax_grad/gradient.py: eager and jitted likelihoods… @@ -691,24 +712,6 @@

Backlog autolens_workspace_developer: broad stale-API rot (56 symbols, no CI) - -2026-08-04 -filed -Nightly release has been blocked 8 nights running — triage the streak - - - -2026-08-04 -filed -HowToLens ch4 tutorial 3: mask overlay is never actually drawn - - - -2026-08-03 -filed -Un-park imaging/features/scaling_relation/slam — the PyAutoArray#431… - -

Epics markdown version

diff --git a/dashboard.md b/dashboard.md index 290101bd..b3d11edd 100644 --- a/dashboard.md +++ b/dashboard.md @@ -42,10 +42,10 @@ anything you could not verify. | Where | Count | |-------|------:| -| [In flight](#in-flight) (`active/`) | 2 | +| [In flight](#in-flight) (`active/`) | 3 | | [Parked](#parked) (`parked.md`) | 3 | | [Planned](#planned) (`planned.md`) | 5 | -| [Backlog](#backlog) (`draft/`) | 132 | +| [Backlog](#backlog) (`draft/`) | 134 | ## Start here @@ -163,6 +163,14 @@ Issued — each has an open GitHub issue and usually a branch. The full record f
+
📋 Board phase 1: the injection seam, proven on the overnight legsissue #303 — issued 2026-08-27 — library-dev — not started; routed by /start_dev, no worktree claimed yet + +``` +/start_dev active/board_without_gh_phase1_seam.md +``` + +
+
📋 Why does XLA CPU's Eigen thread pool wedge on the multi_dataset vmap…issue #1530 — issued 2026-08-27 — not started — research follow-up to the shipped jax-compile-stall epic ``` @@ -255,10 +263,10 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned. ## Backlog -**132** filed prompts, not started. Each section is sorted most-pickable first (priority, then size). **24** of them belong to an epic and are listed only under [Epics](#epics) below. +**134** filed prompts, not started. Each section is sorted most-pickable first (priority, then size). **24** of them belong to an epic and are listed only under [Epics](#epics) below.
-feature — 28 +feature — 29
📋 Numba CPU likelihood phase 1: batched MGE convolution + operated-matrix caching — autoarray · medium · supervised · high @@ -300,6 +308,14 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
+
📋 Board phase 2: the remaining four legs onto the seam — pyautobrain · small · supervised · normal + +``` +/start_dev draft/feature/pyautobrain/board_without_gh_phase2_legs.md +``` + +
+
📋 Brain board follow-ups: what real mornings surface — pyautobrain · small · supervised · normal ``` @@ -612,7 +628,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
-maintenance — 20 +maintenance — 21
📋 Untrack the generated FITS test artifacts in autoarray — libraries · small · supervised · medium @@ -638,6 +654,14 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
+
📋 Two organs are missing this session's fixes, and the hook still ships… — organs · small · supervised · normal + +``` +/start_dev draft/maintenance/organs/session_fixes_reach_only_two_organs.md +``` + +
+
📋 Mirror drifted library config keys into the workspace configs — workspaces · small · supervised · normal ``` @@ -1173,20 +1197,23 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | Date | Event | Task | |------|-------|------| | 2026-08-27 | issued | Why does XLA CPU's Eigen thread pool wedge on the multi_dataset vmap… | +| 2026-08-27 | filed | Two organs are missing this session's fixes, and the hook still ships… | | 2026-08-27 | filed | Should TransformedMessage carry its own support, rather than the… | | 2026-08-27 | filed | One construction path for plane-bound lensing quantities | | 2026-08-27 | filed | Multi-plane time delays | | 2026-08-27 | filed | LensCalc NumPy Hessian step is too coarse for multi-plane tracers | | 2026-08-27 | filed | Cross-validate multi-plane ray tracing | +| 2026-08-27 | filed | Board phase 2: the remaining four legs onto the seam | +| 2026-08-27 | issued | Board phase 1: the injection seam, proven on the overnight legs | | 2026-08-26 | filed | jax_profiling/gradient/imaging/pixelization.py: 3.2% of its pin move… | -| 2026-08-26 | filed | The Brain board should work in a session that has no gh | -| 2026-08-24 | filed | wiki-currency's --check-version gate rots on every library main merge | -| 2026-08-24 | filed | interferometer/jax_grad/gradient.py: eager and jitted likelihoods… |
… 10 more (40 left) | Date | Event | Task | |------|-------|------| +| 2026-08-26 | filed | The Brain board should work in a session that has no gh | +| 2026-08-24 | filed | wiki-currency's --check-version gate rots on every library main merge | +| 2026-08-24 | filed | interferometer/jax_grad/gradient.py: eager and jitted likelihoods… | | 2026-08-24 | filed | Un-park multi_galaxy/features/scaling_relation/slam once a capped run… | | 2026-08-24 | filed | Induct PyAutoReduce into the PyAutoHands release machinery (date… | | 2026-08-24 | filed | Heart's local smoke runner cannot run any CTI workspace — no autocti… | @@ -1194,14 +1221,14 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-23 | filed | Phase 3: stop installing pynufft in Hands/Heart CI and PyAutoCTI… | | 2026-08-23 | filed | Brain board follow-ups: what real mornings surface | | 2026-08-22 | filed | smoke_install.sh's stale jax<0.7 pin — CI is on the right jax… | -| 2026-08-22 | filed | Untrack the generated FITS test artifacts in autoarray | -| 2026-08-22 | filed | The reconstruction noise map describes a different estimator than the… | -| 2026-08-22 | filed | Point-source JSON datasets record no resolution regime |
… 10 more (30 left) | Date | Event | Task | |------|-------|------| +| 2026-08-22 | filed | Untrack the generated FITS test artifacts in autoarray | +| 2026-08-22 | filed | The reconstruction noise map describes a different estimator than the… | +| 2026-08-22 | filed | Point-source JSON datasets record no resolution regime | | 2026-08-22 | filed | Is Intel macOS a supported platform, and what is the numpy-only… | | 2026-08-21 | filed | Rectangular mesh split: Bilinear (fast CPU default) vs RTU… | | 2026-08-20 | filed | Numba CPU likelihood phase 2: kernel-CDF numba fast path (the 49-88%… | @@ -1209,14 +1236,14 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-19 | filed | autofit_profiling: bootstrap the repo + general PyAutoFit profiling… | | 2026-08-19 | issued | @PyAutoFit TransformedMessage.factor_gradient crashes on first… | | 2026-08-19 | filed | Release board: local run_logs enrichment | -| 2026-08-19 | filed | RTD organism docs currency: Nerves page, organ-count drift, hands.md… | -| 2026-08-19 | filed | Deduplicate repos_sync.py's check/write pairs | -| 2026-08-19 | filed | Bug in autocti_workspace: the dataset_1d results/database example… |
… 10 more (20 left) | Date | Event | Task | |------|-------|------| +| 2026-08-19 | filed | RTD organism docs currency: Nerves page, organ-count drift, hands.md… | +| 2026-08-19 | filed | Deduplicate repos_sync.py's check/write pairs | +| 2026-08-19 | filed | Bug in autocti_workspace: the dataset_1d results/database example… | | 2026-08-18 | parked | single-source-density-design | | 2026-08-18 | parked | prior-message-collapse-design | | 2026-08-18 | filed | @PyAutoFit TransformedMessage.logpdf/pdf omit the transform… | @@ -1224,14 +1251,14 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-17 | filed | Teach repos_sync --write to stamp organ config surfaces | | 2026-08-14 | filed | Three jax_likelihood pins are stale by ~1.24e-4 and fail the smoke… | | 2026-08-09 | found | isothermal-ell-sph-oversampling-at-the-cusp | -| 2026-08-08 | parked | pyautoreduce-slacs1430-acs-comparison | -| 2026-08-08 | filed | Regenerate autolens_workspace markdown/ so the MGE pages show… | -| 2026-08-07 | filed | Regenerate setup_notebook-drifted notebooks in… |
… 10 more (10 left) | Date | Event | Task | |------|-------|------| +| 2026-08-08 | parked | pyautoreduce-slacs1430-acs-comparison | +| 2026-08-08 | filed | Regenerate autolens_workspace markdown/ so the MGE pages show… | +| 2026-08-07 | filed | Regenerate setup_notebook-drifted notebooks in… | | 2026-08-06 | filed | Triage: Convolver "No blurring_image provided" warning in canonical… | | 2026-08-06 | filed | Rewrite PyAutoCTI docs/api — 55 of 89 autosummary entries are dead | | 2026-08-06 | filed | Dependency-cap refresh 2026-08: safe bumps, astropy 8 decision, two… | @@ -1239,9 +1266,6 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-04 | filed | dataset/imaging/jwst_lw is untracked because the gitignore was never… | | 2026-08-04 | filed | cosmos_web_ring stores boolean masks as float64, wasting ~3.4 MB of… | | 2026-08-04 | filed | autolens_workspace_developer: broad stale-API rot (56 symbols, no CI) | -| 2026-08-04 | filed | Nightly release has been blocked 8 nights running — triage the streak | -| 2026-08-04 | filed | HowToLens ch4 tutorial 3: mask overlay is never actually drawn | -| 2026-08-03 | filed | Un-park imaging/features/scaling_relation/slam — the PyAutoArray#431… |
diff --git a/draft/feature/pyautobrain/board_without_gh.md b/draft/feature/pyautobrain/board_without_gh.md index cedb04a5..665a90cc 100644 --- a/draft/feature/pyautobrain/board_without_gh.md +++ b/draft/feature/pyautobrain/board_without_gh.md @@ -7,7 +7,8 @@ Repos: Difficulty: large Autonomy: supervised Priority: normal -Status: formalised +Status: phased +Split-into: draft/feature/pyautobrain/board_without_gh_phase1_seam.md, draft/feature/pyautobrain/board_without_gh_phase2_legs.md Filed: 2026-08-26 The board is the morning door, and on mobile it is mostly blind. Eleven of its @@ -74,6 +75,27 @@ Phase 0 is choosing, not coding. They are not equal in cost or in permanence. Prefer 2 if the probe says it works, else 1. Do not build 1 before running the probe — it is the more complex design and option 2 would obsolete it. +### The probe was run — 2026-08-27. Options 2 and 3 are dead; build 1. + +`$GH_TOKEN` is set, and from a subprocess it reaches exactly what it reached in +August: nothing repo-scoped. + +| Request (Bearer $GH_TOKEN, direct REST) | Result | +|---|---| +| `GET /user` | **200** — returns the login | +| `GET /rate_limit` | **200** | +| `GET /repos/PyAutoLabs/PyAutoBrain` | **403** — "GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App for this organization." | +| `GET /repos/PyAutoLabs/PyAutoBrain/actions/runs` | **403** — same | + +So option 2 needs an org-admin action that has not happened, and option 3 dies +on the same credential path without the install being worth trying. **Option 1 +(the injection seam) is the design**, and the header stays `Autonomy: +supervised` because the seam is a contract between the `/board` skill and +`_board.py` that outlives whoever writes it. + +Re-run the probe before starting anyway — it is four `curl`s, and the day an +admin connects the App, option 2 obsoletes the seam. + ## Already done (2026-08-26 — `complete/2026/08/mobile-performance-review.md`) Do not redo these; build on them. diff --git a/draft/feature/pyautobrain/board_without_gh_phase2_legs.md b/draft/feature/pyautobrain/board_without_gh_phase2_legs.md new file mode 100644 index 00000000..49e37529 --- /dev/null +++ b/draft/feature/pyautobrain/board_without_gh_phase2_legs.md @@ -0,0 +1,28 @@ +# Board phase 2: the remaining four legs onto the seam + +Type: feature +Target: pyautobrain +Repos: +- PyAutoBrain +Difficulty: small +Autonomy: supervised +Priority: normal +Status: formalised +Filed: 2026-08-27 +Parent: draft/feature/pyautobrain/board_without_gh.md +Blocked-by: draft/feature/pyautobrain/board_without_gh_phase1_seam.md + +Phase 2: port the four legs phase 1 did not cover onto the seam it built — +`versions: no stamps resolved` (`bin/version_drift.sh`), `community: scan +unavailable (exit 4)` (`agents/conductors/community/_community.py`), `resume: +pending-release PR search failed`, and `upkeep: open-issue count unavailable`. + +Two of these live in scripts rather than in `_board.py`, so the phase's real +question is whether each script grows its own `--github-data` seam or whether +the board gathers on their behalf. Answer it in the phase-1 contract's terms; +do not invent a second shape. + +## Done when + +A remote render reports **zero** unread legs, or names each remaining one with +a reason that is not "no `gh`" — the parent prompt's headline criterion. diff --git a/draft/maintenance/organs/session_fixes_reach_only_two_organs.md b/draft/maintenance/organs/session_fixes_reach_only_two_organs.md new file mode 100644 index 00000000..a5d02a87 --- /dev/null +++ b/draft/maintenance/organs/session_fixes_reach_only_two_organs.md @@ -0,0 +1,62 @@ +# Two organs are missing this session's fixes, and the hook still ships the uv bug + +Type: maintenance +Target: organs +Repos: +- PyAutoMind +- PyAutoHeart +- PyAutoHands +Difficulty: small +Autonomy: supervised +Priority: normal +Status: formalised +Filed: 2026-08-27 + +The 2026-08-27 mobile-workflow pass shipped two things that are, by +construction, only half-rolled-out. Both need a session with **all four organs +attached** — this one could not attach PyAutoHeart or PyAutoHands (`add_repo` +was refused), which is the same "a drift check is only as strong as the number +of repos your session can see" the last three passes each paid for once. + +## 1. Opt Heart and Hands into the generated remote-session block + +`PyAutoMind/policy/remote_sessions.md` is now the single source, written into +each repo's `AGENTS.md` between `` by +`repos_sync.py --write`, with a drift check. Mind and Brain carry the markers; +Heart and Hands still carry their own hand-written text, which is already a pass +behind (it says "bootstrap if pytest misbehaves", a trigger that no longer +fires). + +Add the markers to both, run `--write`, and confirm `--check` reports the block +for four repos rather than two. No gate order: `firewall_gate.yml` is +path-filtered to `scripts/repos_sync.py`, which this does not touch. + +## 2. Promote the uv-tool repair from the bootstrap into the hook + +`scripts/session_bootstrap.sh` gained `repair_uv_tools`: uv creates each tool +env's `bin/python` as a symlink to `/usr/local/bin/python3`, the hook replaces +that path with a wrapper that `exec`s the session venv, and the exec replaces +argv — so every tool env resolves `sys.prefix` to the venv and mypy, flake8, +black, poetry and pyright all die with `ModuleNotFoundError` naming themselves. +Measured 2026-08-27; `--check` called them all `3.12 OK` until the same commit +taught it to run each tool. + +The fix belongs in `policy/session_start_hook.sh`, beside `retool_uv_tools` +which creates the condition — the hook is what a **single-repo** session runs, +and that session never calls the bootstrap. It was left in `scripts/` because +the hook is generated into all four organs and two of them could not be +attached: changing it here would have made `firewall_gate.yml` red on repos this +session could not regenerate. + +So: move the function into the hook, regenerate all four copies, keep the +bootstrap's call (it is idempotent, and the bootstrap runs the hook anyway), and +keep the `--check` leg where it is. + +## Done when + +- `repos_sync.py --check` is clean with all four organs checked out, and the + remote-session block leg names four repos. +- A fresh remote session that runs only the SessionStart hook — no bootstrap + call — has a working `mypy`, `flake8` and `black`. +- The existing tests still pass, plus a hook-level version of + `test_a_tool_env_pointed_at_the_session_venv_is_repaired`. diff --git a/policy/remote_sessions.md b/policy/remote_sessions.md new file mode 100644 index 00000000..b1681983 --- /dev/null +++ b/policy/remote_sessions.md @@ -0,0 +1,44 @@ +## Remote sessions (Claude Code on web and mobile) + +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. + +- **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 # ~10s cold, ~1s warm + bash PyAutoMind/scripts/session_bootstrap.sh --check # report only + ``` + + 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. diff --git a/scripts/repos_sync.py b/scripts/repos_sync.py index d9b4e270..89c81a90 100644 --- a/scripts/repos_sync.py +++ b/scripts/repos_sync.py @@ -77,6 +77,8 @@ MAP_END = "" HISTORY_BEGIN = "" HISTORY_END = "" +REMOTE_BEGIN = "" +REMOTE_END = "" ORGANS_BEGIN = "" ORGANS_END = "" @@ -108,10 +110,32 @@ SESSION_HOOKS = "session-start hooks (generated)" +# What a Claude Code web/mobile session must know before its first command: +# bootstrap unconditionally, run the suite in parallel, and reach GitHub through +# the MCP tools because there is no `gh`. Same shape as the history policy — +# one source file, N generated copies, a drift check — and inline everywhere for +# the same reason: a session may hold any subset of the organs, and the session +# that needs this most (several organs, so no hook fires) is the one where only +# repo content is guaranteed to be loaded. +# +# It was hand-written per repo until three passes in a row found a copy that had +# not learned what the previous pass measured, and one pass found two organs +# still shipping a bug a third had fixed. The per-repo halves were the argument +# against generating it, so they are gone: no test counts, no timings, no +# declared deps — those rot, and a rotting number in every repo's context is +# worse than none. A repo's own dependencies stay in its +# `.claude/session-python.txt`, which the hook reads at run time. +REMOTE_SESSIONS_FILE = "policy/remote_sessions.md" + + def load_history_policy(mind_root): return (mind_root / HISTORY_POLICY_FILE).read_text().rstrip("\n") +def load_remote_sessions(mind_root): + return (mind_root / REMOTE_SESSIONS_FILE).read_text().rstrip("\n") + + def load_session_hook(mind_root): return (mind_root / SESSION_HOOK_FILE).read_text() @@ -532,6 +556,30 @@ def check_history_blocks(root, repos, hpol): return problems +def check_remote_blocks(root, repos, remote): + """Same contract as the history block: opt in with the markers, and the copy + must then be the canonical text verbatim. + + Opt-in, not mandatory, so a repo that has not added the markers is skipped + rather than failing a session (or a CI leg) that cannot see it. That is also + the honest state of this rollout — see the module comment on + REMOTE_SESSIONS_FILE.""" + problems = [] + for name in repos: + agents = root / name / "AGENTS.md" + if not agents.exists(): + continue + text = agents.read_text() + if REMOTE_BEGIN not in text or REMOTE_END not in text: + continue # opt-in: repo hasn't added the remote-session markers yet + if extract_block(text, REMOTE_BEGIN, REMOTE_END) != remote: + problems.append( + f"'{name}': remote-session block is stale — run " + f"`python3 PyAutoMind/scripts/repos_sync.py --write`" + ) + return problems + + # -------------------------------------------------------------------------- # CLAUDE.md → AGENTS.md pointer (repo hygiene) # -------------------------------------------------------------------------- @@ -1174,6 +1222,7 @@ def main(): smap = system_map(categories, repos) hpol = load_history_policy(mind_root) + remote = load_remote_sessions(mind_root) hook_text = load_session_hook(mind_root) if args.write: @@ -1191,6 +1240,8 @@ def main(): for name in repos: write_block(root / name / "AGENTS.md", hpol, HISTORY_BEGIN, HISTORY_END, required=False) + write_block(root / name / "AGENTS.md", remote, + REMOTE_BEGIN, REMOTE_END, required=False) for rel, bold in PUBLIC_TABLE_TARGETS: write_block(root / rel, organ_public_table(repos, bold=bold), ORGANS_BEGIN, ORGANS_END, required=False) @@ -1210,6 +1261,8 @@ def main(): lambda: check_map_blocks(root, repos, smap), "never-rewrite-history blocks (generated)": lambda: check_history_blocks(root, repos, hpol), + "remote-session blocks (generated)": + lambda: check_remote_blocks(root, repos, remote), "public front-door organ tables (generated)": lambda: check_public_tables(root, repos), "hub organism blurb (organs present)": lambda: check_hub_blurb(root, repos), diff --git a/scripts/session_bootstrap.sh b/scripts/session_bootstrap.sh index 20512b98..97b64d78 100755 --- a/scripts/session_bootstrap.sh +++ b/scripts/session_bootstrap.sh @@ -34,6 +34,12 @@ VENV="${PYAUTO_SESSION_VENV:-$HOME/.pyauto/session-py312}" say() { printf '[bootstrap] %s\n' "$*" >&2; } +# What a suite here needs before it can even collect: pytest itself, PyYAML +# (imported at collection time), and xdist (the `-n auto` every remote-session +# instruction names). One string, so the suite can lift it out and run it on a +# real interpreter rather than on a stand-in that cannot disagree with it. +IMPORT_PROBE='import importlib.util, sys; sys.stdout.write(" ".join(m for m in ("pytest", "yaml", "xdist") if not importlib.util.find_spec(m)))' + py312_ready() { [ -x "$VENV/bin/python" ] \ && "$VENV/bin/python" -c 'import sys; raise SystemExit(sys.version_info[:2] != (3, 12))' >/dev/null 2>&1 \ @@ -92,6 +98,53 @@ extras_state() { return "$rc" } +# The session's OTHER interpreters: uv's tool environments. +# +# uv creates each tool env with `bin/python` as a SYMLINK to whatever `python3` +# was at install time — here `/usr/local/bin/python3`. The hook then repoints +# that same path at the session venv, so every tool env's python now resolves +# its prefix to the VENV: `sys.prefix` is the venv, the tool's own +# site-packages is never on `sys.path`, and the console script dies with +# `ModuleNotFoundError: No module named 'flake8'` — with flake8 sitting +# installed two directories away. +# +# Measured 2026-08-27, post-bootstrap: mypy, flake8, black and poetry all dead +# this way; `ruff` survived (a native binary) and `pytest` survived (its shim +# points straight at the venv, which has pytest). `--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. +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)" + [ -x "$base" ] || base="$(command -v python3.12 2>/dev/null)" + [ -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 — see the system-default 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 + ln -sfn "$base" "$link" + prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" + if [ "$prefix" = "${tool%/}" ]; then + say "repointed $(basename "${tool%/}") at $base (it resolved into $VENV, not its own env)" + else + say "WARNING: $(basename "${tool%/}") still resolves to ${prefix:-nothing} — it will not run" + fi + done +} + shallow_repos() { local root repo out="" root="$(dirname "$MIND_DIR")" @@ -101,6 +154,14 @@ shallow_repos() { printf '%s' "${out# }" } +# A seam for the suite (and for a hand repair): run just this leg. The tools +# directory and the venv are both overridable, so the test can build a pair of +# real environments and reproduce the breakage exactly. +if [ "${1:-}" = "--repair-uv-tools" ]; then + repair_uv_tools + exit 0 +fi + if [ "${1:-}" = "--check" ]; then rc=0 if py312_ready; then @@ -153,13 +214,39 @@ if [ "${1:-}" = "--check" ]; then # this workspace's code are checked; a linter needs no deps. case "$tool" in python3|pytest) - if missing="$("$interp" -c 'import importlib,sys; sys.stdout.write(" ".join(m for m in ("pytest","yaml","xdist") if not importlib.util.find_spec(m)))' 2>/dev/null)" \ - && [ -n "$missing" ]; then + # Two failure modes, and they must not look alike. + # + # The probe RAN and named modules -> report them. The probe + # could not run at all -> that is a failure too, and used to + # be reported as success: the snippet said `import importlib` + # (which does not bind `importlib.util`), so on every real + # CPython it raised AttributeError, the `&&` short-circuited, + # and the OK line printed. Measured in a fresh container on + # 2026-08-27: `pytest: 3.12 OK` beside a `python3 -m pytest` + # that answered `No module named pytest`. The test that was + # meant to cover this drove a SHELL SCRIPT standing in for + # python, which answered happily — so the guard and the bug + # coexisted. Hence $IMPORT_PROBE, lifted out and run on a + # real interpreter by the suite. + if ! missing="$("$interp" -c "$IMPORT_PROBE" 2>/dev/null)"; then + say "$tool: 3.12 but the import probe would not run ($path) — treat this session as unbootstrapped"; rc=1 + continue + fi + if [ -n "$missing" ]; then say "$tool: 3.12 but cannot import: $missing ($path) — it will fail collection, not the code"; rc=1 continue fi ;; esac + # Version is necessary, not sufficient — and neither is an + # importable pytest. The last thing left is whether the tool RUNS: + # a uv tool env whose python resolves into the session venv reaches + # a 3.12 interpreter that cannot see the tool's own site-packages, + # and answers ModuleNotFoundError. Ask it. + if ! "$path" --version >/dev/null 2>&1; then + say "$tool: 3.12 but will not run ($path) — run this script with no arguments"; rc=1 + continue + fi say "$tool: 3.12 OK ($path)" else say "$tool: running on $real, not 3.12 ($path) — its results will disagree with CI"; rc=1 @@ -203,6 +290,11 @@ for repo in "$root"/*/; do CLAUDE_PROJECT_DIR="${repo%/}" "$hook" || say "WARNING: ${repo%/} hook failed" done +# The hook rebuilds uv's tools on 3.12; this repairs the link that rebuild +# cannot fix from inside, because the path it depends on is one the hook itself +# repoints afterwards. +repair_uv_tools + # Make the fix apply to THIS process tree too, not only to shells the session # starts after the env file is read. A caller that sources us gets the PATH; a # caller that runs us gets the message. diff --git a/tests/test_repos_sync_remote_block.py b/tests/test_repos_sync_remote_block.py new file mode 100644 index 00000000..d87b5776 --- /dev/null +++ b/tests/test_repos_sync_remote_block.py @@ -0,0 +1,101 @@ +"""The remote-session block is generated, so no organ can be born without it. + +Three passes of mobile-performance review in a row found a repo whose copy of +this guidance had not learned what the previous pass measured — and one of them +found two organs still shipping a bug an earlier pass had fixed, in a file that +looked like cosmetic drift and was executable. The text was hand-written per +repo because the copies genuinely differed: each named its own test count, its +own timings, its own declared deps. + +Those per-repo halves are the thing that rots, so the canonical text has none of +them, and this file pins that: a number in every repo's always-loaded context is +worse than no number as soon as it is wrong. + +Conventions, as in the sibling repos_sync tests: + +1. **Fictional fixtures only.** `tests/**` is KEEP-copied verbatim into the + public template, so nothing here names a real repository. +2. **Prove each leg FAILS.** A drift check that cannot fail is decoration. +""" + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import repos_sync # noqa: E402 + +MIND = Path(__file__).resolve().parents[1] +CANON = repos_sync.load_remote_sessions(MIND) + +REPOS = {"OrganCore": {"category": "organ"}, "LibAlpha": {"category": "library"}} + + +def _repo(root, name, body): + (root / name).mkdir(parents=True, exist_ok=True) + (root / name / "AGENTS.md").write_text(body) + + +def _blocked(text): + return f"# OrganCore\n\n{repos_sync.REMOTE_BEGIN}\n{text}\n{repos_sync.REMOTE_END}\n" + + +def test_a_repo_carrying_the_canonical_text_is_clean(tmp_path): + _repo(tmp_path, "OrganCore", _blocked(CANON)) + assert repos_sync.check_remote_blocks(tmp_path, REPOS, CANON) == [] + + +def test_a_stale_copy_is_drift(tmp_path): + stale = CANON.replace("unconditionally", "if pytest misbehaves") + assert stale != CANON + _repo(tmp_path, "OrganCore", _blocked(stale)) + problems = repos_sync.check_remote_blocks(tmp_path, REPOS, CANON) + assert len(problems) == 1 and "OrganCore" in problems[0] + assert "--write" in problems[0] + + +def test_a_repo_without_the_markers_is_skipped_not_failed(tmp_path): + """Opt-in: a session that cannot see an organ must not fail on its behalf. + + Half of the organs are attached in a typical remote session, and the check + runs there as well as in CI. + """ + _repo(tmp_path, "OrganCore", "# OrganCore\n\nno markers here\n") + assert repos_sync.check_remote_blocks(tmp_path, REPOS, CANON) == [] + + +def test_a_repo_that_is_not_checked_out_is_skipped(tmp_path): + assert repos_sync.check_remote_blocks(tmp_path, REPOS, CANON) == [] + + +def test_write_fills_the_block_and_is_idempotent(tmp_path): + _repo(tmp_path, "OrganCore", _blocked("stale text")) + agents = tmp_path / "OrganCore" / "AGENTS.md" + + repos_sync.write_block(agents, CANON, repos_sync.REMOTE_BEGIN, + repos_sync.REMOTE_END, required=False) + assert repos_sync.check_remote_blocks(tmp_path, REPOS, CANON) == [] + once = agents.read_text() + + repos_sync.write_block(agents, CANON, repos_sync.REMOTE_BEGIN, + repos_sync.REMOTE_END, required=False) + assert agents.read_text() == once + + +def test_the_canonical_text_carries_no_per_repo_numbers(): + """The reason it could not be generated before, removed rather than encoded. + + A test count or a timing is true of one repo on one day; generated into + every repo's always-loaded context, it is a confident wrong answer in the + other three the moment a suite grows. + """ + offenders = [line for line in CANON.splitlines() + if re.search(r"\b\d+\s*(tests|s on|cores? and)\b", line)] + assert not offenders, offenders + + +def test_the_text_names_the_three_things_a_session_must_do_first(): + for needle in ("session_bootstrap.sh", "-n auto", "GITHUB_ACCESS.md", + "is-ancestor"): + assert needle in CANON, f"the block no longer mentions {needle}" diff --git a/tests/test_session_bootstrap.py b/tests/test_session_bootstrap.py index 0bc10f8a..da4f253f 100644 --- a/tests/test_session_bootstrap.py +++ b/tests/test_session_bootstrap.py @@ -36,6 +36,7 @@ import json import os import subprocess +import sys from pathlib import Path import pytest @@ -461,6 +462,70 @@ def test_check_fails_a_pytest_that_has_the_right_version_but_cannot_import(tmp_p assert r.returncode != 0 +def _import_probe(): + """The probe as the script really spells it, lifted from the script.""" + for line in BOOTSTRAP.read_text().splitlines(): + if line.startswith("IMPORT_PROBE="): + return line.split("=", 1)[1].strip().strip("'") + raise AssertionError("session_bootstrap.sh no longer defines IMPORT_PROBE") + + +def test_the_import_probe_runs_on_a_real_interpreter(): + """The check above drove a shell script standing in for python. + + A stand-in answers whatever the fixture says, so it can never disagree with + the snippet — and the snippet was wrong: `import importlib` does not bind + `importlib.util`, so on every real CPython the probe raised AttributeError, + the `&&` short-circuited, and `--check` printed `3.12 OK`. Measured in a + fresh container on 2026-08-27, beside a `python3 -m pytest` that answered + `No module named pytest`. + + So this one runs the real string on the real interpreter. Sufficiency, not + plumbing: the fixture cannot be the thing under test. + """ + import importlib.util + import sys + + r = subprocess.run([sys.executable, "-c", _import_probe()], + capture_output=True, text=True, timeout=60) + assert r.returncode == 0, ( + "the probe cannot run, so --check silently skips it: " + r.stderr) + expected = {m for m in ("pytest", "yaml", "xdist") + if not importlib.util.find_spec(m)} + assert set(r.stdout.split()) == expected + + +def test_check_fails_when_the_import_probe_cannot_run(tmp_path): + """"Could not ask" is a failure, not a pass. + + The two outcomes used to be indistinguishable — both fell through to the OK + line — which is what let a broken probe read as a healthy session. + """ + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + interp = fake_bin / "fake-python3.12" + interp.write_text( + "#!/bin/sh\n" + "case \"$2\" in\n" + " *version_info*) echo '3.12' ;;\n" + " *find_spec*) exit 1 ;;\n" # the AttributeError case + "esac\n" + ) + interp.chmod(0o755) + shim = fake_bin / "pytest" + shim.write_text(f"#!{interp}\n") + shim.chmod(0o755) + + env = dict(os.environ) + env["PATH"] = f"{fake_bin}:{env['PATH']}" + r = subprocess.run(["bash", str(BOOTSTRAP), "--check"], capture_output=True, + text=True, env=env, timeout=180) + + assert "pytest: 3.12 OK" not in r.stderr, r.stderr + assert "import probe would not run" in r.stderr, r.stderr + assert r.returncode != 0 + + # -------------------------------------------------------------------------- # 6. Everything else the venv owns # @@ -679,3 +744,111 @@ def test_bootstrap_runs_every_sibling_repos_hook(tmp_path): assert r.returncode == 0, r.stderr assert (ran / "FakeHeart").exists(), r.stderr assert (ran / "FakeHands").exists(), r.stderr + + +# -------------------------------------------------------------------------- +# 7. uv's tool environments +# +# The session venv fix has a blast radius nobody costed. uv creates every tool +# env with `bin/python` as a symlink to whatever `python3` was at install time +# — `/usr/local/bin/python3` — and the hook then repoints THAT path at the +# session venv. So each tool env's interpreter resolves its prefix to the venv, +# the tool's own site-packages never reaches `sys.path`, and the console script +# dies on `ModuleNotFoundError` with the package installed two directories away. +# +# Measured 2026-08-27 in a bootstrapped session: mypy, flake8, black, poetry and +# pyright all dead this way, while `--check` reported each as `3.12 OK` — the +# interpreter they reach IS 3.12, it is just not theirs. A session then lints +# clean by not linting, and CI finds out. +# -------------------------------------------------------------------------- + + +def _venv(path): + subprocess.run([sys.executable, "-m", "venv", str(path)], check=True, + capture_output=True, timeout=180) + return path + + +def _prefix_of(python): + r = subprocess.run([str(python), "-c", "import sys; print(sys.prefix)"], + capture_output=True, text=True, timeout=60) + return r.stdout.strip() + + +def _repair(tools_dir, venv): + env = dict(os.environ) + env["PYAUTO_UV_TOOLS_DIR"] = str(tools_dir) + env["PYAUTO_SESSION_VENV"] = str(venv) + return subprocess.run(["bash", str(BOOTSTRAP), "--repair-uv-tools"], + capture_output=True, text=True, env=env, timeout=300) + + +def test_a_tool_env_pointed_at_the_session_venv_is_repaired(tmp_path): + """The exact breakage: a tool env resolving to somebody else's prefix.""" + session_venv = _venv(tmp_path / "session") + tools = tmp_path / "tools" + tool = _venv(tools / "widget") + + # The breakage needs the WRAPPER, not a bare symlink. CPython looks for + # `pyvenv.cfg` beside the executable as invoked, so a tool env whose python + # merely symlinks elsewhere still finds its own config and is fine. What + # loses it is `/usr/local/bin/python3` being a shell wrapper that `exec`s + # the venv (it is a wrapper precisely because a symlink there lost the venv + # — the other half of this same script): the exec replaces argv, and the + # tool env is gone. + hijacked = tmp_path / "usr-local-python3" + hijacked.write_text(f'#!/bin/sh\nexec "{session_venv}/bin/python" "$@"\n') + hijacked.chmod(0o755) + + link = tool / "bin" / "python" + link.unlink() + link.symlink_to(hijacked) + assert _prefix_of(link) == str(session_venv), "fixture did not reproduce it" + + r = _repair(tools, session_venv) + assert "repointed widget" in r.stderr, r.stderr + assert _prefix_of(link) == str(tool) + + +def test_a_healthy_tool_env_is_left_alone(tmp_path): + session_venv = _venv(tmp_path / "session") + tools = tmp_path / "tools" + tool = _venv(tools / "widget") + before = (tool / "bin" / "python").resolve() + + r = _repair(tools, session_venv) + assert "repointed" not in r.stderr, r.stderr + assert (tool / "bin" / "python").resolve() == before + + +def test_check_fails_a_tool_that_has_the_right_version_but_will_not_run(tmp_path): + """`3.12 OK` was printed for tools that answered ModuleNotFoundError. + + The version probe asks the interpreter; it never asks the tool. A tool that + cannot run is the same class of finding as one on the wrong interpreter — + both mean the session is not linting what CI lints. + """ + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + interp = fake_bin / "fake-python3.12" + interp.write_text( + "#!/bin/sh\n" + "case \"$2\" in\n" + " *version_info*) echo '3.12' ;;\n" + " *find_spec*) printf '' ;;\n" + " --version) exit 1 ;;\n" # ModuleNotFoundError, as measured + "esac\n" + ) + interp.chmod(0o755) + broken = fake_bin / "black" # a linter, not one of the two probed + broken.write_text(f"#!{interp}\nimport black\n") + broken.chmod(0o755) + + env = dict(os.environ) + env["PATH"] = f"{fake_bin}:{env['PATH']}" + r = subprocess.run(["bash", str(BOOTSTRAP), "--check"], capture_output=True, + text=True, env=env, timeout=180) + + assert "black: 3.12 OK" not in r.stderr, r.stderr + assert "will not run" in r.stderr, r.stderr + assert r.returncode != 0