@@ -483,6 +483,7 @@ def _count(key):
483483
484484 return {
485485 "generated" : _dt .date .today ().isoformat (),
486+ "home" : _mind_home (mind ),
486487 "total" : len (records ),
487488 "issued_count" : len (in_flight ),
488489 "by_work_type" : _count ("work_type" ),
@@ -501,6 +502,28 @@ def _cell(value: str) -> str:
501502 return str (value ).replace ("|" , "\\ |" )
502503
503504
505+ def _mind_home (mind : Path ) -> str :
506+ """The Mind repo's GitHub home (`https://github.com/<org>/PyAutoMind`).
507+
508+ Read from `repos.yaml` — the single source of repo identity — so no
509+ tenant org name is baked into organ code (the tenant-firewall rule: a
510+ fork's Mind carries its own org there). Empty string when unknown; the
511+ renderers then degrade to relative links and skip the Pages pointer.
512+ """
513+ f = mind / "repos.yaml"
514+ if not f .is_file ():
515+ return ""
516+ m = re .search (r"^\s{2}PyAutoMind:\s*\n\s+github:\s*([^\s#]+)" ,
517+ f .read_text (encoding = "utf-8" , errors = "replace" ), re .M )
518+ return f"https://github.com/{ m .group (1 )} " if m else ""
519+
520+
521+ def _pages_url (home : str ) -> str :
522+ """The GitHub Pages site URL for a repo home, `''` when underivable."""
523+ m = re .match (r"https://github\.com/([^/]+)/([^/]+)$" , home )
524+ return f"https://{ m .group (1 ).lower ()} .github.io/{ m .group (2 )} /" if m else ""
525+
526+
504527def _summary_label (value : str ) -> str :
505528 """Task text rendered inside a `<summary>` — HTML, not markdown.
506529
@@ -567,6 +590,19 @@ def _items(chunks: list) -> list:
567590 return out [:- 1 ] if out else []
568591
569592
593+ def _registry_payload (e : dict , key : str , verb : str ) -> str :
594+ """The copy payload for a parked/planned registry row.
595+
596+ A registry row may name its prompt file; without one there is no
597+ start_dev target, so route the slug as free prose instead.
598+ """
599+ prompt = (e ["prompt" ].split () or ["" ])[0 ]
600+ if prompt .endswith (".md" ):
601+ return f"/start_dev { prompt } "
602+ return (f"/route { verb } the { key } PyAutoMind task "
603+ f"{ e ['slug' ]} — its record is in { key } .md" )
604+
605+
570606def _bullet (r : dict ) -> str :
571607 """One backlog prompt as a row — the phone-readable unit of this page.
572608
@@ -611,6 +647,12 @@ def render_dashboard(c: dict) -> str:
611647 "to reveal that command, copy it, and paste it into a Claude Code "
612648 "chat to route Claude straight to the task." ,
613649 "" ,
650+ ]
651+ pages = _pages_url (c .get ("home" , "" ))
652+ if pages :
653+ L += [f"Reading on a phone? The [one-tap copy version]({ pages } ) puts "
654+ "the command on your clipboard with a single tap of 📋." , "" ]
655+ L += [
614656 "Tasks only — the organism's health lives with the Heart (`/health`), "
615657 "not here." ,
616658 "" ,
@@ -674,13 +716,7 @@ def render_dashboard(c: dict) -> str:
674716 head += f" — <a href=\" { e ['issue' ]} \" >issue #{ e ['issue_no' ]} </a>"
675717 if e ["status" ]:
676718 head += f" — { _summary_label (_clip (e ['status' ]))} "
677- # A registry row may name its prompt file; without one there is no
678- # start_dev target, so route the slug as free prose instead.
679- prompt = (e ["prompt" ].split () or ["" ])[0 ]
680- payload = (f"/start_dev { prompt } " if prompt .endswith (".md" ) else
681- f"/route { verb } the { key } PyAutoMind task "
682- f"{ e ['slug' ]} — its record is in { key } .md" )
683- items .append (_task_row (head , payload ))
719+ items .append (_task_row (head , _registry_payload (e , key , verb )))
684720 L += _items (items ) or ["- _(none)_" ]
685721 L += ["" , "</details>" , "" ]
686722
@@ -704,6 +740,176 @@ def render_dashboard(c: dict) -> str:
704740 return "\n " .join (L ).rstrip ("\n " ) + "\n "
705741
706742
743+ _HTML_CSS = """\
744+ :root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;
745+ --line:#d1d9e0;--btn:#f6f8fa;--ok:#1a7f37;--accent:#0969da}
746+ @media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#f0f6fc;
747+ --muted:#9198a1;--line:#3d444d;--btn:#151b23;--ok:#3fb950;--accent:#4493f8}}
748+ *{box-sizing:border-box}
749+ body{margin:0 auto;max-width:44rem;padding:1rem 1rem 4rem;background:var(--bg);
750+ color:var(--fg);font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",
751+ Helvetica,Arial,sans-serif}
752+ h1{font-size:1.35rem;margin:.4rem 0}
753+ h2{font-size:1.15rem;margin-top:2rem;border-bottom:1px solid var(--line);
754+ padding-bottom:.3rem}
755+ h3{font-size:1rem;margin:1.2rem 0 .2rem}
756+ a{color:var(--accent);text-decoration:none}
757+ a:hover{text-decoration:underline}
758+ .muted{color:var(--muted)}
759+ .facets{color:var(--muted);font-size:.85em}
760+ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em;
761+ background:var(--btn);padding:.1em .3em;border-radius:4px}
762+ .task{display:flex;gap:.6rem;align-items:flex-start;padding:.45rem 0;
763+ border-bottom:1px solid var(--line)}
764+ .task p{margin:.25rem 0 0;flex:1;overflow-wrap:anywhere}
765+ button.copy{flex:0 0 auto;width:2.6rem;height:2.6rem;font-size:1.1rem;
766+ border:1px solid var(--line);border-radius:8px;background:var(--btn);
767+ cursor:pointer;color:var(--fg)}
768+ button.copy.ok{color:var(--ok);border-color:var(--ok)}
769+ details{margin:.5rem 0}
770+ summary{cursor:pointer;font-weight:600;padding:.4rem 0}
771+ """
772+
773+ # One tap on 📋 → the command is on the clipboard; the button flashes ✓. The
774+ # textarea path covers browsers without the async clipboard API.
775+ _HTML_JS = """\
776+ async function copyCmd(b){
777+ const cmd=b.dataset.cmd;
778+ try{await navigator.clipboard.writeText(cmd);}
779+ catch(e){const t=document.createElement("textarea");t.value=cmd;
780+ document.body.appendChild(t);t.select();document.execCommand("copy");
781+ t.remove();}
782+ b.textContent="\\ u2713";b.classList.add("ok");
783+ setTimeout(()=>{b.textContent="\\ ud83d\\ udccb";b.classList.remove("ok");},1200);}
784+ document.addEventListener("click",e=>{
785+ const b=e.target.closest("button.copy");if(b)copyCmd(b);});
786+ """
787+
788+
789+ def _attr (value : str ) -> str :
790+ """Escape a string for a double-quoted HTML attribute."""
791+ return (str (value ).replace ("&" , "&" ).replace ('"' , """ )
792+ .replace ("<" , "<" ).replace (">" , ">" ))
793+
794+
795+ def _html_task (text_html : str , payload : str ) -> str :
796+ """One task row on the HTML page: a real copy button, then the text."""
797+ return (f'<div class="task"><button class="copy" '
798+ f'data-cmd="{ _attr (payload )} " aria-label="Copy the Claude '
799+ f'command">📋</button><p>{ text_html } </p></div>' )
800+
801+
802+ def render_dashboard_html (c : dict ) -> str :
803+ """Render the census as `dashboard.html` — the one-tap-copy twin.
804+
805+ Same content as `render_dashboard`, different constraint: this page is
806+ served by GitHub Pages (see PyAutoMind's `pages_dashboard.yml`), so it may
807+ carry the JavaScript that GitHub's markdown rendering strips — a real
808+ copy-to-clipboard button per task, which the markdown page cannot have (in
809+ the GitHub mobile app fenced blocks offer no copy affordance at all). Links
810+ are absolute (from the census `home`) because Pages serves this file away
811+ from the repo blobs; with no home they fall back to relative paths. The
812+ Hygiene section stays markdown-only — this page exists to pick from, not
813+ to audit. Self-contained by design: inline CSS/JS, no external assets.
814+ """
815+ records = sorted (c ["records" ], key = _pick_key )
816+ home = c .get ("home" , "" )
817+ blob = f"{ home } /blob/main/" if home else ""
818+
819+ def link (path , text_html ):
820+ return f'<a href="{ _attr (blob + path )} ">{ text_html } </a>'
821+
822+ def record_row (r ):
823+ text = link (r ["path" ], _summary_label (r ["title" ]))
824+ facets = " · " .join (_summary_label (x ) for x in
825+ (r ["target" ], r ["difficulty" ],
826+ r ["autonomy" ], r ["priority" ]) if x != "-" )
827+ if facets :
828+ text += f' — <span class="facets">{ facets } </span>'
829+ return _html_task (text , f"/start_dev { r ['path' ]} " )
830+
831+ H = [
832+ "<!doctype html>" ,
833+ '<html lang="en">' ,
834+ "<head>" ,
835+ '<meta charset="utf-8">' ,
836+ '<meta name="viewport" content="width=device-width, initial-scale=1">' ,
837+ "<title>PyAutoMind task dashboard</title>" ,
838+ f"<!-- generated by `pyauto-brain intake dashboard --apply` on "
839+ f"{ c ['generated' ]} — regenerate, do not hand-edit -->" ,
840+ f"<style>{ _HTML_CSS } </style>" ,
841+ "</head>" ,
842+ "<body>" ,
843+ "<h1>📋 PyAutoMind task dashboard</h1>" ,
844+ "<p>Every task the Mind is holding. Tap a task's 📋 and its "
845+ "<code>/start_dev</code> command is on your clipboard — paste it into "
846+ "a Claude Code chat to route Claude straight to that task.</p>" ,
847+ f'<p class="muted">In flight { c ["issued_count" ]} · '
848+ f'Parked { len (c ["parked" ])} · Planned { len (c ["planned" ])} · '
849+ f'Backlog { c ["total" ]} '
850+ + (f' · { link ("dashboard.md" , "markdown version" )} ' if home else "" )
851+ + "</p>" ,
852+ "<h2>Start here</h2>" ,
853+ ]
854+
855+ high = [r for r in records if r ["priority" ] == "high" ]
856+ quick = [r for r in records
857+ if r ["difficulty" ] == "small" and r ["autonomy" ] == "safe" ]
858+ for title , note , rows in (
859+ ("Highest priority" , "filed as high" , high ),
860+ ("Quick wins" , "small enough, and safe enough to run unattended" , quick ),
861+ ):
862+ shown = rows [:PICK_LIST_MAX ]
863+ more = (f" — showing { len (shown )} of { len (rows )} "
864+ if len (rows ) > len (shown ) else "" )
865+ H += [f'<h3>{ title } <span class="facets">({ note } ){ more } </span></h3>' ]
866+ H += [record_row (r ) for r in shown ] or ['<p class="muted">(none right now)</p>' ]
867+
868+ H += ["<h2>In flight</h2>" ,
869+ '<p class="muted">Issued — each has an open GitHub issue and '
870+ "usually a branch.</p>" ]
871+ for r in c ["in_flight" ]:
872+ text = link (r ["path" ], _summary_label (r ["title" ]))
873+ if r ["issue_no" ]:
874+ text += f' — <a href="{ _attr (r ["issue" ])} ">issue #{ r ["issue_no" ]} </a>'
875+ if r ["status" ]:
876+ text += (f' — <span class="facets">'
877+ f'{ _summary_label (_clip (r ["status" ]))} </span>' )
878+ H .append (_html_task (text , f"/start_dev { r ['path' ]} " ))
879+ if not c ["in_flight" ]:
880+ H .append ('<p class="muted">(nothing in flight)</p>' )
881+
882+ for key , heading , verb in (("parked" , "Parked" , "resume" ),
883+ ("planned" , "Planned" , "start" )):
884+ rows = c [key ]
885+ H += [f"<h2>{ heading } </h2>" , "<details>" ,
886+ f"<summary>{ len (rows )} task(s)</summary>" ]
887+ for e in rows :
888+ text = f"<b>{ _summary_label (e ['slug' ])} </b>"
889+ if e ["issue_no" ]:
890+ text += (f' — <a href="{ _attr (e ["issue" ])} ">'
891+ f'issue #{ e ["issue_no" ]} </a>' )
892+ if e ["status" ]:
893+ text += (f' — <span class="facets">'
894+ f'{ _summary_label (_clip (e ["status" ]))} </span>' )
895+ H .append (_html_task (text , _registry_payload (e , key , verb )))
896+ if not rows :
897+ H .append ('<p class="muted">(none)</p>' )
898+ H .append ("</details>" )
899+
900+ H += ["<h2>Backlog</h2>" ,
901+ f'<p class="muted">{ c ["total" ]} filed prompts, not started — '
902+ "sorted most-pickable first (priority, then size).</p>" ]
903+ for wt , n in c ["by_work_type" ].items ():
904+ rows = [r for r in records if r ["work_type" ] == wt ]
905+ H += ["<details>" , f"<summary>{ wt } — { n } </summary>" ]
906+ H += [record_row (r ) for r in rows ]
907+ H += ["</details>" ]
908+
909+ H += [f"<script>{ _HTML_JS } </script>" , "</body>" , "</html>" ]
910+ return "\n " .join (H ) + "\n "
911+
912+
707913def _dashboard_body (page : str ) -> str :
708914 """The page minus its generation stamp — what `--check` compares.
709915
@@ -1454,7 +1660,8 @@ def main(argv=None):
14541660 sub .add_parser ("census" , help = "inventory all filed prompts (always read-only)" )
14551661
14561662 db = sub .add_parser ("dashboard" , help = "render the census as the Mind task "
1457- "page; --apply writes dashboard.md" )
1663+ "page; --apply writes dashboard.md "
1664+ "+ its one-tap-copy dashboard.html" )
14581665 db .add_argument ("--check" , action = "store_true" ,
14591666 help = "exit 1 if the committed dashboard.md has drifted from "
14601667 "what this run renders (the generation date is ignored, "
@@ -1508,29 +1715,39 @@ def main(argv=None):
15081715
15091716 if a .cmd == "dashboard" :
15101717 c = census (mind )
1511- page = render_dashboard (c )
1718+ # Two renderings of one census: the markdown page GitHub renders in
1719+ # the repo, and its one-tap-copy HTML twin GitHub Pages serves.
1720+ pages = {"dashboard.md" : render_dashboard (c ),
1721+ "dashboard.html" : render_dashboard_html (c )}
15121722 if a .check :
1513- target = mind / "dashboard.md"
1514- on_disk = target .read_text (encoding = "utf-8" ) if target .is_file () else ""
1515- if _dashboard_body (on_disk ) == _dashboard_body (page ):
1516- print ("dashboard.md is current" )
1723+ stale = []
1724+ for name , want in pages .items ():
1725+ target = mind / name
1726+ on_disk = (target .read_text (encoding = "utf-8" )
1727+ if target .is_file () else "" )
1728+ if _dashboard_body (on_disk ) != _dashboard_body (want ):
1729+ stale .append (name )
1730+ if not stale :
1731+ print ("dashboard.md + dashboard.html are current" )
15171732 return 0
1518- print ("dashboard.md is stale — regenerate with "
1733+ print (f" { ' + ' . join ( stale ) } stale — regenerate with "
15191734 "`pyauto-brain intake dashboard --apply`" , file = sys .stderr )
15201735 return 1
15211736 written = None
15221737 if a .apply :
1523- (mind / "dashboard.md" ).write_text (page , encoding = "utf-8" )
1524- written = "dashboard.md"
1738+ for name , want in pages .items ():
1739+ (mind / name ).write_text (want , encoding = "utf-8" )
1740+ written = " + " .join (pages )
15251741 if a .as_json :
15261742 summary = {k : v for k , v in c .items () if k != "records" }
1527- print (json .dumps ({"census" : summary , "page" : page , "written" : written },
1528- indent = 2 ))
1743+ print (json .dumps ({"census" : summary , "page" : pages ["dashboard.md" ],
1744+ "html" : pages ["dashboard.html" ],
1745+ "written" : written }, indent = 2 ))
15291746 elif written :
15301747 print (f"Wrote: { written } ({ c ['total' ]} prompts, "
15311748 f"{ len (c ['hygiene' ])} hygiene flag(s))" )
15321749 else :
1533- print (page , end = "" )
1750+ print (pages [ "dashboard.md" ] , end = "" )
15341751 return 0
15351752
15361753 if a .cmd == "classify" :
0 commit comments