diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 9d969eb..25b0469 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -379,6 +379,14 @@ def write_prompt(mind: Path, decision: dict, body_text: str, source_note: str): _FIX_PR_RE = re.compile(r"^Fix:.*(?:PR\s*#\d+|/pull/\d+)", re.MULTILINE | re.IGNORECASE) +# The other half of the same failure: a session finishes the work, writes the +# outcome into the prompt's own `Status:` header — `shipped`, `superseded`, +# `absorbed` — and then leaves the file in `draft/`, where it keeps rendering as +# pickable backlog. Read off the parsed header (not the body) so a prompt that +# merely *describes* shipped sibling work is never flagged. +DONE_STATUSES = ("shipped", "superseded", "absorbed", "complete", "completed", + "done", "retired") + def parse_header(text: str) -> dict: """Extract the light metadata header (`Field: value` lines) from a prompt. @@ -400,6 +408,19 @@ def parse_header(text: str) -> dict: return fields +def _done_status(header: dict) -> str: + """The first word of a `Status:` header that declares the work finished. + + Returns `""` for a status that is merely *about* finished work (`phases 1-3 + SHIPPED; phase 4 open`, `split (phases 1-2 SHIPPED; phase 3 open)`): only a + status that *opens* on a done-word means the prompt itself is spent. Empty + string is falsey, so callers read as a plain condition. + """ + first = header.get("status", "").strip().lower().lstrip("*_`").split() + return first[0].rstrip(":;,.") if first and \ + first[0].rstrip(":;,.") in DONE_STATUSES else "" + + def _prefix_match(path: str, prefix: str) -> bool: """Match a census path against a user prefix, with or without `draft/`.""" sans = path[len("draft/"):] if path.startswith("draft/") else path @@ -675,6 +696,10 @@ def census(mind: Path) -> dict: if _FIX_PR_RE.search(text): drift.append(f"{rel} — body records a fix PR, but the prompt " "never left draft/ (reconcile its lifecycle)") + elif _done_status(header): + drift.append(f"{rel} — its own `Status:` says " + f"{_done_status(header)}, but the prompt never " + "left draft/ (reconcile its lifecycle)") def _count(key): out = {} @@ -829,6 +854,50 @@ def _pick_key(r: dict) -> tuple: r["target"], r["path"]) +# --- freshness banner ---------------------------------------------------------- +# The page is only as current as the files it is generated from, and the way it +# goes wrong is asymmetric: `dashboard_refresh.yml` self-heals a stale *render* +# on every push to main, but nothing self-heals a stale *prompt* — a task that +# shipped without its prompt advancing to complete/ keeps rendering as pickable +# backlog until a human reconciles it. So the banner states the generation date +# and hands over the whole reconcile-then-regenerate chore as one copyable +# message, in the same 📋 idiom as every task row. +REFRESH_PAYLOAD = """\ +Bring the PyAutoMind dashboard up to date. Work in the PyAutoMind checkout: + +1. `git fetch origin && git status`. If behind `origin/main`, `git pull --ff-only` + before touching anything. +2. `python3 scripts/lifecycle.py check`, `orphans`, and `index --check`. Fix + whatever drift they report. +3. Reconcile finished work — this is the part nothing automates. For every prompt + under `draft/` and `active/`, decide whether it is already done: a `Status:` + header saying shipped/superseded/absorbed, a merged PR named in its body, or a + record in `complete/` whose scope already covers it (check `complete/index.md` + and grep the dated buckets). Treat a same-subject record as evidence, not + proof — read both and confirm the scope really matches before retiring a + prompt. +4. For each one that IS done, write its record and retire the prompt: + `python3 scripts/lifecycle.py record --date --from-file + --apply`, where ends with `## Original prompt` followed by the + prompt's full text. Then `git rm` the prompt file and repoint every + cross-reference to it (grep the slug across `draft/`, `active/`, `epics.md` + and the registry files). +5. Regenerate the page: `pyauto-brain intake --apply dashboard`. Never hand-edit + `dashboard.md` or `dashboard.html` — they are generated. +6. Commit and push to `main`, so `dashboard_refresh.yml` agrees with the tree. + +Report what you retired, what you deliberately left in the backlog and why, and +anything you could not verify.""" + +REFRESH_BLURB = ( + "generated from `active/`, `draft/` and the registry files, so it is only " + "as current as they are. `dashboard_refresh.yml` re-renders it on every " + "push to `main` — that heals a stale page, but not a stale prompt: a task " + "that shipped without its prompt advancing to `complete/` keeps rendering " + "here as pickable backlog. Reconciling those is the refresh below." +) + + def _task_row(summary: str, payload: str) -> str: """One task as a single collapsed row: `▸ 📋 `. @@ -1021,6 +1090,11 @@ def render_dashboard(c: dict) -> str: "[Recent](#recent) is the same work by date — what has been happening " "rather than what to do next.", "", + f"> **Last updated {c['generated']}.** This page is {REFRESH_BLURB}", + "", + _task_row("Refresh this page — reconcile finished prompts, " + "then regenerate", REFRESH_PAYLOAD), + "", "| Where | Count |", "|-------|------:|", f"| [In flight](#in-flight) (`active/`) | {c['issued_count']} |", @@ -1172,6 +1246,15 @@ def render_dashboard(c: dict) -> str: # The dashboard-only half of the page script: the shared clipboard # handler lives in the board theme, this reveals the Recent feed a page # at a time. +# The freshness banner is a dashboard-only element (no other organ board carries +# one), so its rule lives here rather than in the shared theme. +_FRESH_CSS = """\ +.fresh{margin:0 0 1.2rem;padding:.7rem .9rem .4rem;border:1px solid var(--edge); + border-radius:11px;background:var(--tint)} +.fresh>p{margin:0 0 .3rem} +.fresh .task{border-bottom:0} +""" + _MORE_JS = """\ // Recent shows one page and reveals the next on each tap of the \u2026 button, // which retires itself once the feed is exhausted. Every row is already in the @@ -1188,6 +1271,17 @@ def render_dashboard(c: dict) -> str: """ +def _md_inline(text: str) -> str: + """Render the inline markdown this module authors (`code` spans only) as HTML. + + The freshness blurb is written once and rendered on both pages; the markdown + page takes it verbatim, this turns its backticks into `` so the HTML + twin does not print them literally. Deliberately not a markdown parser — + it handles exactly the one construct the blurb uses. + """ + return re.sub(r"`([^`]+)`", r"\1", text) + + def _attr(value: str) -> str: """Escape a string for a double-quoted HTML attribute.""" return (str(value).replace("&", "&").replace('"', """) @@ -1237,7 +1331,7 @@ def record_row(r): "PyAutoMind Dashboard", f"", - f"", + f"", "", "", hero(THEME_ORGAN, "Dashboard", @@ -1250,6 +1344,11 @@ def record_row(r): stats((c["issued_count"], "In flight"), (len(c["parked"]), "Parked"), (len(c["planned"]), "Planned"), (c["total"], "Backlog")), ] + H += [f'

Last updated {c["generated"]}. ' + f'This page is {_md_inline(REFRESH_BLURB)}

', + _html_task("Refresh this page — reconcile finished prompts, " + "then regenerate", REFRESH_PAYLOAD), + "
"] if home: H.append(f'

' f'{link("dashboard.md", "markdown version")}

') diff --git a/tests/test_intake_dashboard.py b/tests/test_intake_dashboard.py index fee6520..70726c3 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -11,6 +11,7 @@ """ import importlib.util +import re import sys from pathlib import Path @@ -480,6 +481,74 @@ def test_a_prompt_merely_citing_a_pr_is_not_drift(tmp_path): assert "Needs lifecycle reconciliation" not in _page(mind) +def test_a_draft_whose_own_status_says_shipped_is_flagged(tmp_path): + """The commonest way a finished task keeps advertising itself as backlog: + the shipping session writes the outcome into the prompt's `Status:` header + and leaves the file in `draft/`.""" + mind = _mind(tmp_path, drafts={ + "bug/widgets/done.md": _prompt("Already fixed", + status="shipped 2026-08-24 (#277)")}) + page = _page(mind) + assert "Needs lifecycle reconciliation" in page + head = page.split("## Start here")[0] + assert "bug/widgets/done.md" in head and "shipped" in head + + +def test_superseded_and_absorbed_statuses_are_drift_too(tmp_path): + for status in ("superseded by the epic", "ABSORBED 2026-08-10", "retired"): + mind = _mind(tmp_path / status.split()[0], + drafts={"bug/widgets/x.md": _prompt("Spent", status=status)}) + assert "Needs lifecycle reconciliation" in _page(mind), status + + +def test_a_partly_shipped_status_is_not_drift(tmp_path): + """A tracker reporting *some* phases shipped is still live work — only a + status that OPENS on a done-word means the prompt itself is spent.""" + for status in ("phases 1-3 SHIPPED; phase 4 open", + "split (phases 1-2 SHIPPED 2026-08-23; phase 3 open)", + "in progress — core landed, real-data swap-in remains"): + mind = _mind(tmp_path / status.split()[0], + drafts={"bug/widgets/x.md": _prompt("Live", status=status)}) + assert "Needs lifecycle reconciliation" not in _page(mind), status + + +# --------------------------------------------------------------------------- # +# freshness: the page says how current it is, and hands over its own refresh +# --------------------------------------------------------------------------- # +def test_the_page_states_when_it_was_generated_and_why_that_can_lie(tmp_path): + mind = _mind(tmp_path, drafts={"bug/widgets/x.md": _prompt("A task")}) + c = _intake.census(mind) + page = _intake.render_dashboard(c) + banner = page.split("| Where | Count |")[0] + assert f"Last updated {c['generated']}" in banner + # the distinction that matters: a self-healing render is not a fresh backlog + assert "dashboard_refresh.yml" in banner and "stale prompt" in banner + + +def test_the_refresh_banner_is_a_copyable_instruction_not_a_bare_command(tmp_path): + mind = _mind(tmp_path, drafts={"bug/widgets/x.md": _prompt("A task")}) + banner = _page(mind).split("| Where | Count |")[0] + assert "📋" in banner, "the banner uses the same one-tap idiom as a task row" + for step in ("lifecycle.py record", "git pull --ff-only", + "pyauto-brain intake --apply dashboard"): + assert step in banner, step + assert "never hand-edit" in banner.lower() + + +def test_the_html_twin_carries_the_banner_with_a_real_copy_button(tmp_path): + mind = _mind(tmp_path, drafts={"bug/widgets/x.md": _prompt("A task")}) + c = _intake.census(mind) + html = _intake.render_dashboard_html(c) + fresh = html.split('
')[1].split("
")[0] + assert f"Last updated {c['generated']}" in fresh + assert 'button class="copy"' in fresh and "data-cmd=" in fresh + # backticks are markdown; the blurb must render them as code spans, while + # the copy payload keeps its own verbatim (see _prose) + assert "draft/" in _prose(fresh) and "`draft/`" not in _prose(fresh) + assert "`git pull --ff-only`" in fresh, "the payload is copied, not rendered" + assert ".fresh{" in html, "the banner ships its own rule, not the shared theme" + + def test_no_epics_file_means_no_epics_section(tmp_path): page = _page(_mind(tmp_path, active={"one.md": _prompt("Solo task")})) assert "## Epics" not in page, "a spawned Mind without epics.md stays clean" @@ -704,10 +773,20 @@ def test_html_ships_every_row_so_a_reader_without_js_sees_the_feed(tmp_path): assert section.count(" str: + """The page minus every copy payload. + + A `data-cmd` attribute is a clipboard literal — the message a human pastes + into a Claude chat — so it legitimately carries markdown that the page must + NOT render. Assertions about how the page *reads* have to exclude it. + """ + return re.sub(r'data-cmd="[^"]*"', "data-cmd=\"\"", html) + + def test_the_html_blurb_renders_its_code_spans(tmp_path): """The blurb is shared with the markdown page; its backticks would otherwise print literally here.""" - html = _intake.render_dashboard_html(_intake.census(_many(tmp_path, 80))) + html = _prose(_intake.render_dashboard_html(_intake.census(_many(tmp_path, 80)))) assert "complete/index.md" in html assert "`complete/index.md`" not in html