diff --git a/README.md b/README.md index 9b18f6f..e208195 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ pyauto-heart watch # the daemon: tick every 5 min, live board on a tty 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 +pyauto-heart fix stale # the evidence gaps + the one plan that clears them all ``` The full CLI surface, the run-from-checkout model, state layout, and verdict diff --git a/REFERENCE.md b/REFERENCE.md index 618af55..da6b28f 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -41,12 +41,18 @@ 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. + dev-box-only rows say what they watch and copy the observe command. An + **evidence gap** carries its remedy instead: ⌨ copies the command that + re-runs that check, 📋 the prompt that does the same in a chat, and the + "clear them all" chip above the list copies ONE plan (prompt, plus a command + chain when every gap has one) that closes the whole tier — the remedies are + keyed off the readiness gate key, never guessed from the reason text. - **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, +- **JSON** — `--json` (schema v3: structured `blockers` with prompts/links and, + on a stale row, the `command` that closes it; board-level `stale_plan`; per-section `action`/`links`/`observed_ago`) — what the Health Agent and mobile consume. - **Issue** — one `[heart-health]` tracking issue opens while cloud checks are diff --git a/bin/pyauto-heart b/bin/pyauto-heart index bd3f98a..455de2c 100755 --- a/bin/pyauto-heart +++ b/bin/pyauto-heart @@ -339,6 +339,8 @@ Topics: dirty — clean up a dirty working tree (real vs generated) drift — current worktree drift entries timing — script timing regressions for that project + stale — the evidence gaps + the check that closes each, + and the ONE plan that closes them all EOF } cmd_fix() { @@ -348,7 +350,7 @@ cmd_fix() { fi local topic="$1"; shift case "$topic" in - ci|dirty|drift|timing) + ci|dirty|drift|timing|stale) env PYTHONPATH="$HEART_HOME" python3 -m heart.fix "$topic" "$@" ;; *) diff --git a/heart/dashboard.py b/heart/dashboard.py index d407fae..c7c65a5 100644 --- a/heart/dashboard.py +++ b/heart/dashboard.py @@ -34,7 +34,11 @@ **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 +prompt to copy. An evidence gap carries more: the ``command`` that re-runs the +check behind it (looked up from ``STALE_REMEDIES`` by the readiness gate key, +never guessed from the sentence), and the whole stale tier carries one +``stale_plan`` — a single prompt, and where possible a single command chain, +that closes every current gap at once. The html surface renders these as one-tap 📋 buttons (the PyAutoMind dashboard pattern); md links them; json carries them verbatim. """ @@ -205,7 +209,10 @@ def _boards_nav_html() -> str: # v2: sections gained links/action/observed_ago; the board gained structured # `blockers` ({text, severity, repo, repo_url, run_url, prompt}). Additive. -SCHEMA_VERSION = 2 +# v3: a blocker gained `command` (the shell remedy behind a stale row, None +# when the gap needs a conversation) and the board gained `stale_plan` — the +# ONE payload that closes every current evidence gap. Additive. +SCHEMA_VERSION = 3 @dataclass @@ -253,6 +260,10 @@ class Board: # was observed (an absent block is honest; an empty one implies "measured, # nothing there"). performance: dict | None = None + # {count, command, prompt}: one copyable plan that closes EVERY current + # evidence gap, so a STALE board is one tap from being cleared instead of + # N. None when nothing is stale, or when no gap has a known remedy. + stale_plan: dict | None = None # --- verdict/state → glyph & colour maps ------------------------------------ @@ -523,6 +534,17 @@ def build_board( red = list(verdict.get("red_reasons") or []) yellow = list(verdict.get("yellow_reasons") or []) stale_reasons = list(verdict.get("stale_reasons") or []) + # The gate key behind each stale reason (readiness.py `stale_details`), + # index for index — the identity a remedy is looked up by. A verdict from + # an older Heart carries no such key; the rows then fall back to the + # generic re-run nudge and no plan is offered (never a guessed remedy). + stale_keys = [ + str(d.get("key") or "") + for d in (verdict.get("stale_details") or []) + if isinstance(d, dict) + ] + if len(stale_keys) != len(stale_reasons): + stale_keys = [] sections: list[Section] = [] @@ -851,8 +873,9 @@ def build_board( yellow_reasons=yellow, sections=sections, stale_reasons=stale_reasons, - blockers=_structure_reasons(red, yellow, stale_reasons, repos), + blockers=_structure_reasons(red, yellow, stale_reasons, repos, stale_keys), performance=performance, + stale_plan=build_stale_plan(stale_reasons, stale_keys), ) @@ -1008,14 +1031,136 @@ def _unobs_section(key: str, title: str) -> Section: ) -def _reason_item(text: str, severity: str, repos: dict) -> dict: +# --- what closes an evidence gap ------------------------------------------- +# +# gate key (heart/readiness.py `stale_details`) -> the check that closes it. +# STALE's rule is the whole design here: every remedy RE-RUNS a check and none +# of them touches code — that is what separates the tier from yellow. `command` +# is what a terminal copies; it is None where the remedy genuinely needs a +# conversation (a release rehearsal is dispatched by the Brain's Release Agent, +# never by the Heart). `step` is the imperative phrase both the per-row prompt +# and the all-in-one plan are written from, so the two can never disagree. +TICK_CMD = "pyauto-heart tick" +VERIFY_INSTALL_CMD = "pyauto-heart verify_install --report-json" +REHEARSE_STEP = ("dispatch a release rehearsal with `/release rehearse`, then " + "`pyauto-heart validate --ingest `") + +STALE_REMEDIES: dict[str, dict] = { + # install verification — the deep pip/conda install-path check + "install_unknown": {"command": VERIFY_INSTALL_CMD, + "step": f"run `{VERIFY_INSTALL_CMD}`"}, + "install_stale": {"command": VERIFY_INSTALL_CMD, + "step": f"re-run `{VERIFY_INSTALL_CMD}` (the evidence expired)"}, + "install_non_release": { + "command": VERIFY_INSTALL_CMD, + "step": (f"re-run `{VERIFY_INSTALL_CMD}` against PyPI/TestPyPI " + "(find-links evidence cannot satisfy a release gate)")}, + # the workspace validation surface — PyAutoHands writes the report, the + # tick reads it, so the remedy names both halves. + "test_unknown": { + "command": TICK_CMD, + "step": (f"re-read the latest workspace validation run with `{TICK_CMD}` " + "(run the suite first if PyAutoHands has no " + "`run_logs/latest/report.json`)")}, + "test_stale": { + "command": TICK_CMD, + "step": (f"re-run the workspace validation suite, then `{TICK_CMD}` " + "(the last report aged out)")}, + # the release-validation rehearsal — Brain-dispatched, so no command + "validation_absent": {"command": None, "step": REHEARSE_STEP}, + "validation_stale": {"command": None, + "step": REHEARSE_STEP + " (the last rehearsal aged out)"}, + "validation_stale_sha": { + "command": None, + "step": REHEARSE_STEP + " (main moved since the last rehearsal)"}, + "validation_profile": {"command": None, + "step": REHEARSE_STEP + " under the `release` profile"}, + "validation_unknown": {"command": None, + "step": REHEARSE_STEP + " so the shipped source is confirmed"}, + # repo/CI evidence the tick re-polls + "lib_unknown": {"command": TICK_CMD, + "step": f"re-poll the repo with `{TICK_CMD}`"}, + "lib_ci_unavailable": { + "command": TICK_CMD, + "step": f"re-poll CI with `{TICK_CMD}` (the query failed, not the CI)"}, + "skew_unknown": {"command": TICK_CMD, + "step": f"re-read the version floors with `{TICK_CMD}`"}, + "skew_pypi_unknown": { + "command": None, + "step": ("re-run the deep PyPI leg " + "(`python3 -m heart.checks.version_skew --pypi` from the Heart " + "checkout) once PyPI answers again")}, +} + +# What a stale row copies when its key has no entry here (a gap added since +# this table, or a verdict from an older Heart that carries no keys at all). +GENERIC_STALE_PROMPT = "/health re-run the stale evidence: {text}" + + +def stale_remedy(key: str) -> dict | None: + """The remedy for one gate key, or None when this board has none.""" + remedy = STALE_REMEDIES.get(key or "") + return dict(remedy) if remedy else None + + +def _stale_prompt(text: str, remedy: dict | None) -> str: + """One gap's Claude prompt: what to re-run, on which gap, and the rule.""" + if not remedy: + return GENERIC_STALE_PROMPT.format(text=text) + return (f"/health {remedy['step']} — the Heart's evidence gap: \"{text}\". " + "Re-run the check only, never change code to clear it; then " + f"`{TICK_CMD}` and re-read `pyauto-heart readiness`.") + + +def build_stale_plan(stale_reasons: list, stale_keys: list) -> dict | None: + """The ONE payload that clears the whole tier. + + A STALE board is normally several gaps at once, and closing them one chip + at a time is exactly the friction that leaves a board stale for weeks. So + the gaps are also published as a single ordered plan: ``prompt`` names every + gap with the check that closes it, and ``command`` is the shell chain that + does the same — offered ONLY when every gap has a command, because a chain + that silently skips a gap reads as "that cleared it" when it did not. + + None when nothing is stale, or when no gap has a known remedy. + """ + if not stale_reasons: + return None + keys = list(stale_keys) + [""] * (len(stale_reasons) - len(stale_keys)) + remedies = [stale_remedy(k) for k in keys[:len(stale_reasons)]] + if not any(remedies): + return None + steps, commands = [], [] + for n, (text, remedy) in enumerate(zip(stale_reasons, remedies), 1): + step = remedy["step"] if remedy else "re-run the check behind it" + steps.append(f"{n}. {text} → {step}") + cmd = (remedy or {}).get("command") + if cmd and cmd not in commands: + commands.append(cmd) + prompt = ( + f"/health clear the Heart's {len(stale_reasons)} evidence gap(s) — re-run " + "the checks named below; never change code to clear one:\n" + + "\n".join(steps) + + f"\nThen run `{TICK_CMD} && pyauto-heart readiness` and report the new verdict." + ) + command = None + if all(r and r.get("command") for r in remedies): + chain = commands + [c for c in (TICK_CMD, "pyauto-heart readiness") + if c not in commands] + command = " && ".join(chain) + return {"count": len(stale_reasons), "command": command, "prompt": prompt} + + +def _reason_item(text: str, severity: str, repos: dict, key: str = "") -> 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). + into the Brain for real problems, and for an evidence gap the check that + actually closes it, looked up by its gate ``key`` (STALE's rule: re-run the + check, never fix code). A stale row also carries ``command`` — the shell + remedy — or None where the remedy needs a conversation. """ head = text.split(":", 1)[0].strip() body = repos.get(head) if isinstance(repos, dict) else None @@ -1027,20 +1172,31 @@ def _reason_item(text: str, severity: str, repos: dict) -> dict: ci = body.get("ci_status") or {} if ci.get("url") and str(ci.get("conclusion") or "") not in ("", "success"): run_url = str(ci["url"]) + command = None if severity == "stale": - prompt = f"/health re-run the stale evidence: {text}" + remedy = stale_remedy(key) + command = (remedy or {}).get("command") + prompt = _stale_prompt(text, remedy) 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} + "repo_url": repo_url, "run_url": run_url, "prompt": prompt, + "command": command} -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 _structure_reasons(red: list, yellow: list, stales: list, repos: dict, + stale_keys: list | None = None) -> list[dict]: + """Every reason as an actionable item. ``stale_keys`` rides index for index + with ``stales`` (empty when the verdict predates them).""" + keys = list(stale_keys or []) + keys += [""] * (len(stales) - len(keys)) + items = [_reason_item(str(t), sev, repos) + for sev, texts in (("red", red), ("yellow", yellow)) + for t in texts] + items += [_reason_item(str(t), "stale", repos, k) for t, k in zip(stales, keys)] + return items def _devbox_enrich( @@ -1108,6 +1264,11 @@ def render_readiness_block(verdict: dict[str, Any], *, quiet: bool = False) -> l if shown < limit: for s in stales[: limit - shown]: lines.append(" " + c_info(f"? {s}")) + # A terminal reading STALE gets the door out of it, not just the diagnosis: + # `fix stale` prints each gap's command and the one plan that clears them + # all. Suppressed in quiet mode — that line is a shell prompt, not a board. + if stales and not quiet: + lines.append(" " + c_meta("→ clear them: pyauto-heart fix stale")) return lines @@ -1170,14 +1331,24 @@ def _md_reason(item: dict) -> str: return text -def _md_prompts_block(items: list[dict]) -> list[str]: +def _md_prompts_block(items: list[dict], plan: dict | None = None) -> list[str]: """A collapsed block of copyable fix prompts (GitHub's fenced-code copy - button makes each one one-tap on the web view).""" + button makes each one one-tap on the web view). + + When the gaps carry a whole-tier ``plan``, it leads: one prompt (and, where + every gap has one, one command chain) that closes all of them. + """ if not items: return [] lines = ["
", "📋 fix prompts — copy one into a Claude Code chat", ""] + if plan and items[0].get("severity") == "stale": + lines += ["**Clear every gap in one go:**", "", "```", plan["prompt"], "```", ""] + if plan.get("command"): + lines += ["```", plan["command"], "```", ""] for it in items[:6]: + if it.get("command"): + lines += ["```", it["command"], "```", ""] lines += ["```", it["prompt"], "```", ""] lines += ["
", ""] return lines @@ -1198,7 +1369,7 @@ def _render_md(board: Board) -> str: if items: lines.append(f"**{label}:** " + "; ".join(_md_reason(i) for i in items[:6])) lines.append("") - lines += _md_prompts_block(items) + lines += _md_prompts_block(items, board.stale_plan) lines += ["| | Check | Status |", "|--|--|--|"] for sec in board.sections: em = _STATE_MD[sec.state] @@ -1235,12 +1406,17 @@ 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.""" +def _copy_btn(payload: str, label: str = "copy", face: str = "📋") -> str: + """A one-tap clipboard button (the PyAutoMind dashboard pattern): tap it + and the payload — a Claude prompt or a command — is ready to paste. + + ``face`` is what the button shows: the bare 📋 for a chip beside a row, a + short worded face (⌨ command chain) where the board offers more than one + payload and the reader has to choose between them. + """ return (f"") + f"data-cmd=\"{_html.escape(payload, quote=True)}\">{_html.escape(face)}") def _html_reason(item: dict) -> str: @@ -1253,19 +1429,42 @@ def _html_reason(item: dict) -> str: if item.get("run_url"): text += (f" " f"run ↗") + if item.get("command"): + text += " " + _copy_btn(item["command"], + "copy the command that re-runs this check", + "⌨") if item.get("prompt"): text += " " + _copy_btn(item["prompt"], "copy the fix prompt for a Claude Code chat") return f"
  • {text}
  • " +def _html_stale_plan(board: Board, items: list[dict]) -> str: + """The one-tap "clear them all" line above the evidence gaps. + + Rendered only when the gaps are the tier on show — the board displays one + tier at a time, and a plan for reasons the reader cannot see is noise. + """ + plan = board.stale_plan + if not plan or not items or items[0].get("severity") != "stale": + return "" + chips = _copy_btn(plan["prompt"], + "copy one prompt that clears every gap, for a Claude Code chat", + "📋 clear them all") + if plan.get("command"): + chips += " " + _copy_btn(plan["command"], + "copy the command chain that re-runs every check", + "⌨ command chain") + return f"

    {chips}

    " + + # The Heart's verdict in the theme's tone vocabulary. The board's own # `_VERDICT_STATE` stays the internal truth; this is only how it is painted. _VERDICT_TONE = {"red": "bad", "yellow": "warn", "stale": "warn", "green": "ok"} _LEDE = ("Is it safe to release? Every check the Heart observes, with the " - "evidence behind each verdict. \U0001f4cb copies a ready-to-paste prompt " - "or command for a Claude Code chat.") + "evidence behind each verdict. \u2328 copies the command that re-runs a " + "check; \U0001f4cb copies a ready-to-paste prompt for a Claude Code chat.") # The page-specific shapes the shared sheet has no opinion on: the per-row # state dot, the evidence list, the stale banner. Written against the theme's @@ -1296,6 +1495,10 @@ def _html_reason(item: dict) -> str: .stale{background:var(--btn);border:1px solid var(--warn);color:var(--warn); padding:.55rem .75rem;border-radius:8px} .reasons{margin:1.5rem 0} +/* The whole-tier fix line sits between the heading and the gaps it closes, so + the reader meets it before the per-row ones. */ +.plan{margin:.4rem 0 .8rem} +.plan .copy{margin-right:.4rem} .reasons li{margin:.3rem 0} .hint{color:var(--muted);font-size:.85em;margin:.5rem 0 0} footer{margin-top:2rem;color:var(--muted);font-size:.82em} @@ -1348,9 +1551,10 @@ def _render_html(board: Board) -> str: 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}
    " + hint = ("

    ⌨ copies the command that re-runs a check; " + "📋 copies a ready-to-paste prompt for a Claude Code chat.

    ") + reasons_html = (f"

    {label}

    " + f"{_html_stale_plan(board, items)}
      {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 "" @@ -1374,7 +1578,7 @@ def _render_html(board: Board) -> str: {_boards_nav_html()}
    Rendered by heart/dashboard.py — one renderer, many surfaces. Observer only: PyAutoHeart never writes outside its own repo/state. -\U0001f4cb buttons copy a Claude prompt or command to your clipboard.
    +\U0001f4cb and \u2328 buttons copy a Claude prompt or a command to your clipboard. """ @@ -1398,6 +1602,9 @@ def to_dict(board: Board) -> dict[str, Any]: # Structured, actionable reasons — what the 📋 buttons copy and where # they link. The flat lists above stay for v1 consumers. "blockers": board.blockers, + # One payload that closes every current evidence gap (v3). None when + # nothing is stale — a sibling board renders it, never re-derives it. + "stale_plan": board.stale_plan, "pages_url": PAGES_URL, "sections": [ { diff --git a/heart/fix.py b/heart/fix.py index ff63377..f3bc4b0 100644 --- a/heart/fix.py +++ b/heart/fix.py @@ -8,6 +8,8 @@ dirty. timing — script-timing regressions for a project: emit the red/yellow list with baselines, ready for paste. + stale — the current evidence gaps and the check that closes each + one, plus the ONE plan that closes them all. Output is plain text (color helpers applied for stderr framing, content stripped so the user can pipe / paste safely). @@ -200,6 +202,52 @@ def fix_timing(project: str) -> int: return 0 +def fix_stale() -> int: + """The evidence gaps and what re-runs each — the terminal's copy of the + board's Evidence-gaps block. + + Same source as the board (the persisted verdict + ``dashboard``'s remedy + table), so the two surfaces cannot prescribe different things. Read-only, + like every other topic here: it prints what to run, it never runs it. + """ + from heart import dashboard, readiness + + verdict = readiness.load_verdict() + reasons = [str(r) for r in (verdict.get("stale_reasons") or [])] + if not reasons: + print(c_ok("no evidence gaps — nothing to re-run"), file=sys.stderr) + return 0 + keys = [str(d.get("key") or "") for d in (verdict.get("stale_details") or []) + if isinstance(d, dict)] + if len(keys) != len(reasons): + keys = [""] * len(reasons) + plan = dashboard.build_stale_plan(reasons, keys) + + print(c_bold(f"Topic: {len(reasons)} evidence gap(s) " + f"(verdict {str(verdict.get('verdict', '?')).upper()}, " + f"score {verdict.get('score', '?')})"), file=sys.stderr) + print(c_meta(" re-run the checks below; never change code to clear a gap"), + file=sys.stderr) + print(file=sys.stderr) + + for text, key in zip(reasons, keys): + remedy = dashboard.stale_remedy(key) + print(f"# {text}") + command = (remedy or {}).get("command") + print(f" {command}" if command + else f" ({remedy['step'] if remedy else 'no remedy on record'})") + print() + if not plan: + return 0 + if plan.get("command"): + print(c_info("# Clear every gap (run this):"), file=sys.stderr) + print(plan["command"]) + print() + print(c_info("# Or paste this into a fresh Claude Code session:"), file=sys.stderr) + print(plan["prompt"]) + return 0 + + def main(argv: list[str]) -> int: if len(argv) < 2: print("usage: pyauto-heart fix [...]", file=sys.stderr) @@ -218,6 +266,8 @@ def main(argv: list[str]) -> int: return fix_dirty(args[0]) if topic == "drift": return fix_drift() + if topic == "stale": + return fix_stale() if topic == "timing": if not args: print("usage: pyauto-heart fix timing ", file=sys.stderr) diff --git a/heart/readiness.py b/heart/readiness.py index 5726fea..6a2abde 100644 --- a/heart/readiness.py +++ b/heart/readiness.py @@ -71,7 +71,10 @@ collected into separate lists and ``verdict = red if red_reasons else yellow if yellow_reasons else stale if stale_reasons else green``. A ``score`` (0–100, weighted penalties) is advisory/sortable only — the colour, -not the number, is the gate. ``compute`` is a pure function of the snapshot for +not the number, is the gate. Each stale reason also travels with the gate key +that produced it in ``stale_details`` (additive — the flat reason lists are +unchanged), so a surface can look up the check that closes a gap instead of +parsing the sentence. ``compute`` is a pure function of the snapshot for easy testing and never raises on partial/malformed data. """ @@ -250,6 +253,10 @@ def compute( # remedy is re-running a check. Never receives a reason whose last known # result was adverse (those stay red/yellow); see the module docstring. stale: list[str] = [] + # The gate key behind each entry of ``stale``, index for index — the + # machine-readable identity of a gap, so a consumer looks its remedy up + # instead of guessing it from the sentence. + stale_keys: list[str] = [] # Out-of-scope-by-profile: dev-box-local evidence gaps under release-ci. # Listed for transparency (never silently green) but non-gating — the # module docstring's "Profiles" section is the rule's home. @@ -259,6 +266,12 @@ def compute( def hit(key: str, n: int = 1) -> None: counts[key] = counts.get(key, 0) + n + def add_stale(msg: str, key: str) -> None: + """File an evidence gap: its sentence, its gate key, its penalty.""" + stale.append(msg) + stale_keys.append(key) + hit(key) + def scope_local(msg: str, key: str) -> None: """File a *gap* in dev-box-local evidence: stale on the default profile; out-of-scope (na, non-gating) under release-ci. Adverse @@ -266,15 +279,13 @@ def scope_local(msg: str, key: str) -> None: if release_ci: na.append(msg + " (dev-box-local — out of scope for release-ci)") else: - stale.append(msg) - hit(key) + add_stale(msg, key) # --- library gates (RED) --- for lib in libs: body = repos.get(lib) if not isinstance(body, dict) or not body: - stale.append(f"{lib}: status unknown") - hit("lib_unknown") + add_stale(f"{lib}: status unknown", "lib_unknown") continue ci = body.get("ci_status", {}) or {} conclusion = ci.get("conclusion") @@ -282,8 +293,8 @@ def scope_local(msg: str, key: str) -> None: # The CI query failed, so we have no evidence either way. That is a # STALE "unknown", never a pass — and it must be *said*, because a # silent unknown here is indistinguishable from a healthy repo. - stale.append(f"{lib}: CI status unavailable ({ci['error']})") - hit("lib_ci_unavailable") + add_stale(f"{lib}: CI status unavailable ({ci['error']})", + "lib_ci_unavailable") elif conclusion not in (None, "", "success"): red.append(f"{lib}: CI {conclusion}") hit("lib_ci") @@ -403,8 +414,10 @@ def scope_local(msg: str, key: str) -> None: ) hit("skew_bad") elif status == "UNKNOWN": - stale.append(f"{w.get('workspace')}: newest {w.get('library')} release unknown") - hit("skew_unknown") + add_stale( + f"{w.get('workspace')}: newest {w.get('library')} release unknown", + "skew_unknown", + ) # --- version skew, PyPI yank leg (deep `version_skew --pypi`; the slice is # absent until that on-demand probe has run — absence is no signal) --- @@ -432,10 +445,10 @@ def scope_local(msg: str, key: str) -> None: ) hit("skew_pypi_floor_yanked") elif status == "UNKNOWN": - stale.append( - f"{w.get('workspace')}: PyPI unreachable for {w.get('package')}" + add_stale( + f"{w.get('workspace')}: PyPI unreachable for {w.get('package')}", + "skew_pypi_unknown", ) - hit("skew_pypi_unknown") # --- manifest drift (YELLOW — identity hygiene vs PyAutoMind/repos.yaml) --- manifest = snapshot.get("manifest_drift") @@ -475,22 +488,21 @@ def scope_local(msg: str, key: str) -> None: ) hit("install_not_ready") elif index == "find-links": - stale.append( + add_stale( "install verification development-only (find-links; " - "PyPI/TestPyPI evidence required)" + "PyPI/TestPyPI evidence required)", + "install_non_release", ) - hit("install_non_release") else: age = _age_days(vi.get("ts"), ref) if age is None or age > INSTALL_STALE_DAYS: - stale.append( + add_stale( f"install verification stale ({index}, " - + ("age unknown)" if age is None else f"{int(age)}d old)") + + ("age unknown)" if age is None else f"{int(age)}d old)"), + "install_stale", ) - hit("install_stale") else: - stale.append("install verification not run") - hit("install_unknown") + add_stale("install verification not run", "install_unknown") # --- release-validation gate (hard) ------------------------------------- # GREEN-for-release now REQUIRES a fresh, passing validation_report whose @@ -515,8 +527,8 @@ def scope_local(msg: str, key: str) -> None: # Nothing is wrong; the rehearsal evidence is simply absent. The # wording must contain "release validation" — the Health Agent # classifier matches on that string to route the remedy. - stale.append("release validation incomplete: no rehearsal for current source") - hit("validation_absent") + add_stale("release validation incomplete: no rehearsal for current source", + "validation_absent") elif outcome == "fail": vr_stages = vr.get("stages") failed_stages = [ @@ -548,46 +560,44 @@ def scope_local(msg: str, key: str) -> None: mismatched.append(lib) vr_profile = str(vr.get("profile") or "").strip().lower() if not commit_shas: - stale.append("release validation source unconfirmed (no commit_shas)") - hit("validation_unknown") + add_stale("release validation source unconfirmed (no commit_shas)", + "validation_unknown") elif mismatched: - stale.append( + add_stale( "release validation stale: source moved since rehearsal (" - + ", ".join(mismatched) + ")" + + ", ".join(mismatched) + ")", + "validation_stale_sha", ) - hit("validation_stale_sha") elif confirmed == 0: - stale.append("release validation source unconfirmed (current HEADs unknown)") - hit("validation_unknown") + add_stale("release validation source unconfirmed (current HEADs unknown)", + "validation_unknown") elif unconfirmed: # Some libs matched, but at least one gated repo's SHA could not # be confirmed either way — an unknown must never be silently # treated as green (same principle every other gate here follows). - stale.append( + add_stale( "release validation partially unconfirmed (repo(s) with unknown " - "current HEAD: " + ", ".join(unconfirmed) + ")" + "current HEAD: " + ", ".join(unconfirmed) + ")", + "validation_unknown", ) - hit("validation_unknown") elif vr_profile != "release": - stale.append( - f"release validation profile '{vr.get('profile') or '?'}' is not 'release'" + add_stale( + f"release validation profile '{vr.get('profile') or '?'}' is not 'release'", + "validation_profile", ) - hit("validation_profile") else: age = _age_days(vr.get("ts"), ref) if age is None or age > VALIDATION_STALE_DAYS: - stale.append( + add_stale( "release validation stale " - + ("(age unknown)" if age is None else f"({int(age)}d old)") + + ("(age unknown)" if age is None else f"({int(age)}d old)"), + "validation_stale", ) - hit("validation_stale") # else: fresh, passing, matching, release profile → GREEN-eligible else: - stale.append("release validation status unknown") - hit("validation_unknown") + add_stale("release validation status unknown", "validation_unknown") else: - stale.append("no release validation for current source") - hit("validation_absent") + add_stale("no release validation for current source", "validation_absent") # --- script timing (YELLOW) --- timing = snapshot.get("script_timing", {}) or {} @@ -663,6 +673,10 @@ def scope_local(msg: str, key: str) -> None: "red_reasons": red, "yellow_reasons": yellow, "stale_reasons": stale, + # Each stale reason with the gate key that produced it — the surfaces + # key their remedy off this and never off the sentence. Additive: the + # flat lists above are the contract every existing consumer reads. + "stale_details": [{"text": t, "key": k} for t, k in zip(stale, stale_keys)], "na_reasons": na, "ts": snapshot.get("ts") or datetime.datetime.now(datetime.timezone.utc).isoformat(), } diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 998b01e..76667aa 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -576,7 +576,7 @@ def test_json_v2_carries_blockers_and_actions(): out = dashboard.render(_failing_snapshot(), v, fmt="json", unobserved=dashboard.LOCAL_ONLY_FAMILIES, now=FRESH_NOW) d = json.loads(out) - assert d["schema_version"] == 2 + assert d["schema_version"] == dashboard.SCHEMA_VERSION assert d["blockers"][0]["prompt"].startswith("/bug ") unobs = [s for s in d["sections"] if s["state"] == "unobserved"] assert unobs and all(s["action"]["payload"].startswith("pyauto-heart") for s in unobs) @@ -786,8 +786,8 @@ def test_board_json_carries_the_performance_block_with_prompts_verbatim(): assert perf["no_run"]["totals"]["unmeasured_slow"] == 7 assert perf["no_run"]["rows"][0]["prompt"] == ROW_PROMPT assert len(perf["history"]) == 3 - # schema v2 stays v2 — the performance block is purely additive. - assert d["schema_version"] == 2 + # the performance block is purely additive — it bumps nothing on its own. + assert d["schema_version"] == dashboard.SCHEMA_VERSION def test_performance_no_run_rows_are_capped_at_ten(): @@ -855,3 +855,126 @@ def test_a_long_out_link_label_cannot_push_the_page_sideways(): fmt="html", now=FRESH_NOW) rule = re.search(r"a\.out\{[^}]*\}", out).group(0) assert "nowrap" not in rule + + +# --- evidence gaps carry the check that closes them -------------------------- +# +# STALE is the board's steady state, so a gap that only names itself leaves the +# reader with nothing to do. Each row carries its own remedy (looked up by the +# readiness gate key, never sniffed from the sentence), and the tier carries one +# plan that closes all of them at once. + +GAPS = ["install verification not run", + "test run status unknown (no report.json)"] + + +def _stale_verdict(reasons, keys, score=80): + return {"verdict": "stale", "score": score, "ts": TS, + "red_reasons": [], "yellow_reasons": [], "stale_reasons": list(reasons), + "stale_details": [{"text": t, "key": k} for t, k in zip(reasons, keys)]} + + +def test_each_gap_carries_the_command_that_closes_it(): + v = _stale_verdict(GAPS, ["install_unknown", "test_unknown"]) + board = dashboard.build_board(make_snapshot(), v, now=FRESH_NOW) + install, test_run = [b for b in board.blockers if b["severity"] == "stale"] + + assert install["command"] == dashboard.VERIFY_INSTALL_CMD + assert test_run["command"] == dashboard.TICK_CMD + # the prompt names the check AND the gap it closes — not the sentence back + assert dashboard.VERIFY_INSTALL_CMD in install["prompt"] + assert GAPS[0] in install["prompt"] + assert install["prompt"].startswith("/health") + # STALE's rule survives the trip to the chip + assert "never change code" in install["prompt"] + assert not any(b["prompt"].startswith("/bug") for b in board.blockers) + + +def test_a_gap_needing_a_conversation_offers_no_command(): + v = _stale_verdict(["no release validation for current source"], ["validation_absent"]) + board = dashboard.build_board(make_snapshot(), v, now=FRESH_NOW) + (gap,) = board.blockers + + assert gap["command"] is None # the Heart never dispatches a rehearsal + assert "/release rehearse" in gap["prompt"] + + +def test_an_unkeyed_gap_falls_back_to_the_generic_nudge(): + # A verdict from an older Heart carries no `stale_details` at all: the row + # must degrade to the old prompt, never to a guessed remedy. + v = {"verdict": "stale", "score": 90, "ts": TS, + "stale_reasons": ["some gap nobody has mapped yet"]} + board = dashboard.build_board(make_snapshot(), v, now=FRESH_NOW) + (gap,) = board.blockers + + assert gap["command"] is None + assert gap["prompt"] == "/health re-run the stale evidence: some gap nobody has mapped yet" + assert board.stale_plan is None + + +def test_the_tier_carries_one_plan_that_clears_every_gap(): + v = _stale_verdict(GAPS, ["install_unknown", "test_unknown"]) + board = dashboard.build_board(make_snapshot(), v, now=FRESH_NOW) + plan = board.stale_plan + + assert plan["count"] == 2 + for gap in GAPS: + assert gap in plan["prompt"] # every gap named, in order + assert plan["prompt"].startswith("/health clear the Heart's 2 evidence gap(s)") + # every gap here has a command, so the whole tier is one shell chain that + # ends by re-reading the verdict + assert plan["command"] == (f"{dashboard.VERIFY_INSTALL_CMD} && {dashboard.TICK_CMD}" + " && pyauto-heart readiness") + + +def test_the_plan_withholds_a_command_chain_it_cannot_complete(): + # A chain that silently skips the rehearsal would read as "that cleared it". + v = _stale_verdict(GAPS + ["no release validation for current source"], + ["install_unknown", "test_unknown", "validation_absent"]) + board = dashboard.build_board(make_snapshot(), v, now=FRESH_NOW) + + assert board.stale_plan["command"] is None + assert "/release rehearse" in board.stale_plan["prompt"] + + +def test_no_plan_when_nothing_is_stale(): + board = dashboard.build_board(make_snapshot(), make_verdict(), now=FRESH_NOW) + assert board.stale_plan is None + + +def test_surfaces_render_the_remedies_and_the_plan(): + v = _stale_verdict(GAPS, ["install_unknown", "test_unknown"]) + snap = make_snapshot() + + html = dashboard.render(snap, v, fmt="html", now=FRESH_NOW) + assert "\U0001f4cb clear them all" in html and "command chain" in html + assert dashboard.VERIFY_INSTALL_CMD in html + + md = dashboard.render(snap, v, fmt="md", now=FRESH_NOW) + assert "Clear every gap in one go" in md + assert dashboard.VERIFY_INSTALL_CMD in md + + d = json.loads(dashboard.render(snap, v, fmt="json", now=FRESH_NOW)) + assert d["stale_plan"]["count"] == 2 + assert all("command" in b for b in d["blockers"]) + + +def test_the_plan_stays_off_a_board_showing_another_tier(): + # The board shows one tier at a time; a plan for gaps the reader cannot see + # is noise (the json surface still carries it as data). + v = _stale_verdict(GAPS, ["install_unknown", "test_unknown"], score=45) + v.update(verdict="red", red_reasons=["PyAutoLens: CI failure"]) + html = dashboard.render(make_snapshot(), v, fmt="html", now=FRESH_NOW) + + assert "\U0001f4cb clear them all" not in html + assert json.loads(dashboard.render(make_snapshot(), v, fmt="json", + now=FRESH_NOW))["stale_plan"]["count"] == 2 + + +def test_the_terminal_verdict_points_at_the_door_out_of_stale(): + v = _stale_verdict(GAPS, ["install_unknown", "test_unknown"]) + block = "\n".join(dashboard.render_readiness_block(v)) + assert "pyauto-heart fix stale" in block + # the prompt hook's one-liner stays a one-liner + quiet = "\n".join(dashboard.render_readiness_block(v, quiet=True)) + assert "fix stale" not in quiet diff --git a/tests/test_readiness.py b/tests/test_readiness.py index 32231af..38bcb03 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -1047,3 +1047,38 @@ def test_library_ci_fetch_error_is_not_counted_as_red(): } v = compute(snap) assert not any("PyAutoLens" in r for r in v["red_reasons"]) + + +# --- every gap says which check produced it --------------------------------- + +def test_stale_details_carry_the_gate_key_beside_each_reason(): + """A surface must be able to look up the check that closes a gap. The key + is the gap's machine-readable identity; the sentence is for humans.""" + snap = make_snapshot() + del snap["verify_install"] + del snap["validation_report"] + v = compute(snap) + + assert v["verdict"] == "stale" + # index for index with the flat list, which is unchanged + assert [d["text"] for d in v["stale_details"]] == v["stale_reasons"] + by_key = {d["key"]: d["text"] for d in v["stale_details"]} + assert by_key["install_unknown"] == "install verification not run" + assert by_key["validation_absent"] == "no release validation for current source" + + +def test_stale_details_are_empty_when_nothing_is_stale(): + v = compute(make_snapshot()) + assert v["verdict"] == "green" + assert v["stale_details"] == [] + + +def test_release_ci_profile_files_no_details_for_out_of_scope_evidence(): + """Under release-ci a dev-box-local gap is `na`, not stale — so it earns no + remedy chip either.""" + snap = make_snapshot() + del snap["test_run"] + v = compute_ci(snap) + + assert not any(d["key"] == "test_unknown" for d in v["stale_details"]) + assert any("test run status unknown" in r for r in v["na_reasons"])