|
| 1 | +# stale-remedies-on-the-heart-board |
| 2 | + |
| 3 | +**Completed:** 2026-08-25 |
| 4 | +**Type:** feature · **Target:** PyAutoHeart, PyAutoBrain · **PRs:** |
| 5 | +PyAutoHeart#177, PyAutoBrain#276, PyAutoMind#315 (branch |
| 6 | +`claude/pyautoheart-stale-r4swxg` in all three) |
| 7 | + |
| 8 | +## Summary |
| 9 | + |
| 10 | +A STALE Heart board offered nothing copyable that closed a gap. Every stale |
| 11 | +reason got the same generic string from `_reason_item()` — |
| 12 | +`/health re-run the stale evidence: <text>` — which hands the sentence back to |
| 13 | +the reader, and a **reason** row carried no command at all on any surface |
| 14 | +(commands existed only on **section** rows: `pyauto-heart fix drift`, |
| 15 | +`fix timing <project>`). That is most of what the board is asked to do: the |
| 16 | +cloud verdict had been `STALE · score 65` on every `heart-health.yml` run since |
| 17 | +2026-08-19, alternating only with a transient `RED · 45`. |
| 18 | + |
| 19 | +The 35-point penalty decomposes uniquely into the three evidence gaps a |
| 20 | +working-tree-less CI job always has — `install_unknown` (10), `test_unknown` |
| 21 | +(10), `validation_absent` (15) — none of which the cloud job can produce, which |
| 22 | +is why the published board never reaches GREEN on its own. |
| 23 | + |
| 24 | +## What shipped |
| 25 | + |
| 26 | +**PyAutoHeart** — `readiness.compute` emits `stale_details`: each stale reason |
| 27 | +with the **gate key** that produced it, index for index with `stale_reasons`. |
| 28 | +The `stale.append(...)` / `hit(...)` pairs collapsed into one `add_stale(msg, |
| 29 | +key)` helper, so a gap cannot be filed without its key or its score penalty. |
| 30 | +Additive: flat reason lists, verdict, score, profiles and the release gate are |
| 31 | +untouched (`tests/test_readiness.py` passed unmodified). |
| 32 | + |
| 33 | +`dashboard.py` keys `STALE_REMEDIES` off that key — never off the reason text: |
| 34 | + |
| 35 | +- a stale row gains `command` (the shell remedy; `None` where the remedy needs a |
| 36 | + conversation) and a prompt naming both the check and the gap; |
| 37 | +- the tier gains `stale_plan` — **one** prompt walking every current gap, plus |
| 38 | + the shell chain that does the same, offered only when *every* gap has a |
| 39 | + command, since a chain that silently skips one reads as if it cleared it; |
| 40 | +- html renders a ⌨ command chip beside the 📋 prompt chip and a "clear them all" |
| 41 | + line above the gaps; md leads its prompts block with the plan; json carries |
| 42 | + `blockers[].command` and `stale_plan` (schema **v3**). |
| 43 | + |
| 44 | +`pyauto-heart fix stale` is the terminal door to the same payloads from the same |
| 45 | +persisted verdict, and `readiness` ends a stale block with `→ clear them: |
| 46 | +pyauto-heart fix stale`. |
| 47 | + |
| 48 | +**PyAutoBrain** — `board/_board.py` forwards each gap's `command` and renders |
| 49 | +the Heart's plan verbatim: a 📋 row with the prompt, a ⌨ row with the chain, one |
| 50 | +digest line for the tier. The Brain derives no remedy of its own, exactly as it |
| 51 | +already treats the Heart's `/bug` prompts. |
| 52 | + |
| 53 | +## Traps and findings |
| 54 | + |
| 55 | +- **The remedy must be keyed, not parsed.** Sniffing the reason sentence would |
| 56 | + have coupled the Brain and the board to prose that changes; the gate key is |
| 57 | + the gap's real identity and readiness already had it in `hit(key)`. |
| 58 | +- **A partial command chain is a lie.** With a release-validation gap in the |
| 59 | + set there is no chain that clears everything, so `stale_plan.command` is |
| 60 | + withheld and only the prompt is offered. Emitting the runnable subset would |
| 61 | + read as "that cleared it". |
| 62 | +- **Fall back, never guess.** A verdict from an older Heart carries no keys, so |
| 63 | + the rows degrade to the old generic nudge and no plan is offered. |
| 64 | +- **STALE's rule survives every path** — every remedy re-runs a check; a stale |
| 65 | + row can never emit a `/bug` prompt (asserted). |
| 66 | +- **The README strip stays one line for STALE.** |
| 67 | + `test_md_brief_is_one_line_unless_something_is_wrong` pins that deliberately; |
| 68 | + a glance surface is the wrong place for a multi-step plan. |
| 69 | +- **`skew_pypi_unknown` keeps a prompt but no command** — the deep PyPI leg has |
| 70 | + no `pyauto-heart` verb, and inventing one would have been a fake remedy. |
| 71 | + |
| 72 | +**Validated:** 615 Heart tests (11 new), 494 Brain tests (3 new). A synthetic |
| 73 | +cloud snapshot reproduces the live `STALE · score 65` with the three current |
| 74 | +gaps, and `fix stale` prints their commands and the one plan. |
| 75 | + |
| 76 | +## Notes |
| 77 | + |
| 78 | +Filed and shipped the same day, straight from the question "why is pyautoheart |
| 79 | +stale" — the answer was legible, but the board had no hand to offer once it was. |
| 80 | +The prompt never passed through `active/` (no issue), so this record was written |
| 81 | +directly with `lifecycle.py record` and the draft removed in the same PR. |
| 82 | + |
| 83 | +## Original prompt |
| 84 | + |
| 85 | +## The complaint |
| 86 | + |
| 87 | +The Heart board's whole promise is the one in its own lede — "📋 copies a |
| 88 | +ready-to-paste prompt or command for a Claude Code chat". That holds for RED |
| 89 | +and YELLOW: a blocker row carries a `/bug Heart board: <text> — failing run: |
| 90 | +<url>` prompt, and a section row can carry a real command (`pyauto-heart fix |
| 91 | +drift`, `pyauto-heart fix timing <project>`). It does **not** hold for STALE. |
| 92 | +Every evidence gap gets the same generic string from `_reason_item()` |
| 93 | +(`heart/dashboard.py:1031`): |
| 94 | + |
| 95 | + /health re-run the stale evidence: install verification not run |
| 96 | + |
| 97 | +That names the gap the human just read and stops. It contains no command, no |
| 98 | +repo, no artifact path, no next step — copying it just hands the sentence back |
| 99 | +to a Claude session that then has to work out the remedy from scratch. A reason |
| 100 | +row has no command chip at all, on any surface; commands exist only on section |
| 101 | +rows. So on a STALE morning there is nothing on the board to copy that actually |
| 102 | +closes the gap. |
| 103 | + |
| 104 | +This matters more than a RED chip would, because STALE is the board's steady |
| 105 | +state: the cloud verdict has been `STALE · score 65` on every `heart-health.yml` |
| 106 | +run since 2026-08-19 (alternating only with a transient `RED · 45` when a repo's |
| 107 | +CI goes red), with exactly three gaps, all of which have a known remedy nobody |
| 108 | +can copy: |
| 109 | + |
| 110 | +| stale reason | gate key | weight | remedy today (undiscoverable) | |
| 111 | +|---|---|---|---| |
| 112 | +| `install verification not run` | `install_unknown` | 10 | `pyauto-heart verify_install --report-json` then `pyauto-heart tick` | |
| 113 | +| `test run status unknown (no report.json)` | `test_unknown` | 10 | a workspace validation run (Hands `run_logs/latest/report.json`) then `pyauto-heart tick` | |
| 114 | +| `no release validation for current source` | `validation_absent` | 15 | `/release rehearse`, then `pyauto-heart validate --ingest <artifacts>` | |
| 115 | + |
| 116 | +## What to build |
| 117 | + |
| 118 | +Give every stale reason a **remedy of its own** — a copyable command where one |
| 119 | +exists, and a targeted prompt where it does not — keyed by the readiness gate |
| 120 | +key, never sniffed out of the reason string. |
| 121 | + |
| 122 | +STALE's rule holds throughout: a remedy **re-runs a check, it never fixes |
| 123 | +code**. Nothing here may emit a `/bug` door for a stale reason. |
| 124 | + |
| 125 | +1. **Carry the key out of readiness.** `heart/readiness.py` already knows the |
| 126 | + identity of each gap — it calls `hit("install_unknown")` beside every |
| 127 | + `stale.append(...)` (and `scope_local(msg, key)` does both at |
| 128 | + `readiness.py:263`). Emit it: an additive `reason_details` list of |
| 129 | + `{text, severity, key}` alongside the existing flat `red_reasons` / |
| 130 | + `yellow_reasons` / `stale_reasons`. The flat lists and the verdict/score |
| 131 | + contract stay byte-for-byte unchanged, so every existing consumer — and an |
| 132 | + older Heart's persisted `release_ready.json` — behaves exactly as before. |
| 133 | + |
| 134 | +2. **A remedy table in the dashboard**, keyed by that key, covering the gate |
| 135 | + keys that actually occur (`install_unknown`, `install_stale`, |
| 136 | + `install_non_release`, `test_unknown`, `test_stale`, `validation_absent`, |
| 137 | + `validation_stale`, `validation_stale_sha`, `validation_profile`, |
| 138 | + `validation_unknown`, `lib_unknown`, `lib_ci_unavailable`, `skew_unknown`). |
| 139 | + Each entry is `{command, prompt}`; `command` may be `None` when the remedy is |
| 140 | + genuinely a conversation (e.g. a rehearsal that needs a human's go-ahead), |
| 141 | + and then the prompt must still be specific — `/release rehearse` for the |
| 142 | + validation family, not `/health re-run the stale evidence: …`. |
| 143 | + |
| 144 | +3. **Fall back, never guess.** A reason that arrives without a key (an older |
| 145 | + snapshot) keeps today's generic prompt. No string matching on reason text. |
| 146 | + |
| 147 | +4. **Render it on every surface**, each in its own idiom, all from the same |
| 148 | + structured item: |
| 149 | + - `--html`: the reason row gains a command chip beside the existing prompt |
| 150 | + 📋 (two payloads, distinct titles — "copy the command" / "copy the fix |
| 151 | + prompt"), so the Evidence-gaps block is finally actionable. |
| 152 | + - `--md`: the collapsed `📋 fix prompts` block gains the command as its own |
| 153 | + fenced line per gap (GitHub's copy button makes it one-tap). |
| 154 | + - `--json`: `blockers[]` gains `command` (additive; bump `schema_version`). |
| 155 | + - `--oneline`: unchanged. |
| 156 | + - README strip (`_render_md_brief`): today it deliberately prints no reasons |
| 157 | + for STALE. Reconsider *only* to the extent of one line naming the gap count |
| 158 | + and the single highest-value command; if that reads as noise on a glance |
| 159 | + surface, leave it alone and say so in the PR. |
| 160 | + |
| 161 | +5. **A CLI door to match**, in the same family as the existing topics: |
| 162 | + `pyauto-heart fix stale` — read the persisted verdict and print each current |
| 163 | + gap with its command and prompt. This is what a terminal-first morning |
| 164 | + copies, and it keeps `heart/fix.py`'s "bundle context, emit a command" |
| 165 | + contract (it must not mutate anything). |
| 166 | + |
| 167 | +6. **Brain leg (PyAutoBrain).** `board/_board.py:245 extract_heart_blockers()` |
| 168 | + forwards a fixed key set; add `command` and render it as a second chip in the |
| 169 | + Readiness & release rows. Same invariant as today — the Brain renders what |
| 170 | + the Heart sends and never re-derives a remedy. |
| 171 | + |
| 172 | +## Acceptance |
| 173 | + |
| 174 | +- On a board whose only reasons are the three gaps above, each Evidence-gaps row |
| 175 | + offers a command that, run on the dev box, clears that row on the next tick. |
| 176 | +- `pyauto-heart fix stale` prints the same commands the board shows, from the |
| 177 | + same verdict — the two surfaces cannot disagree. |
| 178 | +- `--json` blockers carry `command`; the Brain board renders it verbatim. |
| 179 | +- No stale reason anywhere emits a `/bug` prompt. |
| 180 | +- Verdict, score, `red_reasons` / `yellow_reasons` / `stale_reasons` and the |
| 181 | + release gate are unchanged: `tests/test_readiness.py` passes untouched, and |
| 182 | + new coverage lands in `tests/test_dashboard.py` (remedy per key, fallback for |
| 183 | + a keyless reason, json/md/html rendering). |
| 184 | + |
| 185 | +## Out of scope |
| 186 | + |
| 187 | +What counts as stale, the weights, the profiles, and the GREEN-for-release gate. |
| 188 | +This prompt is about the *hand* the board offers once a gap exists, not about |
| 189 | +which gaps exist. |
0 commit comments