diff --git a/board/_board.py b/board/_board.py index cb959ba..7c8e38e 100755 --- a/board/_board.py +++ b/board/_board.py @@ -428,8 +428,10 @@ def sparkline(history): 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).""" + """EVERY door, from the two single sources: the dispatcher registry + (conductors + faculties — all the agents) and skills/*/SKILL.md (the + non-agent doors: compositions, dev-flow entries, ship/cleanup workflows). + Never a second hand-written roster.""" script = ( f'source "{BRAIN_HOME}/bin/pyauto-brain"; ' 'for v in "${CONDUCTOR_ORDER[@]}"; do printf "conductor\\t%s\\t%s\\n" "$v" "${AGENT_DESC[$v]}"; done; ' @@ -445,6 +447,20 @@ def collect_doors(): parts = line.split("\t", 2) if len(parts) == 3: doors.append({"tier": parts[0], "verb": parts[1], "desc": parts[2]}) + agent_verbs = {d["verb"] for d in doors} + # board (this page) and wake_up (superseded BY this page) stay off the + # roster on purpose. + skip = agent_verbs | {"board", "wake_up"} + for skill in sorted((BRAIN_HOME / "skills").glob("*/SKILL.md")): + verb = skill.parent.name + if verb in skip: + continue + m = re.search(r"^description:\s*(.+)$", skill.read_text(encoding="utf-8"), + re.M) + desc = m.group(1).strip() if m else "" + # First sentence only — the SKILL.md carries the full contract. + desc = re.split(r"(?<=[.!?]) ", desc, maxsplit=1)[0][:140] + doors.append({"tier": "skill", "verb": verb, "desc": desc}) return doors @@ -996,12 +1012,21 @@ def community_row(e, note_html): doors = data["doors"] if doors: H.append("

🚪 All doors

") - H.append("
every conductor and faculty") + H.append("
every agent and workflow door") for d in doors: + if d["tier"] == "skill": + continue tier = ' (faculty)' \ if d["tier"] == "faculty" else "" H.append(_row(f'/{esc(d["verb"])}{tier} — {esc(d["desc"])}', f"/{d['verb']}")) + skills = [d for d in doors if d["tier"] == "skill"] + if skills: + H.append('

Workflow doors — compositions and ' + 'dev-flow entries, no agent of their own:

') + for d in skills: + H.append(_row(f'/{esc(d["verb"])} — {esc(d["desc"])}', + f"/{d['verb']}")) H.append("
") if data["degraded"]: diff --git a/board/_publish.py b/board/_publish.py index f0500ec..d9cb49f 100755 --- a/board/_publish.py +++ b/board/_publish.py @@ -160,9 +160,20 @@ def main(argv=None): 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 + # Idempotence ignores the timestamp: an unchanged observation must not + # bump the file (and re-trigger brain_board.yml) just because the clock + # moved between two runs. + if DEVBOX_FILE.exists(): + try: + prev = json.loads(DEVBOX_FILE.read_text()) + except (json.JSONDecodeError, OSError): + prev = None + if prev is not None and \ + {k: v for k, v in prev.items() if k != "ts"} == \ + {k: v for k, v in payload.items() if k != "ts"}: + 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) diff --git a/tests/test_board.py b/tests/test_board.py index a9d8b08..906ce46 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -245,10 +245,17 @@ def test_json_surface_is_complete_and_derives_org(tmp_path): assert s["open_issues"] == 42 # Community section reuses the Ears' scan surface wholesale. assert s["community"]["counts"]["awaiting_response"] == 0 - # The doors roster comes from the dispatcher registry, both tiers. + # The doors roster covers every agent (dispatcher registry, both tiers) + # AND every workflow door (skills/ minus the agents). verbs = {d["verb"] for d in s["doors"]} assert {"intake", "health", "vitals"} <= verbs - assert "board" not in verbs # surfaces are not agents + skill_verbs = {d["verb"] for d in s["doors"] if d["tier"] == "skill"} + assert {"route", "prm", "start_dev", "issue_cleanup"} <= skill_verbs + for d in s["doors"]: + if d["tier"] == "skill": + assert d["desc"], d["verb"] # frontmatter description parsed + assert "board" not in verbs # the page never lists itself + assert "wake_up" not in verbs # superseded BY this page # Sibling boards resolved against the pages base. assert s["boards"]["heart"].endswith("/PyAutoHeart/") @@ -512,10 +519,19 @@ def test_publish_commits_and_pushes_to_main_only(tmp_path): 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. + # Re-publishing an identical observation pushes nothing new — and the + # comparison ignores the timestamp by design (an unchanged observation + # must not re-trigger the board just because the clock moved). 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 + state_file = brain / "state" / "devbox_board.json" + stored = json.loads(state_file.read_text()) + stored["ts"] = "2020-01-01T00:00:00Z" + state_file.write_text(json.dumps(stored, indent=2, sort_keys=True) + "\n") + r2b = subprocess.run([str(BRAIN), "board", "publish", "--no-hygiene"], + capture_output=True, text=True, env=env, cwd=tmp_path) + assert "nothing to push" in r2b.stdout # Off main, publish refuses (guard against feature-branch commits). subprocess.run(["git", "-C", str(brain), "checkout", "-qb", "other"], check=True)