diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index f2d31545..eaec734c 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/agents/_common.sh b/agents/_common.sh index 42aad531..d7eda85f 100755 --- a/agents/_common.sh +++ b/agents/_common.sh @@ -3,9 +3,15 @@ # # Resolves the sibling PyAuto organ CLIs (pyauto-heart, autohands) the same way # the autohands shim resolves them: prefer PATH, fall back to the sibling -# checkout under ~/Code/PyAutoLabs/. Nothing here is pip-installed. - -PYAUTO_ROOT="${PYAUTO_ROOT:-$HOME/Code/PyAutoLabs}" +# checkout under the workspace root. Nothing here is pip-installed. +# +# The root itself comes from bin/_pyauto_root.sh — the one resolver, which +# derives it from this checkout's own location and names no absolute path at +# all. Hardcoding a developer-box path here made every agent that +# sources this file resolve into a non-existent directory in a Claude Code +# remote session ($HOME=/root, checkouts under /home/user) and report empty +# rather than fail. +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../bin/_pyauto_root.sh" # _resolve_bin — echo a runnable invocation or # print an install hint to stderr and return 127. @@ -48,8 +54,11 @@ _resolve_dir() { # (organs are typically cloned side by side), so check it first. local brain_parent brain_parent="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../.." && pwd)" + # $PYAUTO_ROOT already resolves to the checkout parent on a developer box, + # so the old instance-named entry here was redundant as well as wrong + # anywhere else. for c in "$brain_parent/$repo" "$PYAUTO_ROOT/$repo" "$HOME/$repo" \ - "$HOME/Code/$repo" "$HOME/Code/PyAutoLabs/$repo"; do + "$HOME/Code/$repo"; do if [[ -d "$c" ]]; then printf '%s' "$c"; return 0; fi done echo "pyauto-brain: '$repo' checkout not found (set $var, or clone it beside PyAutoBrain / under $PYAUTO_ROOT)" >&2 diff --git a/agents/_pyauto_root.py b/agents/_pyauto_root.py new file mode 100644 index 00000000..acc9ecfb --- /dev/null +++ b/agents/_pyauto_root.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""agents/_pyauto_root.py — the single answer to "where is the workspace root?". + +The Python half of `bin/_pyauto_root.sh`; the two must agree, because a shell +agent and the Python it shells out to have to resolve the same tree. Keep the +order below identical to the shell file's. + +The workspace root is the directory holding the organ checkouts side by side +(PyAutoBrain/, PyAutoMind/, PyAutoHeart/, ...). It is *derived*, never named: + +1. an explicit ``PYAUTO_ROOT`` in the environment — the operator's word, taken + verbatim; +2. the parent of this checkout, when that parent holds at least one sibling + organ. It asks where the code actually *is* rather than where it is usually + kept, so it is true on a developer box and in a remote session alike; +3. the parent of this checkout regardless — the best available guess, reported + as unverified so a caller can say "I looked here and it was not a + workspace" instead of silently finding nothing. + +Consumers used to default to a hardcoded developer-box path instead of step 2. +On that box it was right; in a remote session ``$HOME`` is ``/root`` while the +checkouts are under ``/home/user``, so every consumer resolved into a directory +that does not exist. Nothing raised — these consumers all degrade — so they +reported empty. That is the failure mode this module exists to remove, and +naming any absolute workspace path here would reintroduce it for the next +environment that does not match. + +Stdlib only. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +__all__ = [ + "pyauto_root", + "pyauto_wt_root", + "workspace_root_reason", + "SIBLING_ORGANS", +] + +# Any one of these beside a directory marks it as a workspace root. PyAutoMind +# is the strongest signal (it carries repos.yaml, the body map most consumers +# want) but a Brain-only remote session is still a legitimate root. +SIBLING_ORGANS = ( + "PyAutoMind", + "PyAutoHeart", + "PyAutoHands", + "PyAutoMemory", + "PyAutoGut", + "PyAutoNerves", +) + +_BRAIN_HOME = Path(__file__).resolve().parents[1] + + +def _is_root(path: Path) -> bool: + """True when `path` holds at least one sibling organ checkout.""" + return any((path / organ).is_dir() for organ in SIBLING_ORGANS) + + +def pyauto_root() -> Path: + """The workspace root, by the order documented above.""" + return Path(workspace_root_reason()[0]) + + +def workspace_root_reason() -> tuple[Path, str]: + """(root, why) — the root and the rule that produced it. + + Callers that degrade when the root is wrong should report the reason: the + whole point of this module is that "resolved to a directory that does not + exist" stops being indistinguishable from "resolved fine, found nothing". + """ + env = os.environ.get("PYAUTO_ROOT") + if env: + return Path(env), "PYAUTO_ROOT" + parent = _BRAIN_HOME.parent + if _is_root(parent): + return parent, "beside this checkout" + # No sibling organ next to us. Still the best guess available — but say so, + # so a degraded caller can report "looked in X, not a workspace" rather + # than an empty result that reads like "nothing to do". + return parent, "unverified (no sibling organ beside this checkout)" + + +def pyauto_wt_root() -> Path: + """Where task worktrees live: beside the workspace root, suffixed ``-wt``. + + Derived from the resolved root rather than from ``$HOME`` for the same + reason the root itself is — an environment whose checkouts are not under + ``~/Code`` has its worktrees elsewhere too. + """ + env = os.environ.get("PYAUTO_WT_ROOT") + if env: + return Path(env) + root = pyauto_root() + return root.parent / f"{root.name}-wt" diff --git a/agents/conductors/clone/_clone.py b/agents/conductors/clone/_clone.py index 202eed68..5deabedd 100644 --- a/agents/conductors/clone/_clone.py +++ b/agents/conductors/clone/_clone.py @@ -28,12 +28,20 @@ import ast import fnmatch import json -import os import subprocess import sys from pathlib import Path -PYAUTO_ROOT = Path(os.environ.get("PYAUTO_ROOT", Path.home() / "Code" / "PyAutoLabs")) +# Workspace root via the one shared resolver (agents/_pyauto_root.py, mirrored +# by bin/_pyauto_root.sh): PYAUTO_ROOT, else beside this checkout, else the +# developer box. Naming an absolute workspace path as the *default* here +# resolved into +# a non-existent tree in a remote session and reported empty rather than +# failing. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +import _pyauto_root # noqa: E402 + +PYAUTO_ROOT = _pyauto_root.pyauto_root() SEED_SECTION = "## Assistant-as-template" diff --git a/agents/conductors/community/_community.py b/agents/conductors/community/_community.py index f4929d96..dfca2491 100644 --- a/agents/conductors/community/_community.py +++ b/agents/conductors/community/_community.py @@ -36,7 +36,16 @@ from datetime import datetime, timezone from pathlib import Path -PYAUTO_ROOT = Path(os.environ.get("PYAUTO_ROOT", Path.home() / "Code" / "PyAutoLabs")) +# Workspace root via the one shared resolver (agents/_pyauto_root.py, mirrored +# by bin/_pyauto_root.sh): PYAUTO_ROOT, else beside this checkout, else the +# developer box. Naming an absolute workspace path as the *default* here +# resolved into +# a non-existent tree in a remote session and reported empty rather than +# failing. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +import _pyauto_root # noqa: E402 + +PYAUTO_ROOT = _pyauto_root.pyauto_root() GH = os.environ.get("COMMUNITY_GH", "gh") SELF_LOGINS = [ s.strip() diff --git a/agents/conductors/hygiene/_hygiene_config.py b/agents/conductors/hygiene/_hygiene_config.py index 0ca2a518..3c5f055e 100755 --- a/agents/conductors/hygiene/_hygiene_config.py +++ b/agents/conductors/hygiene/_hygiene_config.py @@ -46,6 +46,17 @@ import os import sys +# The one workspace-root resolver, shared with bin/_pyauto_root.sh, so a +# remote session (checkouts under /home/user, $HOME=/root) prescans the same +# tree a developer box does instead of an empty one. +sys.path.insert(0, os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__))))) +import _pyauto_root # noqa: E402 + + +def _default_root() -> str: + return str(_pyauto_root.pyauto_root()) + try: import yaml except Exception: @@ -243,7 +254,10 @@ def main() -> int: description="Config drift prescan for the hygiene conductor: library " "config keys absent downstream, and workspace config files " "with no library counterpart.") - ap.add_argument("--root", default=os.path.expanduser("~/Code/PyAutoLabs")) + ap.add_argument("--root", default=_default_root(), + help="workspace root holding the organ checkouts " + "(default: PYAUTO_ROOT, else the parent of this " + "PyAutoBrain checkout)") ap.add_argument("--detail", action="store_true", help="list every drifted key path and orphan file, grouped " "by the config file / repo it belongs to. Default is " diff --git a/agents/conductors/profiling/_profiling.py b/agents/conductors/profiling/_profiling.py index f67c433a..5ffbc311 100644 --- a/agents/conductors/profiling/_profiling.py +++ b/agents/conductors/profiling/_profiling.py @@ -40,7 +40,16 @@ from pathlib import Path from typing import Any -PYAUTO_ROOT = Path(os.environ.get("PYAUTO_ROOT", Path.home() / "Code" / "PyAutoLabs")) +# Workspace root via the one shared resolver (agents/_pyauto_root.py, mirrored +# by bin/_pyauto_root.sh): PYAUTO_ROOT, else beside this checkout, else the +# developer box. Naming an absolute workspace path as the *default* here +# resolved into +# a non-existent tree in a remote session and reported empty rather than +# failing. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +import _pyauto_root # noqa: E402 + +PYAUTO_ROOT = _pyauto_root.pyauto_root() HEART_STATE_DIR = Path(os.environ.get("HEART_STATE_DIR", Path.home() / ".pyauto-heart")) # Config axes per hardware tier. Local runs are clamped/capped by the diff --git a/agents/conductors/release/nightly.sh b/agents/conductors/release/nightly.sh index fd162f4d..067461a7 100644 --- a/agents/conductors/release/nightly.sh +++ b/agents/conductors/release/nightly.sh @@ -59,6 +59,11 @@ set -uo pipefail HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +# This driver writes GitHub Actions variables and reads run state; without gh it +# cannot do either, and a release driver must not discover that halfway through. +. "$HERE/../../../bin/_gh.sh" +require_gh nightly || exit $? source "$HERE/../../_common.sh" BRAIN_REPO="PyAutoLabs/PyAutoHands" # where release-blocker labels are curated first diff --git a/agents/conductors/workspace/_workspace.py b/agents/conductors/workspace/_workspace.py index 067b0004..056152ce 100644 --- a/agents/conductors/workspace/_workspace.py +++ b/agents/conductors/workspace/_workspace.py @@ -24,12 +24,20 @@ import argparse import json -import os import re import sys from pathlib import Path -PYAUTO_ROOT = Path(os.environ.get("PYAUTO_ROOT", Path.home() / "Code" / "PyAutoLabs")) +# Workspace root via the one shared resolver (agents/_pyauto_root.py, mirrored +# by bin/_pyauto_root.sh): PYAUTO_ROOT, else beside this checkout, else the +# developer box. Naming an absolute workspace path as the *default* here +# resolved into +# a non-existent tree in a remote session and reported empty rather than +# failing. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +import _pyauto_root # noqa: E402 + +PYAUTO_ROOT = _pyauto_root.pyauto_root() # Library package -> (workspace repo, HowTo repo). PyAutoReduce's workspace is # being born (the founding demonstrated-need case); it has no HowTo yet. diff --git a/agents/faculties/review/_review.py b/agents/faculties/review/_review.py index 8ecc93b1..44aeaee5 100755 --- a/agents/faculties/review/_review.py +++ b/agents/faculties/review/_review.py @@ -18,13 +18,18 @@ import argparse import json -import os import re import subprocess import sys from pathlib import Path -WT_BASE = Path.home() / "Code" / "PyAutoLabs-wt" +# The one workspace-root resolver, shared with bin/_pyauto_root.sh, so a +# remote session (checkouts under /home/user, $HOME=/root) resolves the same +# tree a developer box does. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +import _pyauto_root # noqa: E402 + +WT_BASE = _pyauto_root.pyauto_wt_root() # Paths that smell like public API / behaviour when changed — flags only, # the reviewing agent judges. @@ -125,9 +130,7 @@ def in_place_repos(task: str) -> list[Path]: lines), never the bare 2-space claims other tooling reads — an in-place entry lists its repos there and the checkouts live at the workspace root. """ - pyauto_root = Path(os.environ.get( - "PYAUTO_ROOT", Path.home() / "Code" / "PyAutoLabs" - )) + pyauto_root = _pyauto_root.pyauto_root() active = pyauto_root / "PyAutoMind" / "active.md" if not active.exists(): return [] @@ -200,7 +203,9 @@ def emit_human(surfaces: list[dict]) -> None: def main(argv=None) -> int: ap = argparse.ArgumentParser(prog="review") - ap.add_argument("--task", default="", help="task worktree name under ~/Code/PyAutoLabs-wt/") + ap.add_argument("--task", default="", + help="task worktree name under the workspace worktree root " + f"({WT_BASE})") ap.add_argument("--repo", action="append", default=[], help="explicit repo checkout path") ap.add_argument("--json", action="store_true", dest="as_json") a = ap.parse_args(argv) diff --git a/agents/faculties/review/review.sh b/agents/faculties/review/review.sh index 18cc0ef5..1770ae2c 100755 --- a/agents/faculties/review/review.sh +++ b/agents/faculties/review/review.sh @@ -12,7 +12,7 @@ # vitals faculty). # # Usage: -# review.sh --task # resolve ~/Code/PyAutoLabs-wt// +# review.sh --task # resolve -wt// # review.sh --repo [--repo ...] # explicit repo checkouts # review.sh ... --json # machine-readable ReviewSurface diff --git a/bin/_gh.sh b/bin/_gh.sh new file mode 100755 index 00000000..e9e65ff8 --- /dev/null +++ b/bin/_gh.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# bin/_gh.sh — one honest answer to "is there a gh here?". +# +# These scripts were written on a developer box, where `gh` is installed and +# authenticated. A Claude Code remote session has no `gh` at all — its GitHub +# access is the GitHub MCP tool surface, which a shell script cannot call. So a +# script that shells out to `gh` in such a session does not misbehave subtly; it +# simply cannot run. +# +# What it used to do was worse than failing: `gh` not found means the command +# substitution is empty, and a caller that reads that as "no open PRs" or "no +# runs" proceeds on an answer it never got. `pyauto-brain board` reported "no +# runs" for seven workflows on a machine that had never been able to ask. +# +# So: say so, once, in the caller's own voice, and point at the page that +# explains the alternative. +# +# Source it via `dirname` rather than `readlink`: some of these scripts are +# exercised with a deliberately lean PATH (see +# tests/test_branch_sweep.py::_gh_free_path, which links in only the tools the +# script actually calls), and a helper that needs an absent command to locate +# itself fails before it can explain anything. +# +# . "$(dirname "${BASH_SOURCE[0]}")/_gh.sh" +# require_gh || exit $? # hard requirement: stop here +# have_gh || skip_this_leg # optional: degrade, but say the reason + +if [ -z "${_PYAUTO_GH_SOURCED:-}" ]; then +_PYAUTO_GH_SOURCED=1 + +# True when `gh` is present AND authenticated. Both matter: an unauthenticated +# gh fails per-call, which reads like a network flake rather than a setup gap. +have_gh() { + command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 +} + +# Print why this cannot run here, and where the alternative is documented. +gh_unavailable_reason() { + if ! command -v gh >/dev/null 2>&1; then + printf 'the GitHub CLI (gh) is not installed in this environment' + else + printf 'the GitHub CLI (gh) is installed but not authenticated' + fi +} + +# Hard requirement. Returns 127 (command-not-found) so a caller's `|| exit $?` +# carries a meaningful status rather than a generic 1. +require_gh() { + have_gh && return 0 + local who="${1:-$(basename "${0:-this script}")}" + { + echo "$who: $(gh_unavailable_reason)." + echo " A Claude Code remote session reaches GitHub through the GitHub MCP" + echo " tools instead, which a shell script cannot call — so run this leg" + echo " from an environment with gh, or perform it through MCP." + echo " Mapping: PyAutoBrain/skills/GITHUB_ACCESS.md" + } >&2 + return 127 +} + +fi diff --git a/bin/_pyauto_root.sh b/bin/_pyauto_root.sh new file mode 100755 index 00000000..825dfa28 --- /dev/null +++ b/bin/_pyauto_root.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# bin/_pyauto_root.sh — the single answer to "where is the workspace root?". +# +# The workspace root is the directory holding the organ checkouts side by side +# (PyAutoBrain/, PyAutoMind/, PyAutoHeart/, ...). Three layouts have to work: +# +# a developer box organs cloned side by side under one directory +# a remote session each in-scope repo cloned directly under the +# session's working directory (web/mobile) +# anywhere else a worktree, a CI checkout, a spawned template +# +# Consumers used to default to a hardcoded developer-box path. On that box it +# was right; in a remote session $HOME is /root while the checkouts are under +# /home/user, so every consumer resolved into a directory that does not exist. +# Nothing crashed, because the consumers are written to degrade — they just +# reported empty. `pyauto-brain board` printed a plausible board with hollow +# sections at exit 0, and the community leg said "body map not found" for a +# file that was present all along, one directory up from the script reading it. +# +# So: derive the root from where THIS checkout actually is, and name no +# absolute path at all — a literal here would only be right for the machine it +# was written on. `bin/pyauto-brain` and `board/_board.py` already resolved it +# this way (BRAIN_HOME.parent); this file makes that the one convention instead +# of the majority one. +# +# Usage (from anywhere in this repo): +# . "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../bin/_pyauto_root.sh" +# then read $PYAUTO_ROOT. An explicit PYAUTO_ROOT in the environment always +# wins, so a caller can still point the tooling at another workspace. + +# Guard against re-sourcing: several agents source both _common.sh and a bin +# helper that each pull this in. +if [ -z "${_PYAUTO_ROOT_SOURCED:-}" ]; then +_PYAUTO_ROOT_SOURCED=1 + +# The parent of this PyAutoBrain checkout. Resolved through readlink so an +# agent invoked via a symlinked bin/ still lands on the real tree. +_pyauto_brain_home() { + cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd +} + +# A directory counts as a workspace root if it holds at least one sibling organ +# besides this one. The Mind is the strongest signal (it carries repos.yaml, +# the body map every consumer wants), but a Brain-only remote session is still +# a legitimate root, so accept any organ sibling. +_pyauto_is_root() { + [ -d "$1/PyAutoMind" ] || [ -d "$1/PyAutoHeart" ] || [ -d "$1/PyAutoHands" ] \ + || [ -d "$1/PyAutoMemory" ] || [ -d "$1/PyAutoGut" ] || [ -d "$1/PyAutoNerves" ] +} + +_pyauto_resolve_root() { + # 1. An explicit override is the operator's word; never second-guess it. + if [ -n "${PYAUTO_ROOT:-}" ]; then + printf '%s' "$PYAUTO_ROOT" + return 0 + fi + # 2. Beside this checkout — the layout that is true in every environment + # the organism actually runs in, remote sessions included. 3. Failing + # that, the parent anyway: the best guess available, and a real path the + # caller can name in a diagnostic. + printf '%s' "$(dirname "$(_pyauto_brain_home)")" +} + +PYAUTO_ROOT="$(_pyauto_resolve_root)" +export PYAUTO_ROOT + +# Task worktrees live beside the workspace root, not inside it, so they derive +# from whatever the root resolved to rather than re-deriving from $HOME. +PYAUTO_WT_ROOT="${PYAUTO_WT_ROOT:-${PYAUTO_ROOT}-wt}" +export PYAUTO_WT_ROOT + +fi diff --git a/bin/branch_sweep.sh b/bin/branch_sweep.sh index 351dfb89..d3b9e37f 100755 --- a/bin/branch_sweep.sh +++ b/bin/branch_sweep.sh @@ -73,8 +73,10 @@ g() { git -C "$REPO" "$@"; } # cannot see open PRs, so we would refuse to sweep anyway — and refusing after # rewriting the caller's clone (an unshallow is not free and not undoable) # would be a rude way to say no. -command -v gh >/dev/null 2>&1 || { - echo "branch_sweep: gh not found — cannot rule out open PRs, refusing to sweep" >&2 +. "$(dirname "${BASH_SOURCE[0]}")/_gh.sh" +have_gh || { + echo "branch_sweep: cannot rule out open PRs, refusing to sweep" >&2 + require_gh branch_sweep exit 1 } open_prs=$(gh pr list --repo "$OWNER/$NAME" --state open --limit 500 \ diff --git a/bin/clean_slate.sh b/bin/clean_slate.sh index 59bb016c..289362e6 100755 --- a/bin/clean_slate.sh +++ b/bin/clean_slate.sh @@ -54,13 +54,15 @@ # ORPHAN no writer found — kept and REPORTED, so a human can look. # Deletion requires positive evidence; every uncertainty lands on ORPHAN. # -# Workspace root: PYAUTO_ROOT (default ~/Code/PyAutoLabs). +# Workspace root: PYAUTO_ROOT (bin/_pyauto_root.sh — the directory holding +# the organ checkouts, derived from this one; ~/Code/PyAutoLabs last). # Preview without changing anything: DRY_RUN=1 clean_slate.sh # Packaging products only: clean_slate.sh --packaging set -u SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -ROOT="${PYAUTO_ROOT:-$HOME/Code/PyAutoLabs}" +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_pyauto_root.sh" +ROOT="$PYAUTO_ROOT" cd "$ROOT" || { echo "workspace root not found: $ROOT" >&2; exit 1; } DRY_RUN="${DRY_RUN:-0}" tag=""; [ "$DRY_RUN" = 1 ] && tag="[dry-run] " diff --git a/bin/ensure_workspace_labels.sh b/bin/ensure_workspace_labels.sh index 889752fa..eb2e6820 100644 --- a/bin/ensure_workspace_labels.sh +++ b/bin/ensure_workspace_labels.sh @@ -48,8 +48,11 @@ REPOS=( PyAutoLabs/autocti_workspace_test ) -if ! command -v gh >/dev/null 2>&1; then - echo "ensure_workspace_labels: 'gh' CLI not installed; skipping label sweep." >&2 +. "$(dirname "${BASH_SOURCE[0]}")/_gh.sh" +if ! have_gh; then + echo "ensure_workspace_labels: skipping label sweep — $(gh_unavailable_reason)." >&2 + echo " Run it where gh is available, or apply the labels via the MCP surface" >&2 + echo " (PyAutoBrain/skills/GITHUB_ACCESS.md)." >&2 exit 0 fi diff --git a/bin/overnight_status.sh b/bin/overnight_status.sh index c6a7f55a..90a339b1 100755 --- a/bin/overnight_status.sh +++ b/bin/overnight_status.sh @@ -15,7 +15,8 @@ # but something a human should read. set -u -command -v gh >/dev/null 2>&1 || { echo "gh not found — cannot fetch run status" >&2; exit 1; } +. "$(dirname "${BASH_SOURCE[0]}")/_gh.sh" +have_gh || { echo "overnight_status: cannot fetch run status" >&2; require_gh overnight_status; exit 1; } # owner/repo:workflow-file (owner defaults to PyAutoLabs when omitted). The # passive morning webhooks (morning_health / morning_status) are excluded. diff --git a/bin/pull_all_main.sh b/bin/pull_all_main.sh index 4688622f..5c2c45be 100755 --- a/bin/pull_all_main.sh +++ b/bin/pull_all_main.sh @@ -8,12 +8,13 @@ # repo that still has tracked local modifications after that — i.e. real work — # is skipped and left completely untouched. Untracked files never block a pull. # -# Workspace root: PYAUTO_ROOT (default ~/Code/PyAutoLabs) — override to point at +# Workspace root: PYAUTO_ROOT (bin/_pyauto_root.sh) — override to point at # a different checkout, mirroring bin/pyauto-brain's sibling resolution. set -u -ROOT="${PYAUTO_ROOT:-$HOME/Code/PyAutoLabs}" +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_pyauto_root.sh" +ROOT="$PYAUTO_ROOT" cd "$ROOT" || { echo "workspace root not found: $ROOT" >&2; exit 1; } for dir in */; do diff --git a/bin/pyauto-brain b/bin/pyauto-brain index d68d4840..e9db4543 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -46,6 +46,8 @@ set -u _self="$(readlink -f "${BASH_SOURCE[0]}")" BRAIN_HOME="$(cd "$(dirname "$_self")/.." && pwd)" +# The one workspace-root resolver (also sourced by agents/_common.sh). +. "$BRAIN_HOME/bin/_pyauto_root.sh" AGENTS_DIR="$BRAIN_HOME/agents" CONDUCTORS_DIR="$AGENTS_DIR/conductors" @@ -153,6 +155,29 @@ EOF echo "Run 'pyauto-brain help ' for that agent's full docs." } +# Every verb runs through here, which makes this the one place guaranteed to +# execute in a remote session — so it is where the session environment gets +# guaranteed too. +# +# The SessionStart hook is the proper mechanism and does the work; but Claude +# Code registers project hooks from the session's project directory, and a +# session holding several organs clones them side by side *under* that +# directory. The project directory is then their parent: not a repo, no +# `.claude/`, no hook registered. Multi-repo sessions therefore came up on the +# container's Python 3.11 — below this organism's floor and below every CI leg, +# with mypy and flake8 judging code against 3.11 rules. +# +# Idempotent and ~0.2s warm, so this is cheap insurance rather than a gate: a +# missing or failing bootstrap never blocks a verb. +ensure_session_environment() { + [[ "${CLAUDE_CODE_REMOTE:-}" == "true" ]] || return 0 + [[ -z "${PYAUTO_SKIP_BOOTSTRAP:-}" ]] || return 0 + local boot + boot="$PYAUTO_ROOT/PyAutoMind/scripts/session_bootstrap.sh" + [[ -x "$boot" ]] || return 0 + bash "$boot" >/dev/null 2>&1 || true +} + main() { local sub="${1:-help}" case "$sub" in @@ -163,6 +188,7 @@ main() { ;; esac shift + ensure_session_environment local script="${AGENT_SCRIPT[$sub]:-}" if [[ -z "$script" || ! -f "$script" ]]; then echo "pyauto-brain: unknown agent '$sub'" >&2 diff --git a/bin/version_drift.sh b/bin/version_drift.sh index 142f6adc..a2698296 100755 --- a/bin/version_drift.sh +++ b/bin/version_drift.sh @@ -22,15 +22,19 @@ # reference tag is skipped and the consensus check runs on the local stamps. set -u -ROOT="${PYAUTO_ROOT:-$HOME/Code/PyAutoLabs}" +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_pyauto_root.sh" +ROOT="$PYAUTO_ROOT" OWNER=PyAutoLabs VERPAT='[0-9]{4}\.[0-9]+\.[0-9]+\.[0-9]+' -have_gh=0 -command -v gh >/dev/null 2>&1 && have_gh=1 +. "$(dirname "${BASH_SOURCE[0]}")/_gh.sh" +# An installed-but-unauthenticated gh fails per call, which reads like a +# network flake; have_gh checks both so the fallback is chosen up front. +gh_ok=0 +have_gh && gh_ok=1 ref="" -[ "$have_gh" -eq 1 ] && ref=$(gh api "repos/$OWNER/PyAutoLens/releases/latest" -q .tag_name 2>/dev/null) +[ "$gh_ok" -eq 1 ] && ref=$(gh api "repos/$OWNER/PyAutoLens/releases/latest" -q .tag_name 2>/dev/null) if [ -n "$ref" ]; then echo "reference (latest PyAutoLens release, for context): $ref" else @@ -60,7 +64,7 @@ for s in "${STAMPS[@]}"; do repo="${s%%:*}"; path="${s#*:}" if [ -f "$ROOT/$repo/$path" ]; then v=$(grep -oE "$VERPAT" "$ROOT/$repo/$path" 2>/dev/null | head -1) - elif [ "$have_gh" -eq 1 ]; then + elif [ "$gh_ok" -eq 1 ]; then v=$(gh api "repos/$OWNER/$repo/contents/$path" -q '.content' 2>/dev/null \ | base64 -d 2>/dev/null | grep -oE "$VERPAT" | head -1) else diff --git a/bin/worktree.sh b/bin/worktree.sh index e2a29287..16f46719 100644 --- a/bin/worktree.sh +++ b/bin/worktree.sh @@ -21,8 +21,9 @@ set -o pipefail -PYAUTO_MAIN="${PYAUTO_MAIN:-$HOME/Code/PyAutoLabs}" -PYAUTO_WT_ROOT="${PYAUTO_WT_ROOT:-$HOME/Code/PyAutoLabs-wt}" +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_pyauto_root.sh" +PYAUTO_MAIN="${PYAUTO_MAIN:-$PYAUTO_ROOT}" +PYAUTO_WT_ROOT="${PYAUTO_WT_ROOT:-${PYAUTO_MAIN}-wt}" # Libraries that participate in PYTHONPATH. Order matters — it mirrors the # order in ~/.bashrc. @@ -379,8 +380,8 @@ worktree_list_claimed() { # Exits 3 when the registry cannot be resolved — see below. # # THIS GUARD FAILS CLOSED. It used to return 0 when `active.md` could not be -# found, which meant a cloud/web/CI session (where the roots are not under the -# default $HOME/Code/PyAutoLabs) got "no conflict" from a guard that had read +# found, which meant a cloud/web/CI session (where the roots were not under the +# then-default $HOME/Code/PyAutoLabs) got "no conflict" from a guard that had read # nothing. That is worse than no guard, because the workflow documents this # call and the skills act on its answer: two sessions could each be told the # same repo was free. It now reports the failure and returns non-zero, so the diff --git a/board/AGENTS.md b/board/AGENTS.md index 2199b8ed..58153e40 100644 --- a/board/AGENTS.md +++ b/board/AGENTS.md @@ -115,3 +115,48 @@ board links). The org/owner is derived from the Mind's body map Env: `PYAUTO_ROOT` (workspace root holding `PyAutoMind/`), `BOARD_GH` (the gh binary; hermetic tests point it at a stub), `BOARD_PAGES_BASE` (sibling-board base URL; tests point it at `file://` fixtures). + +## Reading the board in a remote session + +A Claude Code remote session (web/mobile) cannot reach either of the board's +data sources by default: + +- **`gh` is not installed.** The overnight sweep, the version stamps, the + community scan and the pending-release search all run through it. Without it + they cannot ask — which is not the same as asking and getting nothing, and + the render now says so (`could not read`, never `no runs`). +- **The sibling Pages boards are refused by the egress policy.** The proxy + answers `403` to `CONNECT` for `.github.io`, so `board.json` and + `badge.json` — the Heart's readiness surface among them — are unreadable. + Retrying never helps; the reason is named in the Degraded section rather than + reported as a flake. Where the sibling repo is checked out, the board falls + back to its local `board/board.json`. + +So a remote render is expected to be partial. It carries a **⚠️ Degraded +render** banner above the sections, a per-leg reason at the foot, a headline +qualified with `partial view (N legs unread)`, and a grey badge rather than a +green one — a green badge is only ever emitted by a render that read +everything and found nothing wrong. + +To get a complete board in a remote session, the environment needs outbound +access to the Pages host. That is cloud-environment configuration on claude.ai, +not something this repo can set: open the environment selector at +`claude.ai/code` (the cloud icon above the message box), edit the environment, +set **Network access** to **Custom**, and add `*.github.io` — keeping **Also +include default list of common package managers** checked, or the session loses +PyPI and GitHub too. It takes effect on the next session, not the running one. + +Two hosts worth knowing about while you are there: + +- `*.blob.core.windows.net` — where GitHub Actions stores run artifacts and + logs. Blocked by default, which is why a workflow's own drift report could + not be downloaded and the run had to be reproduced locally instead + (`PyAutoMind/complete/2026/08/wiki-currency-baseline-drift.md`). It is a + broad entry: all of Azure Blob Storage, not just GitHub's buckets. +- `objects.githubusercontent.com` and `raw.githubusercontent.com` are already + in the default Trusted list — they need no entry. + +The allowlist is per environment; there is no organization-level one, so an +environment you add later starts from the defaults again. And none of this +installs `gh` — that is not in the image at all, so GitHub still goes through +the MCP tools (`skills/GITHUB_ACCESS.md`). diff --git a/board/_board.py b/board/_board.py index abeae152..8a4514c4 100755 --- a/board/_board.py +++ b/board/_board.py @@ -58,9 +58,14 @@ THEME_ORGAN = "brain" # whose logo this page wears BRAIN_HOME = Path(__file__).resolve().parents[1] -# Default workspace root = the checkout's parent (the standard sibling layout -# the sizing faculty also assumes) — no instance path named here. -PYAUTO_ROOT = Path(os.environ.get("PYAUTO_ROOT", BRAIN_HOME.parent)) +# Workspace root via the one shared resolver (agents/_pyauto_root.py, mirrored +# by bin/_pyauto_root.sh) — no instance path named here. ROOT_REASON records +# which rule produced it, so a degraded leg can say whether it found nothing or +# looked in the wrong tree. +sys.path.insert(0, str(BRAIN_HOME / "agents")) +import _pyauto_root # noqa: E402 + +PYAUTO_ROOT, ROOT_REASON = _pyauto_root.workspace_root_reason() GH = os.environ.get("BOARD_GH", "gh") POLICY_PATH = BRAIN_HOME / "config" / "policy.yaml" @@ -176,10 +181,16 @@ def collect_overnight(jobs, org, degraded): run = (runs or {}).get("workflow_runs") or [None] run = run[0] if run is None: - if runs is None: + # "no runs" and "could not ask" are different facts and must not + # render the same. Without gh (a remote session has none) every + # workflow reported "no runs" — a claim about the world, from a + # board that had never been able to query it. + unreadable = runs is None + if unreadable: degraded.append(f"overnight: could not read {repo}/{wf}") rows.append({"repo": repo, "workflow": wf, "conclusion": None, - "age_h": None, "url": None, "blocked": False}) + "age_h": None, "url": None, "blocked": False, + "blocked_reason": None, "unreadable": unreadable}) continue conclusion = run.get("conclusion") or run.get("status") blocked = False @@ -207,15 +218,42 @@ def collect_overnight(jobs, org, degraded): "url": run.get("html_url"), "blocked": blocked, "blocked_reason": blocked_reason, + "unreadable": False, }) return rows -def _fetch_json(url): +def _fetch_reason(exc): + """Why a published surface could not be read, in the reader's terms. + + "unreachable" covers two very different situations and the difference is + the actionable part: a transient network failure is worth retrying, while + an egress policy that refuses the host will refuse it every time until + someone adds it to the environment's allowlist. A remote session hits the + second — the sibling boards are GitHub Pages, and the proxy answers 403 to + CONNECT for that host — so the board reported "unreachable" every morning + for a reason no amount of retrying would change. + """ + code = getattr(exc, "code", None) + reason = str(getattr(exc, "reason", exc)) + if code in (403, 407) or "403" in reason or "CONNECT" in reason.upper(): + return ("blocked by this environment's network policy — add the Pages " + "host to the environment allowlist (see board/AGENTS.md)") + if isinstance(exc, (json.JSONDecodeError, ValueError)): + return "served content that is not the expected JSON" + if "timed out" in reason.lower(): + return "timed out" + return f"unreachable ({reason[:80]})" + + +def _fetch_json(url, why=None): + """Parsed JSON, or None. `why` (a one-element list) receives the reason.""" try: with urllib.request.urlopen(url, timeout=15) as resp: return json.loads(resp.read().decode("utf-8")) - except (urllib.error.URLError, json.JSONDecodeError, ValueError, OSError): + except (urllib.error.URLError, json.JSONDecodeError, ValueError, OSError) as e: + if why is not None: + why.append(_fetch_reason(e)) return None @@ -233,15 +271,39 @@ def fetch_badge(pages_base, repo): COMMUNITY_STALE_DAYS = 7 +def _local_published_json(repo, name): + """The same surface from a sibling checkout, when one is here. + + The published copy is authoritative, but a session that cannot reach it and + *does* hold the checkout can still answer from the tree rather than + reporting nothing. + """ + for rel in (Path("board") / name, Path("docs") / name, Path(name)): + candidate = PYAUTO_ROOT / repo / rel + try: + if candidate.is_file(): + return json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + continue + return None + + def fetch_heart_board(pages_base, repo, degraded): """The Heart board's published machine surface (board.json, schema v2) — ONE read, two consumers (the blockers and the performance block below). None when unreachable; the degraded row is recorded here, once.""" - board = _fetch_json(f"{pages_base}/{repo}/board.json") - if board is None: - degraded.append("readiness: Heart board.json unreachable " - "(blockers shown on the Heart board only)") - return board + why = [] + board = _fetch_json(f"{pages_base}/{repo}/board.json", why) + if board is not None: + return board + board = _local_published_json(repo, "board.json") + if board is not None: + degraded.append(f"readiness: published Heart board {why[0] if why else 'unreachable'}; " + f"read the local {repo} checkout instead") + return board + degraded.append(f"readiness: Heart board.json {why[0] if why else 'unreachable'} " + "(blockers shown on the Heart board only)") + return None def extract_heart_blockers(board): @@ -816,9 +878,21 @@ def verdict(data): def headline(data): + """The one line a reader acts on — qualified by how much was actually read. + + "clear to work" is a claim of knowledge, and a board whose legs could not + be queried has none. A remote session (no gh, Pages blocked by the egress + policy) rendered every section empty and still headlined "clear to work" in + brightgreen: the strongest possible all-clear, from a board that had asked + nothing. Absence of findings is not a finding. + """ blocking, attention = verdict(data) n = len(blocking) + len(attention) - return "clear to work" if n == 0 else f"{n} need you" + core = "clear to work" if n == 0 else f"{n} need you" + unread = len(data.get("degraded") or []) + if unread: + core += f" · partial view ({unread} leg{'s' if unread != 1 else ''} unread)" + return core def badge_color(data): @@ -827,6 +901,10 @@ def badge_color(data): return "red" if attention: return "orange" + # Nothing found AND nothing unread is the only green. Nothing found because + # nothing could be asked is grey, not green. + if data.get("degraded"): + return "lightgrey" return "brightgreen" @@ -844,6 +922,8 @@ def render_badge(data): def _overnight_line(r): + if r.get("unreadable"): + return f"? {r['repo']}/{r['workflow']} — could not read (see Degraded)" if r["conclusion"] is None: return f"– {r['repo']}/{r['workflow']} — no runs" if r["blocked"]: @@ -872,6 +952,16 @@ def render_md(data): f"Morning sync (local, terminal): `{MORNING_CMD}`", "", ] + # A partial board that looks whole is worse than a short one: a reader who + # cannot see that three legs failed will read their silence as good news. + # The detail stays at the foot; this says up front how much to trust. + if data["degraded"]: + L += [ + f"> ⚠️ **Degraded render — {len(data['degraded'])} leg(s) could not be " + "read.** Sections below may be incomplete; see **Degraded** at the " + "foot for which and why.", + "", + ] if blocking: L.append("## 🚨 Blocking") L += [f"- {b}" for b in blocking] + [""] @@ -1161,7 +1251,9 @@ def render_html(data): if r["conclusion"] is not None else "") subject = f'{esc(r["workflow"])}{age}{link}' failed = r["conclusion"] not in (None, "success") - if r["conclusion"] is None: + if r.get("unreadable"): + state, tone, reason = "could not read", "y", "" + elif r["conclusion"] is None: state, tone, reason = "no runs", "n", "" elif r["blocked"]: state, tone = "blocked at a gate", "y" diff --git a/skills/GITHUB_ACCESS.md b/skills/GITHUB_ACCESS.md new file mode 100644 index 00000000..25fa956e --- /dev/null +++ b/skills/GITHUB_ACCESS.md @@ -0,0 +1,97 @@ +# GitHub access: `gh` is not always there + +Shared by every skill that touches GitHub. Read this once, at the top of a run, +and act on what it says for the rest of the run. + +## The problem this page exists for + +Most of the workflow was written on a developer box, where `gh` is installed and +authenticated, so the skills spell their GitHub steps as `gh` commands — 19 skill +bodies and 15 scripts in this repo do. **A Claude Code remote session has no +`gh` at all.** Its GitHub access is the GitHub MCP server: a set of +`mcp__github__*` tools the harness provides, with the session's own repository +scope already applied. + +Nothing announced this. A `/prm` run on mobile loaded ~8k tokens of close-out +procedure, ran its first `gh pr view`, got `command not found`, and then had to +re-derive the whole procedure through MCP — every time, from scratch. One +completion record logged it after the fact: + +> shipped from a `web-github` session (no task worktree, no `gh`; issue and PR +> driven through the GitHub MCP surface) +> — `PyAutoMind/complete/2026/08/status-sh-repos-missing-source.md` + +So: decide once, up front, which surface you have, and read the procedure +through that lens. + +## Decide once, at the start of the run + +```bash +command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 \ + && echo "gh" || echo "mcp" +``` + +- **`gh`** — follow the skill's commands as written. +- **`mcp`** — the `gh` lines are *the operation to perform*, not the command to + run. Translate with the table below. Do not try to install `gh`, and do not + report the task as blocked: the MCP surface can do everything the close-out + needs except delete a branch. + +A session cannot be half-and-half. Probe once; don't re-probe per step. + +## The mapping + +| Operation | `gh` | MCP tool | +|---|---|---| +| Read a PR (state, head sha, mergeability) | `gh pr view --json ...` | `pull_request_read` (`method: "get"`) | +| PR files / diff | `gh pr diff ` | `pull_request_read` (`method: "get_diff"` / `"get_files"`) | +| PR review threads | `gh pr view --json reviews` | `pull_request_read` (`method: "get_review_comments"`) | +| List PRs | `gh pr list --repo ` | `list_pull_requests` | +| Find PRs by query | `gh search prs ...` | `search_pull_requests` | +| Open a PR | `gh pr create` | `create_pull_request` | +| Edit a PR (title, body, base) | `gh pr edit` | `update_pull_request` | +| Merge a PR | `gh pr merge ` | `merge_pull_request` | +| Update a PR from its base | `gh pr update-branch` | `update_pull_request_branch` | +| CI runs for a sha | `gh run list`, `gh pr checks` | `actions_list` (`workflow_runs`), `get_check_run` | +| One run's detail / jobs | `gh run view ` | `actions_get`, `get_job_logs` | +| Re-run a workflow | `gh run rerun` | `actions_run_trigger` | +| Read an issue | `gh issue view ` | `issue_read` | +| List / search issues | `gh issue list`, `gh search issues` | `list_issues`, `search_issues` | +| Open, edit or close an issue | `gh issue create/edit/close` | `issue_write` | +| Comment on an issue or PR | `gh issue comment` | `add_issue_comment` | +| Reply in a review thread | `gh api .../comments` | `add_reply_to_pull_request_comment` | +| Resolve a review thread | `gh api graphql` | `resolve_review_thread` | +| Read a file at a ref | `gh api .../contents/

` | `get_file_contents` | +| Commit a file | `gh api -X PUT .../contents` | `create_or_update_file`, `push_files` | +| List branches / commits / tags | `gh api .../branches` | `list_branches`, `list_commits`, `list_tags` | +| Create a branch | `gh api -X POST .../git/refs` | `create_branch` | +| Releases | `gh release view/list` | `get_latest_release`, `list_releases`, `get_release_by_tag` | +| Who am I | `gh api user` | `get_me` | + +Tool names are given unprefixed; the harness exposes them as +`mcp__github__`. If a name is not loaded, `ToolSearch` fetches its schema. + +## What the MCP surface cannot do + +- **Delete a remote branch.** There is no MCP tool for it, and in a proxied web + session `git push origin --delete` is refused by the egress proxy anyway. + Branch cleanup is out of scope for such a run: skip it silently rather than + reporting a failed close-out. (`/prm` already documents this probe.) +- **Reach a repo outside the session's scope.** The session is scoped to a + repository set at start; `add_repo` extends it. A call outside that scope is + denied — that is the scope working, not an auth problem to route around. + +## Two rules that apply on both surfaces + +1. **Every comment, review or reply you author ends with the attribution + footer** — a blank line, a `---` rule, then + `_Generated by [Claude Code](https://claude.ai/code)_`. The server strips + duplicates, so include it even where the tool adds one. +2. **Be frugal.** Post when a round resolves the task, hits a real blocker, or + raises a question. The diff is the record; don't narrate each fix. + +## For script authors + +Shell scripts in this repo must not assume `gh`. Source `bin/_gh.sh` and call +`require_gh` — it exits with the pointer to this page instead of letting +`command not found` surface as a confusing failure two steps later. diff --git a/skills/issue_cleanup/issue_cleanup.md b/skills/issue_cleanup/issue_cleanup.md index 83499c65..969ec711 100644 --- a/skills/issue_cleanup/issue_cleanup.md +++ b/skills/issue_cleanup/issue_cleanup.md @@ -24,6 +24,12 @@ reply* — this skill never touches those, it routes them there); `$create-issue and `$update-issue` (single-issue primitives); `$hygiene` (code-quality debt, not the tracker). +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Why this needs care The obvious rule — *"a PyAutoMind `complete/` record references this issue, so diff --git a/skills/issue_cleanup/reference.md b/skills/issue_cleanup/reference.md index 51f6bfe3..c0f3d0ea 100644 --- a/skills/issue_cleanup/reference.md +++ b/skills/issue_cleanup/reference.md @@ -5,6 +5,12 @@ holds the collection commands, the record header taxonomy, the evidence rules, the dashboard layout, the per-bucket execution recipes, the recap, and the execution-environment fallback. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Collect open issues Resolve the repo list and owners from `PyAutoMind/repos.yaml`. For each repo: diff --git a/skills/prm/SKILL.md b/skills/prm/SKILL.md index b3d5a69a..719a5076 100644 --- a/skills/prm/SKILL.md +++ b/skills/prm/SKILL.md @@ -1,6 +1,6 @@ --- name: prm -description: The last thing you type for a task — "PR, CI green, then merge", then the full close-out. Watches a feature PR's checks until every workflow run and every matrix leg is green, merges (library-first gate honoured), closes the issue, moves the PyAutoMind prompt active/ → complete/, reconciles and regenerates the Mind dashboard, removes the task worktree and deletes the merged branches. Use when the user says merge this PR once CI is green, close this task out, or types /prm. Runs anywhere gh is authenticated: CLI, mobile Claude Code chat, Codex. +description: The last thing you type for a task — "PR, CI green, then merge", then the full close-out. Watches a feature PR's checks until every workflow run and every matrix leg is green, merges (library-first gate honoured), closes the issue, moves the PyAutoMind prompt active/ → complete/, reconciles and regenerates the Mind dashboard, removes the task worktree and deletes the merged branches. Use when the user says merge this PR once CI is green, close this task out, or types /prm. Runs on any GitHub surface — gh on a local CLI, the GitHub MCP tools in a mobile or web session, Codex. --- # /prm — PR, CI green, then merge, then close the task out diff --git a/skills/prm/prm.md b/skills/prm/prm.md index 38128971..d388cafd 100644 --- a/skills/prm/prm.md +++ b/skills/prm/prm.md @@ -9,6 +9,8 @@ and hands back a ledger of what it did. Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. gh mechanics + snippets: [`reference.md`](reference.md). +No `gh` in this session? [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md) maps every +step below onto the GitHub MCP tools. ## Principle: compose, don't recompute @@ -31,16 +33,30 @@ generated page. /prm --no-wait # judge CI once and report; merge only if already green ``` -## Environment: runs anywhere gh is authenticated +## Environment: decide your GitHub surface first -Local CLI, mobile Claude Code chat, and Codex all work — every step is `gh`, no -checkout required. Detect which you are in: +Local CLI, mobile Claude Code chat, and Codex all work — but **not all of them +have `gh`**. This page spells its GitHub steps as `gh` commands because that is +the clearest way to name each operation; on a surface without `gh` they are the +*operation*, not the command. Read +[`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md) once and translate as you go. -- **Local** — `$PYAUTO_ROOT` (default `~/Code/PyAutoLabs`) holds the sibling - repos. Branch detection and post-merge worktree cleanup are available. -- **Remote (mobile/codex)** — no multi-repo checkout. Resolve the PR from the - argument or by listing candidates (below), and **skip the local-only cleanup** - with a one-line note. Never `cd` into a repo that isn't there. +Probe both axes once, at the start of the run, and remember the answers: + +```bash +command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 \ + && echo "gh" || echo "mcp" # which GitHub surface +``` + +- **Local** — `$PYAUTO_ROOT` (see `bin/_pyauto_root.sh`) holds the sibling repos, + and `gh` is normally present. Branch detection and post-merge worktree cleanup + are available. +- **Remote (mobile/web/codex)** — no multi-repo checkout, and **no `gh` at all**: + GitHub access is the `mcp__github__*` tool surface. Resolve the PR from the + argument or by listing candidates (below), drive every step through MCP, and + **skip the local-only cleanup** with a one-line note. Never `cd` into a repo + that isn't there, and never report the close-out blocked for want of `gh` — + everything below except deleting a branch has an MCP equivalent. - **Proxied web session (Claude Code on the web)** — remote as above, **plus it cannot delete remote branches at all**: the egress proxy refuses ref deletions. Probe once, before the close-out, and remember the answer for the whole run: @@ -59,7 +75,8 @@ checkout required. Detect which you are in: In order: explicit argument → current branch (`gh pr view --json` in the repo you are in) → the claimed task in `PyAutoMind/active.md` (its `library-pr:` / -`workspace-pr:` entries; on mobile read it with `gh api`) → `gh pr list` across +`workspace-pr:` entries; without a checkout read it with `gh api` or the MCP +`get_file_contents` tool) → `gh pr list` across the claimed repos. If more than one candidate survives, **list them numbered and ask once** — never guess which PR to merge. Report each target as `owner/repo#N — title — branch` before doing anything. diff --git a/skills/prm/reference.md b/skills/prm/reference.md index 5b09251f..22f0409a 100644 --- a/skills/prm/reference.md +++ b/skills/prm/reference.md @@ -4,6 +4,12 @@ Mechanics for [`prm.md`](prm.md). Lazy-loaded: read it when you actually run the step. `gh` in this workspace is old (2.4.0) — it has **no `gh search`** and some newer flags are absent, so everything below sticks to `gh pr` / `gh api`. +> **GitHub surface.** These are the `gh` mechanics. A Claude Code remote +> session has no `gh` and reaches GitHub through the `mcp__github__*` tools +> instead — each snippet below names an *operation* that +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md) maps onto its MCP equivalent. +> Probe once (`command -v gh`) and read this page through that lens. + ## Resolving the PR ```bash diff --git a/skills/repo_cleanup/SKILL.md b/skills/repo_cleanup/SKILL.md index 7eb983a2..18c9090f 100644 --- a/skills/repo_cleanup/SKILL.md +++ b/skills/repo_cleanup/SKILL.md @@ -36,6 +36,12 @@ but this also mutates); post-merge cleanup in `CLAUDE.md` (once per shipped task this covers residue when that flow is skipped); the start_dev branch survey (task start — this is between-tasks hygiene). +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Safety principles (non-negotiable) 1. **Audit first, act second.** Phase 1 is a read-only report. Nothing diff --git a/skills/repo_cleanup/reference.md b/skills/repo_cleanup/reference.md index c444a5cc..daa4f7ef 100644 --- a/skills/repo_cleanup/reference.md +++ b/skills/repo_cleanup/reference.md @@ -4,6 +4,12 @@ Factored out of `SKILL.md`. The body is authoritative for the flow; this holds the audit commands, the dashboard layout, the per-bucket execution recipes, the recap, and the execution-environment fallback. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Audit canonical checkouts For each in-scope repo at `$PYAUTO_MAIN/`: diff --git a/skills/ship_library/reference.md b/skills/ship_library/reference.md index eb617559..8214109b 100644 --- a/skills/ship_library/reference.md +++ b/skills/ship_library/reference.md @@ -4,6 +4,12 @@ Factored out of `ship_library.md`. The body skill is authoritative for the flow; this file holds the verbatim PR format, the API-Changes rules, and the workspace-impact analysis. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Writing the `## API Changes` section (judgement — stays in the reasoning model) Analyse **all** commits on the branch and the full diff against the base branch. diff --git a/skills/ship_library/ship_library.md b/skills/ship_library/ship_library.md index fac2184a..0fd9adc2 100644 --- a/skills/ship_library/ship_library.md +++ b/skills/ship_library/ship_library.md @@ -15,6 +15,12 @@ the organ boundary, the readiness gate, and the execution-environment model. PR format, the execution contract, and the impact analysis are in [`reference.md`](reference.md). +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Steps ### 1. Identify affected repos (Mind) diff --git a/skills/ship_workspace/reference.md b/skills/ship_workspace/reference.md index 1eab3aa1..01ebbb03 100644 --- a/skills/ship_workspace/reference.md +++ b/skills/ship_workspace/reference.md @@ -3,6 +3,12 @@ Factored out of `ship_workspace.md`. The body skill is authoritative for the flow; this file holds the PR format, the merge gate, and the issue/Mind formats. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## PR body format The PR body **MUST** include a `## Scripts Changed` section. diff --git a/skills/ship_workspace/ship_workspace.md b/skills/ship_workspace/ship_workspace.md index a037b8fe..36ec0a69 100644 --- a/skills/ship_workspace/ship_workspace.md +++ b/skills/ship_workspace/ship_workspace.md @@ -15,6 +15,12 @@ entry point, not an agent. Read [`../WORKFLOW.md`](../WORKFLOW.md) for the organ boundary, readiness gate and execution-environment model; PR format, merge gate and issue/Mind formats are in [`reference.md`](reference.md). +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Steps ### 1. Identify affected workspace repos (Mind) diff --git a/skills/start_dev/reference.md b/skills/start_dev/reference.md index d36bdb9b..b760488b 100644 --- a/skills/start_dev/reference.md +++ b/skills/start_dev/reference.md @@ -4,6 +4,12 @@ Long-form templates and procedures factored out of `start_dev.md` to keep the primary skill concise. The body skill is authoritative for the flow; this file holds the verbatim formats it points at. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Issue body The issue body is assembled and created by the Mind primitive `/create_issue` diff --git a/skills/start_dev/start_dev.md b/skills/start_dev/start_dev.md index 47d15a06..59c8055e 100644 --- a/skills/start_dev/start_dev.md +++ b/skills/start_dev/start_dev.md @@ -12,6 +12,12 @@ risk judgement belong to Brain. Read Brain-agent entry points, Memory consultation, the execution-environment model, and the Mind registry paths used below. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Usage ``` diff --git a/skills/start_dev_for_user/reference.md b/skills/start_dev_for_user/reference.md index a85ff135..9dd71260 100644 --- a/skills/start_dev_for_user/reference.md +++ b/skills/start_dev_for_user/reference.md @@ -4,6 +4,12 @@ Factored out of `start_dev_for_user.md` (read by repo path `PyAutoBrain/skills/start_dev_for_user/reference.md`). Holds the tone rules, comment templates, registry formats, and the forward-compatibility note. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Tone rules (every comment on a `user-facing: true` task) - First/second person, contractions allowed — a polite teammate, not a status bot. diff --git a/skills/start_dev_for_user/start_dev_for_user.md b/skills/start_dev_for_user/start_dev_for_user.md index d2e7e1b2..dbf01cca 100644 --- a/skills/start_dev_for_user/start_dev_for_user.md +++ b/skills/start_dev_for_user/start_dev_for_user.md @@ -15,6 +15,12 @@ the organ boundary, the Feature Agent, Memory consultation and the execution-environment model. Tone rules, comment templates and registry formats are in [`reference.md`](reference.md) (`PyAutoBrain/skills/start_dev_for_user/reference.md`). +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Usage ``` diff --git a/skills/start_library/start_library.md b/skills/start_library/start_library.md index 8b768369..4a5b8efc 100644 --- a/skills/start_library/start_library.md +++ b/skills/start_library/start_library.md @@ -9,6 +9,12 @@ Workflow entry point — the worktree/branch setup is the dev workflow's own state is **Mind**. Read [`../WORKFLOW.md`](../WORKFLOW.md) for the organ boundary, the worktree helpers, the execution-environment model, and the registry paths. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Steps ### 1. Conflict guard diff --git a/skills/start_workspace/reference.md b/skills/start_workspace/reference.md index bde79c01..0ba36c3d 100644 --- a/skills/start_workspace/reference.md +++ b/skills/start_workspace/reference.md @@ -3,6 +3,12 @@ Factored out of `start_workspace.md`. The body skill is authoritative for the flow; this file holds the verbatim impact-report and registry formats. +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Linked-mode API-change impact When a "Library PR Created" comment exists on the issue, fetch the upstream diff --git a/skills/start_workspace/start_workspace.md b/skills/start_workspace/start_workspace.md index 777ffd33..e8ad050f 100644 --- a/skills/start_workspace/start_workspace.md +++ b/skills/start_workspace/start_workspace.md @@ -11,6 +11,12 @@ Workflow entry point — worktree/branch setup is the dev workflow's own helpers, execution-environment model, and registry paths. Verbose formats live in [`reference.md`](reference.md). +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Steps ### 1. Conflict guard diff --git a/skills/update_issue/SKILL.md b/skills/update_issue/SKILL.md index 863806cb..cb97d05c 100644 --- a/skills/update_issue/SKILL.md +++ b/skills/update_issue/SKILL.md @@ -10,6 +10,12 @@ is part of the development cycle (the `start_*` → `ship_*` lifecycle; `start_dev_for_user` calls it at milestones). It reads the PyAutoMind registry to find the active issue. Organ boundary: [`../WORKFLOW.md`](../WORKFLOW.md). +> **GitHub surface.** The `gh` commands below name the *operation*, not +> necessarily the command: a Claude Code remote session has no `gh` and +> reaches GitHub through the `mcp__github__*` tools instead. Probe once +> (`command -v gh`) and translate via +> [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). + ## Usage ``` diff --git a/tests/test_board.py b/tests/test_board.py index 8b5ff0b4..836fd437 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -283,8 +283,11 @@ def test_json_surface_is_complete_and_derives_org(tmp_path): # Overnight rows: one per policy job, owner defaulted onto the derived org. assert s["overnight"], "policy overnight_jobs rendered no rows" for row in s["overnight"]: + # `unreadable` distinguishes "asked, no runs" from "could not ask" — + # without it a board that cannot reach GitHub renders every row as + # "no runs", which is a claim it has not earned. assert set(row) == {"repo", "workflow", "conclusion", "age_h", "url", - "blocked", "blocked_reason"} + "blocked", "blocked_reason", "unreadable"} assert row["repo"].startswith("ExampleOrg/") assert row["conclusion"] == "success" # Versions: every stamp resolves to the fixture, so consensus + no drift. diff --git a/tests/test_board_degraded.py b/tests/test_board_degraded.py new file mode 100644 index 00000000..8f5f381e --- /dev/null +++ b/tests/test_board_degraded.py @@ -0,0 +1,183 @@ +"""A board that could not ask must not answer. + +In a Claude Code remote session the board's two data sources are both absent: +there is no `gh`, and the sibling GitHub Pages boards are refused by the +environment's egress policy (403 to CONNECT). The render went on regardless and +came out looking healthy — seven scheduled workflows reported "no runs", the +readiness line said "unreachable", and the headline read **clear to work** in +brightgreen. Every one of those is a claim about the world made by a process +that had not been able to query it. + +These tests pin the distinction the render now has to keep: *found nothing* and +*could not look* are different answers, and only the first earns a green board. +""" + +import json +import sys +import urllib.error +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BRAIN_HOME / "board")) + +import _board # noqa: E402 + + +# The published JSON surface's key set, pinned in test_board.py. Built from the +# same list so this file cannot drift into testing a shape the board never emits. +SURFACE_KEYS = { + "generated", "org", "overnight", "heart", "heart_blockers", "heart_plan", + "performance", + "hands", "versions", "community", "resume", "open_issues", "hygiene", + "devbox", "autonomy", "doors", "boards", "degraded", "history", +} + + +def _surface(**over): + """A complete, healthy surface — every key present, nothing wrong.""" + base = {k: None for k in SURFACE_KEYS} + base.update({ + "generated": "2026-01-01 00:00 UTC", + "org": "ExampleOrg", + "overnight": [], + "heart": {}, + "heart_blockers": [], + "performance": {}, + # Shapes the render and the verdict actually subscript, kept minimal + # and healthy so these tests isolate the degradation behaviour. + "versions": {"drift": [], "consensus": None}, + "community": {}, + "resume": {"counts": {}, "pending_prs": [], "tasks": []}, + "open_issues": {}, + "hygiene": {}, + "autonomy": [], + "doors": {}, + "boards": {}, + "degraded": [], + "history": [], + }) + base.update(over) + return base + + +# -------------------------------------------------------------------------- +# "no runs" vs "could not ask" +# -------------------------------------------------------------------------- + +def _row(**over): + row = {"repo": "Org/Repo", "workflow": "wf.yml", "conclusion": None, + "age_h": None, "url": None, "blocked": False, + "blocked_reason": None, "unreadable": False} + row.update(over) + return row + + +def test_a_workflow_with_no_runs_says_so(): + line = _board._overnight_line(_row(unreadable=False)) + assert "no runs" in line + + +def test_a_workflow_that_could_not_be_read_does_not_claim_no_runs(): + line = _board._overnight_line(_row(unreadable=True)) + assert "no runs" not in line, "an unaskable workflow still reports as empty" + assert "could not read" in line + + +def test_the_two_states_render_differently(): + assert _board._overnight_line(_row(unreadable=True)) != \ + _board._overnight_line(_row(unreadable=False)) + + +# -------------------------------------------------------------------------- +# The headline is a claim, and must be qualified by what was read +# -------------------------------------------------------------------------- + +def test_a_complete_board_with_nothing_wrong_is_clear_and_green(): + data = _surface() + assert _board.headline(data) == "clear to work" + assert _board.badge_color(data) == "brightgreen" + + +def test_a_degraded_board_never_claims_an_unqualified_all_clear(): + data = _surface(degraded=["overnight: could not read Org/Repo/wf.yml"]) + head = _board.headline(data) + assert head != "clear to work" + assert "partial" in head + assert "1 leg unread" in head + + +def test_a_degraded_board_is_not_green(): + """The green badge is the strongest signal the board emits; unread legs + have not earned it.""" + assert _board.badge_color(_surface(degraded=["a"])) != "brightgreen" + + +def test_real_findings_still_outrank_degradation(): + """Degradation qualifies the headline; it must not mask an actual blocker.""" + data = _surface(degraded=["a", "b"]) + blocking, attention = ["something is on fire"], [] + _real = _board.verdict + _board.verdict = lambda d: (blocking, attention) + try: + assert _board.badge_color(data) == "red" + assert "need you" in _board.headline(data) + finally: + _board.verdict = _real + + +def test_banner_appears_at_the_top_not_only_the_foot(): + """A reader who stops after the headline must still learn the render is + partial — the foot section alone did not reach them.""" + md = _board.render_md(_surface(degraded=["overnight: could not read x"])) + head, sep, foot = md.partition("## Degraded") + assert sep, "no Degraded section at all" + assert "Degraded render" in head, "the banner is not above the sections" + assert "could not read x" in foot, "the detail left the foot section" + + +def test_a_healthy_board_carries_no_banner(): + """The banner must mean something; a clean render does not wear one.""" + md = _board.render_md(_surface()) + assert "Degraded render" not in md + assert "## Degraded" not in md + + +# -------------------------------------------------------------------------- +# Why a published surface could not be read +# -------------------------------------------------------------------------- + +def test_a_policy_block_is_named_as_one_not_as_a_flake(): + """403 on CONNECT will never succeed on retry — say so, once.""" + reason = _board._fetch_reason(urllib.error.HTTPError( + "https://example.invalid/board.json", 403, "Forbidden", {}, None)) + assert "network policy" in reason + assert "allowlist" in reason + + +def test_a_timeout_is_not_reported_as_a_policy_block(): + reason = _board._fetch_reason(urllib.error.URLError("timed out")) + assert "network policy" not in reason + assert "timed out" in reason + + +def test_fetch_records_its_reason_for_the_caller(): + why = [] + assert _board._fetch_json("http://127.0.0.1:9/nothing.json", why) is None + assert why, "the caller was given no reason to report" + + +def test_local_checkout_answers_when_the_published_copy_is_unreachable(tmp_path, + monkeypatch): + """A session holding the checkout can still answer from the tree.""" + repo = "SomeOrgan" + (tmp_path / repo / "board").mkdir(parents=True) + (tmp_path / repo / "board" / "board.json").write_text( + json.dumps({"blockers": [{"text": "from the local tree"}]})) + monkeypatch.setattr(_board, "PYAUTO_ROOT", tmp_path) + + degraded = [] + board = _board.fetch_heart_board("https://unreachable.invalid", repo, degraded) + assert board is not None, "held the checkout and still reported nothing" + assert board["blockers"][0]["text"] == "from the local tree" + assert degraded, "a fallback answer must still be flagged as degraded" + assert "local" in degraded[0] diff --git a/tests/test_branch_sweep.py b/tests/test_branch_sweep.py index 390673c3..ec1d0848 100644 --- a/tests/test_branch_sweep.py +++ b/tests/test_branch_sweep.py @@ -246,11 +246,18 @@ def test_repeated_delete_failures_stop_early(tmp_path): def test_refuses_to_run_without_gh(world): - """Blind to open PRs means blind to in-flight work: refuse, do not guess.""" + """Blind to open PRs means blind to in-flight work: refuse, do not guess. + + The refusal must also say what to do instead. A remote session has no `gh` + at all, so "gh not found" alone reads as a broken machine rather than the + wrong surface — the message names the alternative and where it is mapped. + """ clone, _, _ = world proc = _sweep(clone, None) assert proc.returncode == 1 - assert "gh not found" in proc.stderr + assert "refusing to sweep" in proc.stderr + assert "gh" in proc.stderr + assert "GITHUB_ACCESS.md" in proc.stderr def test_rejects_unknown_mode(world): diff --git a/tests/test_gh_surface.py b/tests/test_gh_surface.py new file mode 100644 index 00000000..aa72adb5 --- /dev/null +++ b/tests/test_gh_surface.py @@ -0,0 +1,120 @@ +"""`gh` is not available on every surface, and the skills must say so. + +The workflow was written on a developer box, so its GitHub steps are spelled as +`gh` commands — 19 skill bodies and 15 scripts here. A Claude Code remote +session has no `gh` at all; its GitHub access is the `mcp__github__*` tool +surface. Nothing said so anywhere, so each mobile run rediscovered it: load the +procedure, run the first `gh`, get `command not found`, re-derive the whole +thing through MCP. + +Two rules, pinned here: + +1. a skill body that drives `gh` links to `skills/GITHUB_ACCESS.md`, which maps + each operation onto its MCP tool; +2. a script that needs `gh` says so in a way that names the alternative, rather + than letting an empty command substitution be read as a real answer. +""" + +import os +import re +import subprocess +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +SKILLS = BRAIN_HOME / "skills" +ACCESS_PAGE = SKILLS / "GITHUB_ACCESS.md" +GH_HELPER = BRAIN_HOME / "bin" / "_gh.sh" + +# A real `gh` invocation, not the word in prose. +GH_CALL = re.compile(r'\bgh (pr|api|issue|run|repo|workflow|auth|search|release)\b') + +# Shared prose pages describe the surface rather than driving it. +EXEMPT = {"GITHUB_ACCESS.md", "COMMANDS.md", "WORKFLOW.md", "OPERATIONS.md"} + + +def _gh_free_env(tmp_path): + """A PATH with the tools these scripts call, minus `gh`.""" + lean = tmp_path / "nogh" + lean.mkdir(exist_ok=True) + for tool in ("bash", "git", "sed", "awk", "grep", "sort", "head", "cut", + "tr", "dirname", "basename", "cat", "wc", "env", "uniq"): + path = subprocess.run(["bash", "-c", f"command -v {tool}"], + capture_output=True, text=True).stdout.strip() + if path and not (lean / tool).exists(): + (lean / tool).symlink_to(path) + env = dict(os.environ) + env["PATH"] = str(lean) + return env + + +def test_access_page_ships_and_maps_the_operations(): + assert ACCESS_PAGE.is_file() + text = ACCESS_PAGE.read_text() + # the operations a close-out cannot do without + for tool in ("pull_request_read", "merge_pull_request", "issue_write", + "add_issue_comment", "actions_list", "get_file_contents"): + assert tool in text, f"{tool} has no mapping" + # and the honest limit + assert "cannot" in text.lower() + + +def test_every_gh_driving_skill_points_at_the_mapping(): + offenders = [] + for path in sorted(SKILLS.glob("*/*.md")): + if path.name in EXEMPT: + continue + text = path.read_text(encoding="utf-8", errors="replace") + if not GH_CALL.search(text): + continue + if "GITHUB_ACCESS.md" not in text: + offenders.append(str(path.relative_to(BRAIN_HOME))) + assert not offenders, ( + "these drive `gh` without telling a gh-less session what to do " + "instead — add a pointer to skills/GITHUB_ACCESS.md:\n " + + "\n ".join(offenders) + ) + + +def test_every_pointer_resolves(): + broken = [] + for path in sorted(SKILLS.glob("*/*.md")): + text = path.read_text(encoding="utf-8", errors="replace") + for rel in re.findall(r'\]\((\.\.?/[^)]*GITHUB_ACCESS\.md)\)', text): + if not (path.parent / rel).is_file(): + broken.append(f"{path.relative_to(BRAIN_HOME)} -> {rel}") + assert not broken, broken + + +def test_require_gh_names_the_alternative(tmp_path): + """The failure must be actionable, and carry a command-not-found status.""" + r = subprocess.run( + ["bash", "-c", f'. "{GH_HELPER}"; require_gh demo'], + capture_output=True, text=True, env=_gh_free_env(tmp_path), timeout=60) + assert r.returncode == 127, r + assert "demo" in r.stderr + assert "gh" in r.stderr + assert "GITHUB_ACCESS.md" in r.stderr + + +def test_have_gh_is_false_without_gh(tmp_path): + r = subprocess.run( + ["bash", "-c", f'. "{GH_HELPER}"; have_gh && echo yes || echo no'], + capture_output=True, text=True, env=_gh_free_env(tmp_path), timeout=60) + assert r.stdout.strip() == "no" + + +def test_helper_locates_itself_without_readlink(tmp_path): + """The scripts run under a lean PATH; the helper must not need one more tool. + + Sourcing via `readlink` broke `branch_sweep.sh` in exactly the environment + whose whole point is "gh is missing" — the helper died before it could say + why. + """ + env = _gh_free_env(tmp_path) + assert not (tmp_path / "nogh" / "readlink").exists(), "fixture links readlink" + r = subprocess.run( + ["bash", str(BRAIN_HOME / "bin" / "branch_sweep.sh"), + "--repo", str(BRAIN_HOME), "--owner", "o", "--name", "n"], + capture_output=True, text=True, env=env, timeout=60) + assert "command not found" not in r.stderr, r.stderr + assert "GITHUB_ACCESS.md" in r.stderr diff --git a/tests/test_workspace_root.py b/tests/test_workspace_root.py new file mode 100644 index 00000000..f9269a64 --- /dev/null +++ b/tests/test_workspace_root.py @@ -0,0 +1,124 @@ +"""The workspace root is derived, never named. + +Regression cover for the mobile-performance review: `agents/_common.sh` and +five Python entrypoints each defaulted the workspace root to a hardcoded +developer-box path under `$HOME`. On that box it is right; in a remote session +`$HOME` is `/root` while the checkouts sit under `/home/user`, so every one of +them resolved into a directory that does not exist. None of them crashed — they +are all written to degrade — so `pyauto-brain board` printed a plausible board +with hollow sections at exit 0, and the community leg reported "body map not +found" for a file that was present one directory up from the script reading it. + +The rule these tests pin: exactly one shell resolver and one Python resolver, +they agree, and no other source file resolves the root for itself. +""" + +import os +import subprocess +import sys +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BRAIN_HOME / "agents")) + +import _pyauto_root # noqa: E402 + +SHELL_RESOLVER = BRAIN_HOME / "bin" / "_pyauto_root.sh" +PY_RESOLVER = BRAIN_HOME / "agents" / "_pyauto_root.py" + +# Shapes that mean "this file resolved the workspace root for itself". The +# resolvers name no absolute path at all, so any $HOME-relative workspace guess +# in executable code is drift by construction. +SELF_RESOLVE_MARKERS = ( + 'PYAUTO_ROOT:-$HOME/', + 'PYAUTO_MAIN:-$HOME/', + 'PYAUTO_WT_ROOT:-$HOME/', + 'os.environ.get("PYAUTO_ROOT", Path.home()', + 'os.environ.get("PYAUTO_ROOT", Path(os.path.expanduser', + 'expanduser("~/Code', + 'Path.home() / "Code"', +) + + +def _sourced_shell_root(env_overrides=None): + env = dict(os.environ) + env.pop("PYAUTO_ROOT", None) + env.pop("PYAUTO_WT_ROOT", None) + env.update(env_overrides or {}) + r = subprocess.run( + ["bash", "-c", f'. "{SHELL_RESOLVER}"; printf "%s" "$PYAUTO_ROOT"'], + capture_output=True, text=True, env=env, timeout=30, + ) + assert r.returncode == 0, r.stderr + return r.stdout + + +def test_shell_and_python_resolvers_agree(): + """A shell agent and the Python it shells out to must see one tree.""" + env_root = os.environ.get("PYAUTO_ROOT") + if env_root: + expected = env_root + else: + expected = str(_pyauto_root.pyauto_root()) + assert _sourced_shell_root() == expected + + +def test_explicit_override_wins_in_both_resolvers(): + """An operator pointing the tooling elsewhere is taken at their word.""" + assert _sourced_shell_root({"PYAUTO_ROOT": "/tmp/elsewhere"}) == "/tmp/elsewhere" + old = os.environ.get("PYAUTO_ROOT") + os.environ["PYAUTO_ROOT"] = "/tmp/elsewhere" + try: + assert _pyauto_root.pyauto_root() == Path("/tmp/elsewhere") + assert _pyauto_root.workspace_root_reason()[1] == "PYAUTO_ROOT" + finally: + if old is None: + del os.environ["PYAUTO_ROOT"] + else: + os.environ["PYAUTO_ROOT"] = old + + +def test_root_resolves_beside_this_checkout(): + """This checkout's own parent is a workspace root, and is found as one.""" + old = os.environ.pop("PYAUTO_ROOT", None) + try: + root, reason = _pyauto_root.workspace_root_reason() + assert reason == "beside this checkout" + assert root == BRAIN_HOME.parent + # ...and the resolved root really holds a sibling organ, which is the + # whole claim: the old default could not have said this. + assert any((root / o).is_dir() for o in _pyauto_root.SIBLING_ORGANS) + finally: + if old is not None: + os.environ["PYAUTO_ROOT"] = old + + +def test_no_entrypoint_resolves_the_root_for_itself(): + """Every consumer delegates; nobody re-derives the root from $HOME.""" + allowed = {SHELL_RESOLVER.resolve(), PY_RESOLVER.resolve()} + offenders = [] + for pattern in ("bin/**/*.sh", "agents/**/*.sh", "agents/**/*.py", + "board/**/*.py"): + for path in BRAIN_HOME.glob(pattern): + if path.resolve() in allowed: + continue + text = path.read_text(encoding="utf-8", errors="replace") + for marker in SELF_RESOLVE_MARKERS: + if marker in text: + offenders.append(f"{path.relative_to(BRAIN_HOME)}: {marker}") + assert not offenders, ( + "these resolve the workspace root themselves instead of using " + "bin/_pyauto_root.sh / agents/_pyauto_root.py:\n " + + "\n ".join(offenders) + ) + + +def test_worktree_root_derives_from_the_resolved_root(): + """Worktrees live beside the root, wherever the root turned out to be.""" + old = os.environ.pop("PYAUTO_WT_ROOT", None) + try: + root = _pyauto_root.pyauto_root() + assert _pyauto_root.pyauto_wt_root() == root.parent / f"{root.name}-wt" + finally: + if old is not None: + os.environ["PYAUTO_WT_ROOT"] = old