From 96b39a46d68c32fdfcff8d943cd904e61f1c0902 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 17:32:19 +0000 Subject: [PATCH] =?UTF-8?q?brain:=20board=20round=202=20=E2=80=94=20readin?= =?UTF-8?q?ess=20deepening,=20dev-box=20publish=20leg,=20autonomy=20strip,?= =?UTF-8?q?=20trend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readiness & release: the section now renders the Heart's structured blockers verbatim from its newly-published board.json (each chip is the blocker's OWN /bug prompt — composed, never re-derived), the Hands badge headline joins it, and a ⏸ blocked-gate row prints its ::warning annotation inline so the morning glance needs no click. Dev box: `pyauto-brain board publish` (board/_publish.py, the Heart's heart/publish.py pattern) distills the hygiene conductor's own --json pre-scan plus worktree state (off-default branches, unpushed, dirty, stashes — names only, home paths collapsed to ~) into state/devbox_board.json: main-only guard, rebase-retry push, and brain_board.yml republishes on that push. morning.sh runs it as its last step (--no-publish to skip), so the board reads 'observed this morning' every day; observations go stale at 48h and drop at 7d. Also: the autonomy log's tail renders as a section, and the header carries a self-carrying 'N need you' trend sparkline (yesterday's published board.json hands its history forward — no commits, no state). 421 tests pass (6 new: blockers verbatim + own-prompt chips, gate annotation inline, devbox fresh/expired render, publish dry-run scrub, publish push/idempotence/main-guard); firewall gate green. Co-Authored-By: Claude --- .github/workflows/brain_board.yml | 7 + bin/morning.sh | 14 +- board/AGENTS.md | 16 +- board/_board.py | 246 ++++++++++++++++++++++++++++-- board/_publish.py | 199 ++++++++++++++++++++++++ board/board.sh | 9 ++ tests/test_board.py | 218 +++++++++++++++++++++++++- 7 files changed, 688 insertions(+), 21 deletions(-) create mode 100755 board/_publish.py diff --git a/.github/workflows/brain_board.yml b/.github/workflows/brain_board.yml index fc3daaf..9b3a998 100644 --- a/.github/workflows/brain_board.yml +++ b/.github/workflows/brain_board.yml @@ -28,6 +28,13 @@ on: schedule: - cron: "30 5 * * *" workflow_dispatch: + # A dev-box publish (`pyauto-brain board publish`, usually morning.sh's + # last step) pushes state/devbox_board.json to main — republish so the + # board shows this morning's local observations a minute later. + push: + branches: [main] + paths: + - "state/devbox_board.json" permissions: contents: read diff --git a/bin/morning.sh b/bin/morning.sh index 54cb2d8..322c9e2 100755 --- a/bin/morning.sh +++ b/bin/morning.sh @@ -9,9 +9,11 @@ # 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 # sync + clean + publish, +# # then board URL # bash PyAutoBrain/bin/morning.sh --digest # also print the board's # # markdown digest (needs gh) +# bash PyAutoBrain/bin/morning.sh --no-publish # skip the dev-box publish # 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 @@ -30,6 +32,16 @@ echo echo "== morning: clean slate (untracked regenerable artifacts + cruft) ==" bash "$HERE/clean_slate.sh" +if [ "${1:-}" != "--no-publish" ]; then + echo + echo "== morning: publish the dev-box observation (hygiene + worktrees) ==" + # Best-effort: the board just shows an older stamp if this leg fails. + # The push itself triggers brain_board.yml, so the board refreshes with + # this morning's local observations a minute later. + bash "$HERE/../board/board.sh" publish \ + || echo "morning: dev-box publish skipped (see above) — the board keeps its last stamp" +fi + echo echo "== morning: done — the rest of the routine is on the board ==" # Board URL from the checkout's own remote (no hardcoded org). diff --git a/board/AGENTS.md b/board/AGENTS.md index 3ced811..f18cbb5 100644 --- a/board/AGENTS.md +++ b/board/AGENTS.md @@ -16,14 +16,20 @@ 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` | +| 🌙 Overnight | scheduled workflows (`config/policy.yaml board: overnight_jobs`); a ⏸ blocked gate's ::warning annotation is rendered inline | `/bug … — ` on failures | +| ❤️ Readiness & release | the Heart board's `badge.json` + `board.json` (structured blockers, each carrying its OWN `/bug` prompt — rendered verbatim, never re-derived) and the Hands badge | `/health`, the blockers' own prompts | | 🏷️ Version consistency | the coupled-set stamps (`board: version_stamps`) | `/bug version drift: …` | -| 💬 Community | the Ears (`community scan`, reused wholesale) | `/community`, `/community triage ` | +| 💬 Community | the Ears (`community scan`, reused wholesale) — every open conversation gets a row | `/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` | +| 🖥️ Dev box | `state/devbox_board.json` — hygiene pre-scan rows + worktree state, pushed by `board publish` (morning.sh's last step); age-stamped, stale at 48h, dropped after 7d | each hygiene row's own delegate door | +| 🤖 Autonomous runs | the tail of the Mind's `autonomy_log.md`, verbatim | — | | 🚪 All doors | `bin/pyauto-brain`'s own registry (never a second copy) | `/` | +The header also carries a **trend sparkline** — the "N need you" count per +day, handed forward through the published `board.json` itself (no commits, no +extra state: each render reads yesterday's page, appends today, caps at 30). + **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 @@ -41,6 +47,10 @@ 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) +bin/pyauto-brain board publish # dev-box leg: distill hygiene + worktree + # state into state/devbox_board.json and + # push (morning.sh runs this for you; + # --dry-run prints, --no-hygiene is fast) ``` Publishing is `.github/workflows/brain_board.yml`: a morning cron plus manual diff --git a/board/_board.py b/board/_board.py index 372d39d..cb959ba 100755 --- a/board/_board.py +++ b/board/_board.py @@ -173,6 +173,7 @@ def collect_overnight(jobs, org, degraded): continue conclusion = run.get("conclusion") or run.get("status") blocked = False + blocked_reason = None 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", []): @@ -180,6 +181,14 @@ def collect_overnight(jobs, org, degraded): if (step.get("conclusion") == "success" and str(step.get("name", "")).startswith(BLOCKED_STEP_PREFIX)): blocked = True + # The blocked step's ::warning annotation names why — + # surface it here so the morning glance needs no click. + anns = gh_json( + [f"repos/{repo}/check-runs/{j.get('id')}/annotations"]) + for a in anns or []: + if "blocked" in str(a.get("title", "")).lower(): + blocked_reason = str(a.get("message", ""))[:200] + break rows.append({ "repo": repo, "workflow": wf, @@ -187,14 +196,12 @@ def collect_overnight(jobs, org, degraded): "age_h": age_h(run.get("created_at")), "url": run.get("html_url"), "blocked": blocked, + "blocked_reason": blocked_reason, }) 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" +def _fetch_json(url): try: with urllib.request.urlopen(url, timeout=15) as resp: return json.loads(resp.read().decode("utf-8")) @@ -202,6 +209,36 @@ def fetch_badge(pages_base, repo): return None +def fetch_badge(pages_base, repo): + """A sibling board's published badge.json — the cross-board headline + contract ({label, message, color}). None when unreachable.""" + return _fetch_json(f"{pages_base}/{repo}/badge.json") + + +HEART_BLOCKER_CAP = 5 + + +def fetch_heart_blockers(pages_base, repo, degraded): + """The Heart board's published machine surface (board.json, schema v2): + structured blockers, each already carrying its own /bug prompt — rendered + here verbatim, never re-derived. [] when the surface is unreachable or + carries no blockers (a GREEN morning).""" + 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 [] + blockers = board.get("blockers") or [] + return [{ + "text": str(b.get("text", ""))[:160], + "severity": b.get("severity"), + "repo": b.get("repo"), + "repo_url": b.get("repo_url"), + "run_url": b.get("run_url"), + "prompt": b.get("prompt"), + } for b in blockers[:HEART_BLOCKER_CAP]] + + 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).""" @@ -323,6 +360,73 @@ def collect_open_issues(org, degraded): return res.get("total_count") +# Dev-box observations (state/devbox_board.json, pushed by `board publish` — +# usually via bin/morning.sh) are honest only with an age: fresh under 48h, +# shown stale up to 7d, then dropped with a re-run hint. +DEVBOX_FILE = Path(os.environ.get( + "BOARD_DEVBOX_FILE", BRAIN_HOME / "state" / "devbox_board.json")) +DEVBOX_FRESH_H = 48 +DEVBOX_EXPIRE_H = 24 * 7 + + +def collect_devbox(): + """The committed dev-box distillation: hygiene worklist rows + worktree + state — the two morning signals a cloud render cannot observe itself.""" + if not DEVBOX_FILE.is_file(): + return None + try: + payload = json.loads(DEVBOX_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + hours = age_h(payload.get("ts")) + if hours is None or hours > DEVBOX_EXPIRE_H: + return None + payload["age_h"] = hours + payload["stale"] = hours > DEVBOX_FRESH_H + return payload + + +AUTONOMY_ROW_CAP = 5 + + +def collect_autonomy(): + """The tail of the Mind's autonomy calibration log — what ran unattended + lately and how it ended (read verbatim; the log stays the record).""" + log = PYAUTO_ROOT / "PyAutoMind" / "autonomy_log.md" + if not log.is_file(): + return [] + rows = re.findall( + r"^\|\s*(\d{4}-\d{2}-\d{2})\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|" + r"[^|]*\|\s*([^|]+?)\s*\|", + log.read_text(encoding="utf-8"), re.M) + return [{"date": d, "task": t[:70], "level": lvl, "outcome": o} + for d, t, lvl, o in rows[-AUTONOMY_ROW_CAP:]] + + +HISTORY_CAP = 30 + + +def updated_history(prev_board, need_you, today): + """Self-carrying trend: yesterday's published board.json hands its history + forward; today's count is appended (one entry per date, newest last).""" + history = list((prev_board or {}).get("history") or []) + history = [h for h in history + if isinstance(h, dict) and h.get("date") != today] + history.append({"date": today, "need_you": need_you}) + return history[-HISTORY_CAP:] + + +def sparkline(history): + """The last fortnight of 'N need you' as unicode blocks ('' if <2 days).""" + values = [int(h.get("need_you", 0)) for h in history][-14:] + if len(values) < 2: + return "" + peak = max(max(values), 1) + blocks = "▁▂▃▄▅▆▇█" + return "".join(blocks[min((v * (len(blocks) - 1) + peak - 1) // peak, + len(blocks) - 1)] for v in values) + + 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).""" @@ -353,10 +457,14 @@ def collect(): pages_base = os.environ.get( "BOARD_PAGES_BASE", f"https://{org.lower()}.github.io") degraded = [] + board_family = board_cfg.get("boards") or {} + heart_repo = board_cfg.get("heart_board", "PyAutoHeart") overnight = collect_overnight(board_cfg.get("overnight_jobs", []), org, degraded) - heart = fetch_badge(pages_base, board_cfg.get("heart_board", "PyAutoHeart")) + heart = fetch_badge(pages_base, heart_repo) if heart is None: degraded.append("readiness: Heart board badge unreachable") + heart_blockers = fetch_heart_blockers(pages_base, heart_repo, degraded) + hands = fetch_badge(pages_base, board_family.get("hands", "PyAutoHands")) versions = collect_versions( board_cfg.get("version_stamps", []), org, board_cfg.get("reference_release_repo"), degraded) @@ -364,20 +472,31 @@ def collect(): 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"), + for name, repo in board_family.items()} + now = datetime.now(timezone.utc) + data = { + "generated": now.strftime("%Y-%m-%d %H:%M UTC"), "org": org, "overnight": overnight, "heart": heart, + "heart_blockers": heart_blockers, + "hands": hands, "versions": versions, "community": community, "resume": resume, "open_issues": open_issues, + "devbox": collect_devbox(), + "autonomy": collect_autonomy(), "doors": collect_doors(), "boards": boards, "degraded": degraded, } + # The trend hands itself forward through the published board.json. + prev = _fetch_json(f"{pages_base}/{board_family.get('brain', 'PyAutoBrain')}/board.json") + blocking, attention = verdict(data) + data["history"] = updated_history( + prev, len(blocking) + len(attention), now.strftime("%Y-%m-%d")) + return data # --------------------------------------------------------------- verdict ---- @@ -448,6 +567,7 @@ 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) + spark = sparkline(data.get("history") or []) L = [ "# PyAutoBrain Board", "", @@ -456,7 +576,8 @@ def render_md(data): "", f"**{headline(data)}**" + (f" — {len(blocking)} blocking, {len(attention)} for attention" - if blocking or attention else ""), + if blocking or attention else "") + + (f" · trend `{spark}`" if spark else ""), "", f"Morning sync (local, terminal): `{MORNING_CMD}`", "", @@ -470,13 +591,26 @@ def render_md(data): if r["url"]: line += f" — [run]({r['url']})" L.append(line) + if r.get("blocked_reason"): + L.append(f" - {r['blocked_reason']}") L.append("") - L.append("## ❤️ Readiness") + L.append("## ❤️ Readiness & release") 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") + for b in data.get("heart_blockers") or []: + sev = f"[{b['severity']}] " if b.get("severity") else "" + line = f" - {sev}{b['text']}" + if b.get("run_url"): + line += f" — [run]({b['run_url']})" + L.append(line) + if b.get("prompt"): + L.append(f" - `{b['prompt']}`") + if data.get("hands"): + L.append(f"- Shipped: **{data['hands'].get('message', '?')}** — " + f"[Hands board]({data['boards'].get('hands', '')})") L.append("") v = data["versions"] L.append("## 🏷️ Version consistency") @@ -543,6 +677,32 @@ def render_md(data): L.append("- `/hygiene` — code-quality debt sweep (local)") L.append("- `/repo_cleanup` — stale branches / stashes / dirty checkouts (local)") L.append("") + devbox = data.get("devbox") + if devbox: + stale = " — STALE, re-run `bash PyAutoBrain/bin/morning.sh`" \ + if devbox.get("stale") else "" + L.append(f"## 🖥️ Dev box (observed {age_label(devbox['age_h'])} " + f"ago via morning.sh{stale})") + for row in devbox.get("hygiene", {}).get("rows", []): + if row.get("status") in ("clean", "unscanned", "deferred", "advisory"): + continue + summary = str(row.get("summary", ""))[:140] + L.append(f"- {row.get('mode')}: {summary} → `{row.get('delegate')}`") + for wt in devbox.get("worktrees", []): + bits = [wt.get("branch") or "?"] + if wt.get("ahead"): + bits.append(f"{wt['ahead']} unpushed") + if wt.get("dirty"): + bits.append("dirty") + if wt.get("stashes"): + bits.append(f"{wt['stashes']} stash(es)") + L.append(f"- worktree {wt.get('repo')}: {' · '.join(bits)}") + L.append("") + if data.get("autonomy"): + L.append("## 🤖 Autonomous runs (latest — the calibration log's tail)") + for a in data["autonomy"]: + L.append(f"- {a['date']} — {a['task']} — {a['outcome']}") + L.append("") if data["degraded"]: L.append("## Degraded") L += [f"- {d}" for d in data["degraded"]] @@ -642,8 +802,13 @@ def render_html(data): "Claude Code chat; ⌨ rows are terminal commands.

", ] verdict_cls = "bad" if blocking else ("warn" if attention else "ok") + spark = sparkline(data.get("history") or []) + spark_html = (f' · trend ' + f'{esc(spark)}') if spark else "" H.append(f'

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

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

') H.append("

⌨ Morning sync (local)

") H.append(_row( "Sync every repo to main + clean generated cruft — run in a terminal " @@ -657,9 +822,11 @@ def render_html(data): if r["conclusion"] is None: H.append(_plain(f' {name} — no runs')) elif r["blocked"]: + reason = (f'
{esc(r["blocked_reason"])}' + if r.get("blocked_reason") else "") H.append(_plain( f' {name} — blocked at a gate, no ' - f'change made ({age_label(r["age_h"])}){link}')) + f'change made ({age_label(r["age_h"])}){link}{reason}')) elif r["conclusion"] == "success": H.append(_plain(f' {name} — success ' f'({age_label(r["age_h"])}){link}')) @@ -670,7 +837,7 @@ def render_html(data): f"/bug overnight: {r['repo']}/{r['workflow']} concluded " f"{r['conclusion']} — {r['url'] or 'no run url'}")) - H.append("

❤️ Readiness

") + H.append("

❤️ Readiness & release

") heart_url = data["boards"].get("heart", "") if data["heart"]: msg = data["heart"].get("message", "?") @@ -682,6 +849,24 @@ def render_html(data): else: H.append(_row("Heart board unreachable — consult the clinician " "directly.", "/health")) + for b in data.get("heart_blockers") or []: + sev_cls = "bad" if b.get("severity") == "red" else "warn" + sev = (f'[{esc(b["severity"])}] ' + if b.get("severity") else "") + links = "".join( + f' {label} ↗' + for label, url in (("repo", b.get("repo_url")), + ("run", b.get("run_url"))) if url) + text = f'{sev}{esc(b["text"])}{links}' + if b.get("prompt"): + H.append(_row(text, b["prompt"])) + else: + H.append(_plain(text)) + if data.get("hands"): + hands_url = data["boards"].get("hands", "") + H.append(_plain( + f'Shipped: {esc(data["hands"].get("message", "?"))} — ' + f'Hands board ↗')) v = data["versions"] H.append("

🏷️ Version consistency

") @@ -773,6 +958,41 @@ def community_row(e, note_html): H.append(_row("Stale branches, stashes, dirty checkouts (runs locally).", "/repo_cleanup")) + devbox = data.get("devbox") + if devbox: + stale = (' — STALE' + if devbox.get("stale") else "") + H.append(f'

🖥️ Dev box (observed ' + f'{age_label(devbox["age_h"])} ago via morning.sh{stale})

') + if devbox.get("stale"): + H.append(_row("Refresh the dev-box observation — run in a " + "terminal at the workspace root.", MORNING_CMD, + term=True)) + for row in devbox.get("hygiene", {}).get("rows", []): + if row.get("status") in ("clean", "unscanned", "deferred", "advisory"): + continue + summary = esc(str(row.get("summary", ""))[:140]) + H.append(_row(f'{esc(str(row.get("mode")))}: {summary}', + str(row.get("delegate") or "/hygiene"))) + for wt in devbox.get("worktrees", []): + bits = [esc(str(wt.get("branch") or "?"))] + if wt.get("ahead"): + bits.append(f'{wt["ahead"]} unpushed') + if wt.get("dirty"): + bits.append("dirty") + if wt.get("stashes"): + bits.append(f'{wt["stashes"]} stash(es)') + H.append(_plain(f'worktree {esc(str(wt.get("repo")))} — ' + f'{" · ".join(bits)}')) + + if data.get("autonomy"): + H.append('

🤖 Autonomous runs (the calibration ' + "log's tail)

") + for a in data["autonomy"]: + H.append(_plain( + f'{esc(a["date"])} ' + f'{esc(a["task"])} — {esc(a["outcome"])}')) + doors = data["doors"] if doors: H.append("

🚪 All doors

") diff --git a/board/_publish.py b/board/_publish.py new file mode 100755 index 0000000..f0500ec --- /dev/null +++ b/board/_publish.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""board/_publish.py — push a distilled dev-box observation into the Brain repo. + +The board's cloud render is honest about what it cannot see: hygiene debt and +worktree state are local-machine facts. This is the sanctioned enrichment path +(the Heart's heart/publish.py pattern): the dev box distills BOTH into +``state/devbox_board.json``, commits, and pushes to the Brain's own repo — +brain_board.yml re-publishes on that push, so the board shows them age-stamped +("observed Nh ago via morning.sh"), going stale at 48h and dropping after 7d. + +The natural caller is ``bin/morning.sh`` — the one command a human already +runs each morning on the machine that has the observations. + +Distilled, never raw: + * hygiene — the conductor's own fast pre-scan (`hygiene.sh --json`, the + HygieneDecision rows verbatim; this module re-derives nothing). + * worktrees — repos under $PYAUTO_ROOT that are off their default branch, + carry unpushed commits, are dirty, or hold stashes. Repo names only. + +Privacy: the repo is public. Any text naming a local filesystem path has the +home directory collapsed to ``~`` before it leaves the machine; repo and +branch names are fine. + +Usage: + pyauto-brain board publish # distill + commit + push + pyauto-brain board publish --dry-run # print the JSON, write nothing + pyauto-brain board publish --no-hygiene # worktrees only (fast) + +Env (hermetic tests override): PYAUTO_ROOT, BOARD_HYGIENE_CMD (the hygiene +entrypoint), BOARD_DEVBOX_FILE (the output path). +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +PYAUTO_ROOT = Path(os.environ.get("PYAUTO_ROOT", BRAIN_HOME.parent)) +DEVBOX_FILE = Path(os.environ.get( + "BOARD_DEVBOX_FILE", BRAIN_HOME / "state" / "devbox_board.json")) +HYGIENE_CMD = os.environ.get( + "BOARD_HYGIENE_CMD", + str(BRAIN_HOME / "agents" / "conductors" / "hygiene" / "hygiene.sh")) + +SCHEMA_VERSION = 1 + + +def _scrub(text): + """Collapse the home directory in any outbound string to `~`.""" + home = os.path.expanduser("~") + out = str(text) + if home and home != "~": + out = out.replace(home, "~") + return out + + +def collect_hygiene(): + """The hygiene conductor's fast pre-scan, verbatim (its --json footing). + None on any failure — the payload degrades to worktrees-only.""" + try: + r = subprocess.run(["bash", HYGIENE_CMD, "--json"], + capture_output=True, text=True, timeout=900) + except (OSError, subprocess.TimeoutExpired): + return None + if r.returncode != 0: + return None + try: + decision = json.loads(r.stdout) + except json.JSONDecodeError: + return None + rows = [] + for row in decision.get("rows", []): + rows.append({ + "mode": row.get("mode"), + "status": row.get("status"), + "count": row.get("count"), + "summary": _scrub(row.get("summary", ""))[:200], + "delegate": row.get("delegate"), + }) + return {"rows": rows} + + +def _git_out(repo, *args): + r = subprocess.run(["git", "-C", str(repo), *args], + capture_output=True, text=True, timeout=60) + return r.stdout.strip() if r.returncode == 0 else None + + +def collect_worktrees(): + """Repos under PYAUTO_ROOT worth a morning glance: off their default + branch, ahead of upstream, dirty, or holding stashes. Names only.""" + rows = [] + if not PYAUTO_ROOT.is_dir(): + return rows + for repo in sorted(PYAUTO_ROOT.iterdir()): + if not (repo / ".git").exists(): + continue + branch = _git_out(repo, "branch", "--show-current") or "?" + ahead_out = _git_out(repo, "rev-list", "--count", "@{u}..HEAD") + ahead = int(ahead_out) if ahead_out and ahead_out.isdigit() else 0 + dirty = bool(_git_out(repo, "status", "--porcelain", + "--untracked-files=no")) + stash_out = _git_out(repo, "stash", "list") or "" + stashes = len([l for l in stash_out.splitlines() if l.strip()]) + off_default = branch not in ("main", "master") + if off_default or ahead or dirty or stashes: + rows.append({"repo": repo.name, "branch": branch, "ahead": ahead, + "dirty": dirty, "stashes": stashes}) + return rows + + +def build_payload(with_hygiene=True): + payload = { + "schema": SCHEMA_VERSION, + "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "worktrees": collect_worktrees(), + } + if with_hygiene: + hygiene = collect_hygiene() + if hygiene is not None: + payload["hygiene"] = hygiene + return payload + + +# The repo the observation is committed to — the Brain checkout itself, or a +# fabricated repo in hermetic tests (which point BOARD_DEVBOX_FILE inside it). +PUBLISH_REPO = Path(os.environ.get("BOARD_PUBLISH_REPO", BRAIN_HOME)) + + +def _git(*args): + return subprocess.run(["git", "-C", str(PUBLISH_REPO), *args], + capture_output=True, text=True, timeout=120) + + +def main(argv=None): + ap = argparse.ArgumentParser(prog="board publish", description=__doc__) + ap.add_argument("--dry-run", action="store_true", + help="print the distilled JSON, write and push nothing") + ap.add_argument("--no-hygiene", action="store_true", + help="skip the hygiene pre-scan (worktrees only)") + ns = ap.parse_args(argv) + + payload = build_payload(with_hygiene=not ns.no_hygiene) + text = json.dumps(payload, indent=2, sort_keys=True) + "\n" + if ns.dry_run: + print(text, end="") + return 0 + + branch = _git("branch", "--show-current").stdout.strip() + if branch != "main": + print(f"board publish: Brain checkout is on '{branch}' — publish " + "commits to main only; switch to main first (or --dry-run)", + file=sys.stderr) + return 2 + + DEVBOX_FILE.parent.mkdir(parents=True, exist_ok=True) + if DEVBOX_FILE.exists() and DEVBOX_FILE.read_text() == text: + print("board publish: devbox observation already current — nothing to push") + return 0 + DEVBOX_FILE.write_text(text) + + rel = os.path.relpath(DEVBOX_FILE, PUBLISH_REPO) + _git("add", rel) + committed = _git("commit", "-m", + "brain: publish dev-box observation (hygiene + worktrees)") + if committed.returncode != 0: + blob = committed.stdout + committed.stderr + if "nothing to commit" in blob: + print("board publish: unchanged after staging — nothing to push") + return 0 + print(blob, file=sys.stderr) + return 1 + + # Concurrent pushes to main are normal; rebase our one commit and retry. + for _attempt in (1, 2, 3): + pushed = _git("push", "origin", "HEAD") + if pushed.returncode == 0: + print(f"board publish: pushed {rel} " + f"({len(payload['worktrees'])} worktree row(s)" + + (", hygiene included" if "hygiene" in payload else "") + + f", ts {payload['ts']}) — brain_board.yml republishes on " + "this push") + return 0 + rebased = _git("pull", "--rebase", "origin", "main") + if rebased.returncode != 0: + print(rebased.stderr or rebased.stdout, file=sys.stderr) + return 1 + print("board publish: could not push after 3 attempts", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/board/board.sh b/board/board.sh index 2c96b2e..0d5b6ac 100755 --- a/board/board.sh +++ b/board/board.sh @@ -14,9 +14,18 @@ # 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/) +# board.sh publish [--dry-run] [--no-hygiene] +# # dev-box leg (_publish.py): distill hygiene + +# # worktree state into state/devbox_board.json +# # and push it (morning.sh's last step) set -uo pipefail HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +if [[ "${1:-}" == "publish" ]]; then + shift + exec python3 "$HERE/_publish.py" "$@" +fi + exec python3 "$HERE/_board.py" "$@" diff --git a/tests/test_board.py b/tests/test_board.py index ca88245..a9d8b08 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -16,6 +16,7 @@ import base64 import json import os +import re import stat import subprocess from datetime import datetime, timedelta, timezone @@ -25,8 +26,34 @@ BRAIN = BRAIN_HOME / "bin" / "pyauto-brain" SURFACE_KEYS = { - "generated", "org", "overnight", "heart", "versions", "community", - "resume", "open_issues", "doors", "boards", "degraded", + "generated", "org", "overnight", "heart", "heart_blockers", "hands", + "versions", "community", "resume", "open_issues", "devbox", "autonomy", + "doors", "boards", "degraded", "history", +} + +AUTONOMY_LOG = """\ +# Autonomy calibration log + +| date | task | effective level | gates | outcome | +|------|------|-----------------|-------|---------| +| 2026-08-01 | first-task (#1) | safe | tests pass | merged-unchanged | +| 2026-08-02 | second-task (#2) | supervised | tests pass | amended | +""" + +HEART_BOARD_JSON = { + "schema_version": 2, + "blockers": [{ + "text": "RepoA: nightly smoke red", + "severity": "red", + "repo": "RepoA", + "repo_url": "https://example.invalid/RepoA", + "run_url": "https://example.invalid/run/9", + "prompt": "/bug Heart board: RepoA nightly smoke red — https://example.invalid/run/9", + }], +} + +BRAIN_PREV_BOARD_JSON = { + "history": [{"date": "2026-08-20", "need_you": 3}], } REPOS_YAML = """\ @@ -82,6 +109,7 @@ def _default_fixtures(**overrides): "html_url": "https://example.invalid/pr/5", }]}, "issue_count.json": {"total_count": 42}, + "annotations.json": [], "comm_issues.json": EMPTY_SEARCH, "comm_prs.json": EMPTY_SEARCH, "comments.json": [], @@ -100,6 +128,7 @@ def _fabricate(tmp_path, fixtures): (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") + (mind / "autonomy_log.md").write_text(AUTONOMY_LOG) # One badge per sibling board named in the declared config surface — # read from policy.yaml so no board repo name is hardcoded here. @@ -114,6 +143,14 @@ def _fabricate(tmp_path, fixtures): (pages / board_repo / "badge.json").write_text(json.dumps({ "schemaVersion": 1, "label": board_repo.lower(), "message": "GREEN", "color": "brightgreen"})) + # The Heart's machine surface (structured blockers) and the Brain's own + # previous page (the self-carrying trend history). + (pages / board_cfg["heart_board"] / "board.json").write_text( + json.dumps(HEART_BOARD_JSON)) + brain_repo = (board_cfg.get("boards") or {}).get("brain", "PyAutoBrain") + (pages / brain_repo).mkdir(parents=True, exist_ok=True) + (pages / brain_repo / "board.json").write_text( + json.dumps(BRAIN_PREV_BOARD_JSON)) fixture_dir = tmp_path / "fixtures" fixture_dir.mkdir() @@ -127,6 +164,7 @@ def _fabricate(tmp_path, fixtures): 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/*/check-runs/*/annotations) cat "{fixture_dir}/annotations.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 ;; @@ -149,6 +187,8 @@ def _run(args, tmp_path, stub): "PYAUTO_ROOT": str(tmp_path), "BOARD_GH": str(stub), "BOARD_PAGES_BASE": f"file://{tmp_path}/pages", + # Isolated from any devbox observation committed to this checkout. + "BOARD_DEVBOX_FILE": str(tmp_path / "devbox_board.json"), "COMMUNITY_GH": str(stub), "COMMUNITY_SEARCH_PAUSE": "0", } @@ -176,7 +216,7 @@ def test_json_surface_is_complete_and_derives_org(tmp_path): assert s["overnight"], "policy overnight_jobs rendered no rows" for row in s["overnight"]: assert set(row) == {"repo", "workflow", "conclusion", "age_h", "url", - "blocked"} + "blocked", "blocked_reason"} assert row["repo"].startswith("ExampleOrg/") assert row["conclusion"] == "success" # Versions: every stamp resolves to the fixture, so consensus + no drift. @@ -184,8 +224,17 @@ def test_json_surface_is_complete_and_derives_org(tmp_path): assert v["consensus"] == VERSION assert v["drift"] == 0 assert v["reference"] == VERSION - # Heart headline via the file:// badge (the cross-board contract). + # Heart headline via the file:// badge (the cross-board contract), plus + # the structured blockers from its published machine surface, verbatim. assert s["heart"]["message"] == "GREEN" + assert s["heart_blockers"] == HEART_BOARD_JSON["blockers"] + assert s["hands"]["message"] == "GREEN" + # Autonomy strip: the log's tail, and the self-carrying trend history. + assert [a["task"] for a in s["autonomy"]] == [ + "first-task (#1)", "second-task (#2)"] + assert s["history"][0] == {"date": "2026-08-20", "need_you": 3} + assert len(s["history"]) == 2 and s["history"][1]["need_you"] == 0 + assert s["devbox"] is None # no observation published in this fixture # 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 @@ -315,6 +364,167 @@ def test_boards_footer_lists_the_family_without_self(tmp_path): assert ">brain" not in footer # a board never links itself +def test_heart_blockers_render_with_their_own_prompts(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures()) + page = _run(["--html"], tmp_path, stub).stdout + blocker = HEART_BOARD_JSON["blockers"][0] + # The Heart's own /bug prompt is the chip payload — never re-derived. + assert f'data-cmd="{blocker["prompt"]}"' in page + assert "nightly smoke red" in page + md = _run([], tmp_path, stub).stdout + assert blocker["prompt"] in md + assert "Shipped: **GREEN**" in md # the Hands headline joined the section + + +def test_blocked_gate_annotation_renders_inline(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(**{ + "jobs.json": {"jobs": [{"id": 77, "steps": [ + {"name": "Blocked at a gate — no release made", + "conclusion": "success"}]}]}, + "annotations.json": [ + {"title": "Nightly release blocked", + "message": "The driver stopped at a gate (exit 3); no release " + "was made.", + "annotation_level": "warning"}]})) + page = _run(["--html"], tmp_path, stub).stdout + assert "The driver stopped at a gate (exit 3)" in page + + +def _devbox_payload(ts): + return { + "schema": 1, + "ts": ts, + "hygiene": {"rows": [ + {"mode": "artifacts", "status": "debris", "count": 4, + "summary": "4 generated packaging leftovers across 2 repos", + "delegate": "/repo_cleanup"}, + {"mode": "crlf", "status": "clean", "count": 0, + "summary": "clean", "delegate": "/refactor"}, + ]}, + "worktrees": [ + {"repo": "RepoA", "branch": "feature/x", "ahead": 2, + "dirty": False, "stashes": 1}], + } + + +def test_devbox_observation_renders_age_stamped(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures()) + fresh = (datetime.now(timezone.utc) - timedelta(hours=5)).strftime( + "%Y-%m-%dT%H:%M:%SZ") + (tmp_path / "devbox_board.json").write_text( + json.dumps(_devbox_payload(fresh))) + page = _run(["--html"], tmp_path, stub).stdout + assert "Dev box" in page and "5h" in page + assert "packaging leftovers" in page + assert 'data-cmd="/repo_cleanup"' in page # the row's own delegate door + assert "crlf" not in page # clean rows are not rendered + assert "feature/x" in page and "2 unpushed" in page + + +def test_expired_devbox_observation_is_dropped(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures()) + old = (datetime.now(timezone.utc) - timedelta(days=9)).strftime( + "%Y-%m-%dT%H:%M:%SZ") + (tmp_path / "devbox_board.json").write_text( + json.dumps(_devbox_payload(old))) + s = json.loads(_run(["--json"], tmp_path, stub).stdout) + assert s["devbox"] is None + + +# ----------------------------------------------------------------- publish -- + + +def _publish_env(tmp_path, hygiene_json=None): + """A fabricated Brain repo (with a bare origin), workspace, and stub + hygiene entrypoint for board publish.""" + workspace = tmp_path / "ws" + dirty_repo = workspace / "RepoA" + dirty_repo.mkdir(parents=True) + subprocess.run(["git", "init", "-q", "-b", "main", str(dirty_repo)], check=True) + (dirty_repo / "f.txt").write_text("x") + subprocess.run(["git", "-C", str(dirty_repo), "add", "."], check=True) + subprocess.run(["git", "-C", str(dirty_repo), "-c", "user.email=t@t", + "-c", "user.name=t", "commit", "-qm", "seed"], check=True) + subprocess.run(["git", "-C", str(dirty_repo), "checkout", "-qb", + "feature/task"], check=True) + + origin = tmp_path / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(origin)], + check=True) + brain = tmp_path / "brainrepo" + subprocess.run(["git", "clone", "-q", str(origin), str(brain)], check=True) + (brain / "seed.txt").write_text("seed") + subprocess.run(["git", "-C", str(brain), "add", "."], check=True) + subprocess.run(["git", "-C", str(brain), "-c", "user.email=t@t", + "-c", "user.name=t", "commit", "-qm", "seed"], check=True) + subprocess.run(["git", "-C", str(brain), "push", "-q", "-u", "origin", + "main"], check=True) + + hyg = tmp_path / "hygiene_stub.sh" + home = os.path.expanduser("~") + default_decision = {"decision": "HygieneDecision", "rows": [ + {"mode": "artifacts", "kind": "debris", "status": "debris", "count": 2, + "summary": f"2 leftovers under {home}/Code somewhere", + "delegate": "/repo_cleanup"}]} + hyg.write_text("#!/usr/bin/env bash\ncat <<'EOF'\n" + + json.dumps(hygiene_json or default_decision) + + "\nEOF\n") + hyg.chmod(hyg.stat().st_mode | stat.S_IEXEC) + + return { + **os.environ, + "PYAUTO_ROOT": str(workspace), + "BOARD_PUBLISH_REPO": str(brain), + "BOARD_DEVBOX_FILE": str(brain / "state" / "devbox_board.json"), + "BOARD_HYGIENE_CMD": str(hyg), + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", + }, brain, origin + + +def test_publish_dry_run_distills_and_scrubs(tmp_path): + env, brain, _origin = _publish_env(tmp_path) + r = subprocess.run([str(BRAIN), "board", "publish", "--dry-run"], + capture_output=True, text=True, env=env, cwd=tmp_path) + assert r.returncode == 0, r.stderr + payload = json.loads(r.stdout) + assert payload["schema"] == 1 + # Worktree scan sees the off-main repo; only names travel. + assert payload["worktrees"][0]["repo"] == "RepoA" + assert payload["worktrees"][0]["branch"] == "feature/task" + # The hygiene summary's home path was collapsed before leaving the box. + summary = payload["hygiene"]["rows"][0]["summary"] + assert os.path.expanduser("~") not in summary + assert "~/Code" in summary + # Dry run wrote nothing. + assert not (brain / "state" / "devbox_board.json").exists() + + +def test_publish_commits_and_pushes_to_main_only(tmp_path): + env, brain, origin = _publish_env(tmp_path) + r = subprocess.run([str(BRAIN), "board", "publish", "--no-hygiene"], + capture_output=True, text=True, env=env, cwd=tmp_path) + assert r.returncode == 0, r.stderr + # The observation landed on the bare origin's main. + shown = subprocess.run( + ["git", "-C", str(origin), "show", "main:state/devbox_board.json"], + capture_output=True, text=True) + assert shown.returncode == 0 + assert json.loads(shown.stdout)["worktrees"][0]["repo"] == "RepoA" + assert "hygiene" not in json.loads(shown.stdout) # --no-hygiene + # Re-publishing an identical observation pushes nothing new. + r2 = subprocess.run([str(BRAIN), "board", "publish", "--no-hygiene"], + capture_output=True, text=True, env=env, cwd=tmp_path) + assert "nothing to push" in r2.stdout + # Off main, publish refuses (guard against feature-branch commits). + subprocess.run(["git", "-C", str(brain), "checkout", "-qb", "other"], + check=True) + r3 = subprocess.run([str(BRAIN), "board", "publish", "--no-hygiene"], + capture_output=True, text=True, env=env, cwd=tmp_path) + assert r3.returncode == 2 + assert "main only" in r3.stderr + + # --------------------------------------------------------------- read-only --