diff --git a/board/AGENTS.md b/board/AGENTS.md index eacdd48e..aa988efd 100644 --- a/board/AGENTS.md +++ b/board/AGENTS.md @@ -76,12 +76,24 @@ goes back to being a plain readable document, because these are lists people scan on a phone before breakfast. Colour is information, never decoration: the accent is organ identity; pills -are task facets, toned so that only the *exception* is coloured (`supervised` +are row facets, toned so that only the *exception* is coloured (`supervised` is 9 of 10 prompts in the Mind, so it stays neutral — tinting it would paint the backlog and say nothing); `ok`/`warn`/`bad` stay reserved for verdict -semantics. `ORGANS` holds the palette — `mind` and `brain` are sampled from -the real logo files, the other four are placeholders in the right hue family, -to be confirmed against each logo when that renderer adopts the theme. +semantics. `ORGANS` holds the palette — five of the six are sampled from the +real logo files; the umbrella board has no logo to sample, so its accent and +tagline are designed to sit in the family instead. + +**This board's own facets.** Every row here says what it is in the same +vocabulary the Mind dashboard uses. The accent is what the row *is* — the +repo a workflow belongs to, the kind of a community conversation, the target +of an in-flight task, a door that acts (a conductor) rather than one that only +opines. A tone is what the row *says*: a conclusion, a Heart verdict, a +version stamp off consensus, how long someone has waited on a reply. An +in-flight task is the one row that composes rather than invents — it reads +the `Type:`/`Target:`/`Difficulty:`/`Autonomy:`/`Priority:` header the Mind +already wrote, so a task looks like itself on both pages. Above the sections, +a header strip carries one number per section that can ask something of a +human; a source that could not be read counts `–`, never a zero. ## Configuration diff --git a/board/_board.py b/board/_board.py index 4cdf578d..17590bf1 100755 --- a/board/_board.py +++ b/board/_board.py @@ -52,7 +52,7 @@ # this page and the Mind dashboard are visibly the same family. sys.path.insert(0, str(Path(__file__).resolve().parent)) from _theme import ( # noqa: E402 - JS as _THEME_JS, boards_footer, css as _theme_css, hero, + JS as _THEME_JS, boards_footer, css as _theme_css, hero, pills, stats, ) THEME_ORGAN = "brain" # whose logo this page wears @@ -228,6 +228,10 @@ def fetch_badge(pages_base, repo): HEART_BLOCKER_CAP = 5 PERF_FLAGGED_CAP = 5 +# A conversation waiting on us this long stops being amber. The Ears own the +# triage; this is only the tone the morning glance gives the wait. +COMMUNITY_STALE_DAYS = 7 + def fetch_heart_board(pages_base, repo, degraded): """The Heart board's published machine surface (board.json, schema v2) — @@ -297,7 +301,10 @@ def extract_heart_performance(board, board_url=""): morning headline: the few worst flagged rows — hang/kill events first, then slowed gates, then SLOW no_run markers that were never measured — each carrying its OWN prompt, rendered verbatim like the blockers, plus - the counts behind them. + the counts behind them. Each row keeps the bucket it came from as `kind` + (event / slowed / never measured) — the classification this function + already makes, surfaced so the render can tone it rather than re-read the + sentence. None when the block is absent: an older Heart publish simply renders no section (an unreachable board.json is already a degraded row). Measurement @@ -328,6 +335,7 @@ def extract_heart_performance(board, board_url=""): + (f" after {took}" if took else ""))[:160], "url": e.get("run_url"), "prompt": e.get("prompt"), + "kind": "event", }) for g in warn: bits = [] @@ -345,6 +353,7 @@ def extract_heart_performance(board, board_url=""): + (f" — {' · '.join(bits)}" if bits else ""))[:160], "url": g.get("actions_url"), "prompt": g.get("prompt"), + "kind": "slowed", }) for r in unmeasured: since = f" since {r['date']}" if r.get("date") else "" @@ -353,6 +362,7 @@ def extract_heart_performance(board, board_url=""): f"{since}, never measured")[:160], "url": r.get("url"), "prompt": r.get("prompt"), + "kind": "never measured", }) return { "flagged": flagged[:PERF_FLAGGED_CAP], @@ -423,10 +433,57 @@ def collect_community(degraded): return None +# The Mind's prompt header, as the intake conductor writes it: `Type:`, +# `Target:`, `Difficulty:`, `Autonomy:`, `Priority:`. Read, never derived — +# an unheaded prompt yields no facets and renders as a bare row. +_FACET_LINE = re.compile( + r"(Type|Target|Difficulty|Autonomy|Priority):\s*(\S.*?)\s*$") +FACET_KEYS = ("type", "target", "difficulty", "autonomy", "priority") +# The header opens the file (a `# title` and a blank line may precede it), and +# the intake conductor writes its fields together. So: a block that starts +# late, or that is a single `Priority:` line, is prose — not a header. +FACET_HEADER_LINES = 8 +FACET_MIN_FIELDS = 2 + + +def prompt_facets(text): + """The header facets of one Mind prompt — the header block, and only it. + + The board shows an in-flight task with the same pills the Mind dashboard + gives it, so a task looks like itself on both pages. + + The block is the run of lines around the first field line: a `# title` + above it, `Status:`/`Repos:`/`Milestone:` lines beside it, and the blank + line that closes it. Reading stops there, so a `Priority:` written into + the prose below is discussion, not the header the Mind maintains — and a + lone field line, or one that starts well down the file, is prose too. + An unheaded prompt yields nothing and renders as a bare row: no pill here + is ever a guess. + """ + found, started = {}, False + for n, line in enumerate(text.splitlines()): + m = _FACET_LINE.match(line) + if m: + if not started and n >= FACET_HEADER_LINES: + break + started = True + found.setdefault(m.group(1).lower(), m.group(2)) + elif started and not line.strip(): + break + if len(found) < FACET_MIN_FIELDS: + found = {} + # `target` keeps the header's own casing — it is a repo name, and the + # Mind writes it as one. The rest are a closed vocabulary the pill tones + # and the work-type glyphs are keyed on, so they normalise. + return {k: (found.get(k, "") if k == "target" + else found.get(k, "").lower()) for k in FACET_KEYS} + + def collect_resume(org, degraded): """Resume context: the Mind's own generated counts (dashboard.md header — - compose, don't recompute), the task files on deck, the queue length, and - open pending-release PRs.""" + compose, don't recompute), the task files on deck (each with the header + facets the Mind already gave it), the queue length, and open + pending-release PRs.""" mind = PYAUTO_ROOT / "PyAutoMind" counts = {} tasks = [] @@ -444,12 +501,14 @@ def collect_resume(org, degraded): for f in sorted(active.glob("*.md")): if f.name == "AGENTS.md": continue + body = f.read_text(encoding="utf-8") title = f.stem - for line in f.read_text(encoding="utf-8").splitlines(): + for line in body.splitlines(): if line.startswith("# "): title = line[2:].strip() break - tasks.append({"path": f"active/{f.name}", "title": title}) + tasks.append({"path": f"active/{f.name}", "title": title, + "facets": prompt_facets(body)}) queue = mind / "queue.md" if queue.is_file(): queue_len = sum( @@ -957,6 +1016,50 @@ def _plain(text_html): return f'

{text_html}

' +def section_counts(data): + """`(number, label)` pairs for the header strip — one per section that can + ask something of a human, in the order those sections appear. + + A source that could not be read contributes `–`, never a zero: the strip + is read before the rows and must not promise a quiet morning the board + cannot see. + """ + # Red as `verdict()` counts it: a gate that stopped on purpose is amber + # there, so it is not red here either. + red = sum(1 for r in data["overnight"] + if r["conclusion"] not in (None, "success") and not r["blocked"]) + blockers = len(data.get("heart_blockers") or []) if data["heart"] else "–" + community = data["community"] + awaiting = (community["counts"]["awaiting_response"] + if community else "–") + issues = data["open_issues"] if data["open_issues"] is not None else "–" + return [(red, "Overnight red"), (blockers, "Blockers"), + (awaiting, "Awaiting"), (len(data["resume"]["tasks"]), "In flight"), + (issues, "Open issues")] + + +def _verdict_tone(message): + """A sibling board's headline, toned. GREEN goes, RED stops, anything + else — including a word this end has never seen — asks for a look.""" + return "g" if message.startswith("GREEN") else ( + "r" if message.startswith("RED") else "y") + + +def _hygiene_row(row): + """One hygiene finding: the summary reads, the mode and status pill.""" + summary = html.escape(str(row.get("summary", ""))[:140]) + return summary + pills((str(row.get("mode") or "?"), ""), + (str(row.get("status") or ""), "y")) + + +# The autonomy log's two judgement columns. Colour marks the exception: the +# level a task ran at only matters when it was unusually free or unusually +# gated, and `merged-unchanged` is what four in five rows say — tinting the +# ordinary outcome would paint the whole log and tell no one anything. +_LEVEL_TONES = {"safe": "g", "human-required": "r"} +_OUTCOME_TONES = {"amended": "y", "rejected": "r", "reverted": "r"} + + # The html twin. Self-contained by the same contract as the Mind dashboard — # no external assets, inline style and script only, one copy button per # actionable row — and dressed by the shared board theme, so this page and @@ -992,6 +1095,11 @@ def render_html(data): H.append(f'

{esc(headline(data))}' f'generated {esc(data["generated"])}' f'{spark_html}

') + # The five numbers a human wants before reading a row — one per section + # that can ask something of them, so the strip doubles as a contents + # page. `–` where a source was unreachable: an absent count is never a + # zero here, the same contract the rows keep. + H.append(stats(*section_counts(data))) H.append("

⌨ Morning sync (local)

") H.append(_row( "Sync every repo to main + clean generated cruft — run in a terminal " @@ -1000,56 +1108,64 @@ def render_html(data): H.append("

🌙 Overnight

") for r in data["overnight"]: - name = f"{esc(r['repo'])}/{esc(r['workflow'])}" + # The workflow is the row's subject; its repo and its conclusion are + # facets, so they read as pills — the repo in the organ accent + # (identity) and the conclusion in its verdict tone. link = f' — run ↗' if r["url"] else "" + age = (f' ({age_label(r["age_h"])})' + if r["conclusion"] is not None else "") + subject = f'{esc(r["workflow"])}{age}{link}' + failed = r["conclusion"] not in (None, "success") if r["conclusion"] is None: - H.append(_plain(f'– {name} — no runs')) + state, tone, reason = "no runs", "n", "" elif r["blocked"]: - reason = (f'
{esc(r["blocked_reason"])}' - if r.get("blocked_reason") else "") - H.append(_plain( - f'⏸ {name} — blocked at a gate, no ' - f'change made ({age_label(r["age_h"])}){link}{reason}')) - elif r["conclusion"] == "success": - H.append(_plain(f'✓ {name} — success ' - f'({age_label(r["age_h"])}){link}')) + state, tone = "blocked at a gate", "y" + reason = ('
no change made — ' + f'{esc(r["blocked_reason"])}' + if r.get("blocked_reason") else + '
no change made') + elif not failed: + state, tone, reason = "success", "g", "" else: - H.append(_row( - f'✗ {name} — {esc(r["conclusion"])} ' - f'({age_label(r["age_h"])}){link}', - f"/bug overnight: {r['repo']}/{r['workflow']} concluded " - f"{r['conclusion']} — {r['url'] or 'no run url'}")) + state, tone, reason = r["conclusion"], "r", "" + # pills() escapes its own values; only the hand-built html above is + # escaped here. + text = subject + reason + pills((r["repo"], ""), (state, tone)) + if failed and not r["blocked"]: + H.append(_row(text, f"/bug overnight: {r['repo']}/{r['workflow']} " + f"concluded {r['conclusion']} — " + f"{r['url'] or 'no run url'}")) + else: + H.append(_plain(text)) H.append("

❤️ Readiness & release

") heart_url = data["boards"].get("heart", "") if data["heart"]: msg = data["heart"].get("message", "?") - cls = "ok" if msg.startswith("GREEN") else ( - "bad" if msg.startswith("RED") else "warn") H.append(_row( - f'Heart verdict: {esc(msg)} — ' - f'Heart board ↗', "/health")) + f'Heart verdict — Heart board ↗' + + pills((msg, _verdict_tone(msg))), "/health")) else: H.append(_row("Heart board unreachable — consult the clinician " - "directly.", "/health")) + "directly." + pills(("unreachable", "y")), "/health")) for b in data.get("heart_blockers") or []: - sev_cls = "bad" if b.get("severity") == "red" else "warn" - sev = (f'[{esc(b["severity"])}] ' - if b.get("severity") else "") + sev = b.get("severity") links = "".join( f' {label} ↗' for label, url in (("repo", b.get("repo_url")), ("run", b.get("run_url"))) if url) - text = f'{sev}{esc(b["text"])}{links}' + text = (f'{esc(b["text"])}{links}' + + pills((sev, "r" if sev == "red" else "y"))) if b.get("prompt"): H.append(_row(text, b["prompt"])) else: H.append(_plain(text)) if data.get("hands"): hands_url = data["boards"].get("hands", "") + shipped = data["hands"].get("message", "?") H.append(_plain( - f'Shipped: {esc(data["hands"].get("message", "?"))} — ' - f'Hands board ↗')) + f'Shipped — Hands board ↗' + + pills((shipped, _verdict_tone(shipped))))) # The Heart's test-performance block, rendered as it arrived — the rows # carry their own prompts, this end never re-derives one. @@ -1060,7 +1176,11 @@ def render_html(data): for f in perf["flagged"]: link = (f' run ↗' if f.get("url") else "") - text = f'{esc(f["text"])}{link}' + # The sentence is the Heart's, verbatim; the pill is the + # bucket it arrived in, so severity is scannable. + kind = f.get("kind") or "" + text = (f'{esc(f["text"])}{link}' + + pills((kind, "r" if kind == "event" else "y"))) if f.get("prompt"): H.append(_row(text, f["prompt"])) else: @@ -1068,22 +1188,23 @@ def render_html(data): else: timings_url = perf.get("board_url") or heart_url H.append(_plain( - f'✓ {perf.get("gates_total", 0)} gates ' - f'timed · nothing flagged — ' - f'full timings ↗')) + f'{perf.get("gates_total", 0)} gates timed — ' + f'full timings ↗' + + pills(("nothing flagged", "g")))) v = data["versions"] H.append("

🏷️ Version consistency

") if v["consensus"] and v["drift"] == 0: - H.append(_plain(f'✓ consistent at ' - f'{esc(v["consensus"])} across the coupled set')) + H.append(_plain( + f'The coupled set agrees at {esc(v["consensus"])}' + + pills(("consistent", "g")))) elif v["consensus"]: for r in v["stamps"]: if not r["ok"]: H.append(_row( - f'✗ {esc(r["repo"])} ' f'{esc(r["version"] or "?")} ≠ consensus ' - f'{esc(v["consensus"])}', + f'{esc(v["consensus"])}' + + pills((r["repo"], ""), ("drift", "r")), f"/bug version drift: {r['repo']} stamp {r['version']} is " f"out of step with the coupled-set consensus {v['consensus']}")) else: @@ -1097,38 +1218,44 @@ def render_html(data): if c: counts = c["counts"] H.append(_row( - f'{counts["open_external"]} external issue(s), ' - f'{counts["open_external_prs"]} external PR(s) open — ' - f'{counts["awaiting_response"]} awaiting our reply. ' - 'Replies stay human-gated in /community.', + 'Replies stay human-gated in /community.' + + pills((f'{counts["open_external"]} issue(s)', ""), + (f'{counts["open_external_prs"]} PR(s)', "n"), + (f'{counts["awaiting_response"]} awaiting our reply', + "y" if counts["awaiting_response"] else "g")), "/community")) - def community_row(e, note_html): - """Every conversation gets its own one-tap triage chip.""" + def community_row(e, note, tone): + """Every conversation gets its own one-tap triage chip. + + The conversation's kind leads the pills in the accent — it is what + this row *is* — and the note takes the tone of how long someone + has been waiting on us. + """ url = e.get("url") or "" title = esc(e.get("title", "")[:80]) - kind = "PR " if e.get("type") == "pr" else "" + kind = "PR" if e.get("type") == "pr" else "issue" link = f'{esc(e["repo"])}#{e["number"]}' \ if url else f'{esc(e["repo"])}#{e["number"]}' return _row( - f'{kind}{link} {note_html} @{esc(e["author"])}: {title}', + f'{link} @{esc(e["author"])}: {title}' + + pills((kind, ""), (note, tone)), f"/community triage {e['repo']}#{e['number']}") awaiting_keys = {(e["repo"], e["number"]) for e in c["awaiting_response"]} for e in c["awaiting_response"]: - days = (f"{e['waiting_days']:.0f}d" - if e.get("waiting_days") is not None else "?") + waited = e.get("waiting_days") + days = f"{waited:.0f}d waiting" if waited is not None else "waiting" H.append(community_row( - e, f'[{days} waiting]')) + e, days, "r" if (waited or 0) >= COMMUNITY_STALE_DAYS else "y")) for e in c["open_external_issues"] + c["open_external_prs"]: if (e["repo"], e["number"]) in awaiting_keys: continue note = ("ours to watch" if e.get("awaiting_response") is False else "unchecked") - H.append(community_row(e, f'[{note}]')) + H.append(community_row(e, note, "n")) for e in c["awaiting_review"]: - H.append(community_row( - e, '[review requested]')) + H.append(community_row(e, "review requested", "y")) else: H.append(_row("Scan unavailable — run the Ears directly.", "/community")) @@ -1136,31 +1263,46 @@ def community_row(e, note_html): counts = data["resume"]["counts"] mind_url = data["boards"].get("mind", "") if counts: - joined = " · ".join(f"{esc(k)} {n}" for k, n in counts.items()) - H.append(_plain(f'{joined} — pick from the ' - f'Mind board ↗')) + H.append(_plain( + f'Pick from the Mind board ↗' + + pills(*[(f"{k} {n}", "" if i == 0 else "n") + for i, (k, n) in enumerate(counts.items())]))) for t in data["resume"]["tasks"]: - H.append(_row(f'{esc(t["path"])} — {esc(t["title"][:80])}', - f"/start_dev {t['path']}")) + # An in-flight task wears the header facets the Mind gave it, so it + # looks like itself on both pages — same pills, same order. + facets = t.get("facets") or {} + H.append(_row( + f'{esc(t["title"][:80])} {esc(t["path"])}' + + pills(facets.get("target"), facets.get("difficulty"), + facets.get("autonomy"), facets.get("priority"), + work_type=facets.get("type")), + f"/start_dev {t['path']}")) pending = data["resume"]["pending_prs"] if pending: for p in pending: H.append(_row( - f'pending-release ' - f'{esc(p["repo"])}#{p["number"]} — {esc(p["title"][:70])}', + f'{esc(p["repo"])}#{p["number"]}' + f' — {esc(p["title"][:70])}' + + pills(("pending-release", "y")), f"/prm {p['url']}")) elif pending is not None: H.append(_plain('no pending-release PRs open')) H.append("

🧹 Upkeep

") + # Standing invitations rather than state, so the one pill each carries is + # the door it routes to — what the 📋 puts on the clipboard, visible + # without tapping it. issue_note = (f"{data['open_issues']} open issue(s) org-wide — " if data["open_issues"] is not None else "") - H.append(_row(f"{issue_note}reconcile the trackers (closing stays " - "confirmation-gated).", "/issue_cleanup")) - H.append(_row("Code-quality debt sweep — slow tests, CLI noise, dep-cap " - "drift (the Hygiene section below is its scan).", "/hygiene")) - H.append(_row("Stale branches, stashes, dirty checkouts (runs locally).", - "/repo_cleanup")) + for text, door in ( + (f"{issue_note}reconcile the trackers (closing stays " + "confirmation-gated).", "/issue_cleanup"), + ("Code-quality debt sweep — slow tests, CLI noise, dep-cap drift " + "(the Hygiene section below is its scan).", "/hygiene"), + ("Stale branches, stashes, dirty checkouts (runs locally).", + "/repo_cleanup"), + ): + H.append(_row(text + pills((door, "")), door)) hygiene = data.get("hygiene") if hygiene: @@ -1171,11 +1313,11 @@ def community_row(e, note_html): if r.get("status") not in HYGIENE_QUIET_STATUSES] if flagged: for row in flagged: - summary = esc(str(row.get("summary", ""))[:140]) - H.append(_row(f'{esc(str(row.get("mode")))}: {summary}', + H.append(_row(_hygiene_row(row), str(row.get("delegate") or "/hygiene"))) else: - H.append(_plain('✓ nothing flagged')) + H.append(_plain("Every mode came back clean" + + pills(("nothing flagged", "g")))) devbox = data.get("devbox") if devbox: @@ -1193,19 +1335,20 @@ def community_row(e, note_html): for row in devbox.get("hygiene", {}).get("rows", []): if row.get("status") in HYGIENE_QUIET_STATUSES: continue - summary = esc(str(row.get("summary", ""))[:140]) - H.append(_row(f'{esc(str(row.get("mode")))}: {summary}', + H.append(_row(_hygiene_row(row), str(row.get("delegate") or "/hygiene"))) for wt in devbox.get("worktrees", []): - bits = [esc(str(wt.get("branch") or "?"))] + # Unpushed work and a dirty tree are the things that lose work; + # a stash is a note to self. Tone them accordingly. + facets = [(str(wt.get("repo")), ""), + (str(wt.get("branch") or "?"), "n")] if wt.get("ahead"): - bits.append(f'{wt["ahead"]} unpushed') + facets.append((f'{wt["ahead"]} unpushed', "y")) if wt.get("dirty"): - bits.append("dirty") + facets.append(("dirty", "y")) if wt.get("stashes"): - bits.append(f'{wt["stashes"]} stash(es)') - H.append(_plain(f'worktree {esc(str(wt.get("repo")))} — ' - f'{" · ".join(bits)}')) + facets.append((f'{wt["stashes"]} stash(es)', "n")) + H.append(_plain("worktree" + pills(*facets))) if data.get("autonomy"): H.append('

🤖 Autonomous runs (the calibration ' @@ -1213,7 +1356,9 @@ def community_row(e, note_html): for a in data["autonomy"]: H.append(_plain( f'{esc(a["date"])} ' - f'{esc(a["task"])} — {esc(a["outcome"])}')) + f'{esc(a["task"])}' + + pills((a["level"], _LEVEL_TONES.get(a["level"], "n")), + (a["outcome"], _OUTCOME_TONES.get(a["outcome"], "n"))))) doors = data["doors"] if doors: @@ -1222,17 +1367,20 @@ def community_row(e, note_html): for d in doors: if d["tier"] == "skill": continue - tier = ' (faculty)' \ - if d["tier"] == "faculty" else "" - H.append(_row(f'/{esc(d["verb"])}{tier} — {esc(d["desc"])}', - f"/{d['verb']}")) + # The tier is the one thing that changes what a door does to the + # world: a conductor acts, a faculty only opines. Accent the + # actors; leave the read-only ones quiet. + H.append(_row( + f'/{esc(d["verb"])} — {esc(d["desc"])}' + + pills((d["tier"], "" if d["tier"] == "conductor" else "n")), + f"/{d['verb']}")) skills = [d for d in doors if d["tier"] == "skill"] if skills: H.append('

Workflow doors — compositions and ' 'dev-flow entries, no agent of their own:

') for d in skills: - H.append(_row(f'/{esc(d["verb"])} — {esc(d["desc"])}', - f"/{d['verb']}")) + H.append(_row(f'/{esc(d["verb"])} — {esc(d["desc"])}' + + pills(("workflow", "n")), f"/{d['verb']}")) H.append("") if data["degraded"]: diff --git a/board/_theme.py b/board/_theme.py index 364c51fe..b9223404 100644 --- a/board/_theme.py +++ b/board/_theme.py @@ -490,6 +490,14 @@ def pills(*values, work_type=None): Of the positional values the first is the target repo/domain — it gets the organ accent, because it is identity, not judgement; the rest take their tone from `_TONES`, defaulting to neutral. + + `_TONES` is the *Mind's* facet vocabulary (a prompt's difficulty, autonomy + and priority). A board whose rows carry different facets — a workflow + conclusion, a Heart verdict, a version stamp — says its own tone by + passing `(value, tone)` instead of a bare string, rather than growing that + table with words it does not share. The tone is a class from the sheet: + `g`/`y`/`r` for go/caution/handle-with-care, `n` for neutral, `""` for the + organ accent. """ out = [] if work_type and work_type != "-": @@ -499,9 +507,14 @@ def pills(*values, work_type=None): f'
') first = True for v in values: + tone = None + if isinstance(v, tuple): + v, tone = v if not v or v == "-": continue - cls = "pill" if first else f'pill {_TONES.get(v, "n")}' + if tone is None: + tone = "" if first else _TONES.get(v, "n") + cls = f"pill {tone}".rstrip() out.append(f'{_esc(v)}') first = False return f'{"".join(out)}' if out else "" diff --git a/board/gallery/board-brain.png b/board/gallery/board-brain.png index 9e7ab6b6..f3cdbe26 100644 Binary files a/board/gallery/board-brain.png and b/board/gallery/board-brain.png differ diff --git a/tests/test_board.py b/tests/test_board.py index 82fc9c2b..cd0ae220 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -173,7 +173,20 @@ def _fabricate(tmp_path, fixtures, heart_board=None): (mind / "active").mkdir(parents=True) (mind / "repos.yaml").write_text(REPOS_YAML) (mind / "dashboard.md").write_text(DASHBOARD_MD) - (mind / "active" / "some_task.md").write_text("# Fix the fixture widget\n") + # The shape the intake conductor writes: a title, a blank line, then the + # header block — `Status:` included, which is not a board facet. + (mind / "active" / "some_task.md").write_text( + "# Fix the fixture widget\n" + "\n" + "Type: bug\n" + "Target: RepoA\n" + "Difficulty: large\n" + "Autonomy: supervised\n" + "Priority: high\n" + "Status: formalised\n" + "\n" + "The body, which mentions Priority: low to prove only the header " + "block is read.\n") (mind / "queue.md").write_text( "# Queue\n\ndraft/feature/repoa/one.md\ndraft/feature/repoa/two.md\n") (mind / "autonomy_log.md").write_text(AUTONOMY_LOG) @@ -288,8 +301,10 @@ def test_json_surface_is_complete_and_derives_org(tmp_path): # Resume: the Mind's own generated counts + the task file + the queue. assert s["resume"]["counts"]["In flight"] == 1 assert s["resume"]["counts"]["Backlog"] == 152 - assert s["resume"]["tasks"] == [ - {"path": "active/some_task.md", "title": "Fix the fixture widget"}] + assert s["resume"]["tasks"] == [{ + "path": "active/some_task.md", "title": "Fix the fixture widget", + "facets": {"type": "bug", "target": "RepoA", "difficulty": "large", + "autonomy": "supervised", "priority": "high"}}] assert s["resume"]["queue_len"] == 2 assert s["resume"]["pending_prs"][0]["repo"] == "ExampleOrg/RepoA" assert s["open_issues"] == 42 @@ -371,6 +386,85 @@ def test_html_is_self_contained_with_one_tap_payloads(tmp_path): assert 'data-cmd="/intake"' in page +# --------------------------------------------------------------- the look -- +# The page dresses in the shared theme's vocabulary (board/_theme.py): the +# accent is identity, a tone is judgement. These assert that each section +# spends colour on a FACET rather than on decoration — a row's repo, its +# state, its tier — so the board is scannable rather than merely blue. + + +def test_the_header_strip_counts_every_section_that_can_ask_something(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(**{ + "runs.json": _run_json("failure")})) + page = _run(["--html"], tmp_path, stub).stdout + for label in ("Overnight red", "Blockers", "Awaiting", "In flight", + "Open issues"): + assert f"{label}" in page + # Seven overnight jobs all red in this fixture, one in-flight task, and + # the org issue count straight off the search. + assert "7Overnight red" in page + assert "1In flight" in page + assert "42Open issues" in page + + +def test_an_unreadable_source_counts_as_a_dash_not_a_zero(tmp_path): + """The strip is read before the rows; it must not promise a quiet morning + the board could not actually see.""" + stub = _fabricate(tmp_path, _default_fixtures(**{ + "issue_count.json": {}, "comm_issues.json": {}, "comm_prs.json": {}})) + page = _run(["--html"], tmp_path, stub, {"BOARD_PAGES_BASE": "file:///nope"}).stdout + assert "–Blockers" in page + assert "–Open issues" in page + assert "0Open issues" not in page + + +def test_an_overnight_row_wears_its_repo_and_its_conclusion(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(**{ + "runs.json": _run_json("failure")})) + page = _run(["--html"], tmp_path, stub).stdout + # The repo is identity, so it takes the accent; the conclusion is a + # judgement, so it takes a tone. + assert 'ExampleOrg/PyAutoBrain' in page + assert 'failure' in page + green = _run(["--html"], tmp_path, + _fabricate(tmp_path / "green", _default_fixtures())).stdout + assert 'success' in green + + +def test_an_in_flight_task_wears_the_minds_own_facets(tmp_path): + """A task looks like itself on both pages: the board reads the header the + Mind wrote and renders the same pills the Mind dashboard gives it.""" + stub = _fabricate(tmp_path, _default_fixtures()) + page = _run(["--html"], tmp_path, stub).stdout + assert '🐛 bug' in page + assert 'RepoA' in page # target = identity + assert 'large' in page # judgement = tone + assert 'high' in page + assert 'supervised' in page # the default + + +def test_a_pill_is_never_a_guess(): + """No header, a lone field line, or one that starts well down the file: + prose, not a header. A row with nothing to say wears nothing.""" + import sys + sys.path.insert(0, str(BRAIN_HOME / "board")) + from _board import FACET_KEYS, prompt_facets # noqa: E402 + blank = dict.fromkeys(FACET_KEYS, "") + assert prompt_facets("# Just a title\n\nSome prose.\n") == blank + assert prompt_facets("# t\n\nprose\n\nPriority: low\n") == blank + assert prompt_facets("\n" * 9 + "Type: bug\nPriority: high\n") == blank + # A partial header is still a header. + assert prompt_facets("Type: bug\nPriority: high\n")["priority"] == "high" + + +def test_a_door_is_accented_only_when_it_acts(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures()) + page = _run(["--html"], tmp_path, stub).stdout + assert 'conductor' in page + assert 'faculty' in page + assert 'workflow' in page + + def test_apply_writes_the_four_pages_files(tmp_path): stub = _fabricate(tmp_path, _default_fixtures()) out = tmp_path / "site" @@ -478,8 +572,10 @@ def test_nothing_flagged_renders_one_quiet_row(tmp_path): stub = _fabricate(tmp_path, _default_fixtures(), {**HEART_BOARD_JSON, "performance": quiet}) page = _run(["--html"], tmp_path, stub).stdout - assert "2 gates timed · nothing flagged" in page + assert "2 gates timed" in page + assert 'nothing flagged' in page assert "full timings ↗" in page + # The markdown twin has no pills, so it keeps saying it in words. assert "2 gates timed · nothing flagged" in _run([], tmp_path, stub).stdout @@ -503,7 +599,8 @@ def test_a_malformed_performance_block_never_breaks_the_render(tmp_path): "no_run": {"rows": [{"repo": "RepoA"}, "junk"]}}}) r = _run(["--html"], tmp_path, stub) assert r.returncode == 0, r.stderr - assert "0 gates timed · nothing flagged" in r.stdout + assert "0 gates timed" in r.stdout + assert 'nothing flagged' in r.stdout def test_blocked_gate_annotation_renders_inline(tmp_path): diff --git a/tests/test_board_theme.py b/tests/test_board_theme.py index d4b4b945..6276b9cb 100644 --- a/tests/test_board_theme.py +++ b/tests/test_board_theme.py @@ -139,8 +139,24 @@ def test_every_work_type_in_the_taxonomy_has_a_glyph(): assert set(_sizing.WORK_TYPES) == set(_theme.WORK_TYPE_GLYPHS) +def test_a_board_may_name_its_own_pill_tone(): + """`_TONES` is the Mind's facet vocabulary. A board whose rows carry other + facets — a workflow conclusion, a Heart verdict — says its tone rather + than teaching that table words the Mind never uses.""" + row = _theme.pills(("ExampleOrg/RepoA", ""), ("failure", "r")) + assert 'ExampleOrg/RepoA' in row + assert 'failure' in row + # An explicit tone wins over the table, in both directions. + assert 'high' in _theme.pills( + ("autoarray", ""), ("high", "n")) + # Bare strings are untouched: first is identity, the rest look themselves up. + assert _theme.pills("autoarray", "high") == _theme.pills( + ("autoarray", ""), ("high", "r")) + + def test_pills_escape_their_values_and_vanish_when_empty(): assert _theme.pills("-", "-", "-", "-") == "" + assert _theme.pills(("-", "r"), ("", "g")) == "" assert "<b>" in _theme.pills("")