From 2de339bf4638df92d69de3481533b6bba4196402 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 26 Sep 2026 13:26:30 +0100 Subject: [PATCH] feat(cockpit): Mind and Cortex renderers emit the state.json feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR C of PyAutoBrain#418. The intake and cortex conductors render a third page from their one census: state.json, the organ-cockpit v1 feed, built through board/_state.build_state so it cannot break the contract. - intake: render_state — red when an in-flight task is awaiting-input or blocked (or prompts have lifecycle drift), yellow on awaiting-merge / library-shipped rows, human review, pending release or picks, green otherwise, grey for an empty Mind. Headline is the Start-here count line. Items reuse the page's payloads (/start_dev , _review_payload, REFRESH_PAYLOAD), capped at 20. `--apply dashboard` writes it; `--check` compares it with `updated` normalised out. - intake: _mind_home now finds `github:` anywhere in the PyAutoMind repos.yaml entry — since the grouped-checkouts layout put `path:` first it returned '' and the pages silently lost their Pages link. - cortex: render_state — red on check problems (linked to the ledger's issue), yellow while a run is running or the check-in is stale (the page script's freshness rule, ported to checkin_stale), green otherwise, grey with no ledgers. render_pages adds it, so `dashboard --apply/--check` and `checkin` all carry it; state_body normalises `updated`. - tests + tests/fixtures/state/{mind,cortex}.json; docs lines in both conductors' AGENTS.md and board/AGENTS.md. Refs PyAutoBrain#418 Co-Authored-By: Claude Fable 5.1 --- agents/conductors/cortex/AGENTS.md | 4 +- agents/conductors/cortex/_cortex.py | 129 ++++++++++++++++++- agents/conductors/intake/AGENTS.md | 2 +- agents/conductors/intake/_intake.py | 186 ++++++++++++++++++++++++++-- board/AGENTS.md | 4 + tests/fixtures/state/cortex.json | 23 ++++ tests/fixtures/state/mind.json | 29 +++++ tests/test_cortex_conductor.py | 93 ++++++++++++++ tests/test_intake_dashboard.py | 84 +++++++++++++ tests/test_state_feed.py | 2 +- 10 files changed, 540 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/state/cortex.json create mode 100644 tests/fixtures/state/mind.json diff --git a/agents/conductors/cortex/AGENTS.md b/agents/conductors/cortex/AGENTS.md index 88c2e33..9d59454 100644 --- a/agents/conductors/cortex/AGENTS.md +++ b/agents/conductors/cortex/AGENTS.md @@ -29,7 +29,7 @@ note`). | `checkin [--dry-run \| --apply] [--push \| --no-push] [--project KEY]` | **Where is my science?** | the door — the sequence below, ending in a summary keyed by project; reaches no cluster; exit **1** when the tree does not check | | `census [--json]` | What is the Cortex holding? | per project: status, summary, runs by state, log length, last update; totals (active projects, runs open, runs running); the check-in stamp; the problems `cortex.py check` would report | | `dashboard --check` | Are the committed pages current? | exit 0 current · **1 stale** · 2 no checkout · 3 unreadable tree | -| `dashboard --apply` | — | writes `dashboard.md` + `dashboard.html` | +| `dashboard --apply` | — | writes `dashboard.md` + `dashboard.html` + `state.json` — the organ-cockpit feed (`board/_state.py` v1): red on `cortex.py check` problems, yellow while a run is running or the check-in is stale (the page script's 180-min rule, judged at render), items carry `checkin_payload` / `resume_payload`; `--check` ignores its `updated` | | `issue [--project KEY] [--apply]` | What sits at the top of each project's issue? | the fenced ledger block (`cortex.py`'s `issue_block`) per project with an `Issue:`; `--apply` writes it into the issue body through `gh` (replacing the block between the markers, or prepending it); never creates an issue; exit **1** without `gh` | ``` @@ -133,7 +133,7 @@ It writes nothing: no stamp, no pages, no ledger. `--dry-run` prints the exact ### `checkin` — the door -1. **Stamp + render.** `checkin.yaml`, then `dashboard.md` + `dashboard.html`. +1. **Stamp + render.** `checkin.yaml`, then `dashboard.md` + `dashboard.html` + `state.json`. 2. **Push** (the rule below). 3. **Summarise, by project** — printed **last**, so a chat sees it above the fold: `key — summary`, Now, the runs **as the ledger holds them**, the last diff --git a/agents/conductors/cortex/_cortex.py b/agents/conductors/cortex/_cortex.py index 3a96731..eceee6c 100644 --- a/agents/conductors/cortex/_cortex.py +++ b/agents/conductors/cortex/_cortex.py @@ -72,6 +72,10 @@ from _theme import ( # noqa: E402 JS as _THEME_JS, boards_footer, css as _theme_css, hero, pills, stats, ) +# The organ-cockpit feed contract (board/_state.py, state.json v1) — the same +# constructor the Brain board uses, so this renderer cannot write a feed the +# cockpit would reject. +from _state import build_state # noqa: E402 THEME_ORGAN = "cortex" # whose logo this page wears CORTEX_REPO = "PyAutoCortex" # an organ name, not an instance fact @@ -696,9 +700,126 @@ def keep(line: str) -> bool: return "\n".join(l for l in page.splitlines() if keep(l)) +# ------------------------------------------------------- the cockpit feed --- +# The third render of the one census (PyAutoBrain#418): `state.json`, the +# per-organ machine surface the cockpit and the phone read (board/_state.py, +# contract v1). Every prompt is a payload the page already copies — +# `checkin_payload`, `resume_payload` — composed, never re-derived. +STATE_ITEM_CAP = 20 + + +def _now() -> _dt.datetime: + """The render clock (one seam, so tests can move it).""" + return _dt.datetime.now(_dt.timezone.utc) + + +def checkin_stale(stamp: str, now: _dt.datetime | None = None) -> bool: + """The page script's freshness rule (`_CHECKIN_JS`), in Python: a missing + or unparseable stamp, or one older than CHECKIN_FRESH_MINUTES, is stale. + The page judges on the reader's clock at load; the feed judges at render.""" + if not stamp: + return True + try: + then = _dt.datetime.fromisoformat(stamp.replace("Z", "+00:00")) + except ValueError: + return True + if then.tzinfo is None: + return True + now = now or _now() + return (now - then).total_seconds() / 60 > CHECKIN_FRESH_MINUTES + + +def _problem_ledger(c: dict, problem: str) -> dict | None: + """The ledger a `cortex.py check` problem names (`projects/.md: …`).""" + for d in c["ledgers"]: + if problem.startswith(f"{d['rel']}:"): + return d + return None + + +def _state_items(c: dict, stale: bool) -> list: + items = [] + for p in c["problems"]: + d = _problem_ledger(c, p) + items.append({"severity": "red", "text": " ".join(p.split()), + "url": (d or {}).get("issue_url") or None, + "prompt": None}) + if stale: + items.append({"severity": "yellow", + "text": f"check-in stale — last " + f"{c.get('checkin') or CHECKIN_NEVER}", + "url": None, "prompt": checkin_payload(c)}) + for d in c["ledgers"]: + row = c["projects"].get(d["key"], {}) + for r in d["runs"]: + if r["state"] != "running": + continue + items.append({ + "severity": "info", + "text": " ".join(f"{d['key']}: {r['ident']} running " + f"({r['partition'] or '?'})".split()), + "url": d.get("issue_url") or None, + "prompt": resume_payload(d["key"], row)}) + order = {"red": 0, "yellow": 1, "info": 2} + items.sort(key=lambda i: order[i["severity"]]) # stable: section order kept + return items[:STATE_ITEM_CAP] + + +def render_state(c: dict) -> str: + """`state.json` — the Cortex's organ-cockpit feed, as JSON text. + + Status: red when `cortex.py check` reports problems; yellow when a run is + on the cluster or the check-in is stale; green otherwise; grey when there + is no ledger at all. Headline `A active · R running`, state-word prefixed + when not green.""" + now = _now().replace(microsecond=0) + n = c["counts"] + stale = checkin_stale(c.get("checkin") or "", now) + if not c["ledgers"]: + status = "grey" + elif c["problems"]: + status = "red" + elif n["running"] > 0 or stale: + status = "yellow" + else: + status = "green" + line = f"{n['active']} active · {n['running']} running" + if status == "grey": + line = "no project ledgers" + home = c.get("home", "") + state = build_state( + organ="cortex", + repo=home.rstrip("/").rsplit("/", 1)[-1] if home else CORTEX_REPO, + status=status, + headline=line if status == "green" else f"{status.upper()} — {line}", + updated=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + # the Pages site this feed is served from; relative when underivable + pages_url=_pages_url(home) or "./", + items=_state_items(c, stale) if c["ledgers"] else [], + ) + return json.dumps(state, indent=2) + "\n" + + +def state_body(text: str) -> str: + """The feed minus its `updated` render stamp — what `--check` compares + (`dashboard_body`'s job for the pages).""" + try: + obj = json.loads(text) + except ValueError: + return text + if isinstance(obj, dict): + obj.pop("updated", None) + return json.dumps(obj, sort_keys=True) + + +def page_body(name: str, text: str) -> str: + return state_body(text) if name == "state.json" else dashboard_body(text) + + def render_pages(c: dict) -> dict: return {"dashboard.md": render_dashboard(c), - "dashboard.html": render_dashboard_html(c)} + "dashboard.html": render_dashboard_html(c), + "state.json": render_state(c)} def cmd_dashboard(root: Path, c: dict, a) -> int: @@ -708,10 +829,10 @@ def cmd_dashboard(root: Path, c: dict, a) -> int: for name, want in pages.items(): target = root / name on_disk = _read(target) if target.is_file() else "" - if dashboard_body(on_disk) != dashboard_body(want): + if page_body(name, on_disk) != page_body(name, want): stale.append(name) if not stale: - print("dashboard.md + dashboard.html are current") + print("dashboard.md + dashboard.html + state.json are current") return RC_OK print(f"{' + '.join(stale)} stale — regenerate with " "`pyauto-brain cortex dashboard --apply`", file=sys.stderr) @@ -1109,7 +1230,7 @@ def cmd_checkin(root: Path, mod, a) -> int: for n in notes: print(f"\nnote: {n}") print(f"\nwould stamp {CHECKIN_FILE}, re-render dashboard.md + " - "dashboard.html, and read back " + "dashboard.html + state.json, and read back " f"{len(keys)} project(s). Run with --apply to check in.") print("The pull is its own verb, on the laptop: " "`pyauto-brain cortex pull`.") diff --git a/agents/conductors/intake/AGENTS.md b/agents/conductors/intake/AGENTS.md index ccec91b..1db2275 100644 --- a/agents/conductors/intake/AGENTS.md +++ b/agents/conductors/intake/AGENTS.md @@ -116,7 +116,7 @@ the page's Hygiene section), and an un-themed prompt falls back to `Target:`. | **classify** | `intake ""` / `intake classify --file P` | classify one raw input; `--apply` writes the prompt | | **ideas** | `intake ideas` | scan `ideas.md`, propose one prompt per bullet; `--apply` writes them | | **census** | `intake census` | inventory every filed prompt (work-type/target/difficulty/status + hygiene flags); always read-only | -| **dashboard** | `intake dashboard` | render the census as the Mind **task** page — picks, in flight, human review, parked, planned, backlog, bundles, recent, epics; `--apply` writes `PyAutoMind/dashboard.md`, `--check` exits 1 on drift | +| **dashboard** | `intake dashboard` | render the census as the Mind **task** page — picks, in flight, human review, parked, planned, backlog, bundles, recent, epics; `--apply` writes `PyAutoMind/dashboard.md` + `dashboard.html` + `state.json` (the organ-cockpit feed, `board/_state.py` v1: red when a task awaits input/is blocked or prompts drift, yellow when work waits on a merge/release/review or picks exist; items carry the page's own copy payloads), `--check` exits 1 on drift (ignoring the page date and the feed's `updated`) | | **formalise** | `intake formalise [prefix]` | retroactively header the prompts census flags — derive the missing fields, insert in place, prose untouched; `--apply` writes | | **reconcile** | `intake reconcile [prefix]` | rank backlog prompts that look already-shipped (vs the `complete/` records / `active/`), and pair live prompts that look like the same work filed twice; always read-only — retiring stays human | | **reconcile --repo** | `intake reconcile --repo [prefix]` | **also** read the target repo's source: identifiers the prompts name that exist upstream, and lines they quote that are **gone** — the two signals that see a prompt with no Mind-side trace. Opt-in; the default path is offline | diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 4ec286d..486726b 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -63,6 +63,10 @@ from _theme import ( # noqa: E402 JS as _THEME_JS, boards_footer, css as _theme_css, hero, pills, stats, ) +# The organ-cockpit feed contract (board/_state.py, state.json v1) — the same +# constructor the Brain board uses, so this renderer cannot write a feed the +# cockpit would reject. +from _state import build_state # noqa: E402 # The batch-status box: one reading of "is a batch in flight", shared with # the Cortex's dashboard (agents/conductors/batch/_status.py). Not `_batch` @@ -1860,7 +1864,10 @@ def _mind_home(mind: Path) -> str: f = mind / "repos.yaml" if not f.is_file(): return "" - m = re.search(r"^\s{2}PyAutoMind:\s*\n\s+github:\s*([^\s#]+)", + # `github:` may follow other keys of the entry (`path:` since the grouped + # checkouts layout) — scan the whole indented block, not just line one. + m = re.search(r"^\s{2}PyAutoMind:[ \t]*\n(?:[ \t]{4,}.*\n)*?[ \t]{4,}github:" + r"[ \t]*([^\s#]+)", f.read_text(encoding="utf-8", errors="replace"), re.M) return f"https://github.com/{m.group(1)}" if m else "" @@ -2849,6 +2856,163 @@ def _dashboard_body(page: str) -> str: if not l.startswith("