Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions complete/2026/08/stale-remedies-on-the-heart-board.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# stale-remedies-on-the-heart-board

**Completed:** 2026-08-25
**Type:** feature Β· **Target:** PyAutoHeart, PyAutoBrain Β· **PRs:**
PyAutoHeart#177, PyAutoBrain#276, PyAutoMind#315 (branch
`claude/pyautoheart-stale-r4swxg` in all three)

## Summary

A STALE Heart board offered nothing copyable that closed a gap. Every stale
reason got the same generic string from `_reason_item()` β€”
`/health re-run the stale evidence: <text>` β€” which hands the sentence back to
the reader, and a **reason** row carried no command at all on any surface
(commands existed only on **section** rows: `pyauto-heart fix drift`,
`fix timing <project>`). That is most of what the board is asked to do: the
cloud verdict had been `STALE Β· score 65` on every `heart-health.yml` run since
2026-08-19, alternating only with a transient `RED Β· 45`.

The 35-point penalty decomposes uniquely into the three evidence gaps a
working-tree-less CI job always has β€” `install_unknown` (10), `test_unknown`
(10), `validation_absent` (15) β€” none of which the cloud job can produce, which
is why the published board never reaches GREEN on its own.

## What shipped

**PyAutoHeart** β€” `readiness.compute` emits `stale_details`: each stale reason
with the **gate key** that produced it, index for index with `stale_reasons`.
The `stale.append(...)` / `hit(...)` pairs collapsed into one `add_stale(msg,
key)` helper, so a gap cannot be filed without its key or its score penalty.
Additive: flat reason lists, verdict, score, profiles and the release gate are
untouched (`tests/test_readiness.py` passed unmodified).

`dashboard.py` keys `STALE_REMEDIES` off that key β€” never off the reason text:

- a stale row gains `command` (the shell remedy; `None` where the remedy needs a
conversation) and a prompt naming both the check and the gap;
- the tier gains `stale_plan` β€” **one** prompt walking every current gap, plus
the shell chain that does the same, offered only when *every* gap has a
command, since a chain that silently skips one reads as if it cleared it;
- html renders a ⌨ command chip beside the πŸ“‹ prompt chip and a "clear them all"
line above the gaps; md leads its prompts block with the plan; json carries
`blockers[].command` and `stale_plan` (schema **v3**).

`pyauto-heart fix stale` is the terminal door to the same payloads from the same
persisted verdict, and `readiness` ends a stale block with `β†’ clear them:
pyauto-heart fix stale`.

**PyAutoBrain** β€” `board/_board.py` forwards each gap's `command` and renders
the Heart's plan verbatim: a πŸ“‹ row with the prompt, a ⌨ row with the chain, one
digest line for the tier. The Brain derives no remedy of its own, exactly as it
already treats the Heart's `/bug` prompts.

## Traps and findings

- **The remedy must be keyed, not parsed.** Sniffing the reason sentence would
have coupled the Brain and the board to prose that changes; the gate key is
the gap's real identity and readiness already had it in `hit(key)`.
- **A partial command chain is a lie.** With a release-validation gap in the
set there is no chain that clears everything, so `stale_plan.command` is
withheld and only the prompt is offered. Emitting the runnable subset would
read as "that cleared it".
- **Fall back, never guess.** A verdict from an older Heart carries no keys, so
the rows degrade to the old generic nudge and no plan is offered.
- **STALE's rule survives every path** β€” every remedy re-runs a check; a stale
row can never emit a `/bug` prompt (asserted).
- **The README strip stays one line for STALE.**
`test_md_brief_is_one_line_unless_something_is_wrong` pins that deliberately;
a glance surface is the wrong place for a multi-step plan.
- **`skew_pypi_unknown` keeps a prompt but no command** β€” the deep PyPI leg has
no `pyauto-heart` verb, and inventing one would have been a fake remedy.

**Validated:** 615 Heart tests (11 new), 494 Brain tests (3 new). A synthetic
cloud snapshot reproduces the live `STALE Β· score 65` with the three current
gaps, and `fix stale` prints their commands and the one plan.

## Notes

Filed and shipped the same day, straight from the question "why is pyautoheart
stale" β€” the answer was legible, but the board had no hand to offer once it was.
The prompt never passed through `active/` (no issue), so this record was written
directly with `lifecycle.py record` and the draft removed in the same PR.

## Original prompt

## The complaint

The Heart board's whole promise is the one in its own lede β€” "πŸ“‹ copies a
ready-to-paste prompt or command for a Claude Code chat". That holds for RED
and YELLOW: a blocker row carries a `/bug Heart board: <text> β€” failing run:
<url>` prompt, and a section row can carry a real command (`pyauto-heart fix
drift`, `pyauto-heart fix timing <project>`). It does **not** hold for STALE.
Every evidence gap gets the same generic string from `_reason_item()`
(`heart/dashboard.py:1031`):

/health re-run the stale evidence: install verification not run

That names the gap the human just read and stops. It contains no command, no
repo, no artifact path, no next step β€” copying it just hands the sentence back
to a Claude session that then has to work out the remedy from scratch. A reason
row has no command chip at all, on any surface; commands exist only on section
rows. So on a STALE morning there is nothing on the board to copy that actually
closes the gap.

This matters more than a RED chip would, because STALE is the board's steady
state: the cloud verdict has been `STALE Β· score 65` on every `heart-health.yml`
run since 2026-08-19 (alternating only with a transient `RED Β· 45` when a repo's
CI goes red), with exactly three gaps, all of which have a known remedy nobody
can copy:

| stale reason | gate key | weight | remedy today (undiscoverable) |
|---|---|---|---|
| `install verification not run` | `install_unknown` | 10 | `pyauto-heart verify_install --report-json` then `pyauto-heart tick` |
| `test run status unknown (no report.json)` | `test_unknown` | 10 | a workspace validation run (Hands `run_logs/latest/report.json`) then `pyauto-heart tick` |
| `no release validation for current source` | `validation_absent` | 15 | `/release rehearse`, then `pyauto-heart validate --ingest <artifacts>` |

## What to build

Give every stale reason a **remedy of its own** β€” a copyable command where one
exists, and a targeted prompt where it does not β€” keyed by the readiness gate
key, never sniffed out of the reason string.

STALE's rule holds throughout: a remedy **re-runs a check, it never fixes
code**. Nothing here may emit a `/bug` door for a stale reason.

1. **Carry the key out of readiness.** `heart/readiness.py` already knows the
identity of each gap β€” it calls `hit("install_unknown")` beside every
`stale.append(...)` (and `scope_local(msg, key)` does both at
`readiness.py:263`). Emit it: an additive `reason_details` list of
`{text, severity, key}` alongside the existing flat `red_reasons` /
`yellow_reasons` / `stale_reasons`. The flat lists and the verdict/score
contract stay byte-for-byte unchanged, so every existing consumer β€” and an
older Heart's persisted `release_ready.json` β€” behaves exactly as before.

2. **A remedy table in the dashboard**, keyed by that key, covering the gate
keys that actually occur (`install_unknown`, `install_stale`,
`install_non_release`, `test_unknown`, `test_stale`, `validation_absent`,
`validation_stale`, `validation_stale_sha`, `validation_profile`,
`validation_unknown`, `lib_unknown`, `lib_ci_unavailable`, `skew_unknown`).
Each entry is `{command, prompt}`; `command` may be `None` when the remedy is
genuinely a conversation (e.g. a rehearsal that needs a human's go-ahead),
and then the prompt must still be specific β€” `/release rehearse` for the
validation family, not `/health re-run the stale evidence: …`.

3. **Fall back, never guess.** A reason that arrives without a key (an older
snapshot) keeps today's generic prompt. No string matching on reason text.

4. **Render it on every surface**, each in its own idiom, all from the same
structured item:
- `--html`: the reason row gains a command chip beside the existing prompt
πŸ“‹ (two payloads, distinct titles β€” "copy the command" / "copy the fix
prompt"), so the Evidence-gaps block is finally actionable.
- `--md`: the collapsed `πŸ“‹ fix prompts` block gains the command as its own
fenced line per gap (GitHub's copy button makes it one-tap).
- `--json`: `blockers[]` gains `command` (additive; bump `schema_version`).
- `--oneline`: unchanged.
- README strip (`_render_md_brief`): today it deliberately prints no reasons
for STALE. Reconsider *only* to the extent of one line naming the gap count
and the single highest-value command; if that reads as noise on a glance
surface, leave it alone and say so in the PR.

5. **A CLI door to match**, in the same family as the existing topics:
`pyauto-heart fix stale` β€” read the persisted verdict and print each current
gap with its command and prompt. This is what a terminal-first morning
copies, and it keeps `heart/fix.py`'s "bundle context, emit a command"
contract (it must not mutate anything).

6. **Brain leg (PyAutoBrain).** `board/_board.py:245 extract_heart_blockers()`
forwards a fixed key set; add `command` and render it as a second chip in the
Readiness & release rows. Same invariant as today β€” the Brain renders what
the Heart sends and never re-derives a remedy.

## Acceptance

- On a board whose only reasons are the three gaps above, each Evidence-gaps row
offers a command that, run on the dev box, clears that row on the next tick.
- `pyauto-heart fix stale` prints the same commands the board shows, from the
same verdict β€” the two surfaces cannot disagree.
- `--json` blockers carry `command`; the Brain board renders it verbatim.
- No stale reason anywhere emits a `/bug` prompt.
- Verdict, score, `red_reasons` / `yellow_reasons` / `stale_reasons` and the
release gate are unchanged: `tests/test_readiness.py` passes untouched, and
new coverage lands in `tests/test_dashboard.py` (remedy per key, fallback for
a keyless reason, json/md/html rendering).

## Out of scope

What counts as stale, the weights, the profiles, and the GREEN-for-release gate.
This prompt is about the *hand* the board offers once a gap exists, not about
which gaps exist.
3 changes: 2 additions & 1 deletion complete/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Token-light navigation over the finished-work records (schema:
only then grep a dated bucket. Curators: edit the band between the CURATED
markers; everything below GENERATED is rebuilt.

1128 records across 7 buckets.
1129 records across 7 buckets.

<!-- CURATED:START -->
## Highlights
Expand Down Expand Up @@ -227,6 +227,7 @@ _(curate hard-won records here β€” survives regeneration.)_
- [spawn-empty-body-privacy-fix](2026/08/spawn-empty-body-privacy-fix.md)
- [spawn-github-instance-automation](2026/08/spawn-github-instance-automation.md)
- [sph-transform-name-check](2026/08/sph-transform-name-check.md) β€” auto-closed by the merge via "Closes #555"
- [stale-remedies-on-the-heart-board](2026/08/stale-remedies-on-the-heart-board.md)
- [stored-sample-reconstruction-guard](2026/08/stored-sample-reconstruction-guard.md) β€” stored samples that current model validation rejects no longer raise raw
- [sub-312-install-tombstone](2026/08/sub-312-install-tombstone.md)
- [tenant-firewall-drift-aug](2026/08/tenant-firewall-drift-aug.md) β€” issue #198; 9 β†’ OK, and a green `--check` is only evidence for the organs actually checked out
Expand Down
4 changes: 2 additions & 2 deletions dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PyAutoMind Dashboard</title>
<!-- generated by `pyauto-brain intake dashboard --apply` on 2026-08-24 β€” regenerate, do not hand-edit -->
<!-- generated by `pyauto-brain intake dashboard --apply` on 2026-08-25 β€” regenerate, do not hand-edit -->
<style>:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;
--line:#d8dee4;--btn:#f6f8fa;--ok:#1a7f37;--warn:#9a6700;--bad:#d1242f;
--accent:#0a7d72;--tint:#0a7d7214;--edge:#0a7d723d;
Expand Down Expand Up @@ -186,7 +186,7 @@
<body>
<header class="hero"><span class="orb"><svg class="mark" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="24" cy="24" r="20.4"/><path d="M20.2,37.6 L20.2,33.6 C20.2,32.4 19.4,31.6 18.4,30.8 C16.8,29.6 15.8,28.2 15.6,26.6 C15.5,25.6 14.9,25.2 13.9,24.9 C12.6,24.5 12.3,23.7 13.0,22.7 C13.8,21.6 14.5,20.6 14.6,19.4 C15.0,15.4 18.2,12.2 22.4,11.8 C27.2,11.3 31.6,14.6 32.3,19.2 C32.8,22.4 31.6,25.0 30.6,27.0 C29.9,28.4 29.6,29.6 29.6,31.2 L29.6,37.6"/><circle cx="24.6" cy="19.6" r="4.4"/><path d="M22.5,19.7 L24.0,21.3 L26.8,18.1"/><path d="M34.2,16.5 L38.8,16.5 M35.2,21.0 L39.4,21.0 M33.6,25.5 L37.2,25.5"/><g fill="currentColor" stroke="none"><circle cx="40.1" cy="16.5" r="1.4"/><circle cx="40.7" cy="21.0" r="1.4"/><circle cx="38.5" cy="25.5" r="1.4"/></g></svg></span><h1>PyAuto<b>Mind</b><span class="kind">Dashboard</span></h1><div class="rule"></div><p class="tag">Intent. Priority. Flow.</p></header><p class="lede">Every task the Mind is holding. Tap a task's πŸ“‹ and its <code>/start_dev</code> command is on your clipboard β€” paste it into a Claude Code chat to route Claude straight to that task. <a href="#recent">Recent</a> is the same work by date β€” what has been happening rather than what to do next.</p>
<ul class="stats"><li><b>1</b><span>In flight</span></li><li><b>3</b><span>Parked</span></li><li><b>5</b><span>Planned</span></li><li><b>139</b><span>Backlog</span></li></ul>
<div class="fresh"><p><b>Last updated 2026-08-24.</b> This page is generated from <code>active/</code>, <code>draft/</code> and the registry files, so it is only as current as they are. <code>dashboard_refresh.yml</code> re-renders it on every push to <code>main</code> β€” that heals a stale page, but not a stale prompt: a task that shipped without its prompt advancing to <code>complete/</code> keeps rendering here as pickable backlog. Reconciling those is the refresh below.</p>
<div class="fresh"><p><b>Last updated 2026-08-25.</b> This page is generated from <code>active/</code>, <code>draft/</code> and the registry files, so it is only as current as they are. <code>dashboard_refresh.yml</code> re-renders it on every push to <code>main</code> β€” that heals a stale page, but not a stale prompt: a task that shipped without its prompt advancing to <code>complete/</code> keeps rendering here as pickable backlog. Reconciling those is the refresh below.</p>
<div class="task"><button class="copy" data-cmd="Bring the PyAutoMind dashboard up to date. Work in the PyAutoMind checkout:

1. `git fetch origin &amp;&amp; git status`. If behind `origin/main`, `git pull --ff-only`
Expand Down
4 changes: 2 additions & 2 deletions dashboard.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# PyAutoMind Dashboard

<!-- generated by `pyauto-brain intake dashboard --apply` on 2026-08-24 β€” regenerate, do not hand-edit -->
<!-- generated by `pyauto-brain intake dashboard --apply` on 2026-08-25 β€” regenerate, do not hand-edit -->

This is the markdown version of the [PyAutoMind Dashboard](https://pyautolabs.github.io/PyAutoMind/), which puts a task's command on your clipboard with a single tap of πŸ“‹.

Every task the Mind is holding, on one page: what is in flight, what is parked, and the whole backlog to pick from. Pick a task and run its `/start_dev` command in a Claude Code chat to start it. [Recent](#recent) is the same work by date β€” what has been happening rather than what to do next.

> **Last updated 2026-08-24.** This page is generated from `active/`, `draft/` and the registry files, so it is only as current as they are. `dashboard_refresh.yml` re-renders it on every push to `main` β€” that heals a stale page, but not a stale prompt: a task that shipped without its prompt advancing to `complete/` keeps rendering here as pickable backlog. Reconciling those is the refresh below.
> **Last updated 2026-08-25.** This page is generated from `active/`, `draft/` and the registry files, so it is only as current as they are. `dashboard_refresh.yml` re-renders it on every push to `main` β€” that heals a stale page, but not a stale prompt: a task that shipped without its prompt advancing to `complete/` keeps rendering here as pickable backlog. Reconciling those is the refresh below.

<details><summary>πŸ“‹ <b>Refresh this page</b> β€” reconcile finished prompts, then regenerate</summary>

Expand Down
Loading