Skip to content

Commit 52461b2

Browse files
authored
Merge pull request #247 from PyAutoLabs/feature/dashboard-epic-grouping
feat: dashboard epic grouping + lifecycle-drift flag
2 parents 80937a3 + f3f4bba commit 52461b2

2 files changed

Lines changed: 240 additions & 54 deletions

File tree

agents/conductors/intake/_intake.py

Lines changed: 161 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -323,16 +323,27 @@ def write_prompt(mind: Path, decision: dict, body_text: str, source_note: str):
323323
# so every field is optional — absence is reported, never fatal.
324324
HEADER_FIELDS = ("type", "target", "difficulty", "autonomy", "priority", "status")
325325

326+
# `Fix:`-anchored PR reference in a draft prompt's body — the idiom a session
327+
# writes when it fixes the bug but forgets to advance the prompt's lifecycle
328+
# (the 2026-08-21 numba psf_weighted_data case: fixed + merged overnight,
329+
# still advertised as top-priority backlog). Line-anchored so a prompt merely
330+
# *citing* a PR as context is never flagged.
331+
_FIX_PR_RE = re.compile(r"^Fix:.*(?:PR\s*#\d+|/pull/\d+)",
332+
re.MULTILINE | re.IGNORECASE)
333+
326334

327335
def parse_header(text: str) -> dict:
328336
"""Extract the light metadata header (`Field: value` lines) from a prompt.
329337
330338
Only scans the top of the file so a stray "Status:" deep in prose does not
331339
fire; first occurrence of each field wins. No YAML — the blessed convention.
340+
`Epic:`/`Phase:` are optional epic-membership fields (dashboard grouping);
341+
they are not in HEADER_FIELDS, so their absence is never header hygiene.
332342
"""
333343
fields = {}
334344
for line in text.splitlines()[:30]:
335-
m = re.match(r"(Type|Target|Difficulty|Autonomy|Priority|Status):\s*(\S.*)",
345+
m = re.match(r"(Type|Target|Difficulty|Autonomy|Priority|Status|"
346+
r"Epic|Phase):\s*(\S.*)",
336347
line.strip())
337348
if m:
338349
fields.setdefault(m.group(1).lower(), m.group(2).strip())
@@ -464,7 +475,7 @@ def census(mind: Path) -> dict:
464475
GitHub issue), and the `parked.md` / `planned.md` rows. This is the Mind's
465476
*work* view — health belongs to the Heart, never here.
466477
"""
467-
records, hygiene = [], []
478+
records, hygiene, drift = [], [], []
468479
for wt in WORK_TYPES:
469480
folder = mind / "draft" / wt
470481
if not folder.is_dir():
@@ -476,6 +487,10 @@ def census(mind: Path) -> dict:
476487
rel = f.relative_to(mind)
477488
header = parse_header(text)
478489
missing = [h for h in HEADER_FIELDS if h not in header]
490+
try:
491+
phase = int(header.get("phase", ""))
492+
except ValueError:
493+
phase = None
479494
records.append({
480495
"path": str(rel),
481496
"work_type": wt,
@@ -488,11 +503,16 @@ def census(mind: Path) -> dict:
488503
"autonomy": header.get("autonomy", "-"),
489504
"priority": header.get("priority", "-"),
490505
"status": header.get("status", "-"),
506+
"epic": header.get("epic", ""),
507+
"phase": phase,
491508
"header": header,
492509
"missing": missing,
493510
})
494511
if len(missing) == len(HEADER_FIELDS):
495512
hygiene.append(f"{rel} — no metadata header (pre-dates intake)")
513+
if _FIX_PR_RE.search(text):
514+
drift.append(f"{rel} — body records a fix PR, but the prompt "
515+
"never left draft/ (reconcile its lifecycle)")
496516

497517
def _count(key):
498518
out = {}
@@ -554,6 +574,7 @@ def _count(key):
554574
"parked": parked,
555575
"planned": planned,
556576
"hygiene": hygiene,
577+
"drift": drift,
557578
}
558579

559580

@@ -677,6 +698,30 @@ def _bullet(r: dict) -> str:
677698
return _task_row(head, f"/start_dev {r['path']}")
678699

679700

701+
def _epic_members(c: dict) -> dict:
702+
"""Backlog prompts grouped by their `Epic:` header slug, phase-ordered.
703+
704+
Members are worked in order *through the epic*, so the dashboard pulls
705+
them out of the pick lists and work-type sections and shows them only
706+
inside their epic's group. Phase-less members sort after phased ones by
707+
filename. A member naming a slug that is not in `epics.md` still groups —
708+
a typo shows up on the page instead of silently rendering standalone.
709+
"""
710+
groups: dict = {}
711+
for r in c["records"]:
712+
if r.get("epic"):
713+
groups.setdefault(r["epic"], []).append(r)
714+
for rows in groups.values():
715+
rows.sort(key=lambda r: (r["phase"] is None,
716+
r["phase"] if r["phase"] is not None else 0,
717+
r["path"]))
718+
return groups
719+
720+
721+
EPIC_ORDER_CAUTION = ("Members are worked in order through the epic's ledger "
722+
"— continue the epic rather than starting one standalone.")
723+
724+
680725
def render_dashboard(c: dict) -> str:
681726
"""Render the census as the Mind's task page (`dashboard.md`).
682727
@@ -715,12 +760,21 @@ def render_dashboard(c: dict) -> str:
715760
f"| [Planned](#planned) (`planned.md`) | {len(c['planned'])} |",
716761
f"| [Backlog](#backlog) (`draft/`) | {c['total']} |",
717762
"",
718-
"## Start here",
719-
"",
720763
]
721-
722-
high = [r for r in records if r["priority"] == "high"]
723-
quick = [r for r in records
764+
if c.get("drift"):
765+
L += ["> ⚠️ **Needs lifecycle reconciliation** — these draft prompts "
766+
"record a fix PR in their body: the work looks done, but the "
767+
"prompt never advanced, so it still renders as backlog:", ""]
768+
L += [f"> - `{d}`" for d in c["drift"]]
769+
L += [""]
770+
L += ["## Start here", ""]
771+
772+
# Epic members never appear in the pick lists or the work-type sections —
773+
# they are worked in order through their epic (bottom of the page).
774+
members = _epic_members(c)
775+
standalone = [r for r in records if not r.get("epic")]
776+
high = [r for r in standalone if r["priority"] == "high"]
777+
quick = [r for r in standalone
724778
if r["difficulty"] == "small" and r["autonomy"] == "safe"]
725779
for title, note, rows in (
726780
("Highest priority", "filed as `high`", high),
@@ -746,23 +800,6 @@ def render_dashboard(c: dict) -> str:
746800
L += _items(flight) or ["- _(nothing in flight)_"]
747801
L += [""]
748802

749-
if c.get("epics"):
750-
L += ["## Epics", "",
751-
"Long-running multi-phase programmes. Each 📋 prompt has Claude "
752-
"read the epic's ledger, work out where it stands, and continue "
753-
"from the next logical point — no hunting for the paired issue. "
754-
"Full record in [`epics.md`](epics.md).", ""]
755-
items = []
756-
for e in c["epics"]:
757-
head = f"<b>{_summary_label(e.get('title') or e['slug'])}</b>"
758-
if e.get("ledger"):
759-
head += f" — ledger: `{e['ledger']}`"
760-
if e.get("status"):
761-
head += f" — {_summary_label(_clip(e['status']))}"
762-
items.append(_task_row(head, _epic_prompt(e)))
763-
L += _items(items)
764-
L += [""]
765-
766803
for key, heading, verb, blurb in (
767804
("parked", "Parked", "resume",
768805
"Started or scoped, not currently in flight — "
@@ -786,15 +823,56 @@ def render_dashboard(c: dict) -> str:
786823
L += _items(items) or ["- _(none)_"]
787824
L += ["", "</details>", ""]
788825

826+
n_members = sum(len(v) for v in members.values())
827+
member_note = (f" **{n_members}** of them belong to an epic and are "
828+
"listed only under [Epics](#epics) below."
829+
if n_members else "")
789830
L += [f"## Backlog", "",
790831
f"**{c['total']}** filed prompts, not started. Each section is sorted "
791-
"most-pickable first (priority, then size).", ""]
792-
for wt, n in c["by_work_type"].items():
793-
rows = [r for r in records if r["work_type"] == wt]
794-
L += ["<details>", f"<summary><b>{wt}</b> — {n}</summary>", ""]
832+
f"most-pickable first (priority, then size).{member_note}", ""]
833+
for wt in c["by_work_type"]:
834+
rows = [r for r in standalone if r["work_type"] == wt]
835+
if not rows:
836+
continue
837+
L += ["<details>", f"<summary><b>{wt}</b> — {len(rows)}</summary>", ""]
795838
L += _items([_bullet(r) for r in rows])
796839
L += ["", "</details>", ""]
797840

841+
# Epics live at the bottom, whole: each epic's resume prompt sits with its
842+
# queued member prompts, grouped and phase-ordered, so nobody picks a
843+
# member standalone out of order from a work-type section above.
844+
known = {e["slug"] for e in c.get("epics") or []}
845+
stray = [s for s in members if s not in known]
846+
if c.get("epics") or stray:
847+
L += ["## Epics", "",
848+
"Long-running multi-phase programmes. Each epic's 📋 prompt has "
849+
"Claude read its ledger, work out where it stands, and continue "
850+
f"from the next logical point. {EPIC_ORDER_CAUTION} "
851+
"Full record in [`epics.md`](epics.md).", ""]
852+
for e in c.get("epics") or []:
853+
rows = members.get(e["slug"], [])
854+
head = f"<b>{_summary_label(e.get('title') or e['slug'])}</b>"
855+
if e.get("ledger"):
856+
head += f" — ledger: `{e['ledger']}`"
857+
if e.get("status"):
858+
head += f" — {_summary_label(_clip(e['status']))}"
859+
resume = _task_row(head, _epic_prompt(e))
860+
if not rows:
861+
L += _items([resume]) + [""]
862+
continue
863+
L += ["<details>",
864+
f"<summary><b>{_summary_label(e.get('title') or e['slug'])}"
865+
f"</b> — {len(rows)} queued prompt(s), in order</summary>", ""]
866+
L += _items([resume] + [_bullet(r) for r in rows])
867+
L += ["", "</details>", ""]
868+
for slug in stray:
869+
rows = members[slug]
870+
L += ["<details>",
871+
f"<summary><b>{_summary_label(slug)}</b> — {len(rows)} "
872+
"queued prompt(s) — ⚠️ not in `epics.md`</summary>", ""]
873+
L += _items([_bullet(r) for r in rows])
874+
L += ["", "</details>", ""]
875+
798876
if c["hygiene"]:
799877
L += ["## Hygiene", "",
800878
f"{len(c['hygiene'])} prompt(s) without a metadata header — they "
@@ -916,11 +994,18 @@ def record_row(r):
916994
f'Backlog {c["total"]}'
917995
+ (f' · {link("dashboard.md", "markdown version")}' if home else "")
918996
+ "</p>",
919-
"<h2>Start here</h2>",
920997
]
921-
922-
high = [r for r in records if r["priority"] == "high"]
923-
quick = [r for r in records
998+
if c.get("drift"):
999+
H += ['<p>⚠️ <b>Needs lifecycle reconciliation</b> — draft prompts '
1000+
"whose body records a fix PR (done, never advanced):</p>", "<ul>"]
1001+
H += [f"<li><code>{_attr(d)}</code></li>" for d in c["drift"]]
1002+
H += ["</ul>"]
1003+
H += ["<h2>Start here</h2>"]
1004+
1005+
members = _epic_members(c)
1006+
standalone = [r for r in records if not r.get("epic")]
1007+
high = [r for r in standalone if r["priority"] == "high"]
1008+
quick = [r for r in standalone
9241009
if r["difficulty"] == "small" and r["autonomy"] == "safe"]
9251010
for title, note, rows in (
9261011
("Highest priority", "filed as high", high),
@@ -953,21 +1038,6 @@ def h2(title, src):
9531038
if not c["in_flight"]:
9541039
H.append('<p class="muted">(nothing in flight)</p>')
9551040

956-
if c.get("epics"):
957-
H += [h2("Epics", "epics.md"),
958-
'<p class="muted">Long-running multi-phase programmes — 📋 '
959-
"copies a prompt that works out where the epic stands from its "
960-
"ledger and continues it from the next logical point.</p>"]
961-
for e in c["epics"]:
962-
text = f"<b>{_summary_label(e.get('title') or e['slug'])}</b>"
963-
if e.get("ledger"):
964-
text += (f' — <span class="facets">ledger: '
965-
f"<code>{_attr(e['ledger'])}</code></span>")
966-
if e.get("status"):
967-
text += (f' — <span class="facets">'
968-
f'{_summary_label(_clip(e["status"]))}</span>')
969-
H.append(_html_task(text, _epic_prompt(e)))
970-
9711041
for key, heading, verb in (("parked", "Parked", "resume"),
9721042
("planned", "Planned", "start")):
9731043
rows = c[key]
@@ -986,15 +1056,55 @@ def h2(title, src):
9861056
H.append('<p class="muted">(none)</p>')
9871057
H.append("</details>")
9881058

1059+
n_members = sum(len(v) for v in members.values())
1060+
member_note = (f" {n_members} of them belong to an epic and are listed "
1061+
"only under Epics below." if n_members else "")
9891062
H += [h2("Backlog", "draft").replace("/blob/main/draft", "/tree/main/draft"),
9901063
f'<p class="muted">{c["total"]} filed prompts, not started — '
991-
"sorted most-pickable first (priority, then size).</p>"]
992-
for wt, n in c["by_work_type"].items():
993-
rows = [r for r in records if r["work_type"] == wt]
994-
H += ["<details>", f"<summary>{wt}{n}</summary>"]
1064+
f"sorted most-pickable first (priority, then size).{member_note}</p>"]
1065+
for wt in c["by_work_type"]:
1066+
rows = [r for r in standalone if r["work_type"] == wt]
1067+
if not rows:
1068+
continue
1069+
H += ["<details>", f"<summary>{wt}{len(rows)}</summary>"]
9951070
H += [record_row(r) for r in rows]
9961071
H += ["</details>"]
9971072

1073+
known = {e["slug"] for e in c.get("epics") or []}
1074+
stray = [s for s in members if s not in known]
1075+
if c.get("epics") or stray:
1076+
H += [h2("Epics", "epics.md"),
1077+
'<p class="muted">Long-running multi-phase programmes — 📋 '
1078+
"copies a prompt that works out where the epic stands from its "
1079+
f"ledger and continues it from the next logical point. "
1080+
f"{EPIC_ORDER_CAUTION}</p>"]
1081+
for e in c.get("epics") or []:
1082+
rows = members.get(e["slug"], [])
1083+
text = f"<b>{_summary_label(e.get('title') or e['slug'])}</b>"
1084+
if e.get("ledger"):
1085+
text += (f' — <span class="facets">ledger: '
1086+
f"<code>{_attr(e['ledger'])}</code></span>")
1087+
if e.get("status"):
1088+
text += (f' — <span class="facets">'
1089+
f'{_summary_label(_clip(e["status"]))}</span>')
1090+
resume = _html_task(text, _epic_prompt(e))
1091+
if not rows:
1092+
H.append(resume)
1093+
continue
1094+
H += ["<details>",
1095+
f"<summary>{_summary_label(e.get('title') or e['slug'])} — "
1096+
f"{len(rows)} queued prompt(s), in order</summary>",
1097+
resume]
1098+
H += [record_row(r) for r in rows]
1099+
H += ["</details>"]
1100+
for slug in stray:
1101+
rows = members[slug]
1102+
H += ["<details>",
1103+
f"<summary>{_summary_label(slug)}{len(rows)} queued "
1104+
"prompt(s) — ⚠️ not in epics.md</summary>"]
1105+
H += [record_row(r) for r in rows]
1106+
H += ["</details>"]
1107+
9981108
H += [f"<script>{_HTML_JS}</script>", "</body>", "</html>"]
9991109
return "\n".join(H) + "\n"
10001110

0 commit comments

Comments
 (0)