diff --git a/.github/workflows/knowledge_board.yml b/.github/workflows/knowledge_board.yml index a19084e..f070995 100644 --- a/.github/workflows/knowledge_board.yml +++ b/.github/workflows/knowledge_board.yml @@ -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 @@ -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" diff --git a/scripts/board.py b/scripts/board.py index 40aa154..7239fda 100644 --- a/scripts/board.py +++ b/scripts/board.py @@ -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 @@ -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) @@ -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) @@ -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 diff --git a/tests/test_board.py b/tests/test_board.py index 026cde0..64ab5c1 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -9,6 +9,7 @@ from __future__ import annotations +import datetime import json import re import sys @@ -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) @@ -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)