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 --