diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index f2d31545d..eaec734c6 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -46,7 +46,26 @@ set -euo pipefail VENV="${PYAUTO_SESSION_VENV:-$HOME/.pyauto/session-py312}" BASE_DEPS=(pytest PyYAML) -EXTRAS_FILE="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/session-python.txt" + +# Which checkout is this copy of the hook installed in? +# +# NOT $CLAUDE_PROJECT_DIR. That is the session's project directory, which equals +# the repo only when the session holds exactly ONE repo. A session scoped to +# several organs clones them side by side under the project directory +# (/home/user/PyAutoMind, /home/user/PyAutoBrain, ...), and then +# $CLAUDE_PROJECT_DIR is their parent — so reading session-python.txt from it +# found nothing, silently, in exactly the sessions that hold the most repos. +# +# Derive it from where this script actually is, in both the installed and the +# canonical location. +HOOK_SELF="$(readlink -f "$0")" +case "$(dirname "$HOOK_SELF")" in + */.claude/hooks) REPO_DIR="$(cd "$(dirname "$HOOK_SELF")/../.." && pwd)" ;; + */policy) REPO_DIR="$(cd "$(dirname "$HOOK_SELF")/.." && pwd)" ;; + *) REPO_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" ;; +esac +WORKSPACE_ROOT="$(dirname "$REPO_DIR")" +EXTRAS_FILE="$REPO_DIR/.claude/session-python.txt" # stderr, not stdout: a SessionStart hook's stdout is fed to the agent as # session context. @@ -173,6 +192,107 @@ retool_uv_tools() { done } +# 4. Honest git history. +# +# A remote session clones shallow. `git merge-base --is-ancestor` then LIES +# across the graft boundary: it reports "not an ancestor" for a commit whose +# ancestry is simply not in the clone, and the ship/close-out skills act on that +# answer. A completion record already logged this the hard way +# (complete/2026/08/status-sh-repos-missing-source.md, "environment note"). +# +# These repos are small (single-digit MB of .git), so unshallowing costs a +# couple of seconds once per container and removes a whole class of wrong +# answer. Bounded and non-fatal: a slow or blocked network leaves a shallow +# clone and a warning, never a failed session start. +ensure_full_clone() { + local repo + for repo in "$WORKSPACE_ROOT"/*/; do + [ -e "${repo}.git/shallow" ] || continue + log "unshallowing $(basename "$repo") (shallow clones make ancestry checks lie)" + if timeout 120 git -C "$repo" fetch --unshallow --quiet 2>/dev/null \ + || timeout 120 git -C "$repo" fetch --depth=2147483647 --quiet 2>/dev/null; then + log " $(basename "$repo"): full history ($(git -C "$repo" rev-list --count HEAD 2>/dev/null) commits)" + else + log " WARNING: $(basename "$repo") is still shallow — run 'git fetch --unshallow' before trusting any ancestry check" + fi + done +} + +# 5. Make the NEXT session in this container start correctly. +# +# Claude Code registers project hooks from the session's project directory. In a +# one-repo session that IS the repo, and `/.claude/settings.json` is found. +# In a session holding several organs the project directory is their parent, +# which is not a repo and has no `.claude/` — so none of the per-repo hooks are +# registered and none of this script runs. That is why a multi-repo session came +# up on the container's Python 3.11: not a broken hook, an unreachable one. +# +# A repo cannot ship a file into its own parent, so install it at run time. The +# settings we write fan out to every sibling repo's own hook, so the layout +# stays "each repo owns its hook" and this file stays generated-from-one-source. +# It takes effect on the next session start in this container. +install_workspace_settings() { + # Only in the multi-repo layout: when the project dir IS this repo, Claude + # Code already found the repo's own settings and there is nothing to add. + [ -n "${CLAUDE_PROJECT_DIR:-}" ] || return 0 + [ "$(readlink -f "$CLAUDE_PROJECT_DIR")" != "$(readlink -f "$REPO_DIR")" ] || return 0 + + local settings="$CLAUDE_PROJECT_DIR/.claude/settings.json" + local fanout="$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + [ -w "$CLAUDE_PROJECT_DIR" ] || { log "WARNING: $CLAUDE_PROJECT_DIR not writable; multi-repo sessions will keep skipping the hook"; return 0; } + + mkdir -p "$(dirname "$fanout")" + cat >"$fanout" <<'FANOUT' +#!/usr/bin/env bash +# GENERATED at session start by a PyAuto repo's own session-start hook. +# Runs every sibling repo's hook, because the workspace root is not a repo and +# therefore has no hook of its own. Each repo's hook is idempotent, so the +# second and later ones cost ~0.2s. +set -u +ROOT="$(cd "$(dirname "$(readlink -f "$0")")/../.." && pwd)" +for repo in "$ROOT"/*/; do + hook="${repo}.claude/hooks/session-start.sh" + [ -x "$hook" ] || continue + CLAUDE_PROJECT_DIR="${repo%/}" "$hook" || \ + printf '[session-start] WARNING: %s failed\n' "$hook" >&2 +done +FANOUT + chmod 0755 "$fanout" + + if [ ! -f "$settings" ]; then + cat >"$settings" <<'SETTINGS' +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} +SETTINGS + log "installed $settings — multi-repo sessions in this container now run the hook" + fi +} + +# Git history and hook reachability have nothing to do with the interpreter, so +# they run first and unconditionally: a container where the 3.12 venv cannot be +# built still wants honest ancestry and a hook that fires next session. +ensure_full_clone +install_workspace_settings + +# PYAUTO_SESSION_SKIP_PYTHON=1 stops here — the seam the hook's own tests use to +# exercise the legs above without building an interpreter. +if [ "${PYAUTO_SESSION_SKIP_PYTHON:-}" = "1" ]; then + log "PYAUTO_SESSION_SKIP_PYTHON=1 — leaving the interpreter alone" + exit 0 +fi + if ensure_venv; then ensure_repo_extras point_system_default "$(readlink -f "$VENV/bin/python")" @@ -184,6 +304,9 @@ if ensure_venv; then echo "export PYAUTO_SESSION_PY312=\"$VENV\"" echo "export VIRTUAL_ENV=\"$VENV\"" echo "export PATH=\"$VENV/bin:\$PATH\"" + # The workspace root, so the Brain's shell/python resolvers agree + # with the session instead of each re-deriving it. + echo "export PYAUTO_ROOT=\"$WORKSPACE_ROOT\"" } >>"$CLAUDE_ENV_FILE" fi log "default python is now $("$VENV/bin/python" -V 2>&1) ($VENV/bin)" diff --git a/dashboard.html b/dashboard.html index 7b75c71f0..861208993 100644 --- a/dashboard.html +++ b/dashboard.html @@ -185,7 +185,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.

- +

Last updated 2026-08-26. 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.

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

Backlog markdown version

-

133 filed prompts, not started — sorted most-pickable first (priority, then size). 25 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). 25 of them belong to an epic and are listed only under Epics below.

-feature — 27 +feature — 28 @@ -269,6 +269,7 @@

Backlog

Remote-MCP deployment tiers (2 + 3) for the results-inspector server✨ featureautofit_assistantlargehuman-requirednormal

Search settings-estimation + profiling infrastructure (n_starts / batch_size / n_batch)✨ featureautolens_profilinglargesupervisednormal

Adopt oversampled PSFs in the start-here dataset chain (option a)✨ featureautolens_workspacelargesupervisednormal

+

The Brain board should work in a session that has no gh✨ featurepyautobrainlargesupervisednormal

Follow-up to rectangular_adapt_cdf.md (issue #322) and Path A✨ featureautoarraytoo-largesupervisednormal

PIEMass.potential_2d_from: implement the missing lensing potential✨ featureautogalaxytoo-largesupervisednormal

autolens_jax_joss benchmark repo + real-data start_here pairing✨ featureautolens_jax_josstoo-largesupervisednormal

@@ -396,6 +397,12 @@

Backlog +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 @@ -443,7 +450,7 @@

Backlog pynufft removal: unswept downstream residue (1 hard break + stale… - + 2026-08-23 filed Phase 3: stop installing pynufft in Hands/Heart CI and PyAutoCTI… @@ -683,12 +690,6 @@

Backlog Cluster-scale gradient-search benchmark (Prodigy vs Nautilus… - -2026-07-30 -filed -plot coverage — follow-ups deferred from plot-coverage-gaps - -

Epics markdown version

diff --git a/dashboard.md b/dashboard.md index e7889c37a..c7020fb77 100644 --- a/dashboard.md +++ b/dashboard.md @@ -45,7 +45,7 @@ anything you could not verify. | [In flight](#in-flight) (`active/`) | 1 | | [Parked](#parked) (`parked.md`) | 3 | | [Planned](#planned) (`planned.md`) | 5 | -| [Backlog](#backlog) (`draft/`) | 133 | +| [Backlog](#backlog) (`draft/`) | 134 | ## Start here @@ -247,10 +247,10 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned. ## Backlog -**133** filed prompts, not started. Each section is sorted most-pickable first (priority, then size). **25** 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). **25** of them belong to an epic and are listed only under [Epics](#epics) below.
-feature — 27 +feature — 28
📋 Numba CPU likelihood phase 1: batched MGE convolution + operated-matrix caching — autoarray · medium · supervised · high @@ -380,6 +380,14 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
+
📋 The Brain board should work in a session that has no gh — pyautobrain · large · supervised · normal + +``` +/start_dev draft/feature/pyautobrain/board_without_gh.md +``` + +
+
📋 Follow-up to rectangular_adapt_cdf.md (issue #322) and Path A — autoarray · too-large · supervised · normal ``` @@ -1165,6 +1173,7 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | Date | Event | Task | |------|-------|------| | 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… | | 2026-08-24 | filed | euclid: CRLF has reached the HPC submit scripts AGENTS.md warns about | @@ -1173,12 +1182,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 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… | | 2026-08-23 | filed | pynufft removal: unswept downstream residue (1 hard break + stale… | -| 2026-08-23 | filed | Phase 3: stop installing pynufft in Hands/Heart CI and PyAutoCTI… |
… 10 more (40 left) | Date | Event | Task | |------|-------|------| +| 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 | @@ -1188,12 +1197,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 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%… | | 2026-08-20 | filed | Numba CPU likelihood phase 1: batched MGE convolution +… | -| 2026-08-19 | filed | autofit_profiling: bootstrap the repo + general PyAutoFit profiling… |
… 10 more (30 left) | Date | Event | Task | |------|-------|------| +| 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… | @@ -1203,12 +1212,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-18 | parked | prior-message-collapse-design | | 2026-08-18 | filed | @PyAutoFit TransformedMessage.logpdf/pdf omit the transform… | | 2026-08-17 | filed | Which other searches need prior-support handling — coverage audit… | -| 2026-08-17 | filed | Teach repos_sync --write to stamp organ config surfaces |
… 10 more (20 left) | Date | Event | Task | |------|-------|------| +| 2026-08-17 | filed | Teach repos_sync --write to stamp organ config surfaces | | 2026-08-16 | filed | LogGaussianPrior misreports its own support as (-inf, inf) | | 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 | @@ -1218,12 +1227,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 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… | -| 2026-08-05 | filed | Give PyAutoFit searches a seed — today no search can be made… |
… 10 more (10 left) | Date | Event | Task | |------|-------|------| +| 2026-08-05 | filed | Give PyAutoFit searches a seed — today no search can be made… | | 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) | @@ -1233,7 +1242,6 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-07-31 | filed | interferometer/start_here.py OOM in nightly release-validation… | | 2026-07-31 | filed | Quick-update plotting cost — minutes per update, and it is not JAX… | | 2026-07-31 | filed | Cluster-scale gradient-search benchmark (Prodigy vs Nautilus… | -| 2026-07-30 | filed | plot coverage — follow-ups deferred from plot-coverage-gaps |
diff --git a/draft/feature/pyautobrain/board_without_gh.md b/draft/feature/pyautobrain/board_without_gh.md new file mode 100644 index 000000000..4e300f4bc --- /dev/null +++ b/draft/feature/pyautobrain/board_without_gh.md @@ -0,0 +1,114 @@ +# The Brain board should work in a session that has no `gh` + +Type: feature +Target: pyautobrain +Repos: +- PyAutoBrain +Difficulty: large +Autonomy: supervised +Priority: normal +Status: formalised +Filed: 2026-08-26 + +The board is the morning door, and on mobile it is mostly blind. Eleven of its +legs read GitHub through `gh api`, and **a Claude Code remote session has no +`gh` at all**. The render is honest about it now (see "Already done" below) — it +says `could not read`, banners the degraded count, and refuses to show a green +badge — but honest-and-blind is still blind. A morning glance on a phone should +be worth taking. + +The one leg that was fixed by configuration rather than code proves the value: +adding `*.github.io` to the environment's network allowlist made the readiness +leg live, and it immediately surfaced a **Heart verdict RED · 45** that the +board had been rendering as "clear to work · brightgreen". The other eleven legs +are still dark for a reason no allowlist can fix. + +## What is actually true in a remote session + +Established by direct measurement on 2026-08-26; **do not re-derive these, and +do not assume they still hold — re-check the two marked (verify) first.** + +| Surface | State | +|---|---| +| `gh` binary | **Not installed.** Not in the image, and not installable as a fix — see the token row. | +| `mcp__github__*` tools | **Work.** This is the session's real GitHub surface. Available to the *agent* only. | +| `$GH_TOKEN` / `$GITHUB_TOKEN` | **Set, but refused.** A direct REST call with them returns `GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App for this organization.` (verify) | +| `api.github.com` | Reachable (200 unauthenticated), and in the default Trusted allowlist. | +| `git` push/fetch | Works — separate credential path via the GitHub proxy. | +| A subprocess calling MCP | **Impossible.** MCP tools are an agent capability; `_board.py` is a subprocess and cannot reach them. | + +That last row is the crux, and it is why "port the board to MCP" is not a +one-line substitution: `board/_board.py` cannot call these tools itself. + +## The eleven legs + +From a live remote render: 7 × `overnight: could not read /`, +plus `versions: no stamps resolved`, `community: scan unavailable (exit 4)`, +`resume: pending-release PR search failed`, `upkeep: open-issue count +unavailable`. All route through `gh_json()` in `board/_board.py`, or through +`bin/overnight_status.sh` / `bin/version_drift.sh` / +`agents/conductors/community/_community.py`. + +## Three ways to close it — decide before building + +Phase 0 is choosing, not coding. They are not equal in cost or in permanence. + +1. **An injection seam.** `_board.py` gains a way to accept pre-fetched GitHub + JSON (a `--github-data `, or stdin), and the `/board` skill — which + *is* the agent — gathers it via `mcp__github__*` when `command -v gh` fails, + then renders. `_board.py` stays a pure renderer and its `gh` path is + untouched for the dev box. Works today, needs no external permission, and is + testable with a fixture file. Costs a documented contract between the skill + and the script. +2. **Enable raw REST.** If an org admin connects the Claude GitHub App for + PyAutoLabs, the injected `$GH_TOKEN` may start working from subprocesses — + at which point `gh_json()` becomes a small REST helper with no seam, no + skill contract, and no MCP involvement, on *every* surface. Much the + cleanest end state **if** it works. Unverified: the refusal message above is + all the evidence there is, so probe it before betting the task on it. +3. **Install `gh` in the environment setup script.** Cheap to try, and probably + dead: `gh` under the proxy authenticates through the same injected-credential + path that option 2's probe tests, so it likely fails identically. Test it in + the same probe; a five-minute answer either way. + +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. + +## Already done (2026-08-26, branch `claude/mobile-performance-review-5qwv8r`) + +Do not redo these; build on them. + +- Rows distinguish *asked and got nothing* from *could not ask* + (`unreadable` on each overnight row) — `no runs` is no longer claimed by a + board that never asked. +- A `⚠️ Degraded render — N leg(s) could not be read` banner above the sections, + a headline qualified with `partial view (N legs unread)`, and a grey badge — + green is emitted only by a render that read everything. +- `_fetch_reason()` names an egress-policy block (403 on CONNECT) as one, rather + than as a retryable "unreachable". +- `fetch_heart_board()` falls back to a sibling checkout's local + `board/board.json` when the published copy is unreachable — **the pattern to + copy** for any leg whose data also exists locally. +- `bin/_gh.sh` (`have_gh` / `require_gh`) and `skills/GITHUB_ACCESS.md`, the + gh→MCP mapping every gh-driving skill now points at. +- `tests/test_board_degraded.py` pins all of the above. + +## Done when + +- A remote render reports **zero** unread legs, or names each remaining one with + a reason that is not "no `gh`". +- The dev-box path is unchanged — `gh` still used where present, same output. +- Whichever option is chosen, the *decision* and its evidence are written down + (`board/AGENTS.md` already has a "Reading the board in a remote session" + section; extend it). +- Tests cover the gh-less path with a fixture, not a live call. +- No leg silently substitutes an empty answer for an unasked question — the + invariant the 2026-08-26 work established, and the one worth protecting. + +## See also + +- `PyAutoBrain/board/AGENTS.md` → "Reading the board in a remote session" +- `PyAutoBrain/skills/GITHUB_ACCESS.md` → the gh→MCP operation mapping +- `draft/feature/pyautobrain/brain_board_follow_ups.md` → the small-nit catch-all + for the same board; this prompt is deliberately separate because it is not a + nit. diff --git a/draft/feature/pyautobrain/brain_board_follow_ups.md b/draft/feature/pyautobrain/brain_board_follow_ups.md index dc684bf85..9caab8818 100644 --- a/draft/feature/pyautobrain/brain_board_follow_ups.md +++ b/draft/feature/pyautobrain/brain_board_follow_ups.md @@ -36,3 +36,7 @@ Remaining candidates — pick up only when a real morning shows the need: `bin/version_drift.sh` read `config/policy.yaml board:` instead of their own copies (the keep-in-step comments then come out). 3. **Whatever a week of mornings surfaces** — append here before starting. + +Filed separately, because it is not a nit: **`board_without_gh.md`** — the +eleven legs that read GitHub through `gh api` are dark in a remote session, +which has no `gh`. A mobile morning is mostly blind until that is closed. diff --git a/policy/session_start_hook.sh b/policy/session_start_hook.sh index f2d31545d..eaec734c6 100755 --- a/policy/session_start_hook.sh +++ b/policy/session_start_hook.sh @@ -46,7 +46,26 @@ set -euo pipefail VENV="${PYAUTO_SESSION_VENV:-$HOME/.pyauto/session-py312}" BASE_DEPS=(pytest PyYAML) -EXTRAS_FILE="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/session-python.txt" + +# Which checkout is this copy of the hook installed in? +# +# NOT $CLAUDE_PROJECT_DIR. That is the session's project directory, which equals +# the repo only when the session holds exactly ONE repo. A session scoped to +# several organs clones them side by side under the project directory +# (/home/user/PyAutoMind, /home/user/PyAutoBrain, ...), and then +# $CLAUDE_PROJECT_DIR is their parent — so reading session-python.txt from it +# found nothing, silently, in exactly the sessions that hold the most repos. +# +# Derive it from where this script actually is, in both the installed and the +# canonical location. +HOOK_SELF="$(readlink -f "$0")" +case "$(dirname "$HOOK_SELF")" in + */.claude/hooks) REPO_DIR="$(cd "$(dirname "$HOOK_SELF")/../.." && pwd)" ;; + */policy) REPO_DIR="$(cd "$(dirname "$HOOK_SELF")/.." && pwd)" ;; + *) REPO_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" ;; +esac +WORKSPACE_ROOT="$(dirname "$REPO_DIR")" +EXTRAS_FILE="$REPO_DIR/.claude/session-python.txt" # stderr, not stdout: a SessionStart hook's stdout is fed to the agent as # session context. @@ -173,6 +192,107 @@ retool_uv_tools() { done } +# 4. Honest git history. +# +# A remote session clones shallow. `git merge-base --is-ancestor` then LIES +# across the graft boundary: it reports "not an ancestor" for a commit whose +# ancestry is simply not in the clone, and the ship/close-out skills act on that +# answer. A completion record already logged this the hard way +# (complete/2026/08/status-sh-repos-missing-source.md, "environment note"). +# +# These repos are small (single-digit MB of .git), so unshallowing costs a +# couple of seconds once per container and removes a whole class of wrong +# answer. Bounded and non-fatal: a slow or blocked network leaves a shallow +# clone and a warning, never a failed session start. +ensure_full_clone() { + local repo + for repo in "$WORKSPACE_ROOT"/*/; do + [ -e "${repo}.git/shallow" ] || continue + log "unshallowing $(basename "$repo") (shallow clones make ancestry checks lie)" + if timeout 120 git -C "$repo" fetch --unshallow --quiet 2>/dev/null \ + || timeout 120 git -C "$repo" fetch --depth=2147483647 --quiet 2>/dev/null; then + log " $(basename "$repo"): full history ($(git -C "$repo" rev-list --count HEAD 2>/dev/null) commits)" + else + log " WARNING: $(basename "$repo") is still shallow — run 'git fetch --unshallow' before trusting any ancestry check" + fi + done +} + +# 5. Make the NEXT session in this container start correctly. +# +# Claude Code registers project hooks from the session's project directory. In a +# one-repo session that IS the repo, and `/.claude/settings.json` is found. +# In a session holding several organs the project directory is their parent, +# which is not a repo and has no `.claude/` — so none of the per-repo hooks are +# registered and none of this script runs. That is why a multi-repo session came +# up on the container's Python 3.11: not a broken hook, an unreachable one. +# +# A repo cannot ship a file into its own parent, so install it at run time. The +# settings we write fan out to every sibling repo's own hook, so the layout +# stays "each repo owns its hook" and this file stays generated-from-one-source. +# It takes effect on the next session start in this container. +install_workspace_settings() { + # Only in the multi-repo layout: when the project dir IS this repo, Claude + # Code already found the repo's own settings and there is nothing to add. + [ -n "${CLAUDE_PROJECT_DIR:-}" ] || return 0 + [ "$(readlink -f "$CLAUDE_PROJECT_DIR")" != "$(readlink -f "$REPO_DIR")" ] || return 0 + + local settings="$CLAUDE_PROJECT_DIR/.claude/settings.json" + local fanout="$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + [ -w "$CLAUDE_PROJECT_DIR" ] || { log "WARNING: $CLAUDE_PROJECT_DIR not writable; multi-repo sessions will keep skipping the hook"; return 0; } + + mkdir -p "$(dirname "$fanout")" + cat >"$fanout" <<'FANOUT' +#!/usr/bin/env bash +# GENERATED at session start by a PyAuto repo's own session-start hook. +# Runs every sibling repo's hook, because the workspace root is not a repo and +# therefore has no hook of its own. Each repo's hook is idempotent, so the +# second and later ones cost ~0.2s. +set -u +ROOT="$(cd "$(dirname "$(readlink -f "$0")")/../.." && pwd)" +for repo in "$ROOT"/*/; do + hook="${repo}.claude/hooks/session-start.sh" + [ -x "$hook" ] || continue + CLAUDE_PROJECT_DIR="${repo%/}" "$hook" || \ + printf '[session-start] WARNING: %s failed\n' "$hook" >&2 +done +FANOUT + chmod 0755 "$fanout" + + if [ ! -f "$settings" ]; then + cat >"$settings" <<'SETTINGS' +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} +SETTINGS + log "installed $settings — multi-repo sessions in this container now run the hook" + fi +} + +# Git history and hook reachability have nothing to do with the interpreter, so +# they run first and unconditionally: a container where the 3.12 venv cannot be +# built still wants honest ancestry and a hook that fires next session. +ensure_full_clone +install_workspace_settings + +# PYAUTO_SESSION_SKIP_PYTHON=1 stops here — the seam the hook's own tests use to +# exercise the legs above without building an interpreter. +if [ "${PYAUTO_SESSION_SKIP_PYTHON:-}" = "1" ]; then + log "PYAUTO_SESSION_SKIP_PYTHON=1 — leaving the interpreter alone" + exit 0 +fi + if ensure_venv; then ensure_repo_extras point_system_default "$(readlink -f "$VENV/bin/python")" @@ -184,6 +304,9 @@ if ensure_venv; then echo "export PYAUTO_SESSION_PY312=\"$VENV\"" echo "export VIRTUAL_ENV=\"$VENV\"" echo "export PATH=\"$VENV/bin:\$PATH\"" + # The workspace root, so the Brain's shell/python resolvers agree + # with the session instead of each re-deriving it. + echo "export PYAUTO_ROOT=\"$WORKSPACE_ROOT\"" } >>"$CLAUDE_ENV_FILE" fi log "default python is now $("$VENV/bin/python" -V 2>&1) ($VENV/bin)" diff --git a/scripts/repos_sync.py b/scripts/repos_sync.py index f26b0c235..d9b4e270d 100644 --- a/scripts/repos_sync.py +++ b/scripts/repos_sync.py @@ -821,19 +821,23 @@ def check_structure_lints(root, repos): # a listed file, or ANY instance fact in an unlisted file, is drift. Phase-3 # config extraction shrinks this list; never grow it casually — a new entry # means a new file an adopting fork must rewrite. +# +# 2026-08-26: seven entries dropped or narrowed. Each named the owner only +# because it hardcoded a workspace root under $HOME; they now delegate to +# bin/_pyauto_root.sh / agents/_pyauto_root.py, which name no absolute path +# at all. The two resolvers carry no entry here, and must not need one. FIREWALL_ALLOWLIST = { - "PyAutoBrain/agents/_common.sh": {"PyAutoLabs"}, "PyAutoBrain/agents/conductors/bug/_bug.py": {"PyAutoArray"}, "PyAutoBrain/agents/conductors/bug/bug.sh": {"PyAutoLabs"}, "PyAutoBrain/agents/conductors/health/health.sh": {"PyAutoNerves"}, - "PyAutoBrain/agents/conductors/hygiene/_hygiene_config.py": {"PyAutoArray", "PyAutoCTI", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, + "PyAutoBrain/agents/conductors/hygiene/_hygiene_config.py": {"PyAutoArray", "PyAutoCTI", "PyAutoFit", "PyAutoGalaxy", "PyAutoLens", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, "PyAutoBrain/agents/conductors/hygiene/_hygiene_optdeps.py": {"HowToFit", "HowToGalaxy", "HowToLens", "autocti_workspace", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, "PyAutoBrain/agents/conductors/hygiene/_hygiene_refs.py": {"PyAutoArray", "PyAutoCTI", "PyAutoFit", "PyAutoGalaxy", "PyAutoLens", "autolens_workspace"}, # hygiene.sh and _hygiene_repos.py carry NO entry on purpose: the conductor # now derives its repo sets from the body map, so it names no instance fact # at all. Re-adding an entry here would re-permit the drift that # check_hygiene_coverage exists to catch. - "PyAutoBrain/agents/conductors/clone/_clone.py": {"HowToFit", "PyAutoFit", "PyAutoLabs", "PyAutoLens", "autofit_assistant", "autofit_workspace", "autolens_assistant"}, + "PyAutoBrain/agents/conductors/clone/_clone.py": {"HowToFit", "PyAutoFit", "PyAutoLens", "autofit_assistant", "autofit_workspace", "autolens_assistant"}, "PyAutoBrain/agents/conductors/clone/clone.sh": {"HowToFit", "PyAutoFit", "autofit_workspace", "autolens_assistant"}, "PyAutoBrain/agents/conductors/community/_community.py": {"Jammy2211", "PyAutoLabs"}, # autofit_workspace: the `_upstream_noise` docstring cites measured noise @@ -841,16 +845,14 @@ def check_structure_lints(root, repos): # file-spread threshold — the names ARE the finding; the code itself # derives its repo sets from the body map. "PyAutoBrain/agents/conductors/intake/_intake.py": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLens", "autofit_workspace", "autolens_workspace"}, - "PyAutoBrain/agents/conductors/profiling/_profiling.py": {"PyAutoLabs", "autolens_profiling"}, + "PyAutoBrain/agents/conductors/profiling/_profiling.py": {"autolens_profiling"}, "PyAutoBrain/agents/conductors/profiling/profiling.sh": {"autolens_profiling"}, "PyAutoBrain/agents/conductors/release/nightly.sh": {"PyAutoLabs", "PyAutoLens"}, "PyAutoBrain/agents/conductors/release/rehearse.sh": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens"}, "PyAutoBrain/agents/conductors/release/validate.sh": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens"}, - "PyAutoBrain/agents/conductors/workspace/_workspace.py": {"HowToFit", "HowToGalaxy", "HowToLens", "PyAutoLabs", "PyAutoReduce", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace", "autoreduce_workspace"}, + "PyAutoBrain/agents/conductors/workspace/_workspace.py": {"HowToFit", "HowToGalaxy", "HowToLens", "PyAutoReduce", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace", "autoreduce_workspace"}, "PyAutoBrain/agents/faculties/memory/_memory.py": {"autolens_assistant"}, "PyAutoBrain/agents/faculties/memory/memory.sh": {"autolens_assistant"}, - "PyAutoBrain/agents/faculties/review/_review.py": {"PyAutoLabs"}, - "PyAutoBrain/agents/faculties/review/review.sh": {"PyAutoLabs"}, # The two autolens tokens are the findings maturation lane's experiment and # mature tiers — surfaces, not new files, so the entries grow rather than # the list. diff --git a/scripts/session_bootstrap.sh b/scripts/session_bootstrap.sh new file mode 100755 index 000000000..430bc3cf9 --- /dev/null +++ b/scripts/session_bootstrap.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# scripts/session_bootstrap.sh — make a remote session's environment correct, +# from anywhere, without depending on the SessionStart hook having fired. +# +# WHY THIS EXISTS +# +# `policy/session_start_hook.sh` is the right mechanism and does the real work. +# But Claude Code registers project hooks from the session's *project +# directory*, and a session scoped to several organs clones them side by side +# under that directory — so the project directory is their parent, which is not +# a repo, has no `.claude/`, and registers no hook at all. Every multi-repo +# remote session therefore came up on the container's Python 3.11: one minor +# below the floor this organism set for itself and below every CI leg, with +# mypy and flake8 reading the interpreter and judging code against 3.11 rules. +# Local-green/CI-red, over and over, for an environment reason no one could see +# from inside the session. +# +# The hook now installs a workspace-root fan-out when it detects that layout, +# which fixes the *next* session in a container. This script is the other half: +# the door anything can knock on to fix the session it is already in. It is +# idempotent and ~0.2s warm, so calling it defensively costs nothing. +# +# bash PyAutoMind/scripts/session_bootstrap.sh # ensure, quietly +# bash PyAutoMind/scripts/session_bootstrap.sh --check # report, change nothing +# +# `bin/pyauto-brain` calls it before dispatch, so every `/verb` is covered. +# Exits 0 whenever the session is usable — this is a bootstrap, never a gate. + +set -u + +MIND_DIR="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)" +HOOK="$MIND_DIR/policy/session_start_hook.sh" +VENV="${PYAUTO_SESSION_VENV:-$HOME/.pyauto/session-py312}" + +say() { printf '[bootstrap] %s\n' "$*" >&2; } + +py312_ready() { + [ -x "$VENV/bin/python" ] \ + && "$VENV/bin/python" -c 'import sys; raise SystemExit(sys.version_info[:2] != (3, 12))' >/dev/null 2>&1 \ + && "$VENV/bin/python" -c 'import pytest, yaml' >/dev/null 2>&1 +} + +shallow_repos() { + local root repo out="" + root="$(dirname "$MIND_DIR")" + for repo in "$root"/*/; do + [ -e "${repo}.git/shallow" ] && out="$out $(basename "$repo")" + done + printf '%s' "${out# }" +} + +if [ "${1:-}" = "--check" ]; then + rc=0 + if py312_ready; then + say "python 3.12: OK ($VENV)" + else + say "python 3.12: MISSING — run this script with no arguments"; rc=1 + fi + if [ -n "$(shallow_repos)" ]; then + say "shallow clones: $(shallow_repos) — ancestry checks are unreliable"; rc=1 + else + say "clones: full history" + fi + # Check the OUTCOME, not the mechanism. The venv on PATH is one way to get + # there; the hook also repoints /usr/local/bin/python{,3} and rebuilds the + # uv tools on 3.12, and a session fixed that way is correct even though the + # venv is not first on PATH. Asserting the mechanism reported a healthy + # session as broken. + # A tool's own --version says nothing about the interpreter it runs on, and + # the interpreter is the whole question: mypy and flake8 read + # sys.version_info and judge code against THAT version's rules. A 3.11 mypy + # is why a session could be locally clean and red in CI. So resolve each + # tool's shebang and ask the interpreter it names. + for tool in python3 pytest mypy ruff flake8 black; do + path="$(command -v "$tool" 2>/dev/null)" || true + if [ -z "$path" ]; then + say "$tool: not installed"; continue + fi + if [ "$tool" = "python3" ]; then + interp="$path" + else + interp="$(head -c 256 "$path" 2>/dev/null | sed -n '1s|^#!\([^ ]*\).*|\1|p')" + fi + real="" + [ -n "$interp" ] && [ -x "$interp" ] && real="$("$interp" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null)" + if [ -z "$real" ]; then + say "$tool: $path (interpreter undetermined)" + elif [ "$real" = "3.12" ]; then + say "$tool: 3.12 OK ($path)" + else + say "$tool: running on $real, not 3.12 ($path) — its results will disagree with CI"; rc=1 + fi + done + exit "$rc" +fi + +[ -x "$HOOK" ] || { say "WARNING: canonical hook missing at $HOOK"; exit 0; } + +# The hook is the single implementation; this script only decides when to run it +# and guarantees CLAUDE_CODE_REMOTE is what the hook keys off, since a caller +# reaching us from a non-hook context (a CLI verb, an agent) may not have it. +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + # A local developer box supplies its own interpreter; nothing to do. + exit 0 +fi + +CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(dirname "$MIND_DIR")}" "$HOOK" || { + say "WARNING: bootstrap incomplete; the session may be on the container's python" + exit 0 +} + +# 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. +# The env-file export only reaches shells the session starts afterwards. When +# there is no env file (a CLI verb, an agent shelling out), say how to fix the +# shell we are in — but only if it actually needs fixing. +if py312_ready && ! python3 -c 'import sys; raise SystemExit(sys.version_info[:2] != (3, 12))' >/dev/null 2>&1; then + say "this shell still resolves $(command -v python3); run: export PATH=\"$VENV/bin:\$PATH\"" +fi +exit 0 diff --git a/tests/test_session_bootstrap.py b/tests/test_session_bootstrap.py new file mode 100644 index 000000000..d0f8a106f --- /dev/null +++ b/tests/test_session_bootstrap.py @@ -0,0 +1,233 @@ +"""What the SessionStart hook DOES at run time, not just whether it is in sync. + +`test_session_hook_sync.py` pins that every repo carries the canonical copy. +These tests pin what that copy does when it runs, because two of its legs were +wrong in ways no sync check could see: + +1. **It read the wrong directory.** The hook took its repo from + `$CLAUDE_PROJECT_DIR`. That equals the repo only in a session holding exactly + ONE repo. A session scoped to several organs clones them side by side *under* + the project directory, so the project directory is their parent — not a repo, + with no `.claude/` of its own. Claude Code registers project hooks from that + directory, so in exactly the sessions holding the most repos, no repo's hook + was registered and none of this ran. The session came up on the container's + Python 3.11: below the floor this repo set for itself and below every CI leg, + with mypy and flake8 reading the interpreter and judging code against 3.11 + rules. Nothing announced it. + +2. **It left clones shallow.** A remote session clones shallow, and + `git merge-base --is-ancestor` then reports "not an ancestor" for a commit + whose ancestry is merely absent from the clone. The ship and close-out + procedures act on that answer. + +Conventions (see `test_session_hook_sync.py`): + +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.** Every assertion is driven with input that must trip + it — including running the hook against the layout that used to defeat it. + +The hook's interpreter legs are skipped via `PYAUTO_SESSION_SKIP_PYTHON=1`, the +seam the hook documents for exactly this purpose: these tests are about git +history and hook reachability, which the hook now does independently of whether +an interpreter can be built. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CANONICAL_HOOK = ROOT / "policy" / "session_start_hook.sh" +BOOTSTRAP = ROOT / "scripts" / "session_bootstrap.sh" + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, + text=True, timeout=60) + + +def _run_hook(hook, *, project_dir, extra_env=None): + env = dict(os.environ) + env.update({ + "CLAUDE_CODE_REMOTE": "true", + "CLAUDE_PROJECT_DIR": str(project_dir), + "PYAUTO_SESSION_SKIP_PYTHON": "1", + }) + env.pop("CLAUDE_ENV_FILE", None) + env.update(extra_env or {}) + return subprocess.run(["bash", str(hook)], capture_output=True, text=True, + env=env, timeout=180) + + +def _install_hook(repo): + """A checked-out repo carrying the canonical hook where the harness looks.""" + hooks = repo / ".claude" / "hooks" + hooks.mkdir(parents=True, exist_ok=True) + dest = hooks / "session-start.sh" + dest.write_text(CANONICAL_HOOK.read_text()) + dest.chmod(0o755) + return dest + + +# -------------------------------------------------------------------------- +# 1. The repo is derived from the hook, not from the project directory +# -------------------------------------------------------------------------- + +def test_multi_repo_layout_installs_a_workspace_root_hook(tmp_path): + """The layout that used to defeat the hook entirely. + + Two repos side by side under a workspace root that is not itself a repo — + a project directory with no `.claude/`, so nothing would have registered + any hook. Running either repo's hook must leave the root able to register + one for the next session. + """ + workspace = tmp_path / "workspace" + for name in ("OrganOne", "OrganTwo"): + _install_hook(workspace / name) + + r = _run_hook(workspace / "OrganOne" / ".claude" / "hooks" / "session-start.sh", + project_dir=workspace) + assert r.returncode == 0, r.stderr + + settings = workspace / ".claude" / "settings.json" + fanout = workspace / ".claude" / "hooks" / "session-start.sh" + assert settings.is_file(), "workspace root got no settings.json" + assert fanout.is_file() and os.access(fanout, os.X_OK) + + registered = json.loads(settings.read_text()) + commands = [ + h["command"] + for group in registered["hooks"]["SessionStart"] + for h in group["hooks"] + ] + assert any("session-start.sh" in c for c in commands), commands + + +def test_single_repo_layout_installs_nothing_extra(tmp_path): + """When the project dir IS the repo, Claude Code already found its settings. + + Writing a second registration there would double-run the hook, so the leg + must be inert in the layout that was never broken. + """ + repo = tmp_path / "OrganOne" + hook = _install_hook(repo) + before = sorted(p.name for p in (repo / ".claude").iterdir()) + + r = _run_hook(hook, project_dir=repo) + assert r.returncode == 0, r.stderr + assert sorted(p.name for p in (repo / ".claude").iterdir()) == before + + +def test_generated_fanout_runs_every_sibling_repos_hook(tmp_path): + """The root hook's whole job: reach each repo's own hook, and say so.""" + workspace = tmp_path / "workspace" + for name in ("OrganOne", "OrganTwo"): + _install_hook(workspace / name) + _run_hook(workspace / "OrganOne" / ".claude" / "hooks" / "session-start.sh", + project_dir=workspace) + + fanout = workspace / ".claude" / "hooks" / "session-start.sh" + assert subprocess.run(["bash", "-n", str(fanout)]).returncode == 0 + + # Replace each repo's hook with a marker so the fan-out's reach is visible. + for name in ("OrganOne", "OrganTwo"): + h = workspace / name / ".claude" / "hooks" / "session-start.sh" + h.write_text(f"#!/usr/bin/env bash\ntouch '{workspace}/{name}.ran'\n") + h.chmod(0o755) + + r = subprocess.run(["bash", str(fanout)], capture_output=True, text=True, + timeout=60) + assert r.returncode == 0, r.stderr + assert (workspace / "OrganOne.ran").exists() + assert (workspace / "OrganTwo.ran").exists() + + +# -------------------------------------------------------------------------- +# 2. Shallow clones +# -------------------------------------------------------------------------- + +@pytest.fixture +def shallow_workspace(tmp_path): + """A workspace whose one repo is a genuine depth-1 clone of a local origin.""" + origin = tmp_path / "origin" + origin.mkdir() + _git("init", "-q", "-b", "main", cwd=origin) + _git("config", "user.email", "t@example.invalid", cwd=origin) + _git("config", "user.name", "T", cwd=origin) + for i in range(3): + (origin / f"f{i}.txt").write_text(str(i)) + _git("add", "-A", cwd=origin) + _git("commit", "-qm", f"c{i}", cwd=origin) + + workspace = tmp_path / "workspace" + workspace.mkdir() + clone = workspace / "OrganOne" + r = _git("clone", "-q", "--depth=1", f"file://{origin}", str(clone), cwd=tmp_path) + assert r.returncode == 0, r.stderr + assert (clone / ".git" / "shallow").exists(), "fixture is not shallow" + return workspace, clone, origin + + +def test_shallow_clone_is_unshallowed(shallow_workspace): + workspace, clone, origin = shallow_workspace + assert _git("rev-parse", "--is-shallow-repository", cwd=clone).stdout.strip() == "true" + + hook = _install_hook(clone) + r = _run_hook(hook, project_dir=workspace) + assert r.returncode == 0, r.stderr + + assert _git("rev-parse", "--is-shallow-repository", cwd=clone).stdout.strip() == "false" + depth = int(_git("rev-list", "--count", "HEAD", cwd=clone).stdout.strip()) + assert depth == 3, f"history still truncated: {depth} commits" + + +def test_shallow_clone_defeats_an_ancestry_check_before_the_fix(shallow_workspace): + """The reason the leg exists, pinned as a fact about git rather than prose. + + `--is-ancestor` answers 'no' for a real ancestor that is merely absent from + a shallow clone, and answers correctly once the history is there. + """ + workspace, clone, origin = shallow_workspace + root_commit = _git("rev-list", "--max-parents=0", "HEAD", cwd=origin).stdout.strip() + + before = _git("merge-base", "--is-ancestor", root_commit, "HEAD", cwd=clone) + assert before.returncode != 0, "fixture did not reproduce the shallow-clone lie" + + hook = _install_hook(clone) + assert _run_hook(hook, project_dir=workspace).returncode == 0 + + after = _git("merge-base", "--is-ancestor", root_commit, "HEAD", cwd=clone) + assert after.returncode == 0, "ancestry still wrong after unshallowing" + + +# -------------------------------------------------------------------------- +# 3. The bootstrap door +# -------------------------------------------------------------------------- + +def test_bootstrap_script_ships_and_is_executable(): + assert BOOTSTRAP.is_file() + assert os.access(BOOTSTRAP, os.X_OK) + assert subprocess.run(["bash", "-n", str(BOOTSTRAP)]).returncode == 0 + + +def test_bootstrap_is_inert_outside_a_remote_session(tmp_path): + """A developer box supplies its own interpreter; the door must not touch it.""" + env = dict(os.environ) + env.pop("CLAUDE_CODE_REMOTE", None) + r = subprocess.run(["bash", str(BOOTSTRAP)], capture_output=True, text=True, + env=env, timeout=60) + assert r.returncode == 0 + assert "[session-start]" not in r.stderr + + +def test_bootstrap_check_reports_rather_than_changes(): + """`--check` is a read-only report; it must never build or fetch anything.""" + r = subprocess.run(["bash", str(BOOTSTRAP), "--check"], capture_output=True, + text=True, timeout=60) + assert r.returncode in (0, 1) + assert "[bootstrap]" in r.stderr + assert "unshallowing" not in r.stderr