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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 124 additions & 1 deletion .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<repo>/.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")"
Expand All @@ -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)"
Expand Down
17 changes: 13 additions & 4 deletions agents/_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command-name> <fallback-path> — echo a runnable invocation or
# print an install hint to stderr and return 127.
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions agents/_pyauto_root.py
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 10 additions & 2 deletions agents/conductors/clone/_clone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
11 changes: 10 additions & 1 deletion agents/conductors/community/_community.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 15 additions & 1 deletion agents/conductors/hygiene/_hygiene_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 "
Expand Down
11 changes: 10 additions & 1 deletion agents/conductors/profiling/_profiling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions agents/conductors/release/nightly.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading