diff --git a/.github/workflows/brain_board.yml b/.github/workflows/brain_board.yml new file mode 100644 index 0000000..fc3daaf --- /dev/null +++ b/.github/workflows/brain_board.yml @@ -0,0 +1,78 @@ +name: Brain Board + +# Publishes the Brain's operational board — the organism's morning door — to +# this repo's GitHub Pages URL: the page (index.html), its badge.json (the +# cross-board headline contract the umbrella router consumes), the raw +# board.json, and the markdown twin board.md. +# +# The board replaces the interactive /wake_up skill: everything that skill +# assembled by driving doors in sequence (overnight scheduled-run sweep, the +# Heart's readiness headline, version-stamp consistency, the community scan, +# resume context from the Mind, the upkeep doors) is collected here on a +# schedule and rendered with one-tap copy-for-Claude payloads. The renderer is +# board/_board.py; its vocabulary is config/policy.yaml `board:`. +# +# Unlike the Mind dashboard (self-healed into the repo, because its state IS +# the repo), nothing here is committed: the board's data is time-varying, so +# it is served from the Pages artifact only — a daily refresh makes no commit +# noise and can never drift against a repo copy. +# +# Cadence: 05:30 UTC daily — after the 02:00 nightly release driver and the +# Heart's 05:00 heart-health refresh (which rewrites the badge this board +# reads), before the human's morning. GitHub cron jitters 0–3 h under load, +# so the page stamps its own generation time and every consumer should trust +# that stamp over the schedule. workflow_dispatch is the manual refresh +# (also what a human taps when the board says it is stale). + +on: + schedule: + - cron: "30 5 * * *" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + publish: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + path: PyAutoBrain + # The board composes, it does not recompute: the community scan reads + # the body map (repos.yaml) and the resume section reads the registry + + # the Mind's own generated dashboard counts, so the Mind is checked out + # as a sibling — the same two-repo layout tests.yml uses. + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/PyAutoMind + path: PyAutoMind + - name: render the board + env: + PYAUTO_ROOT: ${{ github.workspace }} + run: | + python3 -m pip install --quiet pyyaml + python3 PyAutoBrain/board/_board.py --apply --out _site + # enablement: true creates the Pages site on first run where the token + # may (Mind/Heart precedent); where it may not (the Hands hit "Resource + # not accessible by integration"), the site must be created once + # out-of-band: gh api -X POST repos//PyAutoBrain/pages -f build_type=workflow + - uses: actions/configure-pages@v5 + with: + enablement: true + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/AGENTS.md b/AGENTS.md index edded74..042cbe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,11 +169,18 @@ via `/route`, and it routes to the right agent; normal usage never says "PyAutoBrain". A few commands are compositions rather than single agents: `/docs` and `/research` route through the dev-flow with their PyAutoMind work-type fixed (no dedicated conductor — added only on demonstrated need, never -for symmetry); `/wake_up` composes sync + `/health` + `/hygiene`; `/prm` composes the +for symmetry); `/prm` composes the end-of-task wrap-up (CI green → merge → `ship_*` completion); `/brain ` is the raw passthrough. Every command routes **through** the Brain; none replaces it. +The morning routine is not a command at all: the **Brain board** +(`board/_board.py`, published to the Brain's GitHub Pages URL each morning by +`brain_board.yml`) carries what `/wake_up` used to assemble — overnight runs, +readiness, community, resume, upkeep — as one-tap 📋 payloads, and +`bin/morning.sh` is the local sync/clean leg you run in a terminal. +`/wake_up` remains only as the fallback door when the board is unreachable. + The command bodies live in `skills//.md`; thin `SKILL.md` wrappers make the same canonical workflows discoverable to skill-aware harnesses. `bin/install.sh` installs both surfaces without duplicating their bodies. Shared diff --git a/README.md b/README.md index a3103be..a35386e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,13 @@ check-up — or just `/route ` and the Brain picks the right door. The full command surface (13 conductors + 5 faculties) is the generated table in [AGENTS.md](AGENTS.md). +Start the day on the **[Brain board](https://pyautolabs.github.io/PyAutoBrain/)** +— the organism's operational dashboard, regenerated each morning: what ran +overnight, the Heart's readiness headline, who in the community is waiting on +a reply, what to resume, and the upkeep doors, each with a one-tap 📋 +copy-for-Claude command. The local sync/clean leg is one terminal command, +`bash bin/morning.sh`. + ## How PyAutoBrain works 1. **A task arrives.** Usually from the Mind's backlog — pick a task on the diff --git a/bin/morning.sh b/bin/morning.sh new file mode 100755 index 0000000..54cb2d8 --- /dev/null +++ b/bin/morning.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# morning.sh — the local half of the morning routine, as ONE terminal command. +# +# The Brain board (https://.github.io/PyAutoBrain/ — rendered by +# board/_board.py, published by brain_board.yml) carries every remote signal +# the old /wake_up skill assembled: overnight runs, readiness, community, +# resume, upkeep. The two steps a cloud render cannot do are the ones that +# touch YOUR checkout — sync and clean-slate. This script is exactly those two +# steps, so the morning is: run this in a terminal, then open the board. +# +# bash PyAutoBrain/bin/morning.sh # sync + clean, then board URL +# bash PyAutoBrain/bin/morning.sh --digest # also print the board's +# # markdown digest (needs gh) +# DRY_RUN=1 bash PyAutoBrain/bin/morning.sh # preview clean-slate only +# +# Both steps are the recoverable, git-aware ones /wake_up auto-ran (its +# guardrail): sync skips any repo with real uncommitted work; clean-slate +# deletes only untracked REGENERABLE artifacts and reports orphans instead of +# removing them. Nothing else is deleted, edited, or bumped here. + +set -u + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +echo "== morning: sync every repo to main (ff-only; real work is skipped) ==" +bash "$HERE/pull_all_main.sh" + +echo +echo "== morning: clean slate (untracked regenerable artifacts + cruft) ==" +bash "$HERE/clean_slate.sh" + +echo +echo "== morning: done — the rest of the routine is on the board ==" +# Board URL from the checkout's own remote (no hardcoded org). +origin="$(git -C "$HERE/.." remote get-url origin 2>/dev/null || true)" +owner="$(printf '%s' "$origin" | sed -E 's#\.git$##; s#.*[:/]([^/:]+)/[^/]+$#\1#')" +if [ -n "$owner" ]; then + echo " https://$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]').github.io/PyAutoBrain/" +fi + +if [ "${1:-}" = "--digest" ]; then + echo + bash "$HERE/../board/board.sh" || echo "morning: board digest unavailable (gh auth?)" +fi diff --git a/bin/overnight_status.sh b/bin/overnight_status.sh index e008cc0..c6a7f55 100755 --- a/bin/overnight_status.sh +++ b/bin/overnight_status.sh @@ -18,8 +18,10 @@ set -u command -v gh >/dev/null 2>&1 || { echo "gh not found — cannot fetch run status" >&2; exit 1; } # owner/repo:workflow-file (owner defaults to PyAutoLabs when omitted). The -# passive morning webhooks (morning_health / morning_status) are excluded — -# /wake_up is their interactive complement, not a re-run of them. +# passive morning webhooks (morning_health / morning_status) are excluded. +# The Brain board renders this same sweep on Pages (board/_board.py reads the +# list from config/policy.yaml `board: overnight_jobs`) — keep the two lists +# in step until this script reads that block too. JOBS=( "PyAutoBrain:nightly-release.yml" "PyAutoHeart:heart-health.yml" diff --git a/bin/pyauto-brain b/bin/pyauto-brain index a25abbc..d68d484 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -35,6 +35,8 @@ # PyAutoFit search catalogue, benchmark record, tier gaps # pyauto-brain sizing [args] (faculty) read-only: the SizingSurface — difficulty estimate for # a PyAutoMind prompt (the heuristic intake + feature both consult) +# pyauto-brain board [args] (surface) render the operational board — the morning +# page brain_board.yml publishes (md/html/json/badge) # pyauto-brain help [name] list agents or show one agent's docs # # Renamed from `pyauto-agent`; the former back-compat shim has been removed now @@ -95,6 +97,14 @@ CONDUCTOR_ORDER=(intake community feature bug refactor workspace eyes profiling FACULTY_ORDER=(vitals review memory samplers sizing) AGENT_ORDER=("${CONDUCTOR_ORDER[@]}" "${FACULTY_ORDER[@]}") +# Surfaces — generated pages, not agents (they neither act nor opine, they +# show). Dispatchable and help-able like agents, but kept out of the +# CONDUCTOR/FACULTY orders so the generated cross-organ command-surface block +# (install.sh --write-agents-surface) is untouched. +AGENT_SCRIPT[board]="$BRAIN_HOME/board/board.sh" +AGENT_DESC[board]="The operational board — render the morning surface (overnight runs, readiness, community, resume, upkeep) as md/html/json/badge; published to Pages by brain_board.yml" +SURFACE_ORDER=(board) + cmd_help() { if [[ $# -gt 0 ]]; then local name="$1" @@ -131,6 +141,15 @@ EOF printf ' %-10s %s\n' "$name" "${AGENT_DESC[$name]}" done echo + # Surfaces are listed as prose, not roster rows: the skill-install tests + # treat every 4-space roster row as a public AGENT (wrapper + generated + # cross-organ surface-block entry), and a surface is neither tier. + echo "Surfaces (generated pages — read-only renders, not agents):" + local surface + for surface in "${SURFACE_ORDER[@]}"; do + printf ' %s — %s\n' "$surface" "${AGENT_DESC[$surface]}" + done + echo echo "Run 'pyauto-brain help ' for that agent's full docs." } diff --git a/bin/version_drift.sh b/bin/version_drift.sh index 73c070e..142f6ad 100755 --- a/bin/version_drift.sh +++ b/bin/version_drift.sh @@ -38,7 +38,10 @@ else fi echo -# repo:path — the coupled release-train stamps (libraries only). The +# repo:path — the coupled release-train stamps (libraries only). The Brain +# board renders this same check on Pages (board/_board.py reads the list from +# config/policy.yaml `board: version_stamps`) — keep the two lists in step +# until this script reads that block too. The # charge-transfer (CTI) calibration stack is intentionally excluded: it is # not on the coupled train and carries its own version line. Workspaces # carry no stamp since the floors-are-authoritative redesign dropped diff --git a/board/AGENTS.md b/board/AGENTS.md new file mode 100644 index 0000000..3ced811 --- /dev/null +++ b/board/AGENTS.md @@ -0,0 +1,62 @@ +# The Brain Board — the operational surface (the morning door) + +> Tier: **surface** — a generated page, not an agent. It decides nothing and +> opines on nothing; it reads what the organs already publish and renders it. +> (Contrast conductors, which act, and faculties, which judge — this only +> shows. The precedent is the Mind dashboard, which lives with the intake +> conductor; the Heart and Hands boards are the sibling shapes.) + +The sixth one-tap board, live at the Brain's GitHub Pages URL +(`https://.github.io/PyAutoBrain/`): the organism's **morning and +general starting point**. It replaces the interactive `/wake_up` composition — +everything that skill assembled by driving doors in sequence is collected on a +schedule and rendered as one page, each actionable row carrying a one-tap 📋 +copy-for-Claude payload: + +| Section | Signal owner | One-tap payload | +|---------|--------------|-----------------| +| ⌨ Morning sync | `bin/morning.sh` (local) | the terminal command itself | +| 🌙 Overnight | scheduled workflows (`config/policy.yaml board: overnight_jobs`) | `/bug … — ` on failures | +| ❤️ Readiness | the Heart board's `badge.json` (cross-board contract) | `/health` | +| 🏷️ Version consistency | the coupled-set stamps (`board: version_stamps`) | `/bug version drift: …` | +| 💬 Community | the Ears (`community scan`, reused wholesale) | `/community`, `/community triage ` | +| 🔄 Resume | the Mind's registry + generated counts; pending-release PRs | `/start_dev …`, `/prm ` | +| 🧹 Upkeep | open-issue count; the cleanup doors | `/issue_cleanup`, `/hygiene`, `/repo_cleanup` | +| 🚪 All doors | `bin/pyauto-brain`'s own registry (never a second copy) | `/` | + +**Compose, don't recompute** — the board re-derives nothing. The community +section imports the community conductor's `build_scan()`; the resume counts +are parsed from the Mind's own generated `dashboard.md`; readiness comes from +the Heart board's published badge. Every unreachable source degrades into an +honest "Degraded" row, never fabricated content. + +**Read-only** — the collect half touches only read-only `gh` endpoints and +public Pages URLs. It never posts, labels, or edits anything on GitHub, and +never writes files outside `--apply`'s output directory. + +## Running + +```bash +bin/pyauto-brain board # markdown digest in the terminal +bin/pyauto-brain board --html # the one-tap page +bin/pyauto-brain board --badge # the cross-board headline contract +bin/pyauto-brain board --apply # write _site/ (what brain_board.yml serves) +``` + +Publishing is `.github/workflows/brain_board.yml`: a morning cron plus manual +dispatch renders `--apply` output and deploys it to GitHub Pages (page + +`badge.json` + `board.json` + `board.md`). Nothing is committed to the repo — +the board is served, not stored, so a daily refresh makes no commit noise. + +## Configuration + +Instance vocabulary lives in `config/policy.yaml` under `board:` (the declared +config surface an adopting fork replaces): `overnight_jobs` (repo:workflow +pairs for the sweep), `version_stamps` (repo:path pairs for the consistency +check), `reference_release_repo`, `heart_board`, and `boards` (the sibling +board links). The org/owner is derived from the Mind's body map +(`PyAutoMind/repos.yaml`) at runtime — never hardcoded here. + +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). diff --git a/board/_board.py b/board/_board.py new file mode 100755 index 0000000..a9b5b9d --- /dev/null +++ b/board/_board.py @@ -0,0 +1,812 @@ +#!/usr/bin/env python3 +"""board/_board.py — the PyAutoBrain operational board (the morning door). + +The sixth one-tap board: a generated page at the Brain's GitHub Pages URL +holding everything /wake_up used to assemble interactively — the overnight +scheduled-run sweep, the Heart's readiness headline, version-stamp consistency, +the community's waiting conversations, resume context from the Mind, and the +upkeep doors — each actionable row carrying a one-tap 📋 copy-for-Claude +payload. The morning routine becomes: run `bin/morning.sh` in a terminal +(the local sync/clean leg), open this board, tap what needs you. + +Shape follows the sibling boards (Heart heart/dashboard.py, Hands +autohands/board.py): a THIN COLLECT (gh CLI + the sibling boards' published +badge.json, the cross-board headline contract) feeding PURE RENDERS +(md / html / json / badge). The board reasons about nothing new — every signal +already has an owner (compose, don't recompute) — and it never mutates +anything: read-only gh endpoints only, no posts, no labels, no writes outside +--apply's output directory. + +Instance vocabulary (which workflows the overnight sweep reads, which stamps +the consistency check compares, which sibling boards exist) lives in +config/policy.yaml under `board:` — the declared config surface an adopting +fork replaces. The org/owner is derived from the Mind's body map +(PyAutoMind/repos.yaml), never hardcoded here. + +Env (hermetic tests override all three): + PYAUTO_ROOT workspace root holding PyAutoMind/ (default: this + checkout's parent — the standard sibling layout) + BOARD_GH the gh binary (default `gh`) + BOARD_PAGES_BASE base URL of the sibling boards (default https://.github.io) + +Exit codes: 0 rendered · 4 inputs unresolvable (no policy / no body map). +""" + +from __future__ import annotations + +import argparse +import base64 +import html +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +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)) +GH = os.environ.get("BOARD_GH", "gh") +POLICY_PATH = BRAIN_HOME / "config" / "policy.yaml" + +# A successful scheduled run carrying a step with this name prefix stopped on +# purpose and made no change (the nightly driver's OUTCOME CONTRACT). Keep in +# sync with bin/overnight_status.sh and nightly-release.yml. +BLOCKED_STEP_PREFIX = "Blocked at a gate" + +VERPAT = r"[0-9]{4}\.[0-9]+\.[0-9]+\.[0-9]+" + +# The local morning leg — the one thing the board cannot do for you. Rendered +# as a copyable TERMINAL command (not a Claude payload) at the top of the page. +MORNING_CMD = "bash PyAutoBrain/bin/morning.sh" + + +def fail(code, msg): + print(f"board: {msg}", file=sys.stderr) + sys.exit(code) + + +# ---------------------------------------------------------------- policy ---- + + +def load_policy(): + """The `board:` block of config/policy.yaml (strict — the board's + vocabulary is declared config, like the sizing faculty's).""" + try: + import yaml + except ImportError: + fail(4, "PyYAML is required (pip install pyyaml)") + if not POLICY_PATH.is_file(): + fail(4, f"policy not found: {POLICY_PATH}") + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) or {} + board = policy.get("board") + if not board: + fail(4, f"no `board:` block in {POLICY_PATH}") + return board + + +def repo_homes(): + """Every `github:` home in PyAutoMind/repos.yaml (regex, no yaml needed — + same parse the community conductor uses). [] when the Mind is absent.""" + body_map = PYAUTO_ROOT / "PyAutoMind" / "repos.yaml" + if not body_map.is_file(): + return [] + return re.findall( + r"^\s+github:\s*(\S+)\s*$", body_map.read_text(encoding="utf-8"), re.M + ) + + +def derive_org(homes): + """The organism's GitHub org = the most common owner in the body map; + falls back to the Brain checkout's own remote when the Mind is absent.""" + owners = [h.split("/")[0] for h in homes if "/" in h] + if owners: + return max(set(owners), key=owners.count) + try: + r = subprocess.run( + ["git", "-C", str(BRAIN_HOME), "remote", "get-url", "origin"], + capture_output=True, text=True, timeout=10, + ) + m = re.search(r"[:/]([^/:]+)/[^/]+?(?:\.git)?$", r.stdout.strip()) + if r.returncode == 0 and m: + return m.group(1) + except OSError: + pass + return None + + +# --------------------------------------------------------------- collect ---- + + +def gh_json(args): + """`gh api ...` -> parsed JSON; None on any failure (the surface degrades + honestly rather than inventing content). Read-only endpoints only.""" + try: + r = subprocess.run( + [GH, "api", *args], capture_output=True, text=True, timeout=120 + ) + except (OSError, subprocess.TimeoutExpired): + return None + if r.returncode != 0: + return None + try: + return json.loads(r.stdout) + except json.JSONDecodeError: + return None + + +def age_h(iso): + try: + then = datetime.fromisoformat(iso.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + return round((datetime.now(timezone.utc) - then).total_seconds() / 3600) + + +def age_label(hours): + if hours is None: + return "?" + return f"{hours}h" if hours < 48 else f"{hours // 24}d" + + +def collect_overnight(jobs, org, degraded): + """Latest run of each scheduled workflow (the what-ran-while-I-slept + glance), with the blocked-at-a-gate refinement from overnight_status.sh.""" + rows = [] + for job in jobs: + repo, _, wf = str(job).partition(":") + if "/" not in repo: + repo = f"{org}/{repo}" + runs = gh_json([f"repos/{repo}/actions/workflows/{wf}/runs?per_page=1"]) + run = (runs or {}).get("workflow_runs") or [None] + run = run[0] + if run is None: + if runs is None: + degraded.append(f"overnight: could not read {repo}/{wf}") + rows.append({"repo": repo, "workflow": wf, "conclusion": None, + "age_h": None, "url": None, "blocked": False}) + continue + conclusion = run.get("conclusion") or run.get("status") + blocked = False + if conclusion == "success" and run.get("id"): + jobs_json = gh_json([f"repos/{repo}/actions/runs/{run['id']}/jobs"]) + for j in (jobs_json or {}).get("jobs", []): + for step in j.get("steps") or []: + if (step.get("conclusion") == "success" + and str(step.get("name", "")).startswith(BLOCKED_STEP_PREFIX)): + blocked = True + rows.append({ + "repo": repo, + "workflow": wf, + "conclusion": conclusion, + "age_h": age_h(run.get("created_at")), + "url": run.get("html_url"), + "blocked": blocked, + }) + return rows + + +def fetch_badge(pages_base, repo): + """A sibling board's published badge.json — the cross-board headline + contract ({label, message, color}). None when unreachable.""" + url = f"{pages_base}/{repo}/badge.json" + 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): + return None + + +def collect_versions(stamps, org, reference_repo, degraded): + """Version-stamp CONSISTENCY across the coupled set (the version_drift.sh + invariant: same stamp as the siblings; the release tag is context only).""" + rows = [] + for s in stamps: + repo, _, path = str(s).partition(":") + v = None + local = PYAUTO_ROOT / repo / path + if local.is_file(): + m = re.search(VERPAT, local.read_text(encoding="utf-8", errors="replace")) + v = m.group(0) if m else None + else: + content = gh_json([f"repos/{org}/{repo}/contents/{path}"]) + if content and content.get("content"): + try: + text = base64.b64decode(content["content"]).decode( + "utf-8", errors="replace") + except (ValueError, TypeError): + text = "" + m = re.search(VERPAT, text) + v = m.group(0) if m else None + rows.append({"repo": repo, "version": v}) + resolved = [r["version"] for r in rows if r["version"]] + consensus = max(set(resolved), key=resolved.count) if resolved else None + for r in rows: + r["ok"] = r["version"] is None or consensus is None or r["version"] == consensus + if not resolved: + degraded.append("versions: no stamps resolved") + reference = None + if reference_repo: + rel = gh_json([f"repos/{org}/{reference_repo}/releases/latest"]) + reference = (rel or {}).get("tag_name") + return { + "stamps": rows, + "consensus": consensus, + "reference": reference, + "drift": sum(1 for r in rows if not r["ok"]), + } + + +def collect_community(degraded): + """The Ears' scan surface, reused wholesale (never re-derived): import the + community conductor and call its build_scan().""" + sys.path.insert(0, str(BRAIN_HOME / "agents" / "conductors" / "community")) + # The community module reads its env at import; mirror the board's gh + # override so hermetic runs stay hermetic. + os.environ.setdefault("COMMUNITY_GH", GH) + try: + import _community + return _community.build_scan() + except SystemExit as e: + degraded.append(f"community: scan unavailable (exit {e.code})") + except Exception as e: # a degraded section, never a dead board + degraded.append(f"community: scan failed ({type(e).__name__})") + finally: + sys.path.pop(0) + return None + + +def collect_resume(org, degraded): + """Resume context: the Mind's own generated counts (dashboard.md header — + compose, don't recompute), the task files on deck, the queue length, and + open pending-release PRs.""" + mind = PYAUTO_ROOT / "PyAutoMind" + counts = {} + tasks = [] + queue_len = None + if mind.is_dir(): + dash = mind / "dashboard.md" + if dash.is_file(): + for label, n in re.findall( + r"^\|\s*\[([^\]]+)\]\([^)]*\)[^|]*\|\s*(\d+)\s*\|", + dash.read_text(encoding="utf-8"), re.M, + ): + counts[label] = int(n) + active = mind / "active" + if active.is_dir(): + for f in sorted(active.glob("*.md")): + if f.name == "AGENTS.md": + continue + title = f.stem + for line in f.read_text(encoding="utf-8").splitlines(): + if line.startswith("# "): + title = line[2:].strip() + break + tasks.append({"path": f"active/{f.name}", "title": title}) + queue = mind / "queue.md" + if queue.is_file(): + queue_len = sum( + 1 for line in queue.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.startswith("#") + and (line.startswith("- ") or line.rstrip().endswith(".md")) + ) + else: + degraded.append("resume: PyAutoMind checkout not found (set PYAUTO_ROOT)") + prs = gh_json( + [f"search/issues?q=org:{org}+is:pr+is:open+label:pending-release"]) + pending = None + if prs is not None: + pending = [{ + "repo": "/".join(i.get("repository_url", "").split("/")[-2:]), + "number": i.get("number"), + "title": i.get("title", ""), + "url": i.get("html_url"), + } for i in prs.get("items", [])] + else: + degraded.append("resume: pending-release PR search failed") + return {"counts": counts, "tasks": tasks, "queue_len": queue_len, + "pending_prs": pending} + + +def collect_open_issues(org, degraded): + """Total open issues across the org — the /issue_cleanup pointer count + (the audit itself stays that skill's confirmation-gated job).""" + res = gh_json([f"search/issues?q=org:{org}+is:issue+is:open&per_page=1"]) + if res is None: + degraded.append("upkeep: open-issue count unavailable") + return None + return res.get("total_count") + + +def collect_doors(): + """The conductor/faculty roster, read from the dispatcher registry itself + (bin/pyauto-brain is the single source; never a second copy here).""" + script = ( + f'source "{BRAIN_HOME}/bin/pyauto-brain"; ' + 'for v in "${CONDUCTOR_ORDER[@]}"; do printf "conductor\\t%s\\t%s\\n" "$v" "${AGENT_DESC[$v]}"; done; ' + 'for v in "${FACULTY_ORDER[@]}"; do printf "faculty\\t%s\\t%s\\n" "$v" "${AGENT_DESC[$v]}"; done' + ) + try: + r = subprocess.run(["bash", "-c", script], + capture_output=True, text=True, timeout=30) + except OSError: + return [] + doors = [] + for line in r.stdout.splitlines(): + parts = line.split("\t", 2) + if len(parts) == 3: + doors.append({"tier": parts[0], "verb": parts[1], "desc": parts[2]}) + return doors + + +def collect(): + board_cfg = load_policy() + homes = repo_homes() + org = derive_org(homes) + if org is None: + fail(4, "cannot derive the GitHub org (no body map, no git remote)") + pages_base = os.environ.get( + "BOARD_PAGES_BASE", f"https://{org.lower()}.github.io") + degraded = [] + overnight = collect_overnight(board_cfg.get("overnight_jobs", []), org, degraded) + heart = fetch_badge(pages_base, board_cfg.get("heart_board", "PyAutoHeart")) + if heart is None: + degraded.append("readiness: Heart board badge unreachable") + versions = collect_versions( + board_cfg.get("version_stamps", []), org, + board_cfg.get("reference_release_repo"), degraded) + community = collect_community(degraded) + resume = collect_resume(org, degraded) + open_issues = collect_open_issues(org, degraded) + boards = {name: f"{pages_base}/{repo}/" + for name, repo in (board_cfg.get("boards") or {}).items()} + return { + "generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + "org": org, + "overnight": overnight, + "heart": heart, + "versions": versions, + "community": community, + "resume": resume, + "open_issues": open_issues, + "doors": collect_doors(), + "boards": boards, + "degraded": degraded, + } + + +# --------------------------------------------------------------- verdict ---- + + +def verdict(data): + """(blocking, attention) — the two tiers of 'needs you'. Blocking = a red + overnight job or a RED Heart; attention = gates blocked on purpose, + version drift, humans waiting on a reply, a non-GREEN Heart.""" + blocking, attention = [], [] + for r in data["overnight"]: + if r["conclusion"] not in (None, "success"): + blocking.append(f"overnight: {r['repo']}/{r['workflow']} {r['conclusion']}") + elif r["blocked"]: + attention.append(f"overnight: {r['repo']}/{r['workflow']} blocked at a gate") + heart_msg = (data["heart"] or {}).get("message", "") + if heart_msg.startswith("RED"): + blocking.append(f"Heart verdict {heart_msg}") + elif heart_msg and not heart_msg.startswith("GREEN"): + attention.append(f"Heart verdict {heart_msg}") + if data["versions"]["drift"]: + attention.append(f"{data['versions']['drift']} version stamp(s) off consensus") + waiting = ((data["community"] or {}).get("counts") or {}).get("awaiting_response", 0) + if waiting: + attention.append(f"{waiting} community conversation(s) awaiting a reply") + return blocking, attention + + +def headline(data): + blocking, attention = verdict(data) + n = len(blocking) + len(attention) + return "clear to work" if n == 0 else f"{n} need you" + + +def badge_color(data): + blocking, attention = verdict(data) + if blocking: + return "red" + if attention: + return "orange" + return "brightgreen" + + +# --------------------------------------------------------------- renders ---- + + +def render_badge(data): + """The cross-board headline contract the umbrella router consumes.""" + return json.dumps({ + "schemaVersion": 1, + "label": "brain", + "message": headline(data), + "color": badge_color(data), + }, indent=2) + "\n" + + +def _overnight_line(r): + if r["conclusion"] is None: + return f"– {r['repo']}/{r['workflow']} — no runs" + if r["blocked"]: + return (f"⏸ {r['repo']}/{r['workflow']} — blocked at a gate, no change " + f"made ({age_label(r['age_h'])})") + icon = "✓" if r["conclusion"] == "success" else "✗" + return f"{icon} {r['repo']}/{r['workflow']} — {r['conclusion']} ({age_label(r['age_h'])})" + + +def render_md(data): + """The terminal/GitHub digest — the same prioritized card /wake_up used + to emit, generated instead of assembled.""" + blocking, attention = verdict(data) + L = [ + "# PyAutoBrain Board", + "", + f"", + "", + f"**{headline(data)}**" + + (f" — {len(blocking)} blocking, {len(attention)} for attention" + if blocking or attention else ""), + "", + f"Morning sync (local, terminal): `{MORNING_CMD}`", + "", + ] + if blocking: + L.append("## 🚨 Blocking") + L += [f"- {b}" for b in blocking] + [""] + L.append("## 🌙 Overnight") + for r in data["overnight"]: + line = f"- {_overnight_line(r)}" + if r["url"]: + line += f" — [run]({r['url']})" + L.append(line) + L.append("") + L.append("## ❤️ Readiness") + if data["heart"]: + L.append(f"- Heart verdict: **{data['heart'].get('message', '?')}** — " + f"[board]({data['boards'].get('heart', '')}) · re-run via `/health`") + else: + L.append("- Heart board unreachable — consult `/health` directly") + L.append("") + v = data["versions"] + L.append("## 🏷️ Version consistency") + if v["consensus"]: + if v["drift"] == 0: + L.append(f"- consistent at `{v['consensus']}` across the coupled set") + else: + for r in v["stamps"]: + if not r["ok"]: + L.append(f"- ✗ {r['repo']} `{r['version']}` ≠ consensus " + f"`{v['consensus']}`") + if v["reference"] and v["reference"] != v["consensus"]: + L.append(f" (latest release tag {v['reference']} — the frozen " + "source stamp trailing it is expected)") + else: + L.append("- no stamps resolved") + L.append("") + L.append("## 💬 Community") + c = data["community"] + if c: + counts = c["counts"] + L.append(f"- {counts['open_external']} external issue(s), " + f"{counts['open_external_prs']} external PR(s) open — " + f"**{counts['awaiting_response']} awaiting our reply** " + "(respond via `/community`; never auto-reply)") + for e in c["awaiting_response"]: + days = (f"{e['waiting_days']:.0f}d" + if e.get("waiting_days") is not None else "?") + L.append(f" - {e['repo']}#{e['number']} [{days} waiting] " + f"@{e['author']}: {e['title'][:70]}") + else: + L.append("- scan unavailable — run `/community` for the live surface") + L.append("") + L.append("## 🔄 Resume") + counts = data["resume"]["counts"] + if counts: + L.append("- " + " · ".join(f"{k} {n}" for k, n in counts.items()) + + f" — [Mind board]({data['boards'].get('mind', '')})") + for t in data["resume"]["tasks"]: + L.append(f" - `/start_dev {t['path']}` — {t['title'][:70]}") + pending = data["resume"]["pending_prs"] + if pending: + L.append(f"- {len(pending)} pending-release PR(s):") + for p in pending: + L.append(f" - {p['repo']}#{p['number']} {p['title'][:60]} — {p['url']}") + elif pending is not None: + L.append("- no pending-release PRs open") + L.append("") + L.append("## 🧹 Upkeep") + if data["open_issues"] is not None: + L.append(f"- {data['open_issues']} open issue(s) org-wide — reconcile " + "via `/issue_cleanup` (closing stays confirmation-gated)") + L.append("- `/hygiene` — code-quality debt sweep (local)") + L.append("- `/repo_cleanup` — stale branches / stashes / dirty checkouts (local)") + L.append("") + if data["degraded"]: + L.append("## Degraded") + L += [f"- {d}" for d in data["degraded"]] + L.append("") + L.append(f"Boards: " + " · ".join( + f"[{name}]({url})" for name, url in data["boards"].items())) + L.append("") + return "\n".join(L) + + +# The html twin — same CSS/JS contract as the Mind dashboard: self-contained +# (no external assets; inline script + href anchors only), one copy button per +# actionable row. +_HTML_CSS = """\ +:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e; + --line:#d1d9e0;--btn:#f6f8fa;--ok:#1a7f37;--warn:#9a6700;--bad:#d1242f; + --accent:#0969da} +@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#f0f6fc; + --muted:#9198a1;--line:#3d444d;--btn:#151b23;--ok:#3fb950;--warn:#d29922; + --bad:#f85149;--accent:#4493f8}} +*{box-sizing:border-box} +body{margin:0 auto;max-width:44rem;padding:1rem 1rem 4rem;background:var(--bg); + color:var(--fg);font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI", + Helvetica,Arial,sans-serif} +h1{font-size:1.35rem;margin:.4rem 0} +h2{font-size:1.15rem;margin-top:2rem;border-bottom:1px solid var(--line); + padding-bottom:.3rem} +a{color:var(--accent);text-decoration:none} +a:hover{text-decoration:underline} +.muted{color:var(--muted)} +.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)} +code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em; + background:var(--btn);padding:.1em .3em;border-radius:4px} +.task{display:flex;gap:.6rem;align-items:flex-start;padding:.45rem 0; + border-bottom:1px solid var(--line)} +.task p{margin:.25rem 0 0;flex:1;overflow-wrap:anywhere} +button.copy{flex:0 0 auto;width:2.6rem;height:2.6rem;font-size:1.1rem; + border:1px solid var(--line);border-radius:8px;background:var(--btn); + cursor:pointer;color:var(--fg)} +button.copy.ok{color:var(--ok);border-color:var(--ok)} +button.copy.term{font-size:.95rem} +details{margin:.5rem 0} +summary{cursor:pointer;font-weight:600;padding:.4rem 0} +""" + +_HTML_JS = """\ +async function copyCmd(b){ + const cmd=b.dataset.cmd; + try{await navigator.clipboard.writeText(cmd);} + catch(e){const t=document.createElement("textarea");t.value=cmd; + document.body.appendChild(t);t.select();document.execCommand("copy"); + t.remove();} + const old=b.textContent; + b.textContent="\\u2713";b.classList.add("ok"); + setTimeout(()=>{b.textContent=old;b.classList.remove("ok");},1200);} +document.addEventListener("click",e=>{ + const b=e.target.closest("button.copy");if(b)copyCmd(b);}); +""" + + +def _attr(s): + return html.escape(str(s), quote=True) + + +def _row(text_html, payload, term=False): + """One actionable row: a copy button (📋 Claude payload, ⌨ terminal + command) then the text.""" + icon, cls, label = ("⌨", "copy term", "Copy the terminal command") \ + if term else ("📋", "copy", "Copy the Claude command") + return (f'

{text_html}

') + + +def _plain(text_html): + return f'

{text_html}

' + + +def render_html(data): + blocking, attention = verdict(data) + esc = html.escape + H = [ + "", + '', + "", + '', + '', + "PyAutoBrain Board", + f"", + f"", + "", + "", + "

🧠 PyAutoBrain Board

", + "

The organism's morning door — what ran overnight, who is waiting, " + "and what needs you. Tap 📋 to put a command on your clipboard for a " + "Claude Code chat; ⌨ rows are terminal commands.

", + ] + verdict_cls = "bad" if blocking else ("warn" if attention else "ok") + H.append(f'

{esc(headline(data))}' + f' — generated {esc(data["generated"])}

') + H.append("

⌨ Morning sync (local)

") + H.append(_row( + "Sync every repo to main + clean generated cruft — run in a terminal " + "at the workspace root, not in a Claude chat.", + MORNING_CMD, term=True)) + + H.append("

🌙 Overnight

") + for r in data["overnight"]: + name = f"{esc(r['repo'])}/{esc(r['workflow'])}" + link = f' — run ↗' if r["url"] else "" + if r["conclusion"] is None: + H.append(_plain(f' {name} — no runs')) + elif r["blocked"]: + H.append(_plain( + f' {name} — blocked at a gate, no ' + f'change made ({age_label(r["age_h"])}){link}')) + elif r["conclusion"] == "success": + H.append(_plain(f' {name} — success ' + f'({age_label(r["age_h"])}){link}')) + else: + H.append(_row( + f' {name} — {esc(r["conclusion"])} ' + f'({age_label(r["age_h"])}){link}', + f"/bug overnight: {r['repo']}/{r['workflow']} concluded " + f"{r['conclusion']} — {r['url'] or 'no run url'}")) + + H.append("

❤️ Readiness

") + heart_url = data["boards"].get("heart", "") + if data["heart"]: + msg = data["heart"].get("message", "?") + cls = "ok" if msg.startswith("GREEN") else ( + "bad" if msg.startswith("RED") else "warn") + H.append(_row( + f'Heart verdict: {esc(msg)} — ' + f'Heart board ↗', "/health")) + else: + H.append(_row("Heart board unreachable — consult the clinician " + "directly.", "/health")) + + v = data["versions"] + H.append("

🏷️ Version consistency

") + if v["consensus"] and v["drift"] == 0: + H.append(_plain(f' consistent at ' + f'{esc(v["consensus"])} across the coupled set')) + elif v["consensus"]: + for r in v["stamps"]: + if not r["ok"]: + H.append(_row( + f' {esc(r["repo"])} ' + f'{esc(r["version"] or "?")} ≠ consensus ' + f'{esc(v["consensus"])}', + f"/bug version drift: {r['repo']} stamp {r['version']} is " + f"out of step with the coupled-set consensus {v['consensus']}")) + else: + H.append(_plain('no stamps resolved')) + if v["reference"] and v["consensus"] and v["reference"] != v["consensus"]: + H.append(f'

Latest release tag {esc(v["reference"])} — ' + "the frozen source stamp trailing it is expected.

") + + H.append("

💬 Community

") + c = data["community"] + if c: + counts = c["counts"] + H.append(_row( + f'{counts["open_external"]} external issue(s), ' + f'{counts["open_external_prs"]} external PR(s) open — ' + f'{counts["awaiting_response"]} awaiting our reply. ' + 'Replies stay human-gated in /community.', + "/community")) + for e in c["awaiting_response"]: + days = (f"{e['waiting_days']:.0f}d" + if e.get("waiting_days") is not None else "?") + url = e.get("url") or "" + title = esc(e.get("title", "")[:80]) + link = f'{esc(e["repo"])}#{e["number"]}' \ + if url else f'{esc(e["repo"])}#{e["number"]}' + H.append(_row( + f'{link} [{days} waiting] ' + f'@{esc(e["author"])}: {title}', + f"/community triage {e['repo']}#{e['number']}")) + else: + H.append(_row("Scan unavailable — run the Ears directly.", "/community")) + + H.append("

🔄 Resume

") + counts = data["resume"]["counts"] + mind_url = data["boards"].get("mind", "") + if counts: + joined = " · ".join(f"{esc(k)} {n}" for k, n in counts.items()) + H.append(_plain(f'{joined} — pick from the ' + f'Mind board ↗')) + for t in data["resume"]["tasks"]: + H.append(_row(f'{esc(t["path"])} — {esc(t["title"][:80])}', + f"/start_dev {t['path']}")) + pending = data["resume"]["pending_prs"] + if pending: + for p in pending: + H.append(_row( + f'pending-release ' + f'{esc(p["repo"])}#{p["number"]} — {esc(p["title"][:70])}', + f"/prm {p['url']}")) + elif pending is not None: + H.append(_plain('no pending-release PRs open')) + + H.append("

🧹 Upkeep

") + issue_note = (f"{data['open_issues']} open issue(s) org-wide — " + if data["open_issues"] is not None else "") + H.append(_row(f"{issue_note}reconcile the trackers (closing stays " + "confirmation-gated).", "/issue_cleanup")) + H.append(_row("Code-quality debt sweep — slow tests, CLI noise, dep-cap " + "drift (runs locally).", "/hygiene")) + H.append(_row("Stale branches, stashes, dirty checkouts (runs locally).", + "/repo_cleanup")) + + doors = data["doors"] + if doors: + H.append("

🚪 All doors

") + H.append("
every conductor and faculty") + for d in doors: + tier = ' (faculty)' \ + if d["tier"] == "faculty" else "" + H.append(_row(f'/{esc(d["verb"])}{tier} — {esc(d["desc"])}', + f"/{d['verb']}")) + H.append("
") + + if data["degraded"]: + H.append("

Degraded

") + for d in data["degraded"]: + H.append(_plain(f'{esc(d)}')) + + nav = " · ".join(f'{esc(name)}' + for name, url in data["boards"].items()) + H.append(f'

Boards: {nav}

') + H += [f"", "", ""] + return "\n".join(H) + "\n" + + +def render_json(data): + return json.dumps(data, indent=2) + "\n" + + +# ------------------------------------------------------------------- cli ---- + + +def main(): + parser = argparse.ArgumentParser(prog="board", description=__doc__) + parser.add_argument("--md", action="store_true", help="markdown digest (default)") + parser.add_argument("--html", action="store_true", help="the one-tap html page") + parser.add_argument("--json", action="store_true", help="the raw surface") + parser.add_argument("--badge", action="store_true", + help="badge.json (the cross-board headline contract)") + parser.add_argument("--apply", action="store_true", + help="write index.html + badge.json + board.json + " + "board.md into --out") + parser.add_argument("--out", default="_site", help="--apply output dir") + args = parser.parse_args() + + data = collect() + if args.apply: + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + (out / "index.html").write_text(render_html(data), encoding="utf-8") + (out / "badge.json").write_text(render_badge(data), encoding="utf-8") + (out / "board.json").write_text(render_json(data), encoding="utf-8") + (out / "board.md").write_text(render_md(data), encoding="utf-8") + print(f"board: wrote {out}/index.html + badge.json + board.json + board.md") + return + if args.html: + print(render_html(data), end="") + elif args.json: + print(render_json(data), end="") + elif args.badge: + print(render_badge(data), end="") + else: + print(render_md(data)) + + +if __name__ == "__main__": + main() diff --git a/board/board.sh b/board/board.sh new file mode 100755 index 0000000..2c96b2e --- /dev/null +++ b/board/board.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# board/board.sh — the PyAutoBrain operational board (the morning door). +# +# A generated SURFACE, not an agent: it decides nothing and opines on nothing — +# it reads what the organs already publish (scheduled-run conclusions, the +# Heart's badge, the Mind's registry, the Ears' scan) and renders the one-tap +# page brain_board.yml serves at the Brain's GitHub Pages URL. Read-only: +# no posts, no labels, no writes outside --apply's output directory. +# +# Usage: +# board.sh # markdown digest to stdout (the terminal read) +# board.sh --html # the one-tap html page +# board.sh --json # the raw surface +# board.sh --badge # badge.json (the cross-board headline contract) +# board.sh --apply [--out DIR] # write index.html + badge.json + +# # board.json + board.md (default _site/) + +set -uo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +exec python3 "$HERE/_board.py" "$@" diff --git a/config/policy.yaml b/config/policy.yaml index d3b32d0..8b94a3d 100644 --- a/config/policy.yaml +++ b/config/policy.yaml @@ -88,3 +88,34 @@ release: autofit_workspace, autogalaxy_workspace, autolens_workspace, HowToFit, HowToGalaxy, HowToLens] tag_repo: PyAutoLabs/PyAutoLens + +# The Brain board (board/_board.py) — the operational surface's vocabulary: +# which scheduled workflows the overnight sweep reads, which stamps the +# version-consistency check compares, and where the sibling one-tap boards +# live. Owner/org is derived from the Mind's body map at runtime, so entries +# here are repo-relative unless a home lies outside the primary org. +# overnight_jobs mirrors bin/overnight_status.sh (its CLI complement) — keep +# the two lists in step until the script reads this block too. +board: + overnight_jobs: + - PyAutoBrain:nightly-release.yml + - PyAutoHeart:heart-health.yml + - PyAutoHeart:workspace-smoke.yml + - PyAutoHands:python_matrix.yml + - PyAutoMind:arxiv_papers.yml + - PyAutoMind:spawn_drift.yml + - autolens_assistant:wiki-currency.yml + version_stamps: + - PyAutoNerves:autonerves/__init__.py + - PyAutoArray:autoarray/__init__.py + - PyAutoFit:autofit/__init__.py + - PyAutoGalaxy:autogalaxy/__init__.py + - PyAutoLens:autolens/__init__.py + reference_release_repo: PyAutoLens + heart_board: PyAutoHeart + boards: + mind: PyAutoMind + heart: PyAutoHeart + hands: PyAutoHands + memory: PyAutoMemory + organism: PyAutoScientist diff --git a/skills/COMMANDS.md b/skills/COMMANDS.md index f1847d6..8dea0ad 100644 --- a/skills/COMMANDS.md +++ b/skills/COMMANDS.md @@ -73,17 +73,16 @@ symmetry. The taxonomy they tag is `PyAutoMind/ROUTING.md`. own no agent and re-implement no reasoning (all judgment defers to the doors they call, so the Brain is not bypassed): -- **`/wake_up`** — start-of-day routine. **Local:** sync every repo to main - (`bin/pull_all_main.sh`) → clean generated cruft, restoring shipped datasets - (`bin/clean_slate.sh`) → consult **`/health`** + **`/hygiene`**. **Everywhere - (gh-API, so it runs on mobile Claude Code chat / Codex too):** an overnight - scheduled-run sweep (`bin/overnight_status.sh`), a version-pin drift check - (`bin/version_drift.sh`), a community scan (`bin/pyauto-brain community scan` - — external users awaiting a response), and resume-context (in-flight work + - pending-release PRs) → one prioritized digest. Auto-runs only the non-destructive steps; - surfaces destructive cleanup for approval; on mobile/codex it skips the - local-only steps. Interactive/terminal only (the automated morning webhooks are - separate). +- **`/wake_up`** — **superseded by the Brain board** (the generated morning + surface `board/_board.py` renders and `brain_board.yml` publishes to Pages: + overnight sweep, readiness headline, version consistency, community scan, + resume context, upkeep doors — each row a one-tap 📋 payload). The local leg + is one terminal command, `bin/morning.sh` (sync via `bin/pull_all_main.sh` + + clean via `bin/clean_slate.sh`). Invoked anyway, the skill runs that local + leg and relays `pyauto-brain board`'s digest — the fallback for a stale or + unreachable board. Auto-runs only the non-destructive steps; surfaces + destructive cleanup for approval. Interactive/terminal only (the automated + morning webhooks are separate). - **`/prm`** — the wrap-up shortcut: *"PR, CI green, then merge"*. Watches a feature PR's checks until **every** workflow run and **every** matrix leg for @@ -110,7 +109,8 @@ audit-first and confirmation-gated: authenticated, including mobile/Codex. Closing needs **two independent evidence legs**, and the record header *key* decides meaning — `issue:` completes, `followup-issue:` / `parent-issue:` / `plan:` mean the record - *spawned* a still-open issue. `/wake_up` runs its audit half read-only. + *spawned* a still-open issue. The Brain board surfaces the open-issue count + and points here; the audit itself stays this door's job. These two are complements, not overlaps: `/repo_cleanup` never touches issues, `/issue_cleanup` never touches git. Neither handles **external** users' issues — diff --git a/skills/board/SKILL.md b/skills/board/SKILL.md new file mode 100644 index 0000000..6f0cb26 --- /dev/null +++ b/skills/board/SKILL.md @@ -0,0 +1,11 @@ +--- +name: board +description: Read the PyAutoBrain operational board — the organism's morning surface (overnight runs, readiness, community, resume, upkeep). Links the published Pages board or relays `pyauto-brain board`'s live digest; read-only — every action routes through the door the board's chips name. Use for a morning status glance or when asked what needs attention. +--- + +# Board + +Follow [`board.md`](board.md) exactly. The board is a generated surface, not +an agent: relay it faithfully (Degraded section included) and route anything +actionable through the doors its chips name — never reply, close, or delete +from here. diff --git a/skills/board/board.md b/skills/board/board.md new file mode 100644 index 0000000..ad6a301 --- /dev/null +++ b/skills/board/board.md @@ -0,0 +1,23 @@ +# /board — read the Brain board (the operational surface) + +The morning door in chat form: relay what the published board shows, or +re-render it live. The board is a generated read-only SURFACE — it decides +nothing; every chip on it routes back through the real doors. + +1. **Point at the page first.** The live board is + `https://.github.io/PyAutoBrain/` (refreshed each morning by + `brain_board.yml`; badge.json beside it is the headline). If the human just + wants the link or the headline, that is the answer — don't re-collect. +2. **Live digest on request** — run `bin/pyauto-brain board` (needs an + authenticated `gh`; add `--json` for the raw surface) and relay its + markdown digest faithfully, including the Degraded section. On a machine + with the local workspace, `PYAUTO_ROOT` is honoured; without one the + resume/community legs degrade honestly and say so. +3. **Never act from the digest.** Community replies stay `/community`'s + human-gated job; closing issues stays `/issue_cleanup`'s; deletion stays + `/repo_cleanup`'s. The board only names the door. +4. The local sync/clean leg is not yours to run from chat — it is the + terminal command `bash PyAutoBrain/bin/morning.sh` on the human's machine. + +Publishing (`--apply`) is CI's job (`brain_board.yml`); run it manually only +when asked to debug the render, writing under `_site/` or a scratch dir. diff --git a/skills/wake_up/SKILL.md b/skills/wake_up/SKILL.md index 213fc01..90e2cf9 100644 --- a/skills/wake_up/SKILL.md +++ b/skills/wake_up/SKILL.md @@ -1,11 +1,12 @@ --- name: wake-up -description: Start-of-day wake-up routine for the PyAuto workspace — sync every repo to main and clean generated cruft (local), then a gh-API status glance (overnight scheduled-run conclusions, version-pin drift, resume context) plus /health and /hygiene, ending in one prioritized digest. Runs on the CLI and on mobile Claude Code chat / Codex (auto-skips local-only steps when there is no workspace). Use when starting the day or asked for a morning status/cleanup pass. +description: Superseded morning door — the routine now lives on the Brain board (Pages) plus the local `bin/morning.sh` sync/clean command. Invoked anyway, it runs that local leg and relays `pyauto-brain board`'s digest. Use only when the board is stale or unreachable, or the user explicitly asks for /wake_up. --- # Wake Up -Follow [`wake_up.md`](wake_up.md) exactly. Composition skill — drive the existing -doors (`/health`, `/hygiene`) and the `bin/` scripts; auto-run only the -non-destructive steps and surface everything destructive for approval. It is -**environment-aware**: full routine locally, gh-API status glance on mobile/codex. +Follow [`wake_up.md`](wake_up.md) exactly. The morning routine is now the +**Brain board** (`https://.github.io/PyAutoBrain/`) plus one terminal +command (`bash PyAutoBrain/bin/morning.sh`); this skill is the fallback that +runs the same legs interactively. Auto-run only the non-destructive steps and +surface everything destructive for approval. diff --git a/skills/wake_up/wake_up.md b/skills/wake_up/wake_up.md index 2680c17..8d89a3e 100644 --- a/skills/wake_up/wake_up.md +++ b/skills/wake_up/wake_up.md @@ -1,96 +1,45 @@ -# /wake_up — start the day: sync, clean, and surface what needs you - -The human-driven start-of-day door. Run it each morning to bring the workspace to -a clean, current, known-good state and get one prioritized digest of what needs -your attention. A **composition** skill — it drives existing doors and the `bin/` -scripts; it owns no state and reasons about nothing new. - -Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. - -## Principle: compose, don't recompute - -Every signal here already has an owner. `/wake_up` **reads and orchestrates** — it -never re-derives what `/health` (Heart's checks) or the automated morning webhooks -already produce. Keep it a thin conductor. - -## Guardrail: auto only the safe steps - -Auto-run **only the recoverable steps** (sync, clean-slate — both git-aware). -Clean-slate does delete: untracked **regenerable** datasets, which the -workspace's own scripts write back on demand, plus generated cruft. It never -touches a tracked file, and datasets with no proven writer are reported, not -removed. Anything else that deletes, edits, or bumps (stray cleanup, branch -deletion, version bumps) is **surfaced in the digest for the human to approve**, -never done automatically. - -## Environment: local vs remote - -`/wake_up` runs on the CLI **and** on mobile Claude Code chat / Codex — which -often have no local multi-repo checkout. Detect which: - -- **Local** — `$PYAUTO_ROOT` (default `~/Code/PyAutoLabs`) contains the sibling - repos (e.g. both `PyAutoMind/` and `PyAutoLens/` are present). Run everything. -- **Remote (mobile/codex)** — they are not. **Skip the local-only steps** (sync, - clean-slate, worktree scan, `/hygiene`) with a one-line note, and run the - gh-API status glance below — it needs only an authenticated `gh`. - -## The routine - -Run in order, then emit the digest. - -### Local-only (skip on mobile/codex) -1. **Sync** — `bash PyAutoBrain/bin/pull_all_main.sh`. Every repo → its default - branch, ff-only; repos with real uncommitted work are skipped untouched. Note - any left **off-main / dirty / behind / diverged**. -2. **Clean slate** — `bash PyAutoBrain/bin/clean_slate.sh` (`DRY_RUN=1` to - preview). Restore shipped datasets, delete untracked regenerable ones - (recreated on demand by the scripts that write them), clear - `output/`/`scratch/` cruft. Note any **orphan dataset** lines — kept, not - deleted, and waiting on a human call. - -### Everywhere (gh-API — mobile/codex-safe) -3. **Overnight sweep** — `bash PyAutoBrain/bin/overnight_status.sh`: latest - scheduled-workflow conclusions (nightly-release, heart-health, matrix CI, - workspace-smoke, wiki-currency, spawn-drift, arxiv). The "what ran while I - slept" glance — a failing `nightly-release` is your release-blocked signal. -4. **Health & release** — **locally**, consult **`/health`** for the rich verdict; - **remotely**, the overnight sweep's `heart-health` / `nightly-release` - conclusions are the readiness/release signal. -5. **Version drift** — `bash PyAutoBrain/bin/version_drift.sh`: version-stamp - *consistency across the coupled libs + workspaces* (they should all carry the - same frozen stamp; the latest release tag is shown for context only, since - the committed source stamp is deliberately not bumped on release). A repo out - of step with its siblings is the real signal. -6. **Community** — `bin/pyauto-brain community scan`: open issues raised by - **other humans** (not you) across every repo, with awaiting-response - ranking. Surface who is waiting and for how long; respond via - **`/community`** (drafts are human-approved there — never auto-reply from - the digest). -6b. **Issue-tracker drift** — run the **audit half of `/issue_cleanup`** - (read-only, `gh` + `PyAutoMind/complete/`): how many issues are open, and how - many look shipped-but-still-open. Report the counts only — **never close - anything from the digest**; closing is `/issue_cleanup`'s own - confirmation-gated step. A rising shipped-but-open count means ship flows are - skipping their issue close. -7. **Resume context** — pick up where you left off: in-flight / parked / queued - work (`PyAutoMind/active.md`, `parked.md`, `queue.md`) + open **pending-release - PRs** (`gh`); **locally** also worktrees with unpushed commits. -8. **Hygiene** *(local)* — consult **`/hygiene`** for cleanup candidates. - -### Digest -Emit one prioritized card: -- 🚨 **Blocking** — release blocked, RED readiness, failing overnight jobs / CI. -- 💬 **Community** — external users awaiting a response (issue, author, days - waiting); point at **`/community`**, never draft replies in the digest. -- ⚠️ **Drifted** — off-main / behind repos, version-pin mismatches. -- 🔄 **Resume** — in-flight / parked tasks, open pending-release PRs. -- 🧹 **Cleanable** — cleanup surfaced *for approval*: list it, never auto-act. - Includes the issue-tracker counts from step 6b (point at **`/issue_cleanup`**) - alongside the git debris (**`/repo_cleanup`**). -- ✅ **Clear** — say so in one line. - -End with a one-line verdict — *"clear to work"* or *"N things need you"* — and, in -remote mode, append *"(remote: local sync/clean/hygiene skipped)"*. - -Interactive/terminal only — the automated morning Slack webhooks are separate and -unchanged. +# /wake_up — superseded by the Brain board (kept as the fallback door) + +**The morning routine no longer runs through this skill.** It is now: + +1. **Terminal** (local, not a Claude chat): `bash PyAutoBrain/bin/morning.sh` — + the sync + clean-slate leg (ff-only pull of every repo, git-aware cleanup of + regenerable artifacts). `--digest` appends the board's markdown digest. +2. **The Brain board** — `https://.github.io/PyAutoBrain/` (rendered by + `board/_board.py`, refreshed each morning by `brain_board.yml`, also linked + from the README): overnight scheduled-run conclusions, the Heart's + readiness headline, version-stamp consistency, community conversations + awaiting a reply, resume context (in-flight/parked/queued + pending-release + PRs), and the upkeep doors — each actionable row carrying its one-tap 📋 + Claude payload (`/bug …`, `/health`, `/community triage …`, + `/start_dev …`, `/prm …`, `/issue_cleanup`, `/hygiene`, `/repo_cleanup`). + +Everything this skill used to assemble interactively lives on that page, on +the same "compose, don't recompute" principle: the board reads what the organs +already publish and never re-derives a verdict. `pyauto-brain board` prints +the identical digest in a terminal when a page is not at hand. + +## When invoked anyway + +`/wake_up` stays a working door, for a stale board or a no-browser session: + +1. **Local sync/clean** — run `bash PyAutoBrain/bin/morning.sh` (auto-run is + safe: both steps are the recoverable, git-aware ones — sync skips repos + with real uncommitted work; clean-slate deletes only untracked regenerable + artifacts and reports orphan datasets instead of removing them). Skip with + a one-line note when there is no local workspace (mobile/codex). +2. **The digest** — run `bin/pyauto-brain board` and relay its markdown digest + (it needs only an authenticated `gh`; degraded sections are listed + honestly). If the board CLI cannot run, fall back to the underlying + scripts it composes: `bin/overnight_status.sh`, `bin/version_drift.sh`, + `pyauto-brain community scan`. +3. **Anything actionable** routes through the same doors the board's chips + name — never auto-reply to the community, never close issues, never delete + from the digest; those stay `/community`, `/issue_cleanup`, + `/repo_cleanup`'s own human-gated jobs. + +Deeper local-only reads the board deliberately leaves to their own doors: +`/health` for the clinician's rich verdict, `/hygiene` for the upkeep sweep. + +Interactive/terminal only — the automated morning Slack webhooks +(morning_health / morning_status) are separate and unchanged. diff --git a/tests/test_board.py b/tests/test_board.py new file mode 100644 index 0000000..a74a166 --- /dev/null +++ b/tests/test_board.py @@ -0,0 +1,317 @@ +"""Contract tests for the Brain board (board/_board.py). + +Hermetic: PYAUTO_ROOT points at a fabricated PyAutoMind, BOARD_GH at a stub +`gh` serving fixture JSON, and BOARD_PAGES_BASE at file:// fixtures for the +sibling boards' badge.json — so the surface is asserted structurally with no +network and no real checkouts. The board is a read-only SURFACE: the stub +records every call so the tests can prove no mutating endpoint is ever hit, +and --apply writes only inside its --out directory. + +Names here are fakes (ExampleOrg/RepoA) per the tenant firewall — the org is +derived from the fabricated body map at runtime, never asserted as an +instance fact. Repo names that DO appear (PyAutoBrain, PyAutoHeart, ...) come +from config/policy.yaml `board:`, the declared config surface. +""" + +import base64 +import json +import os +import stat +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +BRAIN = BRAIN_HOME / "bin" / "pyauto-brain" + +SURFACE_KEYS = { + "generated", "org", "overnight", "heart", "versions", "community", + "resume", "open_issues", "doors", "boards", "degraded", +} + +REPOS_YAML = """\ +repos: + RepoA: + github: ExampleOrg/RepoA + category: library + RepoB: + github: ExampleOrg/RepoB + category: workspace +""" + +DASHBOARD_MD = """\ +# Dashboard + +| Where | Count | +|-------|------:| +| [In flight](#in-flight) (`active/`) | 1 | +| [Parked](#parked) (`parked.md`) | 3 | +| [Planned](#planned) (`planned.md`) | 6 | +| [Backlog](#backlog) (`draft/`) | 152 | +""" + +VERSION = "2026.8.17.1" +RECENT = (datetime.now(timezone.utc) - timedelta(hours=3)).strftime( + "%Y-%m-%dT%H:%M:%SZ") + +EMPTY_SEARCH = {"items": []} + + +def _run_json(runs_conclusion="success"): + return {"workflow_runs": [{ + "conclusion": runs_conclusion, + "status": "completed", + "created_at": RECENT, + "id": 1, + "html_url": "https://example.invalid/run/1", + }]} + + +def _default_fixtures(**overrides): + fx = { + "runs.json": _run_json(), + "jobs.json": {"jobs": [{"steps": [ + {"name": "ordinary step", "conclusion": "success"}]}]}, + "contents.json": {"content": base64.b64encode( + f'__version__ = "{VERSION}"\n'.encode()).decode()}, + "release.json": {"tag_name": VERSION}, + "pending.json": {"items": [{ + "repository_url": "https://api.github.com/repos/ExampleOrg/RepoA", + "number": 5, + "title": "waiting release train", + "html_url": "https://example.invalid/pr/5", + }]}, + "issue_count.json": {"total_count": 42}, + "comm_issues.json": EMPTY_SEARCH, + "comm_prs.json": EMPTY_SEARCH, + "comments.json": [], + } + fx.update(overrides) + return fx + + +def _fabricate(tmp_path, fixtures): + """A PYAUTO_ROOT with a fabricated Mind, file:// sibling-board badges, and + a stub gh serving per-endpoint fixture JSON, logging every invocation.""" + mind = tmp_path / "PyAutoMind" + (mind / "active").mkdir(parents=True) + (mind / "repos.yaml").write_text(REPOS_YAML) + (mind / "dashboard.md").write_text(DASHBOARD_MD) + (mind / "active" / "some_task.md").write_text("# Fix the fixture widget\n") + (mind / "queue.md").write_text( + "# Queue\n\ndraft/feature/repoa/one.md\ndraft/feature/repoa/two.md\n") + + # One badge per sibling board named in the declared config surface — + # read from policy.yaml so no board repo name is hardcoded here. + import yaml + board_cfg = yaml.safe_load( + (BRAIN_HOME / "config" / "policy.yaml").read_text())["board"] + pages = tmp_path / "pages" + board_repos = set((board_cfg.get("boards") or {}).values()) + board_repos.add(board_cfg["heart_board"]) + for board_repo in board_repos: + (pages / board_repo).mkdir(parents=True) + (pages / board_repo / "badge.json").write_text(json.dumps({ + "schemaVersion": 1, "label": board_repo.lower(), + "message": "GREEN", "color": "brightgreen"})) + + fixture_dir = tmp_path / "fixtures" + fixture_dir.mkdir() + for name, payload in fixtures.items(): + (fixture_dir / name).write_text(json.dumps(payload)) + + stub = tmp_path / "gh" + stub.write_text(f"""#!/usr/bin/env bash +echo "$@" >> "{tmp_path}/gh_calls.log" +for arg in "$@"; do + case "$arg" in + repos/*/actions/workflows/*) cat "{fixture_dir}/runs.json"; exit 0 ;; + repos/*/actions/runs/*/jobs) cat "{fixture_dir}/jobs.json"; exit 0 ;; + repos/*/contents/*) cat "{fixture_dir}/contents.json"; exit 0 ;; + repos/*/releases/latest) cat "{fixture_dir}/release.json"; exit 0 ;; + search/issues?q=*pending-release*) cat "{fixture_dir}/pending.json"; exit 0 ;; + "search/issues?q="*"is:issue+is:open&per_page=1") cat "{fixture_dir}/issue_count.json"; exit 0 ;; + q=org:*is:issue*) cat "{fixture_dir}/comm_issues.json"; exit 0 ;; + q=org:*is:pr*) cat "{fixture_dir}/comm_prs.json"; exit 0 ;; + q=repo:*) cat "{fixture_dir}/comm_prs.json"; exit 0 ;; + */comments) cat "{fixture_dir}/comments.json"; exit 0 ;; + esac +done +exit 1 +""") + stub.chmod(stub.stat().st_mode | stat.S_IEXEC) + return stub + + +def _run(args, tmp_path, stub): + env = { + **os.environ, + "PYAUTO_ROOT": str(tmp_path), + "BOARD_GH": str(stub), + "BOARD_PAGES_BASE": f"file://{tmp_path}/pages", + "COMMUNITY_GH": str(stub), + "COMMUNITY_SEARCH_PAUSE": "0", + } + return subprocess.run( + [str(BRAIN), "board", *args], + capture_output=True, text=True, env=env, cwd=tmp_path, + ) + + +def _surface(tmp_path, fixtures=None): + stub = _fabricate(tmp_path, fixtures or _default_fixtures()) + r = _run(["--json"], tmp_path, stub) + assert r.returncode == 0, r.stderr + return json.loads(r.stdout), tmp_path / "gh_calls.log" + + +# ----------------------------------------------------------------- surface -- + + +def test_json_surface_is_complete_and_derives_org(tmp_path): + s, _ = _surface(tmp_path) + assert set(s) == SURFACE_KEYS + assert s["org"] == "ExampleOrg" # derived from the fabricated body map + # 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"]: + assert set(row) == {"repo", "workflow", "conclusion", "age_h", "url", + "blocked"} + assert row["repo"].startswith("ExampleOrg/") + assert row["conclusion"] == "success" + # Versions: every stamp resolves to the fixture, so consensus + no drift. + v = s["versions"] + assert v["consensus"] == VERSION + assert v["drift"] == 0 + assert v["reference"] == VERSION + # Heart headline via the file:// badge (the cross-board contract). + assert s["heart"]["message"] == "GREEN" + # Resume: the Mind's own generated counts + the task file + the queue. + assert s["resume"]["counts"]["In flight"] == 1 + assert s["resume"]["counts"]["Backlog"] == 152 + assert s["resume"]["tasks"] == [ + {"path": "active/some_task.md", "title": "Fix the fixture widget"}] + assert s["resume"]["queue_len"] == 2 + assert s["resume"]["pending_prs"][0]["repo"] == "ExampleOrg/RepoA" + assert s["open_issues"] == 42 + # Community section reuses the Ears' scan surface wholesale. + assert s["community"]["counts"]["awaiting_response"] == 0 + # The doors roster comes from the dispatcher registry, both tiers. + verbs = {d["verb"] for d in s["doors"]} + assert {"intake", "health", "vitals"} <= verbs + assert "board" not in verbs # surfaces are not agents + # Sibling boards resolved against the pages base. + assert s["boards"]["heart"].endswith("/PyAutoHeart/") + + +def test_all_green_is_clear_to_work(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures()) + r = _run(["--badge"], tmp_path, stub) + badge = json.loads(r.stdout) + assert badge == {"schemaVersion": 1, "label": "brain", + "message": "clear to work", "color": "brightgreen"} + + +def test_overnight_failure_is_blocking_and_red(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(**{ + "runs.json": _run_json("failure")})) + r = _run(["--badge"], tmp_path, stub) + badge = json.loads(r.stdout) + assert badge["color"] == "red" + assert badge["message"].endswith("need you") + md = _run([], tmp_path, stub).stdout + assert "🚨 Blocking" in md + + +def test_blocked_gate_is_attention_not_blocking(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(**{ + "jobs.json": {"jobs": [{"steps": [ + {"name": "Blocked at a gate — no release made", + "conclusion": "success"}]}]}})) + r = _run(["--badge"], tmp_path, stub) + badge = json.loads(r.stdout) + assert badge["color"] == "orange" + html_page = _run(["--html"], tmp_path, stub).stdout + assert "blocked at a gate" in html_page + + +# -------------------------------------------------------------------- html -- + + +def test_html_is_self_contained_with_one_tap_payloads(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(**{ + "runs.json": _run_json("failure")})) + r = _run(["--html"], tmp_path, stub) + page = r.stdout + # Self-containment: inline script and href anchors are allowed; external + # ASSETS are not (the invariant the Heart board's tests settled on). + # data-cmd payloads legitimately carry URLs, so strip them first. + import re + stripped = re.sub(r'data-cmd="[^"]*"', 'data-cmd=""', page) + assert "