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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo> # 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
Expand Down
10 changes: 8 additions & 2 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,18 @@ same `state.json` + `release_ready.json`, so they cannot disagree.
- **Pages board** — <https://pyautolabs.github.io/PyAutoHeart/>, 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
Expand Down
4 changes: 3 additions & 1 deletion bin/pyauto-heart
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ Topics:
dirty <repo> — clean up a dirty working tree (real vs generated)
drift — current worktree drift entries
timing <project> — 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() {
Expand All @@ -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" "$@"
;;
*)
Expand Down
257 changes: 232 additions & 25 deletions heart/dashboard.py

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions heart/fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
dirty.
timing <proj> — 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).
Expand Down Expand Up @@ -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 <topic> [...]", file=sys.stderr)
Expand All @@ -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 <project>", file=sys.stderr)
Expand Down
98 changes: 56 additions & 42 deletions heart/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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.
Expand All @@ -259,31 +266,35 @@ 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
local evidence never routes through here — it stays red/yellow."""
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")
if ci.get("error"):
# 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")
Expand Down Expand Up @@ -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) ---
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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 = [
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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(),
}
Expand Down
Loading
Loading