From a0f6a4f911461e917ba27ced40a1247d2860359c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 13:40:04 +0000 Subject: [PATCH 1/2] fix(session): make the remote-session bootstrap reachable and honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session holding several organs clones them side by side under the session's project directory. Claude Code registers project hooks from that directory — which is then their parent: not a repo, no `.claude/`, no hook registered. So in exactly the sessions holding the most repos, no repo's SessionStart hook ran, and the session 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. Nothing announced it; it surfaced as local-green/CI-red. - `policy/session_start_hook.sh` derives its repo from the hook's own location instead of `$CLAUDE_PROJECT_DIR`, so `session-python.txt` is read from the repo that owns it rather than from a parent that has none. - When it detects the multi-repo layout it installs a workspace-root `settings.json` plus a fan-out that runs each sibling repo's hook, so the next session in the container starts correctly. - It unshallows the session's clones. A shallow clone makes `git merge-base --is-ancestor` report "not an ancestor" for a commit whose ancestry is merely absent, and the ship and close-out procedures act on that answer (already logged once in `complete/2026/08/status-sh-repos-missing-source.md`). Here that was 249 of 4398 commits. - Git history and hook reachability now run before, and independently of, the interpreter legs: a container that cannot build the venv still wants honest ancestry and a hook that fires next time. `PYAUTO_SESSION_SKIP_PYTHON=1` is the seam the tests use. - The env file now also exports `PYAUTO_ROOT`. `scripts/session_bootstrap.sh` is the other half — the door anything can knock on to fix the session it is already in, which `bin/pyauto-brain` now calls before dispatch. `--check` reports each tool's real interpreter (resolved via its shebang; a tool's own `--version` says nothing about what it runs on). `repos_sync.py`: seven tenant-firewall allowlist entries dropped or narrowed. Each named the owner only because it hardcoded a workspace root under `$HOME`; those consumers now delegate to the Brain's resolvers, which name no absolute path at all. Tests: 8 new, covering the layout that used to defeat the hook entirely, the fan-out's reach, and the shallow-clone lie (asserted against git itself, before and after). All four of the behavioural ones fail against the previous hook. 224 PyAutoMind tests pass; `lifecycle.py check` and `repos_sync.py --check` OK. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012PDoeCuecjutdnwkxsTqg7 --- .claude/hooks/session-start.sh | 125 ++++++++++++++++- policy/session_start_hook.sh | 125 ++++++++++++++++- scripts/repos_sync.py | 16 ++- scripts/session_bootstrap.sh | 121 +++++++++++++++++ tests/test_session_bootstrap.py | 233 ++++++++++++++++++++++++++++++++ 5 files changed, 611 insertions(+), 9 deletions(-) create mode 100755 scripts/session_bootstrap.sh create mode 100644 tests/test_session_bootstrap.py 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/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 From 34ea944a78b40dc8c83271df073ead9dfc3b4ad0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 14:30:31 +0000 Subject: [PATCH 2/2] =?UTF-8?q?prompt:=20file=20board=5Fwithout=5Fgh=20?= =?UTF-8?q?=E2=80=94=20the=20eleven=20legs=20a=20remote=20session=20cannot?= =?UTF-8?q?=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Brain 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 2026-08-26 work made that blindness visible rather than green-and-wrong; this prompt is for closing it. Carries the measurements so the next session does not re-derive them: gh absent, MCP tools working but agent-only (a subprocess cannot call them), the injected $GH_TOKEN refused at the proxy with an org-level message, git and api.github.com otherwise fine. That combination is why "port the board to MCP" is a design question rather than a substitution, so phase 0 is a probe and a decision between three named options, not code. Filed separately from brain_board_follow_ups.md, which is the small-nit catch-all for the same board; cross-linked both ways. Sized `large` on the sizing faculty's own read (score 9) rather than the medium first guessed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012PDoeCuecjutdnwkxsTqg7 --- dashboard.html | 21 ++-- dashboard.md | 24 ++-- draft/feature/pyautobrain/board_without_gh.md | 114 ++++++++++++++++++ .../pyautobrain/brain_board_follow_ups.md | 4 + 4 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 draft/feature/pyautobrain/board_without_gh.md diff --git a/dashboard.html b/dashboard.html index 4f804cba5..4930f7112 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.

-
  • 1In flight
  • 3Parked
  • 5Planned
  • 133Backlog
+
  • 1In flight
  • 3Parked
  • 5Planned
  • 134Backlog

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

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

@@ -390,6 +391,12 @@

Backlog The 50 newest things to happen to the work in hand, newest first — issued, parked, filed. Every other section on this page is laid out by state, which is exactly why none of them can answer “what has been happening?”. Shipped work is not here: it is read from complete/index.md, and a thousand records deep it would crowd out everything anyone can still act on. Showing the newest 10; … opens the next 10.

+ + + + + + @@ -443,7 +450,7 @@

Backlog Phase 3: stop installing pynufft in Hands/Heart CI and PyAutoCTI…

- + @@ -683,12 +690,6 @@

Backlog plot coverage — follow-ups deferred from plot-coverage-gaps

- - - - - -
2026-08-26filedThe 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

Epics markdown version

diff --git a/dashboard.md b/dashboard.md index 57bb54cea..1afa10f3e 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 ``` @@ -1164,6 +1172,7 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 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 | 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 | 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… | -| 2026-08-23 | filed | Brain board follow-ups: what real mornings surface |
… 10 more (40 left) | Date | Event | Task | |------|-------|------| +| 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… | @@ -1188,12 +1197,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 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… | -| 2026-08-19 | issued | @PyAutoFit TransformedMessage.factor_gradient crashes on first… |
… 10 more (30 left) | Date | Event | Task | |------|-------|------| +| 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 | @@ -1203,12 +1212,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 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 | -| 2026-08-16 | filed | LogGaussianPrior misreports its own support as (-inf, inf) |
… 10 more (20 left) | Date | Event | Task | |------|-------|------| +| 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 | | 2026-08-08 | parked | pyautoreduce-slacs1430-acs-comparison | @@ -1218,12 +1227,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 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… | -| 2026-08-04 | filed | dataset/imaging/jwst_lw is untracked because the gitignore was never… |
… 10 more (10 left) | Date | Event | Task | |------|-------|------| +| 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 | @@ -1233,7 +1242,6 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 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 | -| 2026-07-30 | filed | Propagate the shear_galaxy-at-(0,0) idiom to group/ and cluster/ |
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.