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..7da96af 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,49 @@ # 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 owner per repo, for repo/run links on blockers. Derived from the +# declared config surface (config/repos.yaml `owner:`), never hardcoded โ€” +# the tenant firewall keeps instance facts out of organ code. +def _repo_owners() -> dict: + import pathlib + + import yaml + + cfg_path = pathlib.Path(__file__).resolve().parents[1] / "config" / "repos.yaml" + cfg = yaml.safe_load(cfg_path.read_text()) or {} + owners: dict = {} + for group in (cfg.get("repos") or {}).values(): + for r in group if isinstance(group, list) else []: + if isinstance(r, dict) and r.get("name") and r.get("owner"): + owners[str(r["name"])] = str(r["owner"]) + return owners + + +REPO_OWNERS = _repo_owners() + +# 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 +145,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 +159,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 +184,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 +354,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 +365,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 +388,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 +447,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 +472,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 +713,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 +725,93 @@ 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 + owner = REPO_OWNERS.get(repo) if repo else None + repo_url = f"https://github.com/{owner}/{repo}" if owner 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 +878,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 +925,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 +997,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 +1013,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 +1061,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 +1089,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 +1110,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 +1121,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 +1156,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 +1169,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 +1195,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 +1214,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 +1239,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 +1262,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..014d24d 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