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
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 25 additions & 7 deletions heart/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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] = []

Expand Down Expand Up @@ -476,6 +480,7 @@ def build_board(
red_reasons=red,
yellow_reasons=yellow,
sections=sections,
stale_reasons=stale_reasons,
)


Expand All @@ -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:
Expand All @@ -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


Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand Down Expand Up @@ -587,9 +603,11 @@ def _render_html(board: Board) -> str:
f"<td class='sum'>{_html.escape(sec.summary)}{details}</td></tr>"
)
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"<li>{_html.escape(r)}</li>" for r in reasons[:8])
reasons_html = f"<div class='reasons'><h2>{label}</h2><ul>{items}</ul></div>"
stale_html = (
Expand Down
62 changes: 43 additions & 19 deletions heart/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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 {}
Expand Down Expand Up @@ -254,20 +275,22 @@ 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))
if parked > 0:
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) ---
Expand Down Expand Up @@ -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) ---
Expand Down Expand Up @@ -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) -------------------------------------
Expand Down Expand Up @@ -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) ---
Expand Down Expand Up @@ -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(),
}

Expand Down
2 changes: 1 addition & 1 deletion tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading