@@ -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.
324324HEADER_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
327335def 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+
680725def 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