From 639f02e4921d8000b54dc0da534ae31f2e3709d8 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 19 Aug 2026 16:45:13 -0400 Subject: [PATCH 1/2] Actionable health board: one-tap prompts, links, dev-box publish (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board's blockers are now structured ({text, severity, repo, run url, prompt}): the Pages html carries one-tap ๐Ÿ“‹ buttons copying ready-made /bug prompts plus repo/run links (the PyAutoMind dashboard pattern), md links them with collapsed copyable prompts, and json (schema v2) carries them verbatim. Grey 'not observed here' rows now say what they watch and copy the observe command; a new 'pyauto-heart publish' distills the dev box's local-only families into state/devbox_board.json (local paths scrubbed) so the cloud page fills in, age-stamped, expiring after 48h. New --md-brief renders the README strip (verdict + linked blockers + board link) that replaces the full table between the heart markers. README restructured on the Mind pattern (organ opening, How PyAutoHeart works, CLI examples); new REFERENCE.md absorbs health_agent/capabilities.md; AI_POLICY/CONTRIBUTING move under .github/; the stale PyAutoBuild boundary audit is retired. Co-Authored-By: Claude Fable 5 --- AI_POLICY.md => .github/AI_POLICY.md | 0 CONTRIBUTING.md => .github/CONTRIBUTING.md | 0 .github/workflows/heart-health.yml | 20 +- README.md | 81 ++--- REFERENCE.md | 140 ++++++++ bin/pyauto-heart | 33 +- health_agent/README.md | 6 +- health_agent/capabilities.md | 102 ------ health_agent/capabilities.yaml | 2 +- health_agent/pyautobuild_boundary_audit.md | 65 ---- heart/dashboard.py | 354 +++++++++++++++++++-- heart/publish.py | 150 +++++++++ tests/test_dashboard.py | 130 +++++++- tests/test_publish.py | 60 ++++ 14 files changed, 887 insertions(+), 256 deletions(-) rename AI_POLICY.md => .github/AI_POLICY.md (100%) rename CONTRIBUTING.md => .github/CONTRIBUTING.md (100%) create mode 100644 REFERENCE.md delete mode 100644 health_agent/capabilities.md delete mode 100644 health_agent/pyautobuild_boundary_audit.md create mode 100644 heart/publish.py create mode 100644 tests/test_publish.py diff --git a/AI_POLICY.md b/.github/AI_POLICY.md similarity index 100% rename from AI_POLICY.md rename to .github/AI_POLICY.md diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/.github/workflows/heart-health.yml b/.github/workflows/heart-health.yml index 15bdcd9..c99bb4a 100644 --- a/.github/workflows/heart-health.yml +++ b/.github/workflows/heart-health.yml @@ -93,10 +93,15 @@ jobs: run: | mkdir -p _site # --cloud marks the local-only checks as "not observed here"; every - # file below is a projection of the same snapshot + verdict. - PYTHONPATH="$PWD" python -m heart.dashboard --cloud --html > _site/index.html - PYTHONPATH="$PWD" python -m heart.dashboard --cloud --badge > _site/badge.json - PYTHONPATH="$PWD" python -m heart.dashboard --cloud --md > board.md + # file below is a projection of the same snapshot + verdict. A + # committed state/devbox_board.json (pyauto-heart publish) is + # auto-detected and fills the unobserved rows, age-stamped. + PYTHONPATH="$PWD" python -m heart.dashboard --cloud --html > _site/index.html + PYTHONPATH="$PWD" python -m heart.dashboard --cloud --badge > _site/badge.json + PYTHONPATH="$PWD" python -m heart.dashboard --cloud --md > board.md + # The README carries only the brief strip; the full board lives on + # the Pages page (one-tap ๐Ÿ“‹ fix prompts) and the step summary. + PYTHONPATH="$PWD" python -m heart.dashboard --cloud --md-brief > readme_strip.md - name: Write the board to the job step summary run: | @@ -115,7 +120,7 @@ jobs: import pathlib, re readme = pathlib.Path("README.md") text = readme.read_text() - board = pathlib.Path("board.md").read_text().strip() + board = pathlib.Path("readme_strip.md").read_text().strip() begin, end = "", "" block = f"{begin}\n{board}\n{end}" if begin in text and end in text: @@ -156,7 +161,10 @@ jobs: for f in sorted(state.glob("*.ci_status.json")): d = json.loads(f.read_text()) if d.get("conclusion") not in (None, "", "success"): - red.append(f"- **{d['name']}**: CI {d.get('conclusion')} ({d.get('workflow','?')})") + line = f"- **{d['name']}**: CI {d.get('conclusion')} ({d.get('workflow','?')})" + if d.get("url"): + line += f" โ€” [failing run]({d['url']})" + red.append(line) for f in sorted(state.glob("*.open_prs.json")): d = json.loads(f.read_text()) age = d.get("max_age_days", 0) diff --git a/README.md b/README.md index 5755cd9..76ce859 100644 --- a/README.md +++ b/README.md @@ -9,56 +9,59 @@ [![health](https://img.shields.io/endpoint?url=https://pyautolabs.github.io/PyAutoHeart/badge.json)](https://pyautolabs.github.io/PyAutoHeart/) - + -## ๐Ÿ”ด PyAuto health โ€” **RED** (score 45) - -_snapshot `2026-08-19T05:27:02.238675+00:00` ยท just now_ - -**Blockers:** autofit_workspace_test: Smoke Tests failure on main - -| | Check | Status | -|--|--|--| -| ๐ŸŸข | Libraries | 6 repos nominal | -| ๐Ÿ”ด | Workspaces | 11 repos, 2 need attention | -| โšช | Worktree drift | not observed here (dev-box only) | -| โšช | Script timing | not observed here (dev-box only) | -| โšช | Import timing | not observed here (dev-box only) | -| โšช | Unit-test timing | not observed here (dev-box only) | -| โšช | Profiling drift | not observed here (dev-box only) | -| โšช | Workspace test-mode timing | not observed here (dev-box only) | -| โšช | Test run | not observed here (dev-box only) | -| โšช | Version skew | not observed here (dev-box only) | - -[Full board](https://pyautolabs.github.io/PyAutoHeart/) -The health layer of the PyAuto organism. Heart continuously watches every -repo โ€” branch state, CI, open PRs, version skew, script timing, workspace -validation โ€” and rolls what it sees into one authoritative verdict: +**PyAutoHeart is the Heart of the PyAutoScientist** โ€” the organism's health +authority. It continuously watches every repository (CI, branches, open PRs, +version skew, script and test timings, workspace validation) and rolls what it +sees into one authoritative verdict: **GREEN / STALE / YELLOW / RED**. GREEN +means it is safe to release. -```bash -pyauto-heart readiness # GREEN / YELLOW / RED, a score, and the reasons -``` +See the live **[health board](https://pyautolabs.github.io/PyAutoHeart/)** for +the whole picture on one page โ€” every check a traffic-light row, and every red +or yellow finding carrying links to the failing run and a one-tap ๐Ÿ“‹ button +that copies a ready-made Claude prompt (`/bug โ€ฆ`), so going from "something is +red" to "an agent is fixing it" is copy โ†’ paste, on a laptop or a phone. -GREEN means it is safe to release. Heart is an observer: it never writes -into other repos and never triggers a build โ€” the Brain reads the verdict -and decides what to do with it. +## How PyAutoHeart works -Daily driving: +1. **Observe.** `pyauto-heart tick` (or the 5-minute daemon) runs the cheap + checks โ€” repo state, CI conclusions, open PRs, worktree drift, timings โ€” + into one cached snapshot. Deep checks (install verification, workspace + validation, URL hygiene) run on demand or on cloud schedules. +2. **Judge.** `pyauto-heart readiness` rolls the snapshot into the verdict and + score. STALE means evidence is missing or expired, nothing known-bad โ€” the + remedy is re-running a check, never fixing code. +3. **Show.** One renderer projects the same snapshot everywhere, so the + surfaces cannot disagree: the [Pages board](https://pyautolabs.github.io/PyAutoHeart/), + the README strip above, the badge, the terminal board, and the JSON surface + agents consume. A daily cloud run publishes the board and keeps a single + `[heart-health]` tracking issue open while anything is degraded. +4. **Enrich.** The cloud can only see API-safe signals; checks needing a + working tree are measured on the dev box, and `pyauto-heart publish` pushes + a distilled observation so the same page fills in, age-stamped. +5. **Gate.** The Heart only observes โ€” it never edits other repos and never + triggers a build. The Brain reads the verdict (`/health`) and decides; + releases require GREEN. + +## CLI examples ```bash +pyauto-heart readiness # GREEN / STALE / YELLOW / RED, a score, and the reasons pyauto-heart tick # one refresh cycle pyauto-heart status # pretty-print the cached state pyauto-heart watch # the daemon: tick every 5 min, live board on a tty -pyauto-heart dashboard # the board (also --md, --html, --json, --oneline) +pyauto-heart dashboard # the board (also --md, --md-brief, --html, --json, --oneline) +pyauto-heart publish # push the dev-box observation to the live board +pyauto-heart fix ci # bundle a failing topic into a paste-ready Claude prompt ``` -Runs from its checkout (`PYTHONPATH` + `PATH`, no pip install); state lives -under `~/.pyauto-heart/`. Which repos are polled, and with what thresholds, -is `config/repos.yaml`. Tests: `pytest tests/`. - -Boundary and agent guidance: [AGENTS.md](AGENTS.md). The organism: -[PyAutoBrain/ORGANISM.md](https://github.com/PyAutoLabs/PyAutoBrain/blob/main/ORGANISM.md), -documented in full at . +The full CLI surface, the run-from-checkout model, state layout, and verdict +semantics are in [REFERENCE.md](REFERENCE.md). How agents should operate this +repo is in [AGENTS.md](AGENTS.md). The organism this repo is the Heart of is +described once in +[PyAutoBrain/ORGANISM.md](https://github.com/PyAutoLabs/PyAutoBrain/blob/main/ORGANISM.md) +and documented in full at . diff --git a/REFERENCE.md b/REFERENCE.md new file mode 100644 index 0000000..618af55 --- /dev/null +++ b/REFERENCE.md @@ -0,0 +1,140 @@ +# PyAutoHeart reference + +The operational detail behind [README.md](README.md): the full CLI surface, the +run model, state layout, checks, verdict semantics, and the board's surfaces. +(Absorbed the former `health_agent/capabilities.md`; the machine-readable +contract agents consume is `health_agent/capabilities.yaml`.) + +## Running + +Heart runs from its checkout โ€” no pip install. `bin/pyauto-heart` resolves its +own repo root; put it on `PATH` (the PyAutoBrain installer does). State lives +under `~/.pyauto-heart/` (override with `HEART_STATE_DIR`); the checkout itself +is never written by checks (the observer rule โ€” the one exception is +`pyauto-heart publish`, which commits Heart's OWN `state/devbox_board.json`). +Which repos are polled, and with what thresholds, is `config/repos.yaml`. +Tests: `pytest tests/`. + +## CLI surface (`bin/pyauto-heart`) + +| Subcommand | Purpose | Health role | +|---|---|---| +| `watch` / `live` | foreground monitor loop (live board on a tty) | runs the tick on a schedule | +| `tick` | one-shot refresh of all checks into `state.json` | produces the snapshot | +| `stop` | kill the daemon (`--all` sweeps orphans) | operational | +| `status` | coloured snapshot (`--json`, `--quiet`) | the agent's detail query | +| `readiness` | the authoritative green/stale/yellow/red verdict + score | **the gate** | +| `dashboard` | the unified board (`--oneline/--md/--md-brief/--html/--json/--badge`, `--cloud`, `--devbox`) | every surface, one renderer | +| `publish` | push the distilled dev-box board into the repo | fills the cloud page's grey rows | +| `logs` | tail the daemon log | operational | +| `fix` | emit a Claude remediation bundle (`ci`/`dirty`/`drift`/`timing`) | remediation entry point | +| `validate` | ingest release-validation artifacts into `validation_report.json` | release rehearsal evidence | +| `smoke` | isolated local workspace smoke suites | deep validation | +| `verify_install` | deep pip/conda install-path check (slow) | deep readiness signal | +| `url_check` / `url_sweep` | offline URL-hygiene guard / ecosystem sweep | monitoring only | + +## The board and its surfaces + +`heart/dashboard.py` is the ONE renderer: every surface is a projection of the +same `state.json` + `release_ready.json`, so they cannot disagree. + +- **Pages board** โ€” , published daily + by `heart-health.yml`. Blockers link the repo and the failing run, and carry + one-tap ๐Ÿ“‹ buttons copying a ready-made `/bug โ€ฆ` Claude prompt; grey + dev-box-only rows say what they watch and copy the observe command. +- **README strip** โ€” the `heart:begin/end` block (`--md-brief`): verdict + + linked blockers + board link, auto-committed by the same workflow. +- **Badge** โ€” `badge.json` on the Pages site, rendered via shields.io. +- **Terminal** โ€” `pyauto-heart dashboard` / `status` / the `watch` daemon. +- **JSON** โ€” `--json` (schema v2: structured `blockers` with prompts/links, + per-section `action`/`links`/`observed_ago`) โ€” what the Health Agent and + mobile consume. +- **Issue** โ€” one `[heart-health]` tracking issue opens while cloud checks are + degraded and closes when clean. + +### Cloud-only honesty and the dev-box publish + +The cloud job only observes API-safe checks (`ci_status`, `open_prs`); the +local-only families (`heart/dashboard.py::LOCAL_ONLY_FAMILIES` โ€” worktree +drift, script/import/unit-test/test-mode timings, profiling drift, test run, +version skew, repo state) render "not observed here" rather than fake green. +`pyauto-heart publish` distills the dev box's board for those families into +`state/devbox_board.json` (states/summaries/counts only โ€” detail lines naming +local filesystem paths are scrubbed) and pushes it; the cloud render merges the +file, stamping each row "observed Nh ago on the dev box" and letting it expire +back to grey after 48 h (`DEVBOX_FRESH_SECONDS`). + +## Checks + +**Continuous** (cheap, every `<30s` tick โ€” `heart/tick.sh`): + +- **repo_state** (`checks/repo_state.sh`) โ€” branch / dirty (real vs generated) / + ahead / behind, per repo. RED when a library is off `main`, has uncommitted + source, or is behind origin. +- **ci_status** (`checks/ci_status.sh`) โ€” latest CI conclusion per repo via + `gh` (the failing run's URL is cached and surfaced on the board). RED when a + library's latest conclusion is not `success`. +- **open_prs** (`checks/open_prs.sh`) โ€” open PR count + max age. YELLOW at `>= 7d`. +- **worktree_drift** (`checks/worktree_drift.sh`) โ€” `PyAutoLabs-wt/` dirs vs + PyAutoMind `active.md` (orphan / missing / dirty). Monitoring. +- **script_timing** (`checks/script_timing.py`) โ€” per-script duration vs rolling + baseline (`>1.5x` slow, `>3x` regression). YELLOW. +- **test_run** (`checks/test_run.py`) โ€” reads the workspace-validation verdict. + YELLOW when not passing / stale / unknown (workspace debt is advisory). +- **version_skew** (`checks/version_skew.py`) โ€” each workspace's pinned version + vs the installed library. RED on AHEAD / MISMATCH / BAD; YELLOW on + BEHIND / UNKNOWN. +- **noise** (`heart/noise.py`) โ€” splits `git status` into genuine source drift + vs regenerated-artifact noise so only real drift drives gates. + +**Deep** (slow, on-demand / cloud cron, never in the tick): + +- **verify_install** (`checks/verify_install.sh`) โ€” pip, conda, and Colab + install-path checks Aโ€“F. RED if the last run has `ready==false`; STALE if it + is find-links-only, older than 14 days, or never run. +- **url_check / url_sweep / url_check_live** โ€” offline regex guard, ecosystem + sweep, and live HTTP reachability audit. **Monitoring only โ€” never gates + readiness.** + +## Readiness verdict (`heart/readiness.py`) + +`compute(snapshot)` is a pure function rolling the snapshot into one verdict: + +- **RED** โ€” library CI failing / off main / dirty / behind; version skew + AHEAD / MISMATCH / BAD; install verification `ready==false`. +- **YELLOW** โ€” workspace validation not passing (standing debt, advisory), + script-timing regressions, stale open PRs / parked scripts, skew BEHIND. +- **STALE** โ€” evidence missing or expired with nothing known-bad; the remedy is + re-running a check, never fixing code. Evidence whose last known result was + adverse stays yellow/red. Releases require GREEN; the dev-ship gate treats + STALE as passing (an evidence gap is organism-scope, not branch-scope). +- **GREEN** โ€” none of the above. + +`red > yellow > stale > green`. The `score` (0โ€“100) is advisory/sortable only โ€” +the colour is the gate. Persisted to `~/.pyauto-heart/release_ready.json`. + +## GitHub workflows (`.github/workflows/`) + +- **heart-health.yml** โ€” daily cloud sweep; renders + publishes the Pages + board, badge, README strip; maintains the `[heart-health]` issue. +- **lib-tests.yml** / **smoke-tests.yml** / **docs-build.yml** โ€” reusable + workflows the libraries and workspaces call; Heart owns the definitions. +- **workspace-smoke.yml** โ†’ **workspace-validation.yml** (workflow_call body) โ€” + scripts + notebooks against the libraries' current `main`; the run history + `test_run` + `readiness` consume. The release rehearsal has its own entry, + **release-integrate.yml**, so a failed rehearsal never overwrites the smoke + verdict (see `docs/release_validation.md`). +- **heart-tests.yml** โ€” Heart's own pytest suite; **url-check.yml** โ€” weekly + URL sweep into one `[url-check]` issue. + +## State (`~/.pyauto-heart/`) + +`state.json` (aggregated snapshot), `release_ready.json` (the verdict), +`validation_report.json`, per-repo sidecars, rolling `timings/`, +`url_check.json`, `verify_install.json`, daemon `heart.pid`, `logs/heart.log`. + +## Internals + +The check framework, the `<30s` tick budget, how to add a check, and the hard +rules (observer-only, colour coding, atomic state writes): +[docs/internals.md](docs/internals.md). diff --git a/bin/pyauto-heart b/bin/pyauto-heart index f231620..bd3f98a 100755 --- a/bin/pyauto-heart +++ b/bin/pyauto-heart @@ -23,6 +23,7 @@ SUBCOMMAND_ORDER=( status readiness dashboard + publish logs fix "# Release validation (ingest-and-judge only; never dispatches a build)" @@ -45,6 +46,7 @@ declare -A SHORT_DESC=( [status]="Print the cached state with green/yellow/red colouring" [readiness]="Print the release-readiness verdict (green/yellow/red + score)" [dashboard]="The unified health board (--oneline/--md/--html/--json); reads cache, no tick" + [publish]="Push a distilled dev-box board into the repo so the cloud page fills its grey rows" [validate]="Ingest release-validation artifacts into validation_report.json" [smoke]="Prepare isolated local environments and run workspace smoke suites" [logs]="Tail the daemon log" @@ -208,8 +210,9 @@ cmd_readiness() { } help_dashboard() { cat < [--profile P] [--testpypi-version V] [--commit-shas FILE] [--out FILE] [--json] diff --git a/health_agent/README.md b/health_agent/README.md index e9e2490..22bcfd5 100644 --- a/health_agent/README.md +++ b/health_agent/README.md @@ -36,8 +36,10 @@ adapts with no edits. | File | What it is | |---|---| | [`capabilities.yaml`](./capabilities.yaml) | Machine-readable manifest of every Heart capability โ€” the abstract-provider self-description the Brain agent reads. | -| [`capabilities.md`](./capabilities.md) | Human-readable audit of Heart's full health surface (CLI, checks, readiness, workflows, state, docs). | -| [`pyautobuild_boundary_audit.md`](./pyautobuild_boundary_audit.md) | Audit confirming no health/readiness gating logic has drifted into PyAutoHands, with the one naming nuance and a follow-up. | + +The human-readable companion audit lives in [`REFERENCE.md`](../REFERENCE.md) +(it absorbed the former `capabilities.md`; the one-off PyAutoHands boundary +audit was retired โ€” see git history). ## Quick use (from the Brain agent) diff --git a/health_agent/capabilities.md b/health_agent/capabilities.md deleted file mode 100644 index f28e290..0000000 --- a/health_agent/capabilities.md +++ /dev/null @@ -1,102 +0,0 @@ -# PyAutoHeart capability audit - -Human-readable companion to [`capabilities.yaml`](./capabilities.yaml). This is -the audit the Health Agent task required: every health-related asset PyAutoHeart -exposes, so the agent knows what it can ask Heart for. The YAML is the source the -agent reads; this page is for humans. - -Audited at commit `a2543d0` (Rename PyAutoPulse to PyAutoHeart). - -## CLI surface (`bin/pyauto-heart`) - -| Subcommand | Purpose | Health role | -|---|---|---| -| `watch` / `live` | foreground monitor loop (live board on a tty) | runs the tick on a schedule | -| `tick` | one-shot refresh of all checks into `state.json` | produces the snapshot | -| `stop` | kill the daemon (`--all` sweeps orphans) | operational | -| `status` | coloured snapshot (`--json`, `--quiet`) | the agent's detail query | -| `readiness` | the authoritative green/yellow/red verdict + score | **the gate** | -| `logs` | tail the daemon log | operational | -| `fix` | emit a Claude remediation bundle (`ci`/`dirty`/`drift`/`timing`) | remediation entry point | -| `verify_install` | deep pip/conda install-path check (slow) | deep readiness signal | -| `url_check` / `url_sweep` | offline URL-hygiene guard / ecosystem sweep | monitoring only | - -## Checks - -**Continuous** (cheap, every `<30s` tick โ€” `heart/tick.sh`): - -- **repo_state** (`checks/repo_state.sh`) โ€” branch / dirty (real vs generated) / - ahead / behind, per repo. RED when a library is off `main`, has uncommitted - source, or is behind origin. -- **ci_status** (`checks/ci_status.sh`) โ€” latest CI conclusion per repo via `gh`. - RED when a library's latest conclusion is not `success`. -- **open_prs** (`checks/open_prs.sh`) โ€” open PR count + max age. YELLOW at - `>= 7d`. -- **worktree_drift** (`checks/worktree_drift.sh`) โ€” `PyAutoLabs-wt/` dirs vs - PyAutoMind `active.md` (orphan / missing / dirty). Monitoring. -- **script_timing** (`checks/script_timing.py`) โ€” per-script duration vs rolling - baseline (`>1.5x` slow, `>3x` regression). YELLOW. -- **test_run** (`checks/test_run.py`) โ€” reads PyAutoHands's - `run_logs/latest/report.json` (the workspace-validation verdict). YELLOW - when not passing / stale / unknown (workspace debt is advisory, never a hard - block). -- **version_skew** (`checks/version_skew.py`) โ€” each workspace's pinned version - vs the installed library. RED on AHEAD / MISMATCH / BAD; YELLOW on - BEHIND / UNKNOWN. -- **noise** (`heart/noise.py`) โ€” splits `git status` into genuine source drift - vs regenerated-artifact noise so only real drift drives gates. - -**Deep** (slow, on-demand / cloud cron, never in the tick): - -- **verify_install** (`checks/verify_install.sh`) โ€” pip, conda, and Colab - install-path checks Aโ€“F. Check B proves one exact release succeeds on Python - 3.12/3.13 and rejects on 3.11; Check E installs historical 2026.2.26.4 on - Python 3.12 because its stack has no Python 3.13 dependency wheels. RED if - the last run has `ready==false`; STALE if it is find-links-only, older than - 14 days, or never run. Moved here from PyAutoHands โ€” install verification is - Heart's job. -- **url_check / url_sweep / url_check_live** โ€” offline regex guard, ecosystem - sweep, and live HTTP reachability audit. **Monitoring only โ€” never gates - readiness.** - -## Readiness verdict (`heart/readiness.py`) - -`compute(snapshot)` is a pure function rolling the snapshot into one verdict: - -- **RED** โ€” library CI failing / off main / dirty / behind; version skew - AHEAD / MISMATCH / BAD; install verification `ready==false`. -- **YELLOW** โ€” workspace validation not passing (standing debt, advisory), - script-timing regressions, stale open PRs / parked scripts, skew BEHIND, stale - or unrun install verification, and any *unknown* (missing report / library - absent). An unknown is never silently green. -- **GREEN** โ€” none of the above. - -`red dominates yellow dominates green`. The `score` (0โ€“100) is advisory/sortable -only โ€” the colour is the gate. Persisted to `~/.pyauto-heart/release_ready.json`. - -## GitHub workflows (`.github/workflows/`) - -- **lib-tests.yml** โ€” reusable unit-test workflow for the 5 libraries (3.12/3.13); - each library's `main.yml` is a thin caller. Heart owns the test definition. -- **heart-health.yml** ("Heart Health") โ€” daily cloud-safe `ci_status` + - `open_prs` sweep; opens/updates one `[heart-health]` issue, closes when clean. -- **url-check.yml** ("URL Check (central)") โ€” weekly ecosystem URL sweep into one - `[url-check]` issue. Monitoring only. -- **workspace-smoke.yml** โ†’ **workspace-validation.yml** (workflow_call-only - body) โ€” heavy scripts + notebooks validation against the libraries' current - `main`; the run history the `test_run` check + `readiness` consume. The - release rehearsal runs on its own entry, **release-integrate.yml**, so a - failed rehearsal never overwrites the smoke verdict. Reuses Build's executor - primitives โ€” does not duplicate them. - -## State (`~/.pyauto-heart/`) - -`state.json` (aggregated snapshot), `release_ready.json` (the verdict), per-repo -sidecars, rolling `timings/`, `url_check.json`, `verify_install.json`, daemon -`heart.pid`, and `logs/heart.log`. - -## Documentation describing health checks - -`README.md` (user-facing), `AGENTS.md` (the Brain/Heart/Hands boundary + call -chain), `docs/internals.md` (internals: the check framework, the `<30s` tick budget, how -to add a check, the observer-only / colour / atomic-write hard rules). diff --git a/health_agent/capabilities.yaml b/health_agent/capabilities.yaml index 876e3e6..0015a65 100644 --- a/health_agent/capabilities.yaml +++ b/health_agent/capabilities.yaml @@ -226,7 +226,7 @@ operations: daemon: "pyauto-heart watch|live|tick|stop # the continuous monitor" logs: "pyauto-heart logs [--tail N] [--follow]" -# Boundary the agent must respect (see pyautobuild_boundary_audit.md). +# Boundary the agent must respect (audit retired 2026-08; see git history). boundary: heart: >- owns all health/readiness checks, the release-validation report spec + ingest, diff --git a/health_agent/pyautobuild_boundary_audit.md b/health_agent/pyautobuild_boundary_audit.md deleted file mode 100644 index e1a2146..0000000 --- a/health_agent/pyautobuild_boundary_audit.md +++ /dev/null @@ -1,65 +0,0 @@ -# PyAutoHands boundary audit โ€” has health/readiness logic drifted? - -The Health Agent task requires verifying that no health/readiness logic still -lives in PyAutoHands (Hands), which must be a **pure executor**. This is the -audit. Performed against PyAutoHands at `114ecec` (Merge #109, -build-pulse-agent-separation). - -## Verdict: clean โ€” no health/readiness *gating* logic in PyAutoHands - -Every actual gate has already moved to PyAutoHeart. What remains in Build is -executor primitives and documentation that correctly points at Heart. - -### What was checked - -Searched all `*.sh / *.py / *.yml / *.md` in PyAutoHands for -`readiness | verify_workspace_version | health | version_skew | verify_install | -url_check`. - -| Finding | Location | Assessment | -|---|---|---| -| `verify_workspace_versions.sh` | โ€” | **Removed.** No longer present; its job is Heart's `version_skew` check. โœ“ | -| readiness mentioned in comments | `release.yml`, `pre_build.sh` | Docs only โ€” both explicitly state readiness is enforced upstream via `pyauto-heart readiness`. โœ“ | -| no `url_check.yml` workflow | `.github/workflows/` | URL hygiene fully owned by Heart's central `url-check.yml`. โœ“ | -| no `readiness.py` / `version_skew.py` / `*health*` | repo-wide | No check modules drifted. โœ“ | - -### One nuance the agent must understand: `aggregate_results.py` - -`autohands/aggregate_results.py` builds a report titled **"Release Readiness -Report"** with a top-level `ready` boolean, and `create_analysis_issue.py` posts -it. This *looks* like a readiness gate but is **not**: - -- `ready` is computed as `not has_failures` โ€” purely "did the workspace scripts - run without failures". It is a **script-run aggregation**, not a green/yellow/red - release gate. -- It is one of Build's **executor primitives**. Heart's `workspace-validation.yml` - checks these primitives out from Build and reuses them (by design โ€” not - duplicated), and Heart's `test_run` check *consumes* the resulting - `report.json` as one YELLOW-capable input into the authoritative `readiness` - verdict. - -So the authoritative gate is `pyauto-heart readiness`; Build's `report.json` -`ready` flag is an *input* to it, named confusingly. **The Health Agent must -treat `pyauto-heart readiness` as the single source of truth and never read -Build's `report.json` directly for the verdict.** - -## Recommendation - -No migration required โ€” the boundary is architecturally correct. The only -residual is **naming**: Build's "Release Readiness Report" / `ready` field shares -vocabulary with Heart's authoritative verdict and could mislead a future reader. - -- **Low-risk, optional:** rename Build's report to "Workspace Validation Report" - / `scripts_passed` (or similar) to remove the ambiguity. This is a - documentation/naming change, not a logic move, so it does not block the Health - Agent. -- A follow-up task capturing this is filed in PyAutoMind: - `maintenance/autohands/rename_release_readiness_report.md`. - -## Boundary the agent enforces - -``` -PyAutoHeart โ€” owns health checks + the authoritative readiness verdict. -PyAutoBrain โ€” Health Agent reasons over Heart's outputs (owns no checks). -PyAutoHands โ€” executor; acts only after a GREEN/YELLOW/RED decision; runs no checks. -``` diff --git a/heart/dashboard.py b/heart/dashboard.py index 84b7edc..d60d84d 100644 --- a/heart/dashboard.py +++ b/heart/dashboard.py @@ -10,7 +10,8 @@ render(snapshot, verdict, validation, *, fmt) -> str fmt = "term" # the full colour board (what `status`/`readiness` show) | "oneline" # compact one-liner for the venv/prompt hook - | "md" # GitHub-flavoured markdown (step summary / issue / README) + | "md" # GitHub-flavoured markdown (step summary / issue) + | "md-brief" # the README strip: verdict + linked blockers + board link | "html" # standalone self-contained page (GitHub Pages) | "json" # the machine surface the Health Agent + mobile consume @@ -23,9 +24,18 @@ **Cloud-only-honest.** The scheduled cloud job only observes the two API-safe checks (ci_status, open_prs); it has no local working tree. Passing the local-only check families in ``unobserved`` makes the board mark them -"not observed here (dev-box only)" instead of silently showing them green. A -dev-box push of the full snapshot can enrich the SAME page by rendering with an -empty ``unobserved`` โ€” never a second, competing page. +"not observed here" instead of silently showing them green. The dev box +enriches the SAME page โ€” never a second, competing page โ€” via +``pyauto-heart publish`` (heart/publish.py), which commits a distilled +``state/devbox_board.json`` this renderer merges in (``devbox=``), each row +age-stamped "observed Nh ago on the dev box" and falling back to unobserved +once the observation is older than ``DEVBOX_FRESH_SECONDS``. + +**Actionable, not just readable.** Every blocker/warning is also structured +(``Board.blockers``: text, repo, run url, and a copyable ``/bug`` prompt), and +sections that need a hand carry an ``action`` โ€” the exact command or Claude +prompt to copy. The html surface renders these as one-tap ๐Ÿ“‹ buttons (the +PyAutoMind dashboard pattern); md links them; json carries them verbatim. """ from __future__ import annotations @@ -71,6 +81,32 @@ # ticks every ~5 min, so an hour without a fresh tick warrants a nudge. STALE_AFTER_SECONDS = 3600 +# A published dev-box observation older than this renders as unobserved again +# (with its age) rather than as live data โ€” a two-day-old drift report shown +# as current would be worse than the honest grey row. +DEVBOX_FRESH_SECONDS = 48 * 3600 + +# GitHub org for repo/run links on blockers. Same assumption fix.py makes. +GH_ORG = "PyAutoLabs" + +# One line per local-only family on WHAT the dev box would observe โ€” shown on +# the grey rows so "not observed here" is a fact with a remedy, not a shrug. +UNOBS_WATCHES = { + "worktree_drift": "task worktrees vs the active.md ledger (orphans, missing, dirty)", + "script_timing": "workspace script runtimes vs their baselines", + "import_time": "library import costs vs their baselines", + "unit_test_timing": "the slowest unit tests vs their baselines", + "profiling_drift": "pinned profiling results vs their baselines", + "workspace_testmode_timing": "TEST_MODE workspace script runtimes vs their baselines", + "test_run": "the latest full workspace test-run verdict", + "version_skew": "workspace version floors vs the newest releases", +} + +# What ๐Ÿ“‹ on a grey row copies: observe the family locally, then publish the +# distilled observation so this page fills in. +OBSERVE_ACTION = {"label": "observe on the dev box", + "payload": "pyauto-heart tick && pyauto-heart publish"} + # Library repos, used to split the per-repo table into libraries vs workspaces # when a repo body carries no group label. Derived from the policy file # (config/repos.yaml `repos.libraries`) โ€” dashboard cannot import readiness @@ -92,7 +128,9 @@ def _library_names() -> tuple: # that links "the webpage" agrees on the URL. PAGES_URL = "https://pyautolabs.github.io/PyAutoHeart/" -SCHEMA_VERSION = 1 +# v2: sections gained links/action/observed_ago; the board gained structured +# `blockers` ({text, severity, repo, repo_url, run_url, prompt}). Additive. +SCHEMA_VERSION = 2 @dataclass @@ -104,6 +142,14 @@ class Section: state: str summary: str details: list[str] = field(default_factory=list) + # {label, url} โ€” e.g. the failing CI runs behind a red repo group. + links: list[dict] = field(default_factory=list) + # {label, payload} โ€” what a ๐Ÿ“‹ button copies for this row (a command or a + # Claude prompt), or None when the row needs no hand. + action: dict | None = None + # "observed 6h ago on the dev box" when this row came from a published + # dev-box observation rather than this render's own snapshot. + observed_ago: str | None = None @dataclass @@ -121,6 +167,10 @@ class Board: # readiness freshness tier: evidence missing/expired, nothing known-bad # (heart/readiness.py) โ€” distinct from the tick-age `stale` bool above. stale_reasons: list[str] = field(default_factory=list) + # The reasons above, STRUCTURED: {text, severity, repo, repo_url, run_url, + # prompt}. The flat lists stay for compatibility; this is what the html + # ๐Ÿ“‹ buttons, the md links, and the json consumers act on. + blockers: list[dict] = field(default_factory=list) # --- verdict/state โ†’ glyph & colour maps ------------------------------------ @@ -287,6 +337,7 @@ def _repo_section( key: str, title: str, repos: dict, want_lib: bool, *, unobserved: Sequence[str] ) -> Section | None: rows: list[tuple[str, str, str]] = [] # (state, name, label) + links: list[dict] = [] for name, body in sorted(repos.items()): if not isinstance(body, dict): continue @@ -297,13 +348,19 @@ def _repo_section( continue state, label = _lib_row(name, body, unobserved=unobserved) rows.append((state, name, label)) + # The way OUT of a red row: the failing run itself. + ci = body.get("ci_status") or {} + if (state == FAIL and ci.get("url") + and str(ci.get("conclusion") or "") not in ("", "success")): + links.append({"label": f"{name} run", "url": str(ci["url"])}) if not rows: return None overall = _worst(s for s, _, _ in rows) n_bad = sum(1 for s, _, _ in rows if s in (FAIL, WARN)) summary = f"{len(rows)} repos" + (f", {n_bad} need attention" if n_bad else " nominal") details = [f"{name:<26} {label}" for _, name, label in rows] - return Section(key=key, title=title, state=overall, summary=summary, details=details) + return Section(key=key, title=title, state=overall, summary=summary, + details=details, links=links[:4]) def build_board( @@ -314,6 +371,7 @@ def build_board( unobserved: Sequence[str] = (), now: datetime.datetime | None = None, stale_after: int = STALE_AFTER_SECONDS, + devbox: dict | None = None, ) -> Board: """Assemble the format-agnostic :class:`Board`. Pure; never raises.""" snapshot = snapshot or {} @@ -372,7 +430,10 @@ def build_board( f"canonical {d.get('repo')}: {d.get('dirty_files')} dirty" for d in canonical[:5] ] - sections.append(Section("worktree_drift", "Worktree drift", st, summary, details)) + action = ({"label": "triage the drift", "payload": "pyauto-heart fix drift"} + if st in (FAIL, WARN) else None) + sections.append(Section("worktree_drift", "Worktree drift", st, summary, + details, action=action)) # Script timing ---------------------------------------------------------- if "script_timing" in unobserved: @@ -394,7 +455,15 @@ def build_board( f"{e['latest_seconds']:.1f}s vs {e['baseline_seconds']:.1f}s ({e['ratio']}ร—)" for e in (timing.get("red") or [])[:5] ] - sections.append(Section("script_timing", "Script timing", st, summary, details)) + action = None + if st in (FAIL, WARN): + top = (timing.get("red") or timing.get("yellow") or [{}])[0] + proj = top.get("project") + if proj: + action = {"label": "bundle the regression context", + "payload": f"pyauto-heart fix timing {proj}"} + sections.append(Section("script_timing", "Script timing", st, summary, + details, action=action)) # Import timing (advisory; off-tick daily) -------------------------------- if "import_time" in unobserved: @@ -627,6 +696,8 @@ def build_board( sections.append(Section("url_check", "URL hygiene", OK, f"{len(uc['repos'])} repos clean (swept {uc.get('ts', '?')})", [])) + sections = _devbox_enrich(sections, devbox, now) + return Board( verdict=v, score=score, @@ -637,11 +708,92 @@ def build_board( yellow_reasons=yellow, sections=sections, stale_reasons=stale_reasons, + blockers=_structure_reasons(red, yellow, stale_reasons, repos), ) def _unobs_section(key: str, title: str) -> Section: - return Section(key, title, UNOBS, "not observed here (dev-box only)", []) + watches = UNOBS_WATCHES.get(key) + return Section( + key, title, UNOBS, + "not observed here โ€” measured on the dev box", + [f"watches {watches}"] if watches else [], + action=dict(OBSERVE_ACTION), + ) + + +def _reason_item(text: str, severity: str, repos: dict) -> dict: + """Structure one flat reason string into an actionable blocker. + + Reasons follow the ``": "`` convention (readiness.py), so + the prefix resolves the repo; the repo's cached ``ci_status.url`` is the + failing run when CI is red. The prompt is what ๐Ÿ“‹ copies โ€” a `/bug` door + into the Brain for real problems, a re-run nudge for evidence gaps + (STALE's rule: re-run the check, never fix code). + """ + head = text.split(":", 1)[0].strip() + body = repos.get(head) if isinstance(repos, dict) else None + repo = head if isinstance(body, dict) else None + repo_url = f"https://github.com/{GH_ORG}/{repo}" if repo else None + run_url = None + if repo: + ci = body.get("ci_status") or {} + if ci.get("url") and str(ci.get("conclusion") or "") not in ("", "success"): + run_url = str(ci["url"]) + if severity == "stale": + prompt = f"/health re-run the stale evidence: {text}" + else: + prompt = f"/bug Heart board: {text}" + if run_url: + prompt += f" โ€” failing run: {run_url}" + return {"text": text, "severity": severity, "repo": repo, + "repo_url": repo_url, "run_url": run_url, "prompt": prompt} + + +def _structure_reasons(red: list, yellow: list, stales: list, repos: dict) -> list[dict]: + return [_reason_item(str(t), sev, repos) + for sev, texts in (("red", red), ("yellow", yellow), ("stale", stales)) + for t in texts] + + +def _devbox_enrich( + sections: list[Section], devbox: dict | None, now: datetime.datetime | None +) -> list[Section]: + """Fill unobserved rows from a published dev-box observation. + + A fresh (< ``DEVBOX_FRESH_SECONDS``) family renders with its real state and + an "observed Nh ago on the dev box" stamp; an expired one stays grey but + says when the dev box last looked. Rows this render observed itself are + never overridden โ€” the dev-box data only ever fills gaps. + """ + if not isinstance(devbox, dict): + return sections + dsecs = devbox.get("sections") or {} + age = _age_seconds(devbox.get("ts"), now) + if age is None or not isinstance(dsecs, dict): + return sections + ago = format_age(age) + out: list[Section] = [] + for sec in sections: + d = dsecs.get(sec.key) + if sec.state != UNOBS or not isinstance(d, dict): + out.append(sec) + continue + if age <= DEVBOX_FRESH_SECONDS and d.get("state") in (OK, WARN, FAIL, INFO): + out.append(Section( + sec.key, sec.title, str(d["state"]), + str(d.get("summary") or ""), + [str(x) for x in (d.get("details") or [])][:8], + links=sec.links, action=sec.action, + observed_ago=f"observed {ago} on the dev box", + )) + else: + out.append(Section( + sec.key, sec.title, UNOBS, + f"not observed here โ€” dev box last looked {ago}", + sec.details, links=sec.links, action=sec.action, + )) + return out # --- readiness header (shared by term + readiness.render_block) -------------- @@ -708,6 +860,42 @@ def _render_oneline(board: Board) -> str: return f"PyAuto {dot} {coloured_word} {tail} (tick {age})" +def _shown_reasons(board: Board) -> tuple[str, list[dict]]: + """The one reason tier a surface displays, structured, worst first.""" + if board.red_reasons: + return "Blockers", [b for b in board.blockers if b["severity"] == "red"] + if board.yellow_reasons: + return "Warnings", [b for b in board.blockers if b["severity"] == "yellow"] + if board.stale_reasons: + return ("Evidence gaps (re-run, don't fix)", + [b for b in board.blockers if b["severity"] == "stale"]) + return "", [] + + +def _md_reason(item: dict) -> str: + """One reason as markdown: repo linked, failing run linked.""" + text = _md_escape(item["text"]) + if item.get("repo") and item.get("repo_url"): + rest = _md_escape(item["text"][len(item["repo"]):]) + text = f"[{item['repo']}]({item['repo_url']}){rest}" + if item.get("run_url"): + text += f" ([run]({item['run_url']}))" + return text + + +def _md_prompts_block(items: list[dict]) -> list[str]: + """A collapsed block of copyable fix prompts (GitHub's fenced-code copy + button makes each one one-tap on the web view).""" + if not items: + return [] + lines = ["
", + "๐Ÿ“‹ fix prompts โ€” copy one into a Claude Code chat", ""] + for it in items[:6]: + lines += ["```", it["prompt"], "```", ""] + lines += ["
", ""] + return lines + + def _render_md(board: Board) -> str: word = _VERDICT_WORD.get(board.verdict, "GREEN") emoji = _STATE_MD[_VERDICT_STATE.get(board.verdict, OK)] @@ -719,29 +907,71 @@ def _render_md(board: Board) -> str: + (" โš ๏ธ **stale โ€” run `pyauto-heart tick`**" if board.stale else ""), "", ] - if board.red_reasons: - lines.append("**Blockers:** " + "; ".join(board.red_reasons[:6])) - lines.append("") - elif board.yellow_reasons: - lines.append("**Warnings:** " + "; ".join(board.yellow_reasons[:6])) - lines.append("") - elif board.stale_reasons: - lines.append("**Evidence gaps (re-run, don't fix):** " - + "; ".join(board.stale_reasons[:6])) + label, items = _shown_reasons(board) + if items: + lines.append(f"**{label}:** " + "; ".join(_md_reason(i) for i in items[:6])) lines.append("") + lines += _md_prompts_block(items) lines += ["| | Check | Status |", "|--|--|--|"] for sec in board.sections: em = _STATE_MD[sec.state] - lines.append(f"| {em} | {sec.title} | {_md_escape(sec.summary)} |") + status = _md_escape(sec.summary) + if sec.observed_ago: + status += f" ยท _{_md_escape(sec.observed_ago)}_" + lines.append(f"| {em} | {sec.title} | {status} |") lines.append("") lines.append(f"[Full board]({PAGES_URL})") return "\n".join(lines) +def _render_md_brief(board: Board) -> str: + """The README strip: verdict + linked blockers + the board link. The full + table lives on the Pages board; the README stays a glance, not a wall.""" + word = _VERDICT_WORD.get(board.verdict, "GREEN") + emoji = _STATE_MD[_VERDICT_STATE.get(board.verdict, OK)] + age = format_age(board.age_seconds, stale=board.stale) + lines = [ + f"## {emoji} PyAuto health โ€” **{word}** (score {board.score})", + "", + f"_snapshot `{board.ts}` ยท {age}_", + "", + ] + label, items = _shown_reasons(board) + if items: + lines.append(f"**{label}:** " + "; ".join(_md_reason(i) for i in items[:4])) + lines.append("") + lines.append(f"**[Full board โ†’]({PAGES_URL})** โ€” live page with one-tap ๐Ÿ“‹ fix prompts") + return "\n".join(lines) + + def _md_escape(text: str) -> str: return text.replace("|", "\\|") +def _copy_btn(payload: str, label: str = "copy") -> str: + """A one-tap clipboard button (the PyAutoMind dashboard pattern): tap ๐Ÿ“‹ + and the payload โ€” a Claude prompt or a command โ€” is ready to paste.""" + return (f"") + + +def _html_reason(item: dict) -> str: + """One blocker/warning as html: repo linked, run linked, prompt one tap away.""" + text = _html.escape(item["text"]) + if item.get("repo") and item.get("repo_url"): + rest = _html.escape(item["text"][len(item["repo"]):]) + text = (f"" + f"{_html.escape(item['repo'])}{rest}") + if item.get("run_url"): + text += (f" " + f"run โ†—") + if item.get("prompt"): + text += " " + _copy_btn(item["prompt"], "copy the fix prompt for a Claude Code chat") + return f"
  • {text}
  • " + + def _render_html(board: Board) -> str: word = _VERDICT_WORD.get(board.verdict, "GREEN") vstate = _VERDICT_STATE.get(board.verdict, OK) @@ -749,6 +979,15 @@ def _render_html(board: Board) -> str: rows = [] for sec in board.sections: cls = _STATE_HTML[sec.state] + summary = _html.escape(sec.summary) + if sec.observed_ago: + summary += f" ยท {_html.escape(sec.observed_ago)}" + for link in sec.links: + summary += (f" " + f"{_html.escape(str(link.get('label', 'link')))} โ†—") + if sec.action and sec.action.get("payload"): + summary += " " + _copy_btn(str(sec.action["payload"]), + str(sec.action.get("label", "copy"))) details = "" if sec.details: items = "".join(f"
  • {_html.escape(d)}
  • " for d in sec.details) @@ -756,16 +995,15 @@ def _render_html(board: Board) -> str: rows.append( f"" f"{_html.escape(sec.title)}" - f"{_html.escape(sec.summary)}{details}" + f"{summary}{details}" ) reasons_html = "" - reasons = board.red_reasons or board.yellow_reasons or board.stale_reasons - if reasons: - label = ("Blockers" if board.red_reasons - else "Warnings" if board.yellow_reasons - else "Evidence gaps") - items = "".join(f"
  • {_html.escape(r)}
  • " for r in reasons[:8]) - reasons_html = f"

    {label}

      {items}
    " + label, items = _shown_reasons(board) + if items: + lis = "".join(_html_reason(i) for i in items[:8]) + hint = ("

    ๐Ÿ“‹ copies a ready-to-paste prompt for a " + "Claude Code chat.

    ") + reasons_html = f"

    {label}

      {lis}
    {hint}
    " stale_html = ( "

    โš ๏ธ This board is stale โ€” the last tick is older than the " "freshness threshold; the numbers may not be current.

    " if board.stale else "" @@ -805,8 +1043,26 @@ def _render_html(board: Board) -> str: font-size: .85rem; }} .reasons {{ margin: 1.5rem 0; }} .reasons h2 {{ font-size: 1rem; }} + .reasons li {{ margin: .25rem 0; }} + a {{ color: #58a6ff; text-decoration: none; }} + a:hover {{ text-decoration: underline; }} + a.out {{ font-size: .85rem; white-space: nowrap; }} + .ago {{ color: #8b949e; font-size: .85rem; }} + .hint {{ color: #8b949e; font-size: .8rem; margin: .5rem 0 0; }} + button.copy {{ background: #21262d; border: 1px solid #30363d; border-radius: 6px; + color: #c9d1d9; cursor: pointer; padding: .05rem .45rem; + margin-left: .35rem; font-size: .85rem; line-height: 1.4; }} + button.copy:hover {{ background: #30363d; }} footer {{ margin-top: 2rem; color: #8b949e; font-size: .8rem; }} - + +

    PyAuto organism health

    {word} ยท score {board.score}

    @@ -815,7 +1071,8 @@ def _render_html(board: Board) -> str: {reasons_html} {''.join(rows)}
    Rendered by heart/dashboard.py โ€” one renderer, many surfaces. - Observer only: PyAutoHeart never writes outside its own repo/state.
    + Observer only: PyAutoHeart never writes outside its own repo/state. + ๐Ÿ“‹ buttons copy a Claude prompt or command to your clipboard.
    """ @@ -835,6 +1092,9 @@ def to_dict(board: Board) -> dict[str, Any]: # read it, and a reason that moves from the red axis to the stale one # would otherwise vanish from both rather than being re-classified. "stale_reasons": board.stale_reasons, + # Structured, actionable reasons โ€” what the ๐Ÿ“‹ buttons copy and where + # they link. The flat lists above stay for v1 consumers. + "blockers": board.blockers, "pages_url": PAGES_URL, "sections": [ { @@ -843,6 +1103,9 @@ def to_dict(board: Board) -> dict[str, Any]: "state": s.state, "summary": s.summary, "details": s.details, + "links": s.links, + "action": s.action, + "observed_ago": s.observed_ago, } for s in board.sections ], @@ -875,11 +1138,12 @@ def render( now: datetime.datetime | None = None, quiet: bool = False, stale_after: int = STALE_AFTER_SECONDS, + devbox: dict | None = None, ) -> str: """Render the unified board in ``fmt``. Pure: snapshot in โ†’ string out.""" board = build_board( snapshot, verdict, validation, - unobserved=unobserved, now=now, stale_after=stale_after, + unobserved=unobserved, now=now, stale_after=stale_after, devbox=devbox, ) if fmt == "term": return _render_term(board, verdict or {}, quiet=quiet) @@ -887,6 +1151,8 @@ def render( return _render_oneline(board) if fmt == "md": return _render_md(board) + if fmt == "md-brief": + return _render_md_brief(board) if fmt == "html": return _render_html(board) if fmt == "json": @@ -911,11 +1177,16 @@ def main(argv: list[str] | None = None) -> int: g = ap.add_mutually_exclusive_group() g.add_argument("--oneline", action="store_true", help="compact one-line summary (venv/prompt)") g.add_argument("--md", action="store_true", help="GitHub-flavoured markdown") + g.add_argument("--md-brief", action="store_true", + help="the README strip: verdict + linked blockers + board link") g.add_argument("--html", action="store_true", help="standalone self-contained HTML page") g.add_argument("--json", action="store_true", help="the machine surface (Health Agent / mobile)") g.add_argument("--badge", action="store_true", help="emit a shields.io endpoint-badge JSON") ap.add_argument("--cloud", action="store_true", help="mark local-only checks as 'not observed here' (cloud job vantage)") + ap.add_argument("--devbox", metavar="PATH", default=None, + help="published dev-box board JSON to fill unobserved rows " + "(default under --cloud: state/devbox_board.json if present)") ap.add_argument("--quiet", action="store_true", help="suppress drill-down details (term)") ap.add_argument("--no-color", action="store_true", help="disable ANSI colours") ap.add_argument("--stale-after", type=int, default=STALE_AFTER_SECONDS, @@ -925,9 +1196,10 @@ def main(argv: list[str] | None = None) -> int: os.environ["NO_COLOR"] = "1" fmt = "term" - for name in ("oneline", "md", "html", "json"): + for name, label in (("oneline", "oneline"), ("md", "md"), + ("md_brief", "md-brief"), ("html", "html"), ("json", "json")): if getattr(ns, name): - fmt = name + fmt = label break if ns.badge: fmt = "badge" # so the no-cache fallback below emits an "unknown" badge @@ -949,6 +1221,22 @@ def main(argv: list[str] | None = None) -> int: validation = snapshot.get("validation_report") or {} unobserved = LOCAL_ONLY_FAMILIES if ns.cloud else () + # The published dev-box observation (heart/publish.py) fills unobserved + # rows; on the cloud job it is auto-detected in the checkout. + import pathlib + devbox = None + devbox_path = ns.devbox + if devbox_path is None and ns.cloud: + default = pathlib.Path(__file__).resolve().parents[1] / "state" / "devbox_board.json" + if default.exists(): + devbox_path = str(default) + if devbox_path: + try: + devbox = json.loads(pathlib.Path(devbox_path).read_text()) + except (OSError, ValueError) as e: + print(f"warning: could not read devbox board {devbox_path}: {e}", + file=sys.stderr) + if ns.badge: board = build_board(snapshot, verdict, validation, unobserved=unobserved, stale_after=ns.stale_after) @@ -956,7 +1244,7 @@ def main(argv: list[str] | None = None) -> int: return 0 print(render(snapshot, verdict, validation, fmt=fmt, unobserved=unobserved, - quiet=ns.quiet, stale_after=ns.stale_after)) + quiet=ns.quiet, stale_after=ns.stale_after, devbox=devbox)) return 0 diff --git a/heart/publish.py b/heart/publish.py new file mode 100644 index 0000000..90fe2a9 --- /dev/null +++ b/heart/publish.py @@ -0,0 +1,150 @@ +"""heart/publish.py โ€” push a distilled dev-box observation into the Heart repo. + +The cloud board is honest about what it cannot see: the local-only check +families render "not observed here". This module is the sanctioned enrichment +path from ``dashboard.py``'s docstring โ€” the dev box distills ITS board's +local-only rows into ``state/devbox_board.json``, commits, and pushes to the +Heart's own repo (the observer rule holds: Heart writes only its own +repo/state). The cloud render then fills those rows, age-stamped +"observed Nh ago on the dev box", falling back to grey once the observation +expires (``dashboard.DEVBOX_FRESH_SECONDS``). + +Privacy: the distilled file carries section states, summaries, and detail +lines ONLY โ€” and any detail line naming a local filesystem path (``/home/``, +``~``, the expanded home dir) is dropped before it leaves the machine. The +repo is public; worktree names and repo names are fine, absolute local paths +are not. ``tests/test_publish.py`` pins this. + +Usage: + pyauto-heart publish # distill + commit + push + pyauto-heart publish --dry-run # print the distilled JSON, write nothing +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +from heart import dashboard, readiness, state + +HEART_ROOT = Path(__file__).resolve().parents[1] +DEVBOX_FILE = HEART_ROOT / "state" / "devbox_board.json" + +# Only the families the cloud job cannot observe travel; everything else the +# cloud measures itself, and merging two vantages of the same family would +# break the unify invariant. repo_state is excluded: it folds into per-repo +# rows, not a section of its own. +PUBLISH_FAMILIES = tuple( + f for f in dashboard.LOCAL_ONLY_FAMILIES if f != "repo_state" +) + +DEVBOX_SCHEMA_VERSION = 1 + + +def _scrub(lines: list[str]) -> list[str]: + """Drop detail lines that name a local filesystem path.""" + home = os.path.expanduser("~") + out = [] + for line in lines: + text = str(line) + if "/home/" in text or "~" in text or (home and home in text): + continue + out.append(text) + return out + + +def build_devbox_board(snapshot: dict | None, verdict: dict | None) -> dict[str, Any]: + """Distill the LOCAL board's local-only families. Pure; never raises.""" + board = dashboard.build_board(snapshot, verdict, unobserved=()) + sections: dict[str, Any] = {} + for sec in board.sections: + if sec.key not in PUBLISH_FAMILIES: + continue + if sec.state == dashboard.UNOBS: + continue # nothing observed locally either โ€” publish no claim + sections[sec.key] = { + "state": sec.state, + "summary": sec.summary, + "details": _scrub(sec.details)[:8], + } + return { + "schema_version": DEVBOX_SCHEMA_VERSION, + "ts": (snapshot or {}).get("ts") or "", + "sections": sections, + } + + +def _git(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(HEART_ROOT), *args], + capture_output=True, text=True) + + +def main(argv: list[str] | None = None) -> int: + import argparse + + ap = argparse.ArgumentParser(prog="pyauto-heart publish") + ap.add_argument("--dry-run", action="store_true", + help="print the distilled JSON, write and push nothing") + ns = ap.parse_args(argv) + + snapshot = state.load() + if snapshot is None: + print("no cached state โ€” run `pyauto-heart tick` first", file=sys.stderr) + return 2 + verdict = readiness.load_verdict() + devbox = build_devbox_board(snapshot, verdict) + payload = json.dumps(devbox, indent=2, sort_keys=True) + "\n" + + if ns.dry_run: + print(payload, end="") + return 0 + if not devbox["sections"]: + print("nothing observed locally to publish โ€” run `pyauto-heart tick` first", + file=sys.stderr) + return 2 + + branch = _git("branch", "--show-current").stdout.strip() + if branch != "main": + print(f"checkout is on '{branch}' โ€” publish commits to main only; " + "switch to main first (or use --dry-run)", file=sys.stderr) + return 2 + + DEVBOX_FILE.parent.mkdir(parents=True, exist_ok=True) + if DEVBOX_FILE.exists() and DEVBOX_FILE.read_text() == payload: + print("devbox board already published and current โ€” nothing to push") + return 0 + DEVBOX_FILE.write_text(payload) + + rel = str(DEVBOX_FILE.relative_to(HEART_ROOT)) + _git("add", rel) + committed = _git("commit", "-m", + "heart: publish dev-box board (local-only check families)") + if committed.returncode != 0 and "nothing to commit" in committed.stdout + committed.stderr: + print("devbox board unchanged after staging โ€” nothing to push") + return 0 + if committed.returncode != 0: + print(committed.stderr or committed.stdout, file=sys.stderr) + return 1 + + # Concurrent pushes (self-heals, other sessions) are normal on main; + # rebase our one commit onto the new tip and retry. + for attempt in (1, 2, 3): + pushed = _git("push", "origin", "HEAD") + if pushed.returncode == 0: + print(f"published {rel} ({len(devbox['sections'])} families, " + f"snapshot {devbox['ts']})") + return 0 + rebased = _git("pull", "--rebase", "origin", "main") + if rebased.returncode != 0: + print(rebased.stderr or rebased.stdout, file=sys.stderr) + return 1 + print("could not push the devbox board after 3 attempts", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 0b9e7eb..a38bec6 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -96,11 +96,18 @@ def test_html_is_self_contained(): red_reasons=["PyAutoLens: CI failure"]), fmt="html", now=FRESH_NOW) assert out.lstrip().startswith("") assert "RED" in out - # No external assets (strict-CSP / renders anywhere): no scripts, no remote - # stylesheets/images, no fetches. - assert ", no fetches. Inline