diff --git a/agents/conductors/intake/AGENTS.md b/agents/conductors/intake/AGENTS.md index d34e9b4..660a7d0 100644 --- a/agents/conductors/intake/AGENTS.md +++ b/agents/conductors/intake/AGENTS.md @@ -83,7 +83,7 @@ schema — light structure over free-form prose. | **classify** | `intake ""` / `intake classify --file P` | classify one raw input; `--apply` writes the prompt | | **ideas** | `intake ideas` | scan `ideas.md`, propose one prompt per bullet; `--apply` writes them | | **census** | `intake census` | inventory every filed prompt (work-type/target/difficulty/status + hygiene flags); always read-only | -| **dashboard** | `intake dashboard` | render the census as the Mind **backlog** page; `--apply` writes `PyAutoMind/dashboard.md` | +| **dashboard** | `intake dashboard` | render the census as the Mind **task** page — picks, in flight, parked, planned, backlog; `--apply` writes `PyAutoMind/dashboard.md`, `--check` exits 1 on drift | | **formalise** | `intake formalise [prefix]` | retroactively header the prompts census flags — derive the missing fields, insert in place, prose untouched; `--apply` writes | | **reconcile** | `intake reconcile [prefix]` | rank backlog prompts that look already-shipped (vs the `complete/` records / `active/`); always read-only — retiring stays human | | **reconcile --repo** | `intake reconcile --repo [prefix]` | **also** read the target repo's source for identifiers the prompts name — the one signal that sees a prompt with no Mind-side trace. Opt-in; the default path is offline | @@ -160,8 +160,9 @@ bin/pyauto-brain intake --apply classify --file tmp/raw.md # write the p bin/pyauto-brain intake ideas # scan ideas.md (dry-run) bin/pyauto-brain intake --apply ideas # write them + mark bullets bin/pyauto-brain intake census # backlog inventory (read-only) -bin/pyauto-brain intake dashboard # backlog page to stdout (dry-run) +bin/pyauto-brain intake dashboard # task page to stdout (dry-run) bin/pyauto-brain intake --apply dashboard # write PyAutoMind/dashboard.md +bin/pyauto-brain intake dashboard --check # exit 1 if the committed page has drifted bin/pyauto-brain intake formalise # propose retroactive headers (dry-run) bin/pyauto-brain intake --apply formalise bug/ # write them, only under bug/ bin/pyauto-brain intake reconcile # rank shipped-but-stale suspects (read-only) diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 90909dd..a670741 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -345,13 +345,66 @@ def _prefix_match(path: str, prefix: str) -> bool: return path.startswith(prefix) or sans.startswith(prefix) +# The registry files (`active.md`, `parked.md`, `planned.md`) are the Mind's +# record of work that is no longer merely filed: an H2 slug per task, then +# `- key: value` bullets (REFERENCE.md "active.md schema"). Values run to +# paragraphs of prose, so the dashboard takes the first line of each and +# truncates — the registry file itself stays the full record. +_REG_HEAD = re.compile(r"^##\s+(\S.*?)\s*$") +_REG_FIELD = re.compile(r"^-\s+([a-z][a-z-]*):\s*(.*)$") +_ISSUE_URL = re.compile(r"https://github\.com/[^/\s]+/[^/\s]+/issues/(\d+)") + + +def parse_registry(path: Path) -> list: + """Parse one registry file into `[{slug, issue, issue_no, status, prompt}]`. + + Tolerant by design: these files are hand-edited by many sessions, so an + entry missing every field still yields a record (a slug alone is the task + name a human picks from). Absent file -> empty list. + """ + if not path.is_file(): + return [] + entries, cur = [], None + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + head = _REG_HEAD.match(line) + if head: + cur = {"slug": head.group(1), "issue": "", "issue_no": "", + "status": "", "prompt": ""} + entries.append(cur) + continue + if cur is None: + continue + field = _REG_FIELD.match(line) + if not field: + continue + key, value = field.group(1), field.group(2).strip() + if key in ("issue", "status", "prompt") and not cur[key]: + cur[key] = value + if key == "issue": + # The value often trails prose ("…/issues/20 (build gated)"), + # so link the matched URL, never the whole field. + m = _ISSUE_URL.search(value) + if m: + cur["issue"], cur["issue_no"] = m.group(0), m.group(1) + return entries + + +def _clip(text: str, limit: int = 130) -> str: + """First line of a registry value, clipped at a word boundary.""" + text = text.strip().splitlines()[0].strip() if text.strip() else "" + if len(text) <= limit: + return text + return text[:limit].rsplit(" ", 1)[0].rstrip(" .,;:—-") + "…" + + def census(mind: Path) -> dict: - """Inventory every filed prompt — one record per `draft//**/*.md`. + """Inventory the Mind's work — filed prompts plus the registry's live rows. - Read-only, always. Walks the WORK_TYPES folders under `draft/` (incl. - `triage/`); `active/` prompts are already dispatched, so they are counted - but not itemised. This is the Mind *backlog* view — health belongs to the - Heart, never here. + Read-only, always. The backlog leg walks the WORK_TYPES folders under + `draft/` (incl. `triage/`), one record per prompt file. The registry leg + itemises what has left the backlog: `active/` prompts (issued — an open + GitHub issue), and the `parked.md` / `planned.md` rows. This is the Mind's + *work* view — health belongs to the Heart, never here. """ records, hygiene = [], [] for wt in WORK_TYPES: @@ -389,16 +442,49 @@ def _count(key): out[r[key]] = out.get(r[key], 0) + 1 return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0]))) + parked = parse_registry(mind / "parked.md") + planned = parse_registry(mind / "planned.md") + # A registry row may name the prompt it drives (`- prompt: active/x.md`); + # that is the only reliable issue link for an issued prompt, since an issue + # URL in the prose body is as likely to be a cross-reference as the task's + # own issue. + by_prompt = {} + for e in parse_registry(mind / "active.md") + parked + planned: + if e["prompt"] and e["prompt"] not in by_prompt: + by_prompt[e["prompt"]] = e + + in_flight = [] active = mind / "active" + for f in sorted(active.glob("*.md")) if active.is_dir() else []: + text = f.read_text(encoding="utf-8", errors="replace") + rel = str(f.relative_to(mind)) + header = parse_header(text) + row = by_prompt.get(rel, {}) + in_flight.append({ + "path": rel, + "title": _title(text), + "target": header.get("target", "-"), + "priority": header.get("priority", "-"), + "issue": row.get("issue", ""), + "issue_no": row.get("issue_no", ""), + # The registry row only. A prompt's own `Status:` header is written + # at conception ("filed"/"formalised") and is stale the moment the + # task is issued, so it would report the opposite of live state. + "status": row.get("status", ""), + }) + return { "generated": _dt.date.today().isoformat(), "total": len(records), - "issued_count": sum(1 for _ in active.glob("*.md")) if active.is_dir() else 0, + "issued_count": len(in_flight), "by_work_type": _count("work_type"), "by_target": _count("target"), "by_difficulty": _count("difficulty"), "by_priority": _count("priority"), "records": records, + "in_flight": in_flight, + "parked": parked, + "planned": planned, "hygiene": hygiene, } @@ -407,44 +493,150 @@ def _cell(value: str) -> str: return str(value).replace("|", "\\|") -def render_dashboard(c: dict) -> str: - """Render the census as the Mind backlog page (`dashboard.md`). +def _label(value: str) -> str: + """Link text for a markdown bullet, made safe to render. - Backlog only, by design: no readiness verdicts, no test state — that is the - Heart's dashboard (`/health`). Links are repo-root-relative so the page - renders cleanly on GitHub. + Brackets would end the link early, and a stray `", "").strip() + return value.replace("[", r"\[").replace("]", r"\]") or "Untitled" + + +# Pick order. The dashboard exists to be *chosen from*, so every list is sorted +# most-pickable first: urgent before routine, small before enormous. Unknown +# (`-`, the headerless prompts) sorts last rather than being hidden. +PRIORITY_RANK = {"high": 0, "medium": 1, "normal": 2, "low": 3} +DIFFICULTY_RANK = {"small": 0, "medium": 1, "large": 2, "too-large": 3} +PICK_LIST_MAX = 12 +# Live GitHub views, for the half of this page a static file cannot hold: the +# issues themselves. Org-wide searches, so a new repo needs no edit here. +GH_SEARCH = "https://github.com/search?q=org%3APyAutoLabs+is%3A{kind}+is%3Aopen&type={kind}s" + + +def _pick_key(r: dict) -> tuple: + return (PRIORITY_RANK.get(r["priority"], 9), + DIFFICULTY_RANK.get(r["difficulty"], 9), + r["target"], r["path"]) + + +def _bullet(r: dict) -> str: + """One backlog prompt as a bullet — the phone-readable unit of this page. + + A bullet 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. + """ + 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 "") + + +def render_dashboard(c: dict) -> str: + """Render the census as the Mind's task page (`dashboard.md`). + + 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 `
`). Links are repo-root-relative so + they resolve in GitHub's web and mobile markdown views alike. + """ + records = sorted(c["records"], key=_pick_key) L = [ - "# PyAutoMind backlog dashboard", + "# PyAutoMind task dashboard", "", f"", "", - f"**{c['total']}** filed prompts in the backlog · **{c['issued_count']}** " - "already dispatched to issues (`active/`). Backlog view only — organism " - "health lives with the Heart (`/health`), not here.", + "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.", + "", + "Tasks only — the organism's health lives with the Heart (`/health`), " + "not here.", + "", + "| Where | Count |", + "|-------|------:|", + f"| [In flight](#in-flight) (`active/`) | {c['issued_count']} |", + f"| [Parked](#parked) (`parked.md`) | {len(c['parked'])} |", + f"| [Planned](#planned) (`planned.md`) | {len(c['planned'])} |", + f"| [Backlog](#backlog) (`draft/`) | {c['total']} |", + "", + f"Live on GitHub: [open issues]({GH_SEARCH.format(kind='issue')}) · " + f"[open pull requests]({GH_SEARCH.format(kind='pr')})", + "", + "## Start here", "", - "| Work-type | Prompts |", - "|-----------|--------:|", ] - L += [f"| {wt} | {n} |" for wt, n in c["by_work_type"].items()] - for wt in c["by_work_type"]: - rows = [r for r in c["records"] if r["work_type"] == wt] - rows.sort(key=lambda r: (r["target"], r["path"])) - L += ["", f"## {wt} ({len(rows)})", "", - "| Prompt | Target | Difficulty | Autonomy | Priority |", - "|--------|--------|------------|----------|----------|"] - L += [f"| [{_cell(r['title'])}]({r['path']}) | {r['target']} " - f"| {r['difficulty']} | {r['autonomy']} | {r['priority']} |" - for r in rows] + + high = [r for r in records if r["priority"] == "high"] + quick = [r for r in records + if r["difficulty"] == "small" and r["autonomy"] == "safe"] + for title, note, rows in ( + ("Highest priority", "filed as `high`", high), + ("Quick wins", "small enough, and safe enough to run unattended", quick), + ): + 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 += [""] + + 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).", ""] + 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)."), + ): + rows = c[key] + L += [f"## {heading}", "", blurb, "", + "
", f"{len(rows)} task(s)", ""] + 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)_"]) + ["", "
", ""] + + L += [f"## Backlog", "", + f"**{c['total']}** filed prompts, not started. Each section is sorted " + "most-pickable first (priority, then size).", ""] + 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 += ["", "
", ""] + if c["hygiene"]: - L += ["", "## Hygiene", "", + L += ["## Hygiene", "", f"{len(c['hygiene'])} prompt(s) without a metadata header — they " - "show `-` above. Re-home or re-run intake on them when touched.", "", + "show no facets above. Re-home or re-run intake on them when " + "touched.", "", "
", "Headerless prompts", ""] L += [f"- `{h.split(' — ')[0]}`" for h in c["hygiene"]] L += ["", "
"] - return "\n".join(L) + "\n" + return "\n".join(L).rstrip("\n") + "\n" + + +def _dashboard_body(page: str) -> str: + """The page minus its generation stamp — what `--check` compares. + + The stamp changes every day the generator runs; comparing it would make + every re-render look like drift and the self-heal push a daily commit. + """ + return "\n".join(l for l in page.splitlines() + if not l.startswith("