diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index a670741..2f22126 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -521,6 +521,43 @@ def _pick_key(r: dict) -> tuple: r["target"], r["path"]) +def _copy_lines(payload: str) -> list: + """A collapsed copy block nested under a task's bullet. + + The page is read on GitHub (often a phone), where the only clipboard + affordance static markdown can offer is the copy button GitHub renders on + fenced code blocks. So every task carries one, holding the exact message + that routes Claude to the task (`/start_dev `) โ€” tap to + expand, tap to copy, paste into a Claude Code chat. Collapsed behind + `
` so each task still reads as one line. The two-space indent + keeps the block inside the bullet's list item; the blank lines around the + fence and after `
` are what make GitHub's renderer treat the + fence as markdown and the next bullet as a new list item rather than raw + HTML โ€” do not remove them. + """ + return ["
๐Ÿ“‹ copy for Claude", + "", + " ```", + f" {payload}", + " ```", + "", + "
"] + + +def _task_item(head: str, payload: str) -> str: + """One task โ€” the bullet line plus its collapsed copy block.""" + return "\n".join([head] + _copy_lines(payload)) + + +def _items(chunks: list) -> list: + """Blank-separate multi-line task items so each `` HTML block + ends before the next bullet starts (see `_copy_lines`).""" + out = [] + for chunk in chunks: + out += [chunk, ""] + return out[:-1] if out else [] + + def _bullet(r: dict) -> str: """One backlog prompt as a bullet โ€” the phone-readable unit of this page. @@ -530,7 +567,8 @@ def _bullet(r: dict) -> str: """ facets = " ยท ".join(x for x in (r["target"], r["difficulty"], r["autonomy"], r["priority"]) if x != "-") - return f"- [{_label(r['title'])}]({r['path']})" + (f" โ€” {facets}" if facets else "") + head = f"- [{_label(r['title'])}]({r['path']})" + (f" โ€” {facets}" if facets else "") + return _task_item(head, f"/start_dev {r['path']}") def render_dashboard(c: dict) -> str: @@ -540,7 +578,9 @@ def render_dashboard(c: dict) -> str: Heart's dashboard (`/health`). Two rules shape the layout: it must be *pickable* (the top of the page answers "what should I do now?", not "how many prompts are there?"), and it must read on a phone (bullets over wide - tables, long sections behind `
`). Links are repo-root-relative so + tables, long sections behind `
`, and a collapsed copy block under + every task so picking one from a phone is copy โ†’ paste into a Claude chat, + not retyping a path โ€” see `_copy_lines`). Links are repo-root-relative so they resolve in GitHub's web and mobile markdown views alike. """ records = sorted(c["records"], key=_pick_key) @@ -552,7 +592,9 @@ def render_dashboard(c: dict) -> str: "", "Every task the Mind is holding, on one page: what is in flight, what " "is parked, and the whole backlog to pick from. Pick a line, then run " - "`/start_dev ` to start it.", + "`/start_dev ` to start it. On a phone, tap **๐Ÿ“‹ copy " + "for Claude** under a task, copy the block, and paste it into a " + "Claude Code chat to route Claude straight to that task.", "", "Tasks only โ€” the organism's health lives with the Heart (`/health`), " "not here.", @@ -581,33 +623,48 @@ def render_dashboard(c: dict) -> str: shown = rows[:PICK_LIST_MAX] more = f" โ€” showing {len(shown)} of {len(rows)}" if len(rows) > len(shown) else "" L += [f"**{title}** ({note}){more}", ""] - L += [_bullet(r) for r in shown] or ["- _(none right now)_"] + L += _items([_bullet(r) for r in shown]) or ["- _(none right now)_"] L += [""] L += ["## In flight", "", "Issued โ€” each has an open GitHub issue and usually a branch. The " "full record for each is in [`active.md`](active.md).", ""] + flight = [] for r in c["in_flight"]: issue = f" โ€” [issue #{r['issue_no']}]({r['issue']})" if r["issue_no"] else "" status = f" โ€” {_clip(r['status'])}" if r["status"] else "" - L.append(f"- [{_label(r['title'])}]({r['path']}){issue}{status}") - L += ([] if c["in_flight"] else ["- _(nothing in flight)_"]) + [""] - - for key, heading, blurb in ( - ("parked", "Parked", "Started or scoped, not currently in flight โ€” " - "resume by moving the row back to `active.md`. " - "Full detail in [`parked.md`](parked.md)."), - ("planned", "Planned", "Scoped but not started; some are not yet prompt " - "files. Full detail in [`planned.md`](planned.md)."), + flight.append(_task_item( + f"- [{_label(r['title'])}]({r['path']}){issue}{status}", + f"/start_dev {r['path']}")) + L += _items(flight) or ["- _(nothing in flight)_"] + L += [""] + + for key, heading, verb, blurb in ( + ("parked", "Parked", "resume", + "Started or scoped, not currently in flight โ€” " + "resume by moving the row back to `active.md`. " + "Full detail in [`parked.md`](parked.md)."), + ("planned", "Planned", "start", + "Scoped but not started; some are not yet prompt " + "files. Full detail in [`planned.md`](planned.md)."), ): rows = c[key] L += [f"## {heading}", "", blurb, "", "
", f"{len(rows)} task(s)", ""] + items = [] for e in rows: issue = f" โ€” [issue #{e['issue_no']}]({e['issue']})" if e["issue_no"] else "" status = f" โ€” {_clip(e['status'])}" if e["status"] else "" - L.append(f"- **{_label(e['slug'])}**{issue}{status}") - L += ([] if rows else ["- _(none)_"]) + ["", "
", ""] + # A registry row may name its prompt file; without one there is no + # start_dev target, so route the slug as free prose instead. + prompt = (e["prompt"].split() or [""])[0] + payload = (f"/start_dev {prompt}" if prompt.endswith(".md") else + f"/route {verb} the {key} PyAutoMind task " + f"{e['slug']} โ€” its record is in {key}.md") + items.append(_task_item( + f"- **{_label(e['slug'])}**{issue}{status}", payload)) + L += _items(items) or ["- _(none)_"] + L += ["", "
", ""] L += [f"## Backlog", "", f"**{c['total']}** filed prompts, not started. Each section is sorted " @@ -615,7 +672,7 @@ def render_dashboard(c: dict) -> str: for wt, n in c["by_work_type"].items(): rows = [r for r in records if r["work_type"] == wt] L += ["
", f"{wt} โ€” {n}", ""] - L += [_bullet(r) for r in rows] + L += _items([_bullet(r) for r in rows]) L += ["", "
", ""] if c["hygiene"]: diff --git a/skills/intake/intake.md b/skills/intake/intake.md index 43433c7..8a7184a 100644 --- a/skills/intake/intake.md +++ b/skills/intake/intake.md @@ -32,6 +32,9 @@ dev workflow (issue, branch, plan). Do not bypass the Brain. - `bin/pyauto-brain intake dashboard` โ€” renders the census as the Mind **task** page (`PyAutoMind/dashboard.md`, linked from that repo's README): the picks worth starting now, then in flight / parked / planned / the whole backlog. + Every task carries a collapsed **๐Ÿ“‹ copy for Claude** block (GitHub's + code-fence copy button) holding the `/start_dev ` message that + routes Claude to that task โ€” the phone path from the page into a session. Dry-run prints it, `--apply` writes it (commit via `prompt_sync_push`), `--check` exits 1 if the committed page has drifted โ€” PyAutoMind's `dashboard_refresh.yml` self-heals that on pushes to main, so regenerate it diff --git a/tests/test_intake_dashboard.py b/tests/test_intake_dashboard.py index 516b32f..69f72f6 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -146,6 +146,56 @@ def test_registry_entry_without_fields_still_lists(tmp_path): assert "**lonely-slug**" in parked +# --------------------------------------------------------------------------- # +# copy blocks: every task carries a paste-ready message that routes Claude +# --------------------------------------------------------------------------- # +COPY_SUMMARY = "๐Ÿ“‹ copy for Claude" + + +def test_backlog_and_picks_carry_a_start_dev_copy_block(tmp_path): + """GitHub's copy button lives on fenced code blocks โ€” the one clipboard a + static page has, and the whole point of the block on a phone.""" + mind = _mind(tmp_path, drafts={ + "bug/widgets/one.md": _prompt("Bug one", priority="high")}) + page = _page(mind) + fence = " ```\n /start_dev draft/bug/widgets/one.md\n ```" + head, backlog = page.split("## In flight")[0], page.split("## Backlog")[1] + assert fence in head and COPY_SUMMARY in head, \ + "the Start-here picks must carry the copy block" + assert fence in backlog and COPY_SUMMARY in backlog, \ + "backlog bullets must carry the copy block" + + +def test_in_flight_copy_block_targets_the_active_prompt(tmp_path): + mind = _mind(tmp_path, active={"widget_rework.md": _prompt("Widget rework")}) + flight = _page(mind).split("## In flight")[1].split("## Parked")[0] + assert " /start_dev active/widget_rework.md" in flight + + +def test_registry_row_copy_block_prefers_its_prompt_path(tmp_path): + """A parked row naming its prompt gets `/start_dev`; a bare slug has no + start_dev target, so it routes as free prose instead.""" + mind = _mind(tmp_path, registries={"parked.md": ( + "# Parked\n\n## with-prompt\n- prompt: active/widget_rework.md\n" + "\n## lonely-slug\n")}) + parked = _page(mind).split("## Parked")[1].split("## Planned")[0] + assert " /start_dev active/widget_rework.md" in parked + assert (" /route resume the parked PyAutoMind task lonely-slug โ€” " + "its record is in parked.md") in parked + + +def test_copy_details_never_swallow_the_next_bullet(tmp_path): + """GitHub's renderer treats lines after `
` as raw HTML until a + blank line โ€” a bullet directly beneath one would vanish from the page.""" + mind = _mind(tmp_path, drafts={ + "bug/widgets/one.md": _prompt("Bug one"), + "bug/widgets/two.md": _prompt("Bug two"), + }, active={"a.md": _prompt("A"), "b.md": _prompt("B")}) + page = _page(mind) + assert "\n-" not in page + assert "\n
" not in page + + # --------------------------------------------------------------------------- # # rendering safety # --------------------------------------------------------------------------- #