From f3f4bba4b6772c2f7563914d74990e9340713e8e Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 21 Aug 2026 18:27:37 -0400 Subject: [PATCH] feat: dashboard epic grouping + lifecycle-drift flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two human-reported problems (PyAutoBrain#246): - Epic members rendered as standalone backlog: prompts may now carry 'Epic: ' (+ optional 'Phase: N') headers; members leave the Start-here pick lists and work-type dropdowns and render only inside their epic's group — resume prompt first, members phase-ordered — in an Epics section moved to the bottom of the page, on both the md and html renders. A member naming an unregistered slug groups loudly rather than silently returning to the backlog. - A fixed-but-never-advanced draft masqueraded as top-priority backlog (the numba psf_weighted_data case: fixed + merged overnight, prompt left in draft/): the census now flags draft prompts whose body carries a line-anchored 'Fix: ... PR #N' as 'needs lifecycle reconciliation' near the top of the page. Line-anchored so prompts merely citing PRs as context never flag. Closes #246. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K21CJX9TAgULDo1fFJtonc --- agents/conductors/intake/_intake.py | 212 +++++++++++++++++++++------- tests/test_intake_dashboard.py | 82 ++++++++++- 2 files changed, 240 insertions(+), 54 deletions(-) diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 948a714..7bad3a0 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -323,16 +323,27 @@ def write_prompt(mind: Path, decision: dict, body_text: str, source_note: str): # so every field is optional — absence is reported, never fatal. HEADER_FIELDS = ("type", "target", "difficulty", "autonomy", "priority", "status") +# `Fix:`-anchored PR reference in a draft prompt's body — the idiom a session +# writes when it fixes the bug but forgets to advance the prompt's lifecycle +# (the 2026-08-21 numba psf_weighted_data case: fixed + merged overnight, +# still advertised as top-priority backlog). Line-anchored so a prompt merely +# *citing* a PR as context is never flagged. +_FIX_PR_RE = re.compile(r"^Fix:.*(?:PR\s*#\d+|/pull/\d+)", + re.MULTILINE | re.IGNORECASE) + def parse_header(text: str) -> dict: """Extract the light metadata header (`Field: value` lines) from a prompt. Only scans the top of the file so a stray "Status:" deep in prose does not fire; first occurrence of each field wins. No YAML — the blessed convention. + `Epic:`/`Phase:` are optional epic-membership fields (dashboard grouping); + they are not in HEADER_FIELDS, so their absence is never header hygiene. """ fields = {} for line in text.splitlines()[:30]: - m = re.match(r"(Type|Target|Difficulty|Autonomy|Priority|Status):\s*(\S.*)", + m = re.match(r"(Type|Target|Difficulty|Autonomy|Priority|Status|" + r"Epic|Phase):\s*(\S.*)", line.strip()) if m: fields.setdefault(m.group(1).lower(), m.group(2).strip()) @@ -464,7 +475,7 @@ def census(mind: Path) -> dict: GitHub issue), and the `parked.md` / `planned.md` rows. This is the Mind's *work* view — health belongs to the Heart, never here. """ - records, hygiene = [], [] + records, hygiene, drift = [], [], [] for wt in WORK_TYPES: folder = mind / "draft" / wt if not folder.is_dir(): @@ -476,6 +487,10 @@ def census(mind: Path) -> dict: rel = f.relative_to(mind) header = parse_header(text) missing = [h for h in HEADER_FIELDS if h not in header] + try: + phase = int(header.get("phase", "")) + except ValueError: + phase = None records.append({ "path": str(rel), "work_type": wt, @@ -488,11 +503,16 @@ def census(mind: Path) -> dict: "autonomy": header.get("autonomy", "-"), "priority": header.get("priority", "-"), "status": header.get("status", "-"), + "epic": header.get("epic", ""), + "phase": phase, "header": header, "missing": missing, }) if len(missing) == len(HEADER_FIELDS): hygiene.append(f"{rel} — no metadata header (pre-dates intake)") + if _FIX_PR_RE.search(text): + drift.append(f"{rel} — body records a fix PR, but the prompt " + "never left draft/ (reconcile its lifecycle)") def _count(key): out = {} @@ -554,6 +574,7 @@ def _count(key): "parked": parked, "planned": planned, "hygiene": hygiene, + "drift": drift, } @@ -677,6 +698,30 @@ def _bullet(r: dict) -> str: return _task_row(head, f"/start_dev {r['path']}") +def _epic_members(c: dict) -> dict: + """Backlog prompts grouped by their `Epic:` header slug, phase-ordered. + + Members are worked in order *through the epic*, so the dashboard pulls + them out of the pick lists and work-type sections and shows them only + inside their epic's group. Phase-less members sort after phased ones by + filename. A member naming a slug that is not in `epics.md` still groups — + a typo shows up on the page instead of silently rendering standalone. + """ + groups: dict = {} + for r in c["records"]: + if r.get("epic"): + groups.setdefault(r["epic"], []).append(r) + for rows in groups.values(): + rows.sort(key=lambda r: (r["phase"] is None, + r["phase"] if r["phase"] is not None else 0, + r["path"])) + return groups + + +EPIC_ORDER_CAUTION = ("Members are worked in order through the epic's ledger " + "— continue the epic rather than starting one standalone.") + + def render_dashboard(c: dict) -> str: """Render the census as the Mind's task page (`dashboard.md`). @@ -715,12 +760,21 @@ def render_dashboard(c: dict) -> str: f"| [Planned](#planned) (`planned.md`) | {len(c['planned'])} |", f"| [Backlog](#backlog) (`draft/`) | {c['total']} |", "", - "## Start here", - "", ] - - high = [r for r in records if r["priority"] == "high"] - quick = [r for r in records + if c.get("drift"): + L += ["> ⚠️ **Needs lifecycle reconciliation** — these draft prompts " + "record a fix PR in their body: the work looks done, but the " + "prompt never advanced, so it still renders as backlog:", ""] + L += [f"> - `{d}`" for d in c["drift"]] + L += [""] + L += ["## Start here", ""] + + # Epic members never appear in the pick lists or the work-type sections — + # they are worked in order through their epic (bottom of the page). + members = _epic_members(c) + standalone = [r for r in records if not r.get("epic")] + high = [r for r in standalone if r["priority"] == "high"] + quick = [r for r in standalone if r["difficulty"] == "small" and r["autonomy"] == "safe"] for title, note, rows in ( ("Highest priority", "filed as `high`", high), @@ -746,23 +800,6 @@ def render_dashboard(c: dict) -> str: L += _items(flight) or ["- _(nothing in flight)_"] L += [""] - if c.get("epics"): - L += ["## Epics", "", - "Long-running multi-phase programmes. Each 📋 prompt has Claude " - "read the epic's ledger, work out where it stands, and continue " - "from the next logical point — no hunting for the paired issue. " - "Full record in [`epics.md`](epics.md).", ""] - items = [] - for e in c["epics"]: - head = f"{_summary_label(e.get('title') or e['slug'])}" - if e.get("ledger"): - head += f" — ledger: `{e['ledger']}`" - if e.get("status"): - head += f" — {_summary_label(_clip(e['status']))}" - items.append(_task_row(head, _epic_prompt(e))) - L += _items(items) - L += [""] - for key, heading, verb, blurb in ( ("parked", "Parked", "resume", "Started or scoped, not currently in flight — " @@ -786,15 +823,56 @@ def render_dashboard(c: dict) -> str: L += _items(items) or ["- _(none)_"] L += ["", "", ""] + n_members = sum(len(v) for v in members.values()) + member_note = (f" **{n_members}** of them belong to an epic and are " + "listed only under [Epics](#epics) below." + if n_members else "") L += [f"## Backlog", "", f"**{c['total']}** filed prompts, not started. Each section is sorted " - "most-pickable first (priority, then size).", ""] - for wt, n in c["by_work_type"].items(): - rows = [r for r in records if r["work_type"] == wt] - L += ["
", f"{wt} — {n}", ""] + f"most-pickable first (priority, then size).{member_note}", ""] + for wt in c["by_work_type"]: + rows = [r for r in standalone if r["work_type"] == wt] + if not rows: + continue + L += ["
", f"{wt} — {len(rows)}", ""] L += _items([_bullet(r) for r in rows]) L += ["", "
", ""] + # Epics live at the bottom, whole: each epic's resume prompt sits with its + # queued member prompts, grouped and phase-ordered, so nobody picks a + # member standalone out of order from a work-type section above. + known = {e["slug"] for e in c.get("epics") or []} + stray = [s for s in members if s not in known] + if c.get("epics") or stray: + L += ["## Epics", "", + "Long-running multi-phase programmes. Each epic's 📋 prompt has " + "Claude read its ledger, work out where it stands, and continue " + f"from the next logical point. {EPIC_ORDER_CAUTION} " + "Full record in [`epics.md`](epics.md).", ""] + for e in c.get("epics") or []: + rows = members.get(e["slug"], []) + head = f"{_summary_label(e.get('title') or e['slug'])}" + if e.get("ledger"): + head += f" — ledger: `{e['ledger']}`" + if e.get("status"): + head += f" — {_summary_label(_clip(e['status']))}" + resume = _task_row(head, _epic_prompt(e)) + if not rows: + L += _items([resume]) + [""] + continue + L += ["
", + f"{_summary_label(e.get('title') or e['slug'])}" + f" — {len(rows)} queued prompt(s), in order", ""] + L += _items([resume] + [_bullet(r) for r in rows]) + L += ["", "
", ""] + for slug in stray: + rows = members[slug] + L += ["
", + f"{_summary_label(slug)} — {len(rows)} " + "queued prompt(s) — ⚠️ not in `epics.md`", ""] + L += _items([_bullet(r) for r in rows]) + L += ["", "
", ""] + if c["hygiene"]: L += ["## Hygiene", "", f"{len(c['hygiene'])} prompt(s) without a metadata header — they " @@ -916,11 +994,18 @@ def record_row(r): f'Backlog {c["total"]}' + (f' · {link("dashboard.md", "markdown version")}' if home else "") + "

", - "

Start here

", ] - - high = [r for r in records if r["priority"] == "high"] - quick = [r for r in records + if c.get("drift"): + H += ['

⚠️ Needs lifecycle reconciliation — draft prompts ' + "whose body records a fix PR (done, never advanced):

", "
    "] + H += [f"
  • {_attr(d)}
  • " for d in c["drift"]] + H += ["
"] + H += ["

Start here

"] + + members = _epic_members(c) + standalone = [r for r in records if not r.get("epic")] + high = [r for r in standalone if r["priority"] == "high"] + quick = [r for r in standalone if r["difficulty"] == "small" and r["autonomy"] == "safe"] for title, note, rows in ( ("Highest priority", "filed as high", high), @@ -953,21 +1038,6 @@ def h2(title, src): if not c["in_flight"]: H.append('

(nothing in flight)

') - if c.get("epics"): - H += [h2("Epics", "epics.md"), - '

Long-running multi-phase programmes — 📋 ' - "copies a prompt that works out where the epic stands from its " - "ledger and continues it from the next logical point.

"] - for e in c["epics"]: - text = f"{_summary_label(e.get('title') or e['slug'])}" - if e.get("ledger"): - text += (f' — ledger: ' - f"{_attr(e['ledger'])}") - if e.get("status"): - text += (f' — ' - f'{_summary_label(_clip(e["status"]))}') - H.append(_html_task(text, _epic_prompt(e))) - for key, heading, verb in (("parked", "Parked", "resume"), ("planned", "Planned", "start")): rows = c[key] @@ -986,15 +1056,55 @@ def h2(title, src): H.append('

(none)

') H.append("
") + n_members = sum(len(v) for v in members.values()) + member_note = (f" {n_members} of them belong to an epic and are listed " + "only under Epics below." if n_members else "") H += [h2("Backlog", "draft").replace("/blob/main/draft", "/tree/main/draft"), f'

{c["total"]} filed prompts, not started — ' - "sorted most-pickable first (priority, then size).

"] - for wt, n in c["by_work_type"].items(): - rows = [r for r in records if r["work_type"] == wt] - H += ["
", f"{wt} — {n}"] + f"sorted most-pickable first (priority, then size).{member_note}

"] + for wt in c["by_work_type"]: + rows = [r for r in standalone if r["work_type"] == wt] + if not rows: + continue + H += ["
", f"{wt} — {len(rows)}"] H += [record_row(r) for r in rows] H += ["
"] + known = {e["slug"] for e in c.get("epics") or []} + stray = [s for s in members if s not in known] + if c.get("epics") or stray: + H += [h2("Epics", "epics.md"), + '

Long-running multi-phase programmes — 📋 ' + "copies a prompt that works out where the epic stands from its " + f"ledger and continues it from the next logical point. " + f"{EPIC_ORDER_CAUTION}

"] + for e in c.get("epics") or []: + rows = members.get(e["slug"], []) + text = f"{_summary_label(e.get('title') or e['slug'])}" + if e.get("ledger"): + text += (f' — ledger: ' + f"{_attr(e['ledger'])}") + if e.get("status"): + text += (f' — ' + f'{_summary_label(_clip(e["status"]))}') + resume = _html_task(text, _epic_prompt(e)) + if not rows: + H.append(resume) + continue + H += ["
", + f"{_summary_label(e.get('title') or e['slug'])} — " + f"{len(rows)} queued prompt(s), in order", + resume] + H += [record_row(r) for r in rows] + H += ["
"] + for slug in stray: + rows = members[slug] + H += ["
", + f"{_summary_label(slug)} — {len(rows)} queued " + "prompt(s) — ⚠️ not in epics.md"] + H += [record_row(r) for r in rows] + H += ["
"] + H += [f"", "", ""] return "\n".join(H) + "\n" diff --git a/tests/test_intake_dashboard.py b/tests/test_intake_dashboard.py index 45fa356..6eb0238 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -350,11 +350,13 @@ def test_check_on_a_missing_dashboard_is_drift(tmp_path): """ -def test_epics_section_sits_under_in_flight_with_a_resume_prompt(tmp_path): +def test_epics_section_sits_at_the_bottom_with_a_resume_prompt(tmp_path): + """Epics group at the bottom — after the Backlog, so members and their + programme read as one unit rather than scattering through the page.""" mind = _mind(tmp_path, registries={"epics.md": _EPICS}) page = _page(mind) - assert page.index("## In flight") < page.index("## Epics") < page.index("## Parked") - epics = page.split("## Epics")[1].split("## Parked")[0] + assert page.index("## Backlog") < page.index("## Epics") + epics = page.split("## Epics")[1] assert "JAX inference programme" in epics assert "PROGRAMME.md" in epics # The copy payload is a procedure — work out the state, then continue. @@ -364,6 +366,80 @@ def test_epics_section_sits_under_in_flight_with_a_resume_prompt(tmp_path): assert "bare-epic" in epics +def _epic_prompt_body(title, epic, phase=None, priority="high"): + phase_line = f"Phase: {phase}\n" if phase is not None else "" + return (f"# {title}\n\nType: feature\nTarget: widgets\n" + f"Difficulty: medium\nAutonomy: supervised\nPriority: {priority}\n" + f"Status: formalised\nEpic: jax-profiling\n{phase_line}\nBody.\n") + + +def test_epic_members_leave_the_pick_lists_and_work_type_sections(tmp_path): + """An `Epic:` member must be workable only through its epic — never + pickable standalone from Start here or a work-type dropdown, whatever its + priority says.""" + mind = _mind(tmp_path, registries={"epics.md": _EPICS}, drafts={ + "feature/widgets/phase_two.md": _epic_prompt_body("Phase two", "jax-profiling", 2), + "feature/widgets/phase_one.md": _epic_prompt_body("Phase one", "jax-profiling", 1), + "feature/widgets/loner.md": _prompt("Standalone thing", priority="high"), + }) + page = _page(mind) + epics_at = page.index("## Epics") + body, epics = page[:epics_at], page[epics_at:] + assert "Phase one" not in body and "Phase two" not in body + assert "Standalone thing" in body + # Grouped under the epic, phase order, with the resume prompt first and + # the start-in-order caution present. + assert epics.index("work out the last completed phase") \ + < epics.index("Phase one") < epics.index("Phase two") + assert "2 queued prompt(s), in order" in epics + assert "in order through the epic" in epics + # The Backlog header points at where the members went. + assert "belong to an epic" in body + + +def test_phaseless_members_sort_after_phased_by_filename(tmp_path): + mind = _mind(tmp_path, registries={"epics.md": _EPICS}, drafts={ + "feature/widgets/b_unphased.md": _epic_prompt_body("B unphased", "jax-profiling"), + "feature/widgets/a_unphased.md": _epic_prompt_body("A unphased", "jax-profiling"), + "feature/widgets/last_phase.md": _epic_prompt_body("The phased one", "jax-profiling", 7), + }) + epics = _page(mind).split("## Epics")[1] + assert epics.index("The phased one") < epics.index("A unphased") \ + < epics.index("B unphased") + + +def test_a_member_of_an_unregistered_epic_still_groups_loudly(tmp_path): + """A typo'd or unfiled slug must not silently return the member to the + standalone backlog — it groups under the stray slug with a warning.""" + body = _prompt("Orphan phase").replace("Status: formalised", + "Status: formalised\nEpic: no-such-epic") + mind = _mind(tmp_path, drafts={"feature/widgets/orphan.md": body}) + page = _page(mind) + assert "## Epics" in page + epics = page.split("## Epics")[1] + assert "Orphan phase" in epics and "not in `epics.md`" in epics + assert "Orphan phase" not in page.split("## Epics")[0] + + +# --------------------------------------------------------------------------- # +# drift: a fixed-but-never-advanced draft must not masquerade as backlog +# --------------------------------------------------------------------------- # +def test_a_draft_recording_a_fix_pr_is_flagged_for_reconciliation(tmp_path): + body = _prompt("Numba-style bug") + \ + "\n## Root cause\n\nFix: @PyAutoThing PR #456 (branch x) — merged.\n" + mind = _mind(tmp_path, drafts={"bug/widgets/fixed_bug.md": body}) + page = _page(mind) + assert "Needs lifecycle reconciliation" in page + assert "bug/widgets/fixed_bug.md" in page.split("## Start here")[0] + + +def test_a_prompt_merely_citing_a_pr_is_not_drift(tmp_path): + body = _prompt("Cites context") + \ + "\nBackground: superseded by workspace PR #60, see also pull/152.\n" + mind = _mind(tmp_path, drafts={"bug/widgets/cites.md": body}) + assert "Needs lifecycle reconciliation" not in _page(mind) + + def test_no_epics_file_means_no_epics_section(tmp_path): page = _page(_mind(tmp_path, active={"one.md": _prompt("Solo task")})) assert "## Epics" not in page, "a spawned Mind without epics.md stays clean"