diff --git a/agents/faculties/vitals/AGENTS.md b/agents/faculties/vitals/AGENTS.md index af3bb4a..5681a4b 100644 --- a/agents/faculties/vitals/AGENTS.md +++ b/agents/faculties/vitals/AGENTS.md @@ -124,7 +124,10 @@ faculty every cycle.) 5. **Explain and recommend.** Produce the structured report below. Recommendations should be actionable and, where Heart offers a remediation entry point, cite it (`pyauto-heart fix ci `, `fix dirty `, `fix drift`, - `fix timing `). Do not invent fixes Heart cannot support. + `fix timing `). For an evidence gap the entry point is + `fix stale`: it prints the check that closes each gap and the one plan that + closes them all — quote those, do not compose your own. Do not invent fixes + Heart cannot support. ## Output schema @@ -143,7 +146,8 @@ Status: (score <0-100>, snapshot ) - (or "None") ### Evidence Gaps -- (or "None") +- (or "None") ### Recommendations - (or "None") diff --git a/agents/faculties/vitals/HEART_CAPABILITIES.md b/agents/faculties/vitals/HEART_CAPABILITIES.md index 206c85f..040b1c8 100644 --- a/agents/faculties/vitals/HEART_CAPABILITIES.md +++ b/agents/faculties/vitals/HEART_CAPABILITIES.md @@ -17,7 +17,10 @@ reimplement inside Brain. - `pyauto-heart watch [seconds]` / `live` — continuous monitoring daemon. - `pyauto-heart stop` / `stop --all` — daemon lifecycle control. - `pyauto-heart fix ` — emits a context bundle/invocation for remediation; - it must not mutate other repos directly. + it must not mutate other repos directly. `fix stale` is the freshness door: + the current evidence gaps, the check that closes each, and the ONE plan + (prompt, plus a command chain where every gap has one) that closes them all — + the same payloads the board's ⌨/📋 chips carry. ## Continuous checks in Heart diff --git a/board/AGENTS.md b/board/AGENTS.md index 25a6357..2199b8e 100644 --- a/board/AGENTS.md +++ b/board/AGENTS.md @@ -17,7 +17,7 @@ copy-for-Claude payload: |---------|--------------|-----------------| | ⌨ Morning sync | `bin/morning.sh` (local) | the terminal command itself | | 🌙 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 | +| ❤️ Readiness & release | the Heart board's `badge.json` + `board.json` (structured blockers, each carrying its OWN `/bug` prompt — or, for an evidence gap, the `command` that re-runs its check — plus `stale_plan`, the one payload that closes every gap; all rendered verbatim, never re-derived) and the Hands badge | `/health`, the plan's prompt or command chain, the blockers' own prompts | | ⏱ Test performance | the Heart board's published `performance` block — rendered verbatim, never re-derived | each row's own prompt | | 🏷️ Version consistency | the coupled-set stamps (`board: version_stamps`) | `/bug version drift: …` | | 💬 Community | the Ears (`community scan`, reused wholesale) — every open conversation gets a row | `/community`, `/community triage ` | diff --git a/board/_board.py b/board/_board.py index eb147cf..abeae15 100755 --- a/board/_board.py +++ b/board/_board.py @@ -256,9 +256,26 @@ def extract_heart_blockers(board): "repo_url": b.get("repo_url"), "run_url": b.get("run_url"), "prompt": b.get("prompt"), + # An evidence gap also arrives with the command that re-runs its check + # (Heart board.json v3); absent on other severities and on an older + # Heart publish. Forwarded verbatim, like everything else here. + "command": b.get("command"), } for b in blockers[:HEART_BLOCKER_CAP]] +def extract_heart_plan(board): + """The Heart's whole-tier remedy — one payload that closes every current + evidence gap ({count, command, prompt}). Rendered here verbatim; the Brain + never derives a remedy of its own. None when nothing is stale, or when the + surface predates the field.""" + plan = (board or {}).get("stale_plan") + if not isinstance(plan, dict) or not plan.get("prompt"): + return None + return {"count": plan.get("count"), + "command": plan.get("command"), + "prompt": str(plan["prompt"])} + + def fetch_heart_blockers(pages_base, repo, degraded): """Fetch-and-extract in one call (the blockers-only door).""" return extract_heart_blockers(fetch_heart_board(pages_base, repo, degraded)) @@ -725,6 +742,7 @@ def collect(): # and the test-performance block. heart_board = fetch_heart_board(pages_base, heart_repo, degraded) heart_blockers = extract_heart_blockers(heart_board) + heart_plan = extract_heart_plan(heart_board) performance = extract_heart_performance( heart_board, f"{pages_base}/{heart_repo}/") hands = fetch_badge(pages_base, board_family.get("hands", "PyAutoHands")) @@ -743,6 +761,7 @@ def collect(): "overnight": overnight, "heart": heart, "heart_blockers": heart_blockers, + "heart_plan": heart_plan, "performance": performance, "hands": hands, "versions": versions, @@ -871,12 +890,20 @@ def render_md(data): f"[board]({data['boards'].get('heart', '')}) · re-run via `/health`") else: L.append("- Heart board unreachable — consult `/health` directly") + plan = data.get("heart_plan") + if plan: + # The gaps are worth one line, not N: the Heart already wrote the plan + # that closes all of them, and `fix stale` prints it in a terminal. + clear = f"`{plan['command']}`" if plan.get("command") else "`pyauto-heart fix stale`" + L.append(f"- Evidence gaps: {plan.get('count', '?')} — clear them all: {clear}") 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("command"): + L.append(f" - `{b['command']}`") if b.get("prompt"): L.append(f" - `{b['prompt']}`") if data.get("hands"): @@ -1166,6 +1193,15 @@ def render_html(data): else: H.append(_row("Heart board unreachable — consult the clinician " "directly." + pills(("unreachable", "y")), "/health")) + plan = data.get("heart_plan") + if plan: + # One tap for the whole stale tier, above the gaps it closes: the + # Claude prompt always, the shell chain when the Heart could offer one. + H.append(_row(f'Clear all {plan.get("count", "?")} evidence gaps — one prompt' + + pills(("stale", "y")), plan["prompt"])) + if plan.get("command"): + H.append(_row("… or the command chain that re-runs every check", + plan["command"], term=True)) for b in data.get("heart_blockers") or []: sev = b.get("severity") links = "".join( diff --git a/tests/test_board.py b/tests/test_board.py index 670d560..8b5ff0b 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -14,6 +14,7 @@ """ import base64 +import html import json import os import re @@ -26,7 +27,8 @@ BRAIN = BRAIN_HOME / "bin" / "pyauto-brain" SURFACE_KEYS = { - "generated", "org", "overnight", "heart", "heart_blockers", "performance", + "generated", "org", "overnight", "heart", "heart_blockers", "heart_plan", + "performance", "hands", "versions", "community", "resume", "open_issues", "hygiene", "devbox", "autonomy", "doors", "boards", "degraded", "history", } @@ -78,7 +80,7 @@ } HEART_BOARD_JSON = { - "schema_version": 2, + "schema_version": 3, "blockers": [{ "text": "RepoA: nightly smoke red", "severity": "red", @@ -86,6 +88,9 @@ "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", + # v3: a stale row carries the command that re-runs its check; a red + # one has no such remedy — the fix is code, not a re-run. + "command": None, }], # Additive to schema v2 — an older Heart publish simply omits it. "performance": HEART_PERFORMANCE, @@ -529,6 +534,63 @@ def test_heart_blockers_render_with_their_own_prompts(tmp_path): assert "Shipped: **GREEN**" in md # the Hands headline joined the section +HEART_BOARD_STALE = { + "schema_version": 3, + "blockers": [ + {"text": "install verification not run", "severity": "stale", + "repo": None, "repo_url": None, "run_url": None, + "command": "pyauto-heart verify_install --report-json", + "prompt": "/health run `pyauto-heart verify_install --report-json` — …"}, + {"text": "no release validation for current source", "severity": "stale", + "repo": None, "repo_url": None, "run_url": None, "command": None, + "prompt": "/health dispatch a release rehearsal with `/release rehearse` — …"}, + ], + "stale_plan": { + "count": 2, + "command": None, + "prompt": "/health clear the Heart's 2 evidence gap(s) — …\n1. …\n2. …", + }, +} + + +def test_a_stale_heart_offers_the_one_plan_that_clears_every_gap(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(), HEART_BOARD_STALE) + page = _run(["--html"], tmp_path, stub).stdout + plan = HEART_BOARD_STALE["stale_plan"] + + # The Heart's own plan is the chip payload — the Brain never derives one. + assert html.escape(plan["prompt"], quote=True) in page + assert "Clear all 2 evidence gaps" in page + # Each gap still arrives with the command that closes it, forwarded whole. + s = json.loads(_run(["--json"], tmp_path, stub).stdout) + assert [b["command"] for b in s["heart_blockers"]] == [ + "pyauto-heart verify_install --report-json", None] + assert s["heart_plan"]["count"] == 2 + # The digest spends one line on the tier, not one per gap. + md = _run([], tmp_path, stub).stdout + assert "Evidence gaps: 2 — clear them all: `pyauto-heart fix stale`" in md + + +def test_a_plan_with_a_command_chain_offers_the_terminal_door(tmp_path): + chain = "pyauto-heart verify_install --report-json && pyauto-heart tick" + board = {**HEART_BOARD_STALE, + "stale_plan": {**HEART_BOARD_STALE["stale_plan"], "command": chain}} + stub = _fabricate(tmp_path, _default_fixtures(), board) + + page = _run(["--html"], tmp_path, stub).stdout + assert f'data-cmd="{html.escape(chain, quote=True)}"' in page + assert "copy term" in page # the ⌨ terminal chip, not the 📋 one + md = _run([], tmp_path, stub).stdout + assert f"clear them all: `{chain}`" in md + + +def test_a_heart_board_without_a_plan_renders_none(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures()) # no stale_plan published + s = json.loads(_run(["--json"], tmp_path, stub).stdout) + assert s["heart_plan"] is None + assert "Evidence gaps:" not in _run([], tmp_path, stub).stdout + + def test_test_performance_rows_carry_their_own_prompts(tmp_path): stub = _fabricate(tmp_path, _default_fixtures(), HEART_BOARD_WITH_EVENT) s = json.loads(_run(["--json"], tmp_path, stub).stdout)