diff --git a/AGENTS.md b/AGENTS.md index 9316cc3..31a772f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,15 @@ release?" gate. The organs, boundaries and the `Brain → Heart (gate) → Hands (execute)` call chain are defined once in `PyAutoBrain/ORGANISM.md`. Heart's side of it: -**observer only** — it reads and emits the authoritative green/yellow/red -verdict, never writes into other repos, and never triggers Build. +**observer only** — it reads and emits the authoritative +green/stale/yellow/red verdict, never writes into other repos, and never +triggers Build. **STALE** is the freshness tier: nothing known-bad, but some +evidence is missing or expired — the remedy is re-running a check, never +fixing code, and evidence whose last known result was adverse stays +yellow/red (`heart/readiness.py` docstring is the canonical definition). +Releases still require GREEN; the dev-ship gate (PyAutoBrain `AUTONOMY.md` +leg 4) treats STALE as passing because an evidence gap is organism-scope, +not branch-scope. For the release-**validation** rehearsal specifically (build-and-exercise the exact source about to ship, before promoting to PyPI — see diff --git a/heart/dashboard.py b/heart/dashboard.py index cea3957..58d2e39 100644 --- a/heart/dashboard.py +++ b/heart/dashboard.py @@ -93,22 +93,25 @@ class Section: class Board: """The whole board as data — every surface is a projection of this.""" - verdict: str # green | yellow | red + verdict: str # green | stale | yellow | red score: int ts: str # snapshot timestamp age_seconds: float | None - stale: bool + stale: bool # board-tick staleness (snapshot age), NOT the verdict tier red_reasons: list[str] yellow_reasons: list[str] sections: list[Section] + # 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) # --- verdict/state → glyph & colour maps ------------------------------------ -_VERDICT_STATE = {"red": FAIL, "yellow": WARN, "green": OK} -_VERDICT_WORD = {"red": "RED", "yellow": "YELLOW", "green": "GREEN"} +_VERDICT_STATE = {"red": FAIL, "yellow": WARN, "stale": INFO, "green": OK} +_VERDICT_WORD = {"red": "RED", "yellow": "YELLOW", "stale": "STALE", "green": "GREEN"} _STATE_MD = {OK: "🟢", WARN: "🟡", FAIL: "🔴", UNOBS: "⚪", INFO: "🔵"} _STATE_HTML = {OK: "ok", WARN: "warn", FAIL: "fail", UNOBS: "unobs", INFO: "info"} -_BADGE_COLOR = {"red": "red", "yellow": "yellow", "green": "brightgreen"} +_BADGE_COLOR = {"red": "red", "yellow": "yellow", "stale": "blue", "green": "brightgreen"} def _colour(state: str, text: str) -> str: @@ -303,6 +306,7 @@ def build_board( score = _as_int(verdict.get("score", 0)) red = list(verdict.get("red_reasons") or []) yellow = list(verdict.get("yellow_reasons") or []) + stale_reasons = list(verdict.get("stale_reasons") or []) sections: list[Section] = [] @@ -476,6 +480,7 @@ def build_board( red_reasons=red, yellow_reasons=yellow, sections=sections, + stale_reasons=stale_reasons, ) @@ -493,6 +498,7 @@ def render_readiness_block(verdict: dict[str, Any], *, quiet: bool = False) -> l lines = [f"{c_info('RELEASE READINESS')} {_glyph(state)} {word} {c_meta(f'score {score}')}"] reds = verdict.get("red_reasons") or [] yellows = verdict.get("yellow_reasons") or [] + stales = verdict.get("stale_reasons") or [] limit = 1 if quiet else 6 shown = 0 for r in reds: @@ -503,6 +509,10 @@ def render_readiness_block(verdict: dict[str, Any], *, quiet: bool = False) -> l if shown < limit: for y in yellows[: limit - shown]: lines.append(" " + c_warn(f"! {y}")) + shown += 1 + if shown < limit: + for s in stales[: limit - shown]: + lines.append(" " + c_info(f"? {s}")) return lines @@ -533,6 +543,8 @@ def _render_oneline(board: Board) -> str: tail = f"{len(board.red_reasons)} blockers" elif board.verdict == "yellow": tail = f"{len(board.yellow_reasons)} warnings" + elif board.verdict == "stale": + tail = f"{len(board.stale_reasons)} evidence gap(s)" else: tail = "all green" age = format_age(board.age_seconds, stale=board.stale) @@ -557,6 +569,10 @@ def _render_md(board: Board) -> str: 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])) + lines.append("") lines += ["| | Check | Status |", "|--|--|--|"] for sec in board.sections: em = _STATE_MD[sec.state] @@ -587,9 +603,11 @@ def _render_html(board: Board) -> str: f"{_html.escape(sec.summary)}{details}" ) reasons_html = "" - reasons = board.red_reasons or board.yellow_reasons + reasons = board.red_reasons or board.yellow_reasons or board.stale_reasons if reasons: - label = "Blockers" if board.red_reasons else "Warnings" + 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}

    " stale_html = ( diff --git a/heart/readiness.py b/heart/readiness.py index 96dd97c..d6aff54 100644 --- a/heart/readiness.py +++ b/heart/readiness.py @@ -25,12 +25,29 @@ under a profile other than ``release``), and — crucially — any *unknown* (missing test-run report, a library absent from the snapshot). An unknown is never silently treated as green and never escalated to red. +- **STALE** (an evidence gap, the freshness tier) when nothing is known-bad but + some evidence is *missing or expired*: a check that was never run, a + passing-but-aged report, a rehearsal whose ``commit_shas`` no longer match + ``main``, an unknown repo/version status. The remedy for a stale reason is to + **re-run the check**, never to fix code — which is exactly what separates it + from yellow. The tier is not a skip lever: evidence whose *last known result + was adverse* stays yellow/red until a fresh run says otherwise; only + unknown or passed-but-expired evidence lands here. - **GREEN** otherwise — which now REQUIRES a fresh, passing release-validation report matching the current source under the ``release`` profile, not just an absence of red signals. -Red dominates yellow structurally: reasons are collected into separate lists -and ``verdict = red if red_reasons else yellow if yellow_reasons else green``. +Consumer semantics: the **release gate needs GREEN** — STALE blocks a release +exactly like YELLOW, but prescribes refreshing evidence rather than a human +acknowledgement. The **dev-ship gate** (PyAutoBrain ``AUTONOMY.md`` leg 4) +treats STALE as passing: an evidence gap is organism-scope, not branch-scope, +and the other three legs gate the branch itself. This is what makes GREEN +reachable in steady state and keeps YELLOW meaning "something is actually +wrong" instead of training ack-fatigue. + +Red dominates yellow, which dominates stale, structurally: reasons are +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 easy testing and never raises on partial/malformed data. @@ -179,6 +196,10 @@ def compute( red: list[str] = [] yellow: list[str] = [] + # The freshness tier: nothing known-bad, but evidence missing/expired — + # 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] = [] counts: dict[str, int] = {} def hit(key: str, n: int = 1) -> None: @@ -188,7 +209,7 @@ def hit(key: str, n: int = 1) -> None: for lib in libs: body = repos.get(lib) if not isinstance(body, dict) or not body: - yellow.append(f"{lib}: status unknown") + stale.append(f"{lib}: status unknown") hit("lib_unknown") continue ci = body.get("ci_status", {}) or {} @@ -254,12 +275,14 @@ def hit(key: str, n: int = 1) -> None: ) hit("test_failing") elif ready is True: + # Passing-but-expired evidence is a freshness gap, not a warning — + # the last known result was good. age = _age_days(test_run.get("ts"), ref) if age is not None and age > TEST_STALE_DAYS: - yellow.append(f"test run stale ({int(age)}d old)") + stale.append(f"test run stale ({int(age)}d old)") hit("test_stale") else: - yellow.append("test run status unknown") + stale.append("test run status unknown") hit("test_unknown") # parked staleness (YELLOW) parked = _as_int(test_run.get("parked_stale_count", 0)) @@ -267,7 +290,7 @@ def hit(key: str, n: int = 1) -> None: yellow.append(f"{parked} stale parked script(s)") hit("parked") else: - yellow.append("test run status unknown (no report.json)") + stale.append("test run status unknown (no report.json)") hit("test_unknown") # --- version skew (RED ahead / YELLOW behind) --- @@ -296,7 +319,7 @@ def hit(key: str, n: int = 1) -> None: yellow.append(f"{w.get('workspace')}: pinned BEHIND installed {w.get('installed')}") hit("skew_behind") elif status == "UNKNOWN": - yellow.append(f"{w.get('workspace')}: installed {w.get('library')} version unknown") + stale.append(f"{w.get('workspace')}: installed {w.get('library')} version unknown") hit("skew_unknown") # --- manifest drift (YELLOW — identity hygiene vs PyAutoMind/repos.yaml) --- @@ -324,13 +347,13 @@ def hit(key: str, n: int = 1) -> None: else: age = _age_days(vi.get("ts"), ref) if age is None or age > INSTALL_STALE_DAYS: - yellow.append( + stale.append( "install verification stale " + ("(age unknown)" if age is None else f"({int(age)}d old)") ) hit("install_stale") else: - yellow.append("install verification not run") + stale.append("install verification not run") hit("install_unknown") # --- release-validation gate (hard) ------------------------------------- @@ -375,45 +398,45 @@ def hit(key: str, n: int = 1) -> None: mismatched.append(lib) profile = str(vr.get("profile") or "").strip().lower() if not commit_shas: - yellow.append("release validation source unconfirmed (no commit_shas)") + stale.append("release validation source unconfirmed (no commit_shas)") hit("validation_unknown") elif mismatched: - yellow.append( + stale.append( "release validation stale: source moved since rehearsal (" + ", ".join(mismatched) + ")" ) hit("validation_stale_sha") elif confirmed == 0: - yellow.append("release validation source unconfirmed (current HEADs unknown)") + stale.append("release validation source unconfirmed (current HEADs unknown)") hit("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). - yellow.append( + stale.append( "release validation partially unconfirmed (repo(s) with unknown " "current HEAD: " + ", ".join(unconfirmed) + ")" ) hit("validation_unknown") elif profile != "release": - yellow.append( + stale.append( f"release validation profile '{vr.get('profile') or '?'}' is not 'release'" ) hit("validation_profile") else: age = _age_days(vr.get("ts"), ref) if age is None or age > VALIDATION_STALE_DAYS: - yellow.append( + stale.append( "release validation stale " + ("(age unknown)" if age is None else f"({int(age)}d old)") ) hit("validation_stale") # else: fresh, passing, matching, release profile → GREEN-eligible else: - yellow.append("release validation status unknown") + stale.append("release validation status unknown") hit("validation_unknown") else: - yellow.append("no release validation for current source") + stale.append("no release validation for current source") hit("validation_absent") # --- script timing (YELLOW) --- @@ -462,13 +485,14 @@ def hit(key: str, n: int = 1) -> None: score -= min(n * w, cap) score = max(0, min(100, score)) - verdict = "red" if red else ("yellow" if yellow else "green") + verdict = "red" if red else ("yellow" if yellow else ("stale" if stale else "green")) return { "verdict": verdict, "score": score, - "reasons": red + yellow, + "reasons": red + yellow + stale, "red_reasons": red, "yellow_reasons": yellow, + "stale_reasons": stale, "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 48b34f3..e3d21d4 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -200,7 +200,7 @@ def test_unknown_fmt_raises(): # --- badge ------------------------------------------------------------------- @pytest.mark.parametrize("verdict,color", [("green", "brightgreen"), ("yellow", "yellow"), - ("red", "red")]) + ("stale", "blue"), ("red", "red")]) def test_badge_endpoint_colour(verdict, color): board = dashboard.build_board(make_snapshot(), make_verdict(verdict, 50), now=FRESH_NOW) b = dashboard.badge_endpoint(board) diff --git a/tests/test_readiness.py b/tests/test_readiness.py index 9c3ad94..76a28d7 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -81,13 +81,14 @@ def test_one_library_ci_failing_is_red(): assert v["score"] == 70 -def test_test_run_stale_is_yellow(): - # ready but ~31 days before the snapshot ts → stale caution, not a blocker. +def test_test_run_stale_is_stale_tier(): + # ready but ~31 days before the snapshot ts → passing-but-expired evidence: + # the freshness tier, not a warning (last known result was good). snap = make_snapshot(test_run={"ready": True, "ts": "2026-05-01T00:00:00+00:00", "parked_stale_count": 0}) v = compute(snap) - assert v["verdict"] == "yellow" - assert any("test run stale" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("test run stale" in r for r in v["stale_reasons"]) def test_test_run_fresh_ready_is_green(): @@ -138,14 +139,14 @@ def test_version_skew_bad_is_red(): assert any("unparseable" in r for r in v["red_reasons"]) -def test_version_skew_unknown_is_yellow(): +def test_version_skew_unknown_is_stale_tier(): snap = make_snapshot(version_skew={"workspaces": [ {"workspace": "autolens_workspace", "library": "PyAutoLens", "pinned": "2026.6.1.1", "installed": None, "status": "UNKNOWN"} ]}) v = compute(snap) - assert v["verdict"] == "yellow" - assert any("version unknown" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("version unknown" in r for r in v["stale_reasons"]) def test_install_verification_failed_is_red(): @@ -159,22 +160,22 @@ def test_install_verification_failed_is_red(): assert v["score"] == 60 -def test_install_verification_stale_is_yellow(): +def test_install_verification_stale_is_stale_tier(): snap = make_snapshot(verify_install={ "ready": True, "ts": "2026-05-01T00:00:00+00:00", # ~31d before snapshot ts "checks": [], }) v = compute(snap) - assert v["verdict"] == "yellow" - assert any("install verification stale" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("install verification stale" in r for r in v["stale_reasons"]) -def test_install_verification_not_run_is_yellow(): +def test_install_verification_not_run_is_stale_tier(): snap = make_snapshot() snap.pop("verify_install") v = compute(snap) - assert v["verdict"] == "yellow" - assert any("install verification not run" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("install verification not run" in r for r in v["stale_reasons"]) def test_install_verification_fresh_pass_is_green(): @@ -194,19 +195,19 @@ def test_validation_fresh_pass_matching_source_is_green(): assert not any("release validation" in r for r in v["reasons"]) -def test_validation_absent_is_yellow(): +def test_validation_absent_is_stale_tier(): snap = make_snapshot() del snap["validation_report"] v = compute(snap) - assert v["verdict"] == "yellow" + assert v["verdict"] == "stale" assert not v["red_reasons"] - assert any("no release validation for current source" in r for r in v["yellow_reasons"]) + assert any("no release validation for current source" in r for r in v["stale_reasons"]) -def test_validation_empty_dict_is_yellow(): +def test_validation_empty_dict_is_stale_tier(): v = compute(make_snapshot(validation_report={})) - assert v["verdict"] == "yellow" - assert any("no release validation" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("no release validation" in r for r in v["stale_reasons"]) def test_validation_failed_is_red(): @@ -219,41 +220,42 @@ def test_validation_failed_is_red(): assert v["score"] == 60 # validation_failed penalty 40 -def test_validation_stale_by_sha_is_yellow(): - # A report whose commit_shas no longer match the current main HEADs is stale: - # the source moved on since the rehearsal → caution, not a blocker, not green. +def test_validation_stale_by_sha_is_stale_tier(): + # A report whose commit_shas no longer match the current main HEADs is + # expired evidence: the source moved on since the rehearsal → freshness + # tier, not a blocker, not green. snap = make_snapshot() snap["repos"]["PyAutoLens"]["ci_status"]["head_sha"] = "f" * 40 # HEAD moved v = compute(snap) - assert v["verdict"] == "yellow" + assert v["verdict"] == "stale" assert not v["red_reasons"] assert any("source moved since rehearsal" in r and "PyAutoLens" in r - for r in v["yellow_reasons"]) + for r in v["stale_reasons"]) -def test_validation_wrong_profile_is_yellow(): +def test_validation_wrong_profile_is_stale_tier(): report = _green_validation_report() report["profile"] = "smoke" report["stages"]["integrate"] = {"status": "pass", "profile": "smoke"} v = compute(make_snapshot(validation_report=report)) - assert v["verdict"] == "yellow" - assert any("profile 'smoke' is not 'release'" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("profile 'smoke' is not 'release'" in r for r in v["stale_reasons"]) -def test_validation_stale_by_age_is_yellow(): +def test_validation_stale_by_age_is_stale_tier(): # passing + matching + release profile, but the rehearsal is >7d old. report = _green_validation_report(ts="2026-05-01T00:00:00+00:00") # ~31d before snap ts v = compute(make_snapshot(validation_report=report)) - assert v["verdict"] == "yellow" - assert any("release validation stale" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("release validation stale" in r for r in v["stale_reasons"]) -def test_validation_no_commit_shas_is_yellow(): +def test_validation_no_commit_shas_is_stale_tier(): report = _green_validation_report() report["commit_shas"] = {} v = compute(make_snapshot(validation_report=report)) - assert v["verdict"] == "yellow" - assert any("source unconfirmed" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("source unconfirmed" in r for r in v["stale_reasons"]) def test_validation_missing_ts_is_yellow_not_silently_green(): @@ -264,8 +266,8 @@ def test_validation_missing_ts_is_yellow_not_silently_green(): report = _green_validation_report() del report["ts"] v = compute(make_snapshot(validation_report=report)) - assert v["verdict"] == "yellow" - assert any("release validation stale" in r and "unknown" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("release validation stale" in r and "unknown" in r for r in v["stale_reasons"]) def test_validation_partial_sha_confirmation_is_yellow_not_green(): @@ -277,16 +279,16 @@ def test_validation_partial_sha_confirmation_is_yellow_not_green(): snap = make_snapshot(validation_report=report) del snap["repos"]["PyAutoLens"]["ci_status"]["head_sha"] v = compute(snap) - assert v["verdict"] == "yellow" - assert any("partially unconfirmed" in r and "PyAutoLens" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("partially unconfirmed" in r and "PyAutoLens" in r for r in v["stale_reasons"]) -def test_validation_ready_unknown_is_yellow(): +def test_validation_ready_unknown_is_stale_tier(): report = _green_validation_report() report["release_ready"] = None v = compute(make_snapshot(validation_report=report)) - assert v["verdict"] == "yellow" - assert any("release validation status unknown" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("release validation status unknown" in r for r in v["stale_reasons"]) def test_validation_failed_dominates_and_reds_first(): @@ -352,12 +354,12 @@ def test_parked_stale_is_yellow(): assert any("parked" in r for r in v["yellow_reasons"]) -def test_missing_test_run_is_yellow_unknown_not_crash(): +def test_missing_test_run_is_stale_unknown_not_crash(): snap = make_snapshot() del snap["test_run"] v = compute(snap) - assert v["verdict"] == "yellow" - assert any("unknown" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("unknown" in r for r in v["stale_reasons"]) assert v["score"] == 90 @@ -478,21 +480,57 @@ def test_red_dominates_yellow(): assert v["reasons"][0] in v["red_reasons"] -def test_missing_library_is_yellow_unknown(): +def test_missing_library_is_stale_unknown(): snap = make_snapshot() del snap["repos"]["PyAutoConf"] v = compute(snap) - assert v["verdict"] == "yellow" - assert any("PyAutoConf" in r and "unknown" in r for r in v["yellow_reasons"]) + assert v["verdict"] == "stale" + assert any("PyAutoConf" in r and "unknown" in r for r in v["stale_reasons"]) def test_empty_snapshot_not_green_no_crash(): v = readiness.compute({}, libraries=LIBS) - assert v["verdict"] == "yellow" # unknowns, never green on no data + assert v["verdict"] == "stale" # all-unknown evidence gaps, never green on no data + assert v["stale_reasons"] and not v["red_reasons"] and not v["yellow_reasons"] assert v["score"] < 100 json.dumps(v) +# --- the freshness tier's own semantics -------------------------------------- + + +def test_yellow_dominates_stale_and_reason_order(): + # A genuine warning (timing regression) + an evidence gap (verify_install + # never run) → YELLOW, with reasons ordered red + yellow + stale. + snap = make_snapshot(script_timing={"red_count": 1}) + snap.pop("verify_install") + v = compute(snap) + assert v["verdict"] == "yellow" + assert v["yellow_reasons"] and v["stale_reasons"] + assert v["reasons"] == v["red_reasons"] + v["yellow_reasons"] + v["stale_reasons"] + + +def test_stale_never_masks_last_known_bad(): + # An old FAILING validation report stays YELLOW — expiry only applies to + # passing evidence. The freshness tier must never be a skip lever. + snap = make_snapshot(test_run={"ready": False, "failed": 5, + "run_label": "old", "ts": "2026-05-01T00:00:00+00:00"}) + v = compute(snap) + assert v["verdict"] == "yellow" + assert any("workspace validation not passing" in r for r in v["yellow_reasons"]) + assert not any("test run" in r for r in v["stale_reasons"]) + + +def test_multiple_evidence_gaps_is_single_stale_verdict(): + snap = make_snapshot() + snap.pop("verify_install") + del snap["validation_report"] + v = compute(snap) + assert v["verdict"] == "stale" + assert len(v["stale_reasons"]) == 2 + assert not v["yellow_reasons"] and not v["red_reasons"] + + def test_score_clamped_to_zero_floor(): snap = make_snapshot(test_run={"ready": False}) for lib in LIBS: @@ -553,7 +591,8 @@ def test_run_with_no_state_cache_still_writes(tmp_path, monkeypatch): importlib.reload(r_mod) v = r_mod.run() assert (tmp_path / "release_ready.json").is_file() - assert v["verdict"] == "yellow" + # empty state = all-unknown evidence gaps -> the freshness tier + assert v["verdict"] == "stale" importlib.reload(state_mod) importlib.reload(r_mod)