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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/knowledge_board.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ name: Dashboard
# and wikis (scripts/board.py, the Heart/Hands dashboard pattern):
# * the GitHub Pages page (one-tap 📋 work prompts for a phone),
# * badge.json (the shields endpoint the README badge reads),
# * state.json (the organ-cockpit feed, Brain contract v1 — validated below),
# * the one-line README strip between the memory:begin/end markers.
#
# Contents-level only: titles and counts, never claim text. Nothing is
Expand Down Expand Up @@ -91,10 +92,18 @@ jobs:
mkdir -p _site
python scripts/board.py --html > _site/index.html
python scripts/board.py --badge > _site/badge.json
python scripts/board.py --state > _site/state.json
python scripts/board.py --md > board.md
cp board.md _site/dashboard.md
python scripts/board.py --md-brief > readme_strip.md

# The contract is owned by the Brain (board/_state.py +
# board/state_schema.json); the Memory only emits it. A feed that has
# drifted from the contract fails the job here, before Pages publishes
# something the cockpit would reject.
- name: Validate the cockpit feed against the Brain contract
run: python3 PyAutoBrain/board/_state.py _site/state.json

- name: Write the board to the job step summary
run: cat board.md >> "$GITHUB_STEP_SUMMARY"

Expand Down
153 changes: 148 additions & 5 deletions scripts/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@
derives from ``git remote`` — nothing is hardcoded, so the script travels
into spawned templates unchanged.

Published by ``.github/workflows/knowledge_board.yml`` (Pages + badge + the
README ``memory:begin/end`` strip). Nothing is committed —
``validate_structure.py`` bans ``.html`` and gates the root allowlist, so the
Published by ``.github/workflows/knowledge_board.yml`` (Pages + badge.json +
state.json, the organ-cockpit feed, + the README ``memory:begin/end`` strip).
Nothing is committed — ``validate_structure.py`` bans ``.html`` and gates the root allowlist, so the
board is rendered fresh in CI, the Heart pattern.

Usage:
python scripts/board.py [--md | --md-brief | --html | --badge | --json]
python scripts/board.py [--md | --md-brief | --html | --badge | --state | --json]
"""

from __future__ import annotations
Expand Down Expand Up @@ -1454,6 +1454,144 @@ def badge_endpoint(snapshot: dict) -> dict:
"message": f"{t['pages']} pages · {pct}% cited", "color": "blueviolet"}


# --- the organ-cockpit feed (state.json, contract v1) ---------------------------
# One small, schema-pinned document per organ that the Brain's cockpit polls
# (``state.json`` on Pages, beside badge.json). It is a PROJECTION of the same
# snapshot every other surface renders, so the cockpit can never disagree with
# this board; the contract is owned by the Brain (``board/state_schema.json``,
# validated by ``board/_state.py`` in CI) and the Memory only ever emits it —
# it never imports the Brain to do so, so the script still travels into
# spawned templates that have no Brain beside them.
STATE_SCHEMA_VERSION = 1
STATE_STATUSES = ("green", "yellow", "red", "stale", "grey")
_STATE_TEXT_MAX = 160
# The inbox can hold dozens of suggestions; the cockpit is a glance, not the
# queue, so only the first few become rows — the board page holds the rest.
_STATE_INBOX_CAP = 5


def _iso_z(ts) -> str:
"""``ts`` as ISO-8601 UTC with a ``Z`` suffix, whole seconds.

``collect`` stamps ``datetime.now(utc).isoformat()`` (``+00:00`` with
microseconds) and a hand-built snapshot may be naive; the cockpit compares
ages across organs on one clock, so both are normalised (naive is read as
UTC). An unparseable or empty stamp becomes *now* — the moment this feed
was written — rather than a string the validator would reject.
"""
try:
t = datetime.datetime.fromisoformat(str(ts)) if ts else None
except ValueError:
t = None
if t is None:
t = datetime.datetime.now(datetime.timezone.utc)
elif t.tzinfo is None:
t = t.replace(tzinfo=datetime.timezone.utc)
return t.astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _clip(text, limit: int = _STATE_TEXT_MAX) -> str:
"""One line of at most ``limit`` chars — the contract's row width."""
text = " ".join(str(text).split())
return text if len(text) <= limit else text[: limit - 1] + "…"


def to_state(snapshot: dict) -> dict:
"""The organ-cockpit feed (fmt='state'): contract v1 of PyAutoBrain#416.

status: grey when no wiki pages were observed (nothing read is never
green — a spawned template or a broken checkout must not look fine);
yellow when a suggestion digest has gone stale, a filing waits on a merge,
or any sub-wiki still carries stub pages or TODO BibTeX keys; else green.
There is deliberately NO red: the Memory holds knowledge, nothing in it can
break or block a release — the worst it can be is behind.

The headline is the badge message (so the cockpit and the README badge say
the same thing), prefixed with the first reason when yellow. Items are the
rows that ask something of a human, most urgent first, each carrying the
same copy-for-Claude prompt or issue link the board page offers.
"""
t = _totals(snapshot)
repo_url = _repo_url(snapshot)
# A remote-less checkout (spawned template, test tree) has no Pages URL;
# state.json is published beside index.html, so "./" is the board itself.
page = pages_url(snapshot) or "./"
state = {
"schema_version": STATE_SCHEMA_VERSION,
"organ": BOARD_KEY,
"repo": snapshot.get("repo") or "PyAutoMemory",
"status": "grey",
"headline": "no wiki pages observed",
"updated": _iso_z(snapshot.get("generated")),
"pages_url": page,
"items": [],
}
if not t["pages"]:
return state

yellow: list[dict] = []
info: list[dict] = []
reasons: list[str] = []
for key, label in (("inbox_last_digest", "arXiv inbox"),
("interests_last_digest", "arXiv interests")):
fresh = _inbox_freshness(snapshot, key)
if fresh["stale"]:
reasons.append(f"{label} digest stale")
yellow.append({
"severity": "yellow",
"text": _clip(f"{label}: no digest since {fresh['stamp']} "
f"({fresh['weekdays']} weekdays) — the nightly "
f"filing may be broken"),
"url": pages_url(snapshot) or None, "prompt": None})
filings = snapshot.get("filings") or []
if filings:
reasons.append("filings awaiting merge")
for f in filings:
yellow.append({
"severity": "yellow",
"text": _clip(f"filing #{f['issue']} reached {f['branch']} "
f"but not main — open and merge its PR"),
"url": (f"{repo_url}/compare/main...{f['branch']}?expand=1"
if repo_url else None),
"prompt": None})
waiting = False
for w in snapshot.get("wikis") or []:
stubs = (w.get("statuses") or {}).get("stub", 0)
todos = w.get("todo", 0)
if not (stubs or todos):
continue
waiting = True
# One row per wiki, but its prompt names the bigger job first: TODO
# keys block citation, stubs only block maturity.
prompt = (_todo_prompt(snapshot, w["name"]) if todos
else _stub_prompt(snapshot, w["name"]))
info.append({
"severity": "info",
"text": _clip(f"{w['name']}: {stubs} stub{'s' if stubs != 1 else ''}"
f", {todos} todo{'s' if todos != 1 else ''}"),
"url": (f"{repo_url}/tree/main/wiki/{w['name']}" if repo_url
else None),
"prompt": prompt})
if waiting:
reasons.append("stubs/todos waiting")
for p in (snapshot.get("inbox") or [])[:_STATE_INBOX_CAP]:
info.append({
"severity": "info",
"text": _clip(f"inbox: {p['title']} ({p.get('days_left')}d left)"),
"url": _queue_issue_url(snapshot,
inbox_actions.INBOX_TARGET_SECTION, p,
"add", source=inbox_actions.INBOX_FILE)
or None,
"prompt": None})

badge = badge_endpoint(snapshot)["message"]
state.update(
status="yellow" if reasons else "green",
headline=_clip(f"{reasons[0]} · {badge}" if reasons else badge),
items=yellow + info)
return state


def render(snapshot: dict, fmt: str = "md") -> str:
if fmt == "md":
return _render_md(snapshot)
Expand All @@ -1463,6 +1601,8 @@ def render(snapshot: dict, fmt: str = "md") -> str:
return _render_html(snapshot)
if fmt == "badge":
return json.dumps(badge_endpoint(snapshot))
if fmt == "state":
return json.dumps(to_state(snapshot), indent=2)
if fmt == "json":
return json.dumps({**snapshot, "pages_url": pages_url(snapshot)},
indent=2, sort_keys=True)
Expand All @@ -1478,12 +1618,15 @@ def main(argv: list[str] | None = None) -> int:
g.add_argument("--md-brief", action="store_true", help="the README strip")
g.add_argument("--html", action="store_true", help="the Pages page")
g.add_argument("--badge", action="store_true", help="shields endpoint JSON")
g.add_argument("--state", action="store_true",
help="organ-cockpit state.json feed (Brain contract v1)")
g.add_argument("--json", action="store_true", help="the machine surface")
ns = ap.parse_args(argv)
snap = collect()
fmt = "md"
for name, label in (("md", "md"), ("md_brief", "md-brief"),
("html", "html"), ("badge", "badge"), ("json", "json")):
("html", "html"), ("badge", "badge"), ("json", "json"),
("state", "state")):
if getattr(ns, name):
fmt = label
break
Expand Down
119 changes: 117 additions & 2 deletions tests/test_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import datetime
import json
import re
import sys
Expand Down Expand Up @@ -158,13 +159,13 @@ def _paper(kind, title, ref, line, done=False, done_date=None):

def test_every_fmt_renders(tmp_path):
snap = board.collect(_tree(tmp_path))
for fmt in ("md", "md-brief", "html", "badge", "json"):
for fmt in ("md", "md-brief", "html", "badge", "state", "json"):
assert board.render(snap, fmt)


def test_contents_level_only_no_body_text_leaks(tmp_path):
snap = board.collect(_tree(tmp_path))
for fmt in ("md", "md-brief", "html", "badge", "json"):
for fmt in ("md", "md-brief", "html", "badge", "state", "json"):
assert BODY_MARKER not in board.render(snap, fmt)


Expand Down Expand Up @@ -792,3 +793,117 @@ def test_one_tap_reads_the_request_back_out_of_the_link(tmp_path):
def test_one_tap_never_touches_the_page_without_a_token(tmp_path):
html = board._render_html(_snap_with_remote(tmp_path))
assert "if(!a||!token()||!repo())return;" in html


# --- the organ-cockpit feed (state.json, contract v1) ---------------------------
# The contract is owned by the Brain (board/_state.py); these tests copy its
# required-keys/enum check rather than importing it, so the Memory suite never
# depends on a sibling checkout for its own feed's shape. CI runs the real
# validator on the published file.
STATE_REQUIRED = ("schema_version", "organ", "repo", "status", "headline",
"updated", "pages_url", "items")
STATE_STATUSES = ("green", "yellow", "red", "stale", "grey")
STATE_SEVERITIES = ("red", "yellow", "info")


def _assert_state_shape(state):
assert all(k in state for k in STATE_REQUIRED)
assert state["schema_version"] == 1 and state["organ"] == "memory"
assert state["status"] in STATE_STATUSES
assert state["headline"].strip() and "\n" not in state["headline"]
assert state["pages_url"].strip()
for item in state["items"]:
assert item["severity"] in STATE_SEVERITIES
assert item["text"].strip() and len(item["text"]) <= 160


def _state_snap(tmp_path):
snap = board.collect(_tree(tmp_path))
snap["owner"], snap["repo"] = "SomeOrg", "MemRepo"
snap["generated"] = FIXTURE_NOW
return snap


def test_state_has_the_contract_shape(tmp_path):
state = board.to_state(_state_snap(tmp_path))
_assert_state_shape(state)
assert state["repo"] == "MemRepo"
assert state["pages_url"] == "https://someorg.github.io/MemRepo/"
assert state["updated"] == "2026-08-26T09:00:00Z"
assert datetime.datetime.fromisoformat(state["updated"][:-1] + "+00:00")


def test_state_updated_is_whole_second_utc_z():
# naive → read as UTC; unparseable → now, still a valid Z stamp
assert board._iso_z("2026-08-26T09:00:00.123456") == "2026-08-26T09:00:00Z"
assert board._iso_z("2026-08-26T10:00:00+01:00") == "2026-08-26T09:00:00Z"
now = board._iso_z("not a date")
assert now.endswith("Z") and datetime.datetime.fromisoformat(now[:-1])


def test_state_stubs_and_todos_become_info_rows_with_prompts(tmp_path):
state = board.to_state(_state_snap(tmp_path))
assert state["status"] == "yellow"
assert state["headline"].startswith("stubs/todos waiting · ")
assert "50% cited" in state["headline"]
(row,) = [i for i in state["items"] if i["text"].startswith("demo:")]
assert row["severity"] == "info" and row["text"] == "demo: 1 stub, 1 todo"
assert row["prompt"] == board._todo_prompt(_state_snap(tmp_path / "x"),
"demo")
assert row["url"] == "https://github.com/SomeOrg/MemRepo/tree/main/wiki/demo"


def test_state_inbox_papers_are_capped_info_rows(tmp_path):
snap = _state_snap(tmp_path)
snap["inbox"] = [dict(snap["inbox"][0], title=f"Paper {n}")
for n in range(8)]
rows = [i for i in board.to_state(snap)["items"]
if i["text"].startswith("inbox:")]
assert len(rows) == 5
assert all(r["severity"] == "info" and "issues/new" in r["url"]
for r in rows)


def test_state_is_green_with_nothing_waiting(tmp_path):
snap = _state_snap(tmp_path)
for w in snap["wikis"]:
w["statuses"], w["todo"] = {"drafted": w["pages"]}, 0
snap["inbox"] = []
state = board.to_state(snap)
assert state["status"] == "green" and state["items"] == []
assert state["headline"] == board.badge_endpoint(snap)["message"]


def test_a_stale_inbox_turns_the_state_yellow(tmp_path):
snap = _inbox(tmp_path, "2026-08-19")
for w in snap["wikis"]:
w["statuses"], w["todo"] = {"drafted": w["pages"]}, 0
state = board.to_state(snap)
_assert_state_shape(state)
assert state["status"] == "yellow"
assert state["headline"].startswith("arXiv inbox digest stale")
assert state["items"][0]["severity"] == "yellow"
assert "no digest since 2026-08-19" in state["items"][0]["text"]


def test_pending_filings_turn_the_state_yellow(tmp_path):
snap = _state_snap(tmp_path)
snap["filings"] = [{"issue": 7, "branch": "queue-filing/issue-7"}]
state = board.to_state(snap)
assert state["status"] == "yellow"
assert state["items"][0]["url"].endswith(
"/compare/main...queue-filing/issue-7?expand=1")


def test_an_empty_tree_is_grey_never_green(tmp_path):
(tmp_path / "wiki").mkdir()
state = board.to_state(board.collect(tmp_path))
_assert_state_shape(state)
assert state["status"] == "grey" and state["items"] == []
# no remote: the feed still names a page — the board beside it
assert state["pages_url"] == "./"


def test_render_state_is_valid_json(tmp_path):
state = json.loads(board.render(_state_snap(tmp_path), "state"))
_assert_state_shape(state)
Loading