@@ -389,6 +389,64 @@ def parse_registry(path: Path) -> list:
389389 return entries
390390
391391
392+ # `epics.md` — the Mind's registry of long-running multi-phase programmes.
393+ # Same H2-slug + `- key: value` shape as active.md, but the fields differ:
394+ # `ledger:` names the epic's canonical state file (may live in another repo),
395+ # `title:`/`status:`/`notes:` are display prose. The dashboard's job is only
396+ # to hand a session enough to WORK OUT where the epic stands — the ledger
397+ # stays the single source of truth.
398+ _EPIC_FIELDS = ("title" , "ledger" , "status" , "notes" )
399+
400+
401+ def parse_epics (path : Path ) -> list :
402+ """Parse `epics.md` into `[{slug, title, ledger, status, notes}]`.
403+
404+ Tolerant like parse_registry: a slug alone still yields a record; absent
405+ file -> empty list (a freshly-spawned Mind has no epics).
406+ """
407+ if not path .is_file ():
408+ return []
409+ entries , cur = [], None
410+ for line in path .read_text (encoding = "utf-8" , errors = "replace" ).splitlines ():
411+ head = _REG_HEAD .match (line )
412+ if head :
413+ cur = {"slug" : head .group (1 )}
414+ cur .update ({k : "" for k in _EPIC_FIELDS })
415+ entries .append (cur )
416+ continue
417+ if cur is None :
418+ continue
419+ field = _REG_FIELD .match (line )
420+ if field and field .group (1 ) in _EPIC_FIELDS and not cur [field .group (1 )]:
421+ cur [field .group (1 )] = field .group (2 ).strip ()
422+ return entries
423+
424+
425+ def _epic_prompt (e : dict ) -> str :
426+ """The one-tap resume prompt: work out where the epic is, then continue.
427+
428+ Deliberately a procedure, not a snapshot — any phase/issue state baked in
429+ here would go stale the moment the epic advances, which is exactly the
430+ problem the button exists to solve."""
431+ name = e .get ("title" ) or e .get ("slug" , "this" )
432+ ledger = e .get ("ledger" , "" )
433+ parts = [f"Continue the '{ name } ' epic." ]
434+ if ledger :
435+ parts .append (
436+ f"Its canonical state lives in { ledger } — read that ledger (and "
437+ "any DECISIONS/RESULTS files beside it) first." )
438+ parts .append (
439+ "Cross-check this epic's entry in PyAutoMind/epics.md, any related "
440+ "rows in PyAutoMind/active.md, and the referenced repos' open issues "
441+ "and PRs, to work out the last completed phase and what is currently "
442+ "in flight. Then pick the next logical step and continue it through "
443+ "the normal workflow (/start_dev — filing the phase's prompt first "
444+ "if none exists), updating the ledger as the work advances." )
445+ if e .get ("notes" ):
446+ parts .append (f"Note: { e ['notes' ]} " )
447+ return " " .join (parts )
448+
449+
392450def _clip (text : str , limit : int = 130 ) -> str :
393451 """First line of a registry value, clipped at a word boundary."""
394452 text = text .strip ().splitlines ()[0 ].strip () if text .strip () else ""
@@ -492,6 +550,7 @@ def _count(key):
492550 "by_priority" : _count ("priority" ),
493551 "records" : records ,
494552 "in_flight" : in_flight ,
553+ "epics" : parse_epics (mind / "epics.md" ),
495554 "parked" : parked ,
496555 "planned" : planned ,
497556 "hygiene" : hygiene ,
@@ -687,6 +746,23 @@ def render_dashboard(c: dict) -> str:
687746 L += _items (flight ) or ["- _(nothing in flight)_" ]
688747 L += ["" ]
689748
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+
690766 for key , heading , verb , blurb in (
691767 ("parked" , "Parked" , "resume" ,
692768 "Started or scoped, not currently in flight — "
@@ -855,7 +931,14 @@ def record_row(r):
855931 H += [f'<h3>{ title } <span class="facets">({ note } ){ more } </span></h3>' ]
856932 H += [record_row (r ) for r in shown ] or ['<p class="muted">(none right now)</p>' ]
857933
858- H += ["<h2>In flight</h2>" ,
934+ def h2 (title , src ):
935+ # Every section links the markdown file it is rendered from — the
936+ # registry file is the full record, the page is the view.
937+ a = (f' <a class="facets" href="{ _attr (blob + src )} ">markdown '
938+ "version</a>" ) if blob else ""
939+ return f"<h2>{ title } { a } </h2>"
940+
941+ H += [h2 ("In flight" , "active.md" ),
859942 '<p class="muted">Issued — each has an open GitHub issue and '
860943 "usually a branch.</p>" ]
861944 for r in c ["in_flight" ]:
@@ -869,10 +952,25 @@ def record_row(r):
869952 if not c ["in_flight" ]:
870953 H .append ('<p class="muted">(nothing in flight)</p>' )
871954
955+ if c .get ("epics" ):
956+ H += [h2 ("Epics" , "epics.md" ),
957+ '<p class="muted">Long-running multi-phase programmes — 📋 '
958+ "copies a prompt that works out where the epic stands from its "
959+ "ledger and continues it from the next logical point.</p>" ]
960+ for e in c ["epics" ]:
961+ text = f"<b>{ _summary_label (e .get ('title' ) or e ['slug' ])} </b>"
962+ if e .get ("ledger" ):
963+ text += (f' — <span class="facets">ledger: '
964+ f"<code>{ _attr (e ['ledger' ])} </code></span>" )
965+ if e .get ("status" ):
966+ text += (f' — <span class="facets">'
967+ f'{ _summary_label (_clip (e ["status" ]))} </span>' )
968+ H .append (_html_task (text , _epic_prompt (e )))
969+
872970 for key , heading , verb in (("parked" , "Parked" , "resume" ),
873971 ("planned" , "Planned" , "start" )):
874972 rows = c [key ]
875- H += [f"<h2> { heading } </h2>" , "<details>" ,
973+ H += [h2 ( heading , f" { key } .md" ) , "<details>" ,
876974 f"<summary>{ len (rows )} task(s)</summary>" ]
877975 for e in rows :
878976 text = f"<b>{ _summary_label (e ['slug' ])} </b>"
@@ -887,7 +985,7 @@ def record_row(r):
887985 H .append ('<p class="muted">(none)</p>' )
888986 H .append ("</details>" )
889987
890- H += ["<h2> Backlog</h2>" ,
988+ H += [h2 ( " Backlog" , "draft" ). replace ( "/blob/main/draft" , "/tree/main/draft" ) ,
891989 f'<p class="muted">{ c ["total" ]} filed prompts, not started — '
892990 "sorted most-pickable first (priority, then size).</p>" ]
893991 for wt , n in c ["by_work_type" ].items ():
0 commit comments