Skip to content

Commit 5c03d1c

Browse files
authored
Merge pull request #232 from PyAutoLabs/claude/dashboard-task-copy-button-jw6cet
intake dashboard: collapsed copy-for-Claude block under every task
2 parents ce49a49 + 4c708f1 commit 5c03d1c

3 files changed

Lines changed: 126 additions & 16 deletions

File tree

agents/conductors/intake/_intake.py

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,43 @@ def _pick_key(r: dict) -> tuple:
521521
r["target"], r["path"])
522522

523523

524+
def _copy_lines(payload: str) -> list:
525+
"""A collapsed copy block nested under a task's bullet.
526+
527+
The page is read on GitHub (often a phone), where the only clipboard
528+
affordance static markdown can offer is the copy button GitHub renders on
529+
fenced code blocks. So every task carries one, holding the exact message
530+
that routes Claude to the task (`/start_dev <prompt-path>`) — tap to
531+
expand, tap to copy, paste into a Claude Code chat. Collapsed behind
532+
`<details>` so each task still reads as one line. The two-space indent
533+
keeps the block inside the bullet's list item; the blank lines around the
534+
fence and after `</details>` are what make GitHub's renderer treat the
535+
fence as markdown and the next bullet as a new list item rather than raw
536+
HTML — do not remove them.
537+
"""
538+
return [" <details><summary>📋 copy for Claude</summary>",
539+
"",
540+
" ```",
541+
f" {payload}",
542+
" ```",
543+
"",
544+
" </details>"]
545+
546+
547+
def _task_item(head: str, payload: str) -> str:
548+
"""One task — the bullet line plus its collapsed copy block."""
549+
return "\n".join([head] + _copy_lines(payload))
550+
551+
552+
def _items(chunks: list) -> list:
553+
"""Blank-separate multi-line task items so each `</details>` HTML block
554+
ends before the next bullet starts (see `_copy_lines`)."""
555+
out = []
556+
for chunk in chunks:
557+
out += [chunk, ""]
558+
return out[:-1] if out else []
559+
560+
524561
def _bullet(r: dict) -> str:
525562
"""One backlog prompt as a bullet — the phone-readable unit of this page.
526563
@@ -530,7 +567,8 @@ def _bullet(r: dict) -> str:
530567
"""
531568
facets = " · ".join(x for x in (r["target"], r["difficulty"],
532569
r["autonomy"], r["priority"]) if x != "-")
533-
return f"- [{_label(r['title'])}]({r['path']})" + (f" — {facets}" if facets else "")
570+
head = f"- [{_label(r['title'])}]({r['path']})" + (f" — {facets}" if facets else "")
571+
return _task_item(head, f"/start_dev {r['path']}")
534572

535573

536574
def render_dashboard(c: dict) -> str:
@@ -540,7 +578,9 @@ def render_dashboard(c: dict) -> str:
540578
Heart's dashboard (`/health`). Two rules shape the layout: it must be
541579
*pickable* (the top of the page answers "what should I do now?", not "how
542580
many prompts are there?"), and it must read on a phone (bullets over wide
543-
tables, long sections behind `<details>`). Links are repo-root-relative so
581+
tables, long sections behind `<details>`, and a collapsed copy block under
582+
every task so picking one from a phone is copy → paste into a Claude chat,
583+
not retyping a path — see `_copy_lines`). Links are repo-root-relative so
544584
they resolve in GitHub's web and mobile markdown views alike.
545585
"""
546586
records = sorted(c["records"], key=_pick_key)
@@ -552,7 +592,9 @@ def render_dashboard(c: dict) -> str:
552592
"",
553593
"Every task the Mind is holding, on one page: what is in flight, what "
554594
"is parked, and the whole backlog to pick from. Pick a line, then run "
555-
"`/start_dev <prompt-path>` to start it.",
595+
"`/start_dev <prompt-path>` to start it. On a phone, tap **📋 copy "
596+
"for Claude** under a task, copy the block, and paste it into a "
597+
"Claude Code chat to route Claude straight to that task.",
556598
"",
557599
"Tasks only — the organism's health lives with the Heart (`/health`), "
558600
"not here.",
@@ -581,41 +623,56 @@ def render_dashboard(c: dict) -> str:
581623
shown = rows[:PICK_LIST_MAX]
582624
more = f" — showing {len(shown)} of {len(rows)}" if len(rows) > len(shown) else ""
583625
L += [f"**{title}** ({note}){more}", ""]
584-
L += [_bullet(r) for r in shown] or ["- _(none right now)_"]
626+
L += _items([_bullet(r) for r in shown]) or ["- _(none right now)_"]
585627
L += [""]
586628

587629
L += ["## In flight", "",
588630
"Issued — each has an open GitHub issue and usually a branch. The "
589631
"full record for each is in [`active.md`](active.md).", ""]
632+
flight = []
590633
for r in c["in_flight"]:
591634
issue = f" — [issue #{r['issue_no']}]({r['issue']})" if r["issue_no"] else ""
592635
status = f" — {_clip(r['status'])}" if r["status"] else ""
593-
L.append(f"- [{_label(r['title'])}]({r['path']}){issue}{status}")
594-
L += ([] if c["in_flight"] else ["- _(nothing in flight)_"]) + [""]
595-
596-
for key, heading, blurb in (
597-
("parked", "Parked", "Started or scoped, not currently in flight — "
598-
"resume by moving the row back to `active.md`. "
599-
"Full detail in [`parked.md`](parked.md)."),
600-
("planned", "Planned", "Scoped but not started; some are not yet prompt "
601-
"files. Full detail in [`planned.md`](planned.md)."),
636+
flight.append(_task_item(
637+
f"- [{_label(r['title'])}]({r['path']}){issue}{status}",
638+
f"/start_dev {r['path']}"))
639+
L += _items(flight) or ["- _(nothing in flight)_"]
640+
L += [""]
641+
642+
for key, heading, verb, blurb in (
643+
("parked", "Parked", "resume",
644+
"Started or scoped, not currently in flight — "
645+
"resume by moving the row back to `active.md`. "
646+
"Full detail in [`parked.md`](parked.md)."),
647+
("planned", "Planned", "start",
648+
"Scoped but not started; some are not yet prompt "
649+
"files. Full detail in [`planned.md`](planned.md)."),
602650
):
603651
rows = c[key]
604652
L += [f"## {heading}", "", blurb, "",
605653
"<details>", f"<summary><b>{len(rows)}</b> task(s)</summary>", ""]
654+
items = []
606655
for e in rows:
607656
issue = f" — [issue #{e['issue_no']}]({e['issue']})" if e["issue_no"] else ""
608657
status = f" — {_clip(e['status'])}" if e["status"] else ""
609-
L.append(f"- **{_label(e['slug'])}**{issue}{status}")
610-
L += ([] if rows else ["- _(none)_"]) + ["", "</details>", ""]
658+
# A registry row may name its prompt file; without one there is no
659+
# start_dev target, so route the slug as free prose instead.
660+
prompt = (e["prompt"].split() or [""])[0]
661+
payload = (f"/start_dev {prompt}" if prompt.endswith(".md") else
662+
f"/route {verb} the {key} PyAutoMind task "
663+
f"{e['slug']} — its record is in {key}.md")
664+
items.append(_task_item(
665+
f"- **{_label(e['slug'])}**{issue}{status}", payload))
666+
L += _items(items) or ["- _(none)_"]
667+
L += ["", "</details>", ""]
611668

612669
L += [f"## Backlog", "",
613670
f"**{c['total']}** filed prompts, not started. Each section is sorted "
614671
"most-pickable first (priority, then size).", ""]
615672
for wt, n in c["by_work_type"].items():
616673
rows = [r for r in records if r["work_type"] == wt]
617674
L += ["<details>", f"<summary><b>{wt}</b> — {n}</summary>", ""]
618-
L += [_bullet(r) for r in rows]
675+
L += _items([_bullet(r) for r in rows])
619676
L += ["", "</details>", ""]
620677

621678
if c["hygiene"]:

skills/intake/intake.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ dev workflow (issue, branch, plan). Do not bypass the Brain.
3232
- `bin/pyauto-brain intake dashboard` — renders the census as the Mind **task**
3333
page (`PyAutoMind/dashboard.md`, linked from that repo's README): the picks
3434
worth starting now, then in flight / parked / planned / the whole backlog.
35+
Every task carries a collapsed **📋 copy for Claude** block (GitHub's
36+
code-fence copy button) holding the `/start_dev <prompt-path>` message that
37+
routes Claude to that task — the phone path from the page into a session.
3538
Dry-run prints it, `--apply` writes it (commit via `prompt_sync_push`),
3639
`--check` exits 1 if the committed page has drifted — PyAutoMind's
3740
`dashboard_refresh.yml` self-heals that on pushes to main, so regenerate it

tests/test_intake_dashboard.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,56 @@ def test_registry_entry_without_fields_still_lists(tmp_path):
146146
assert "**lonely-slug**" in parked
147147

148148

149+
# --------------------------------------------------------------------------- #
150+
# copy blocks: every task carries a paste-ready message that routes Claude
151+
# --------------------------------------------------------------------------- #
152+
COPY_SUMMARY = "<summary>📋 copy for Claude</summary>"
153+
154+
155+
def test_backlog_and_picks_carry_a_start_dev_copy_block(tmp_path):
156+
"""GitHub's copy button lives on fenced code blocks — the one clipboard a
157+
static page has, and the whole point of the block on a phone."""
158+
mind = _mind(tmp_path, drafts={
159+
"bug/widgets/one.md": _prompt("Bug one", priority="high")})
160+
page = _page(mind)
161+
fence = " ```\n /start_dev draft/bug/widgets/one.md\n ```"
162+
head, backlog = page.split("## In flight")[0], page.split("## Backlog")[1]
163+
assert fence in head and COPY_SUMMARY in head, \
164+
"the Start-here picks must carry the copy block"
165+
assert fence in backlog and COPY_SUMMARY in backlog, \
166+
"backlog bullets must carry the copy block"
167+
168+
169+
def test_in_flight_copy_block_targets_the_active_prompt(tmp_path):
170+
mind = _mind(tmp_path, active={"widget_rework.md": _prompt("Widget rework")})
171+
flight = _page(mind).split("## In flight")[1].split("## Parked")[0]
172+
assert " /start_dev active/widget_rework.md" in flight
173+
174+
175+
def test_registry_row_copy_block_prefers_its_prompt_path(tmp_path):
176+
"""A parked row naming its prompt gets `/start_dev`; a bare slug has no
177+
start_dev target, so it routes as free prose instead."""
178+
mind = _mind(tmp_path, registries={"parked.md": (
179+
"# Parked\n\n## with-prompt\n- prompt: active/widget_rework.md\n"
180+
"\n## lonely-slug\n")})
181+
parked = _page(mind).split("## Parked")[1].split("## Planned")[0]
182+
assert " /start_dev active/widget_rework.md" in parked
183+
assert (" /route resume the parked PyAutoMind task lonely-slug — "
184+
"its record is in parked.md") in parked
185+
186+
187+
def test_copy_details_never_swallow_the_next_bullet(tmp_path):
188+
"""GitHub's renderer treats lines after `</details>` as raw HTML until a
189+
blank line — a bullet directly beneath one would vanish from the page."""
190+
mind = _mind(tmp_path, drafts={
191+
"bug/widgets/one.md": _prompt("Bug one"),
192+
"bug/widgets/two.md": _prompt("Bug two"),
193+
}, active={"a.md": _prompt("A"), "b.md": _prompt("B")})
194+
page = _page(mind)
195+
assert "</details>\n-" not in page
196+
assert "</details>\n <details>" not in page
197+
198+
149199
# --------------------------------------------------------------------------- #
150200
# rendering safety
151201
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)