diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index a4eb185..1138344 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -501,15 +501,20 @@ def _cell(value: str) -> str: return str(value).replace("|", "\\|") -def _label(value: str) -> str: - """Link text for a markdown bullet, made safe to render. - - Brackets would end the link early, and a stray `", "").strip() - return value.replace("[", r"\[").replace("]", r"\]") or "Untitled" + value = (value.replace("&", "&").replace("<", "<") + .replace(">", ">")) or "Untitled" + return re.sub(r"`([^`]+)`", r"\1", value) # Pick order. The dashboard exists to be *chosen from*, so every list is sorted @@ -529,37 +534,33 @@ 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. +def _task_row(summary: str, payload: str) -> str: + """One task as a single collapsed row: `โ–ธ ๐Ÿ“‹ `. 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. + fenced code blocks. So every task row is a `
` whose summary IS + the task line โ€” the ๐Ÿ“‹ toggle sits at the left of the text, costing no + extra line and no repeated label โ€” and whose hidden body is the fenced + message that routes Claude to the task: tap the row, tap copy, paste into + a Claude Code chat. The blank lines around the fence and after + `
` are what make GitHub's renderer treat the fence as markdown + and the next row as a new element rather than raw HTML โ€” do not remove + them. The summary is HTML (see `_summary_label`); markdown would not + render there. """ - 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)) + return "\n".join([f"
๐Ÿ“‹ {summary}", + "", + "```", + 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`).""" + """Blank-separate task rows so each `` HTML block ends before + the next row starts (see `_task_row`).""" out = [] for chunk in chunks: out += [chunk, ""] @@ -567,16 +568,21 @@ def _items(chunks: list) -> list: def _bullet(r: dict) -> str: - """One backlog prompt as a bullet โ€” the phone-readable unit of this page. + """One backlog prompt as a row โ€” the phone-readable unit of this page. - A bullet wraps; a five-column table does not. GitHub's mobile view scrolls + A row wraps; a five-column table does not. GitHub's mobile view scrolls wide tables sideways, which makes a 133-row backlog unusable on a phone, - so the metadata rides after an em dash instead of in columns. + so the metadata rides after an em dash instead of in columns. The title is + an `` because the row lives in a ``; its href is repo-root- + relative, which resolves correctly from the page's own blob URL. """ - facets = " ยท ".join(x for x in (r["target"], r["difficulty"], - r["autonomy"], r["priority"]) if x != "-") - head = f"- [{_label(r['title'])}]({r['path']})" + (f" โ€” {facets}" if facets else "") - return _task_item(head, f"/start_dev {r['path']}") + facets = " ยท ".join(_summary_label(x) for x in + (r["target"], r["difficulty"], + r["autonomy"], r["priority"]) if x != "-") + head = f"{_summary_label(r['title'])}" + if facets: + head += f" โ€” {facets}" + return _task_row(head, f"/start_dev {r['path']}") def render_dashboard(c: dict) -> str: @@ -585,11 +591,12 @@ def render_dashboard(c: dict) -> str: Tasks only, by design: no readiness verdicts, no test state โ€” that is the 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 `
`, 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. + many prompts are there?"), and it must read on a phone (rows over wide + tables, long sections behind `
`, and every task a single + collapsed row whose ๐Ÿ“‹ toggle hides its copy block, so picking one from a + phone is copy โ†’ paste into a Claude chat, not retyping a path โ€” see + `_task_row`). Links are repo-root-relative so they resolve from the + page's GitHub blob URL. """ records = sorted(c["records"], key=_pick_key) L = [ @@ -600,9 +607,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. 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.", + "`/start_dev ` to start it. On a phone, tap a task's ๐Ÿ“‹ " + "to reveal that command, copy it, and paste it into a Claude Code " + "chat to route Claude straight to the task.", "", "Tasks only โ€” the organism's health lives with the Heart (`/health`), " "not here.", @@ -639,11 +646,12 @@ def render_dashboard(c: dict) -> str: "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 "" - flight.append(_task_item( - f"- [{_label(r['title'])}]({r['path']}){issue}{status}", - f"/start_dev {r['path']}")) + head = f"{_summary_label(r['title'])}" + if r["issue_no"]: + head += f" โ€” issue #{r['issue_no']}" + if r["status"]: + head += f" โ€” {_summary_label(_clip(r['status']))}" + flight.append(_task_row(head, f"/start_dev {r['path']}")) L += _items(flight) or ["- _(nothing in flight)_"] L += [""] @@ -661,16 +669,18 @@ def render_dashboard(c: dict) -> str: "
", 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 "" + head = f"{_summary_label(e['slug'])}" + if e["issue_no"]: + head += f" โ€” issue #{e['issue_no']}" + if e["status"]: + head += f" โ€” {_summary_label(_clip(e['status']))}" # 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)) + items.append(_task_row(head, payload)) L += _items(items) or ["- _(none)_"] L += ["", "
", ""] diff --git a/skills/intake/intake.md b/skills/intake/intake.md index 8a7184a..5182f86 100644 --- a/skills/intake/intake.md +++ b/skills/intake/intake.md @@ -32,9 +32,10 @@ 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. + Every task renders as one collapsed row whose leading ๐Ÿ“‹ toggle hides a + code fence (GitHub's 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 c359d63..79a25dd 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -85,16 +85,18 @@ def test_quick_wins_are_small_and_safe_only(tmp_path): assert "Safe but large" not in quick -def test_every_backlog_prompt_is_a_bullet_not_a_wide_table_row(tmp_path): - """Wide tables scroll sideways on a phone; bullets wrap. Pin the shape.""" +def test_every_backlog_prompt_is_one_collapsed_row_not_a_wide_table(tmp_path): + """Wide tables scroll sideways on a phone; rows wrap. Pin the shape: one + `
` per task whose summary is `๐Ÿ“‹ โ€” facets`.""" mind = _mind(tmp_path, drafts={ "bug/widgets/one.md": _prompt("Bug one"), "feature/widgets/two.md": _prompt("Feature two"), }) page = _page(mind) backlog = page.split("## Backlog")[1] - assert "- [Bug one](draft/bug/widgets/one.md)" in backlog - assert "- [Feature two](draft/feature/widgets/two.md)" in backlog + assert ('
๐Ÿ“‹ ' + "Bug one โ€” ") in backlog + assert 'Feature two' in backlog # The only table on the page is the 2-column where/count summary. assert backlog.count("|") == 0, "the backlog must not render as tables" assert "bug โ€” 1" in backlog, \ @@ -122,7 +124,8 @@ def test_in_flight_links_the_registry_issue_and_its_live_status(tmp_path): active={"widget_rework.md": _prompt("Widget rework")}, registries={"active.md": ACTIVE_MD}) flight = _page(mind).split("## In flight")[1].split("## Parked")[0] - assert "[issue #42](https://github.com/ExampleOrg/Widgets/issues/42)" in flight, \ + assert ('' + "issue #42") in flight, \ "the link must be the matched URL, not the field's trailing prose" assert "(opened after the spike)" not in flight assert "library-dev" in flight @@ -170,33 +173,42 @@ def test_in_flight_prompt_with_no_registry_row_claims_no_issue(tmp_path): def test_registry_entry_without_fields_still_lists(tmp_path): mind = _mind(tmp_path, registries={"parked.md": "# Parked\n\n## lonely-slug\n"}) parked = _page(mind).split("## Parked")[1].split("## Planned")[0] - assert "**lonely-slug**" in parked + assert "lonely-slug" in parked # --------------------------------------------------------------------------- # -# copy blocks: every task carries a paste-ready message that routes Claude +# copy blocks: every task row's ๐Ÿ“‹ toggle hides a paste-ready message # --------------------------------------------------------------------------- # -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.""" + static page has, and the whole point of the row's hidden body 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 ```" + 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, \ + assert fence in head and "
๐Ÿ“‹ " 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" + assert fence in backlog and "
๐Ÿ“‹ " in backlog, \ + "backlog rows must carry the copy block" + + +def test_task_row_is_one_line_with_no_repeated_label(tmp_path): + """The ๐Ÿ“‹ toggle rides at the left of the task text on the SAME line โ€” + an extra 'copy for Claude' line per task doubled the page's height.""" + mind = _mind(tmp_path, drafts={ + "bug/widgets/one.md": _prompt("Bug one", priority="high")}) + page = _page(mind) + assert "copy for Claude" not in page + assert ('
๐Ÿ“‹ ' + "Bug one") in page, \ + "the summary line must open with ๐Ÿ“‹ then the task text" 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 + assert "\n/start_dev active/widget_rework.md\n" in flight def test_registry_row_copy_block_prefers_its_prompt_path(tmp_path): @@ -206,21 +218,21 @@ def test_registry_row_copy_block_prefers_its_prompt_path(tmp_path): "# 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 + assert "\n/start_dev active/widget_rework.md\n" in parked + assert ("\n/route resume the parked PyAutoMind task lonely-slug โ€” " + "its record is in parked.md\n") in parked -def test_copy_details_never_swallow_the_next_bullet(tmp_path): +def test_copy_details_never_swallow_the_next_row(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.""" + blank line โ€” a row 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 - assert "
\n
" not in page # --------------------------------------------------------------------------- # @@ -237,12 +249,16 @@ def test_a_prompt_titled_with_an_html_comment_cannot_swallow_the_page(tmp_path): assert "Visible after the comment" in page -def test_bracketed_title_does_not_break_the_link(tmp_path): +def test_title_markup_survives_the_html_summary(tmp_path): + """Summaries are HTML: brackets pass through untouched, raw angle brackets + are escaped, and a title's `code` span renders as (GitHub does not + process markdown inside ).""" mind = _mind(tmp_path, drafts={ - "bug/widgets/b.md": _prompt("[JAX] fails on [gpu]", priority="high")}) + "bug/widgets/b.md": _prompt("[JAX] `grad(x)` fails on x<0", + priority="high")}) page = _page(mind) - assert r"\[JAX\] fails on \[gpu\]" in page - assert "(draft/bug/widgets/b.md)" in page + assert ('' + "[JAX] grad(x) fails on x<0") in page # --------------------------------------------------------------------------- #