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}
{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("{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("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'' 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("")