From 25e172131d11729120999f8d3c3f401b026a2771 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 27 Aug 2026 21:07:30 -0400 Subject: [PATCH 1/2] feat(intake): Themes: keyword list and theme-keyed cross-repo bundling (#311) Bundles phase 1. Auto-bundling keyed on Target: grouped by where code lives, not what the work is about. Prompts now carry an optional `Themes:` list (same form as `Repos:`, controlled vocabulary in PyAutoMind/themes.md): the first keyword is the primary theme and the grouping key, the rest are affinity for packing. - parse_list_header / parse_themes / unknown_themes; census records carry `themes`, the census carries the vocabulary and unknown flags. - auto_bundles(): pool by primary theme (Target as fallback for un-themed prompts); greedy affinity packing (seed = highest priority, then max Jaccard overlap of theme lists; ties priority -> seed's target -> path) under the existing caps; every prompt in <= 1 bundle. Titles = theme + shared secondaries; theme-keyed cards gain a Repo column; unknown keywords warn on the card and in Hygiene. - intake classify --themes a,b writes `Themes:` after `Repos:`; formalisation never blocks on it. - Un-themed Minds render byte-identically to before (regression test pins the card head, table and html row). - 12 tests. Closes #311 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Px3t8Ggy1PUmL9JdiLGip3 --- agents/conductors/intake/AGENTS.md | 33 ++- agents/conductors/intake/_intake.py | 371 +++++++++++++++++++++++----- tests/test_intake_dashboard.py | 241 ++++++++++++++++++ 3 files changed, 579 insertions(+), 66 deletions(-) diff --git a/agents/conductors/intake/AGENTS.md b/agents/conductors/intake/AGENTS.md index 8f291c9..77018de 100644 --- a/agents/conductors/intake/AGENTS.md +++ b/agents/conductors/intake/AGENTS.md @@ -84,6 +84,15 @@ Writes the light header PyAutoMind blesses (`README.md` "Prompt file format"), extended with `Difficulty:/Autonomy:/Priority:`. No YAML frontmatter, no required schema — light structure over free-form prose. +`Themes:` is written at formalisation, directly under `Repos:` and in the same +list shape (`intake classify --themes mge,jax-gradient`, primary keyword +first). `Target:` says where the code lives; `Themes:` says what the work is +about, and it is what the auto-bundler groups on. The vocabulary is +`PyAutoMind/themes.md` — a markdown list a human edits, read by `parse_themes`, +never a second copy in here. Optional and never blocking: a prompt formalises +with or without it, an unknown keyword still groups (⚠️ on the card, counted in +the page's Hygiene section), and an un-themed prompt falls back to `Target:`. + ## Modes | Mode | Command | What it does | @@ -119,10 +128,26 @@ per member, one shared worktree per repo), which makes it the exact opposite of an epic: no order, no phase gate, and members stay in every pick list above, because a bundle is an additional VIEW and never a replacement. Two sources: **pinned** entries in `PyAutoMind/bundles.md` (plus any prompt whose header says -`Bundle: `), and **auto** bundles this renderer computes — same target -repo, no epic member, no declared `Blocked-by:`, no `human-required`, no -`too-large`, packed under a size cap (`BUNDLE_*`: small 1 / medium 2 / large 4 -points, cap 8, at most one large, 2-4 members) in priority-then-path order. +`Bundle: `), and **auto** bundles this renderer computes — no epic +member, no declared `Blocked-by:`, no `human-required`, no `too-large`, packed +under a size cap (`BUNDLE_*`: small 1 / medium 2 / large 4 points, cap 8, at +most one large, 2-4 members). + +Auto bundles are pooled by `_pool_key`: a prompt's **primary theme** (the first +`Themes:` bullet) when it has one — the topical key, cross-repo by design — and +its **`Target:`** when it does not, which is what the bundler keyed on before +themes existed, so an un-themed backlog renders exactly as it did. Every prompt +has one key, so it lands in at most one auto bundle. Inside a pool +`_pack_by_affinity` seeds with the most pickable member (priority, then path) +and then adds whichever remaining candidate that still fits shares the most +keywords with the seed (Jaccard over the whole list; ties: priority, then the +seed's repo, then path) — so a large pool splits by what the work is about +rather than by filename order. With no themes every overlap is 0.0 and this +reduces, member for member, to the old priority-then-path first fit. A +theme-keyed card is titled `` plus the keywords every member shares +(`mge · jax-gradient`) and its members table carries a **Repo** column, because +it is cross-repo by construction; a Target-keyed card keeps ` — bundle +n` and no column, where that column would be a constant. The proposals are then ranked (most urgent member, then biggest session, then slug) and only the first `BUNDLE_LIST_MAX` reach the page, with a footer saying what was left off and that pinning keeps it — the same pick-list-not-inventory diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 18e2c03..bd1ae90 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -275,8 +275,14 @@ def infer_autonomy(level: str, factors: dict) -> str: return "safe" -def analyse(text: str, source: str): - """Classify raw text into a full IntakeDecision (never writes).""" +def analyse(text: str, source: str, themes=None): + """Classify raw text into a full IntakeDecision (never writes). + + `themes` is the optional `Themes:` keyword list the caller assigns at + formalisation (primary first). Absent, any `Themes:` block the raw input + already carries is kept; absent that too, the prompt is simply un-themed — + formalisation never waits on a theme. + """ repos = _repos_in(text) # What the input DECLARES outranks what its prose merely suggests — the same # rule the feature and bug conductors apply (the faculty owns it). Raw @@ -306,6 +312,9 @@ def analyse(text: str, source: str): priority = declared.get("priority") or infer_priority(text) workflow = infer_workflow(target, repos) + themes = [k for k in dict.fromkeys(_theme_key(t) for t in (themes or [])) + if k] or parse_theme_list(text) + title = _title(strip_declarations(text, decl_spans)) slug = _slug(title) folder = work_type if confidence != "low" else "triage" @@ -321,7 +330,7 @@ def analyse(text: str, source: str): # low-confidence triage filing that means `Type: triage`, not the provisional # guess — the guess still rides in the IntakeDecision's `work_type` field. header = _render_header(title, folder, target_display, repos, level, - autonomy, priority) + autonomy, priority, themes) return { "source": source, "title": title, @@ -332,6 +341,7 @@ def analyse(text: str, source: str): "target": target, "target_display": target_display, "repos_affected": repos, + "themes": themes, "difficulty": level, "difficulty_score": score, "difficulty_factors": factors, @@ -354,11 +364,20 @@ def analyse(text: str, source: str): } -def _render_header(title, work_type, target_display, repos, level, autonomy, priority): +def _render_header(title, work_type, target_display, repos, level, autonomy, + priority, themes=None): lines = [f"# {title}", "", f"Type: {work_type}", f"Target: {target_display}"] if repos: lines.append("Repos:") lines += [f"- {REPO_DISPLAY.get(r, r)}" for r in repos] + # `Themes:` rides directly under `Repos:` — the same list shape, and the + # pair reads as "where the code lives, then what the work is about" + # (vocabulary: `PyAutoMind/themes.md`). Optional and never blocking: a + # prompt formalises with or without it, and the auto-bundler falls back to + # `Target:` for anything un-themed. + if themes: + lines.append("Themes:") + lines += [f"- {t}" for t in themes] lines += [f"Difficulty: {level}", f"Autonomy: {autonomy}", f"Priority: {priority}", "Status: formalised"] return "\n".join(lines) @@ -667,6 +686,89 @@ def parse_bundles(path: Path) -> list: return entries +# --- themes: what the work is ABOUT ------------------------------------------ +# `Target:` says where a prompt's code LIVES — a mechanical key (one worktree +# per repo), which made the auto-bundler read as "three things that live in +# autoarray". `Themes:` says what the work is ABOUT, which is the useful +# grouping and is routinely cross-repo. Same list shape as `Repos:`: a bare +# `Themes:` line then `- keyword` bullets, first bullet = the PRIMARY theme. +_LIST_BULLET = re.compile(r"^\s*-\s+(\S.*?)\s*$") + + +def parse_list_header(text: str, field: str) -> list: + """The `Repos:` / `Themes:` list header: a bare `Field:` then `- ` bullets. + + Scans only the top of the file — the same window `parse_header` reads — so + a fenced example deep in a prompt's prose can never declare anything. The + list closes at the first line that is not a bullet: the header block is + contiguous by construction (see `_render_header`). + """ + out, collecting = [], False + head = re.compile(rf"^{field}:\s*$", re.I) + for line in text.splitlines()[:30]: + if head.match(line.strip()): + collecting = True + continue + if not collecting: + continue + m = _LIST_BULLET.match(line) + if not m: + break + out.append(m.group(1)) + return out + + +def _theme_key(value: str) -> str: + """One `Themes:` bullet normalised to a vocabulary keyword.""" + return value.split("#")[0].strip().strip("`*_").strip().lower() + + +def parse_theme_list(text: str) -> list: + """A prompt's `Themes:` keywords — normalised, de-duplicated, order kept. + + Order is the whole signal: the first keyword is the grouping key, the rest + are packing affinity, so this must never sort. + """ + out = [] + for raw in parse_list_header(text, "Themes"): + key = _theme_key(raw) + if key and key not in out: + out.append(key) + return out + + +# `themes.md` — the Mind's controlled vocabulary for `Themes:`: prose plus one +# `- : ` bullet each, so a human adds a theme by editing one +# markdown list and PyAutoBrain never holds a second copy of it. +_THEME_ENTRY = re.compile(r"^-\s+`?([A-Za-z0-9][A-Za-z0-9._-]*)`?\s*:\s*(\S.*)$") + + +def parse_themes(mind: Path) -> dict: + """Parse `/themes.md` into `{keyword: one-line meaning}`. + + Tolerant like `parse_epics`, with one deliberate consequence: an absent or + empty file yields `{}`, which DISABLES the unknown-keyword warning rather + than flagging every keyword in the backlog. A freshly-spawned Mind has no + vocabulary yet, and a renderer that shouted at all of it would be noise. + """ + f = Path(mind) / "themes.md" + if not f.is_file(): + return {} + out: dict = {} + for line in f.read_text(encoding="utf-8", errors="replace").splitlines(): + m = _THEME_ENTRY.match(line.strip()) + if m: + out.setdefault(m.group(1).lower(), m.group(2).strip()) + return out + + +def unknown_themes(themes: list, vocab: dict) -> list: + """The keywords a prompt declares that `themes.md` does not know.""" + if not vocab: + return [] + return [t for t in themes if t not in vocab] + + # What a bundle COSTS. One session carries a few independent tasks; the cap is # what stops a "bundle" becoming a to-do list. Points rather than a count, # because four small tasks and one large one are not the same session: @@ -726,64 +828,153 @@ def _auto_excluded(r: dict, pinned: set) -> str: return "" +def _jaccard(a: list, b: list) -> float: + """Keyword overlap of two theme lists; 0.0 when either side is empty.""" + sa, sb = set(a), set(b) + return len(sa & sb) / len(sa | sb) if sa and sb else 0.0 + + +def _pool_key(r: dict) -> tuple: + """The auto-bundler's grouping key: PRIMARY THEME, else `Target`. + + `("theme", )` when the prompt declares one — the + topical key, and cross-repo by design. `("target", )` otherwise, + which is exactly what the bundler keyed on before themes existed, so an + un-themed backlog groups unchanged. Every prompt has exactly one key, so + it lands in at most one auto bundle. + """ + themes = r.get("themes") or [] + return ("theme", themes[0]) if themes else ("target", r["target"]) + + +def _pack_by_affinity(rows: list) -> list: + """Pack one pool into bundles under the size caps, by keyword affinity. + + Seed with the most pickable member (priority, then path); then repeatedly + add whichever remaining candidate that still FITS shares the most keywords + with the seed (Jaccard over the whole `Themes:` list), ties broken by + priority, then by sharing the seed's repo, then by path. When nothing fits, + the bundle closes and the next seeds from what is left — so a large pool + splits by what the work is about rather than by filename order. + + Not "close the pack at the first thing that does not fit": a candidate that + is too big is skipped, not terminal, or two large tasks in a row would + leave the first alone in a pack of one (which is then dropped — the + highest-priority member of the pool, silently missing from the page). + + With no themes anywhere every overlap is 0.0 and the tie-breaks reduce to + priority-then-path, which is the first-fit pass this replaced, member for + member. + """ + rest = sorted(rows, key=lambda r: (PRIORITY_RANK.get(r["priority"], 9), + r["path"])) + packs = [] + while rest: + seed, rest = rest[0], rest[1:] + pack = [seed] + points = BUNDLE_SIZE_POINTS.get(seed["difficulty"], BUNDLE_UNKNOWN_POINTS) + large = 1 if seed["difficulty"] == "large" else 0 + while True: + best, best_i = None, -1 + for i, r in enumerate(rest): + cost = BUNDLE_SIZE_POINTS.get(r["difficulty"], + BUNDLE_UNKNOWN_POINTS) + is_large = 1 if r["difficulty"] == "large" else 0 + if (points + cost > BUNDLE_POINT_CAP + or len(pack) >= BUNDLE_MAX_MEMBERS + or large + is_large > BUNDLE_MAX_LARGE): + continue + rank = (-_jaccard(seed.get("themes") or [], + r.get("themes") or []), + PRIORITY_RANK.get(r["priority"], 9), + 0 if r["target"] == seed["target"] else 1, + r["path"]) + if best is None or rank < best: + best, best_i = rank, i + if best_i < 0: + break + r = rest.pop(best_i) + pack.append(r) + points += BUNDLE_SIZE_POINTS.get(r["difficulty"], + BUNDLE_UNKNOWN_POINTS) + large += 1 if r["difficulty"] == "large" else 0 + packs.append(pack) + return packs + + +def _shared_secondaries(pack: list, primary: str) -> list: + """The keywords EVERY member of a pack carries, minus the primary theme. + + Ordered by the seed's own list, because that is the order a human wrote + and the only one that is not an alphabetisation of somebody's tags. + """ + shared = set.intersection(*[set(m.get("themes") or []) for m in pack]) + return [t for t in (pack[0].get("themes") or []) + if t != primary and t in shared] + + +def _theme_title(theme: str, pack: list, n: int) -> str: + """`mge · jax-gradient` — the primary theme plus what every member shares. + + Numbered only from the second bundle of a pool onwards: a bundle is picked + BY NAME (the title rides in the copied prompt), so two cards may not carry + the same one, but the common single-bundle pool should read as its theme + and nothing else. + """ + title = " · ".join([theme] + _shared_secondaries(pack, theme)) + return title if n == 1 else f"{title} — bundle {n}" + + +def _unknown_in(rows: list) -> list: + """Every `Themes:` keyword a card's members carry that `themes.md` lacks.""" + out = [] + for r in rows: + for t in r.get("unknown_themes") or []: + if t not in out: + out.append(t) + return sorted(out) + + def auto_bundles(c: dict) -> list: """Propose bundles from the backlog — deterministic, and render-only. Never written back to `bundles.md`: only human pins are persisted, so the nightly re-render commits no churn and a proposal that stops making sense - simply stops being proposed. Same input -> same output, always: draft - prompts are grouped by target repo (the folder taxonomy, not the free-prose - `Target:` header), ordered most-pickable first (priority, then path) and - packed greedily under the size cap. A pack of one is not a bundle, so it is - dropped rather than shown. + simply stops being proposed. Same input -> same output, always. + + Prompts are pooled by `_pool_key` — primary theme when they declare one, + target repo when they do not — and each pool is packed by `_pack_by_affinity` + under the size cap. A pack of one is not a bundle, so it is dropped rather + than shown. """ pinned = {m for b in (c.get("bundles") or []) for m in b["members"]} groups: dict = {} for r in c.get("records") or []: if not _auto_excluded(r, pinned): - groups.setdefault(r["target"], []).append(r) - out = [] - for target in sorted(groups): - rows = sorted(groups[target], - key=lambda r: (PRIORITY_RANK.get(r["priority"], 9), - r["path"])) - packs = [] - while rows: - cur, rest, points, large = [], [], 0, 0 - for r in rows: - cost = BUNDLE_SIZE_POINTS.get(r["difficulty"], - BUNDLE_UNKNOWN_POINTS) - is_large = 1 if r["difficulty"] == "large" else 0 - # First fit down the ordered list, NOT "close the pack at the - # first thing that does not fit": two large tasks in a row - # would otherwise leave the first one alone in a pack of one, - # which is then dropped — the highest-priority member in the - # repo, silently missing from the page. - if (points + cost > BUNDLE_POINT_CAP - or len(cur) >= BUNDLE_MAX_MEMBERS - or large + is_large > BUNDLE_MAX_LARGE): - rest.append(r) - continue - cur.append(r) - points += cost - large += is_large - if not cur: - break # nothing fits an empty pack — cannot happen, never loop - packs.append(cur) - rows = rest - n = 0 - for pack in packs: + groups.setdefault(_pool_key(r), []).append(r) + out, used = [], {} + # Pools sort by their key TEXT, theme and target alike, so a mixed backlog + # interleaves alphabetically rather than listing every theme before every + # repo — and a Mind with no themes at all keeps exactly its old order. + for kind, key in sorted(groups, key=lambda k: (k[1], k[0])): + for pack in _pack_by_affinity(groups[(kind, key)]): if len(pack) < BUNDLE_MIN_MEMBERS: continue - n += 1 + # Numbered per KEY TEXT rather than per pool, so a theme and a + # target that happen to share a name cannot mint the same slug. + used[key] = n = used.get(key, 0) + 1 out.append({ - "slug": f"auto-{target}-{n}", + "slug": f"auto-{key}-{n}", # Numbered, not described: two proposals over the same repo # would otherwise carry the same name on the page and in the # copied prompt, and a bundle is picked by name. - "title": f"{target} — bundle {n}", - "origin": "auto", "target": target, "members": pack, - "rationale": "", "status": "", "unknown": False, + "title": (_theme_title(key, pack, n) if kind == "theme" + else f"{key} — bundle {n}"), + "origin": "auto", "pool": kind, + "theme": key if kind == "theme" else "", + "target": key if kind == "target" else "", + "members": pack, "rationale": "", "status": "", + "unknown": False, "unknown_themes": _unknown_in(pack), "points": _bundle_points(pack), }) return out @@ -875,13 +1066,15 @@ def _resolve(paths): rows += sorted((r for r in declared.get(b["slug"], []) if r["path"] not in seen), key=lambda r: r["path"]) cards.append({**b, "members": rows, "unknown": False, + "unknown_themes": _unknown_in(rows), "points": _bundle_points(rows)}) known = {b["slug"] for b in c.get("bundles") or []} for slug in sorted(s for s in declared if s not in known): rows = sorted(declared[slug], key=lambda r: r["path"]) cards.append({"slug": slug, "title": slug, "origin": "pinned", "members": rows, "rationale": "", "status": "", - "unknown": True, "points": _bundle_points(rows)}) + "unknown": True, "unknown_themes": _unknown_in(rows), + "points": _bundle_points(rows)}) auto = auto_bundles(c) ranked = sorted(auto, key=lambda b: ( min(PRIORITY_RANK.get(m.get("priority", "-"), 9) for m in b["members"]), @@ -921,6 +1114,12 @@ def _bundle_head(b: dict) -> str: BUNDLE_TABLE_HEAD = ["| Prompt | Difficulty | Priority | Status |", "|--------|------------|----------|--------|"] +# A theme-keyed bundle is cross-repo by design, so its members must say WHERE +# each task lives. A target-keyed (fallback) or pinned card does not get the +# column: every row would carry the same value, and a constant column is noise. +BUNDLE_TABLE_HEAD_REPO = ["| Prompt | Repo | Difficulty | Priority | Status |", + "|--------|------|------------|----------|--------|"] + BUNDLE_BLURB = ( "Sets of INDEPENDENT tasks that make sense in one orchestrated session: " "an architect session plans them, subagents implement them, and every " @@ -944,12 +1143,16 @@ def _bundle_rows_md(b: dict) -> list: question here is not "which do I pick?" but "what am I taking on in one session?" — which is a comparison, and comparisons are tables. """ - rows = list(BUNDLE_TABLE_HEAD) + repo = b.get("pool") == "theme" + rows = list(BUNDLE_TABLE_HEAD_REPO if repo else BUNDLE_TABLE_HEAD) for r in b["members"]: link = f"{_summary_label(_clip(r['title'], 70))}" - rows.append(f"| {_cell(link)} | {_cell(r.get('difficulty', '-'))} | " - f"{_cell(r.get('priority', '-'))} | " - f"{_cell(_summary_label(_clip(r.get('status', '-'), 40)))} |") + cells = [_cell(link)] + if repo: + cells.append(_cell(_summary_label(r.get("target", "-")))) + cells += [_cell(r.get("difficulty", "-")), _cell(r.get("priority", "-")), + _cell(_summary_label(_clip(r.get("status", "-"), 40)))] + rows.append("| " + " | ".join(cells) + " |") return rows @@ -961,6 +1164,9 @@ def _bundle_section(cards: list) -> list: head = _bundle_head(b) if b.get("unknown"): head += " — ⚠️ not in `bundles.md`" + if b.get("unknown_themes"): + head += (" — ⚠️ theme(s) not in `themes.md`: " + + _summary_label(", ".join(b["unknown_themes"]))) L += ["
", f"{head}", ""] L += _items([_task_row(BUNDLE_RUN_LABEL, bundle_prompt(b))]) if b.get("rationale"): @@ -994,17 +1200,26 @@ def _bundle_section_html(cards: list, blob: str) -> list: head = _bundle_head(b) if b.get("unknown"): head += " — ⚠️ not in bundles.md" + if b.get("unknown_themes"): + head += (" — ⚠️ theme(s) not in themes.md: " + + _summary_label(", ".join(b["unknown_themes"]))) H += ["
", f"{head}", _html_task(BUNDLE_RUN_LABEL, bundle_prompt(b))] if b.get("rationale"): H.append(f'

{_summary_label(b["rationale"])}

') + repo = b.get("pool") == "theme" H += ['', - "" + "" + ("" if repo else "") + + "" ""] for r in b["members"]: H += ["", f'', + f'{_summary_label(_clip(r["title"], 70))}'] + if repo: + H.append('') + H += [ f'', f'', f'" not in cards[1] and "" in cards[2] + + +def test_affinity_packing_beats_filename_order(tmp_path): + """Inside a pool the next member is the one that shares the most keywords + with the seed — so a big pool splits by what the work is about, not by + whichever filename sorts early.""" + mind = _mind(tmp_path, registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a_seed.md": _themed( + "Seed", "mge", "jax-gradient", "interferometer", + difficulty="small", priority="high"), + "feature/widgets/b_plain.md": _themed("Plain B", "mge", + difficulty="small"), + "feature/widgets/c_overlap.md": _themed("Overlap C", "mge", + "jax-gradient", + difficulty="small"), + "feature/widgets/d_overlap.md": _themed("Overlap D", "mge", + "interferometer", + difficulty="small"), + "feature/widgets/e_plain.md": _themed("Plain E", "mge", + difficulty="small"), + "feature/widgets/f_plain.md": _themed("Plain F", "mge", + difficulty="small"), + }) + bundles = _intake.auto_bundles(_intake.census(mind)) + assert [[m["title"] for m in b["members"]] for b in bundles] == [ + ["Seed", "Overlap C", "Overlap D", "Plain B"], + ["Plain E", "Plain F"]] + assert [b["slug"] for b in bundles] == ["auto-mge-1", "auto-mge-2"] + # A pool's second bundle is numbered: a bundle is picked BY NAME, and the + # title rides in the copied orchestration prompt. + assert [b["title"] for b in bundles] == ["mge", "mge — bundle 2"] + + +def test_a_cards_title_carries_the_keywords_every_member_shares(tmp_path): + """`mge · jax-gradient` says what the session is; `mge` alone says it when + the members agree on nothing else.""" + shared = _mind(tmp_path / "shared", registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a.md": _themed("A", "mge", "jax-gradient"), + "feature/widgets/b.md": _themed("B", "mge", "jax-gradient"), + }) + assert _intake.auto_bundles(_intake.census(shared))[0]["title"] == \ + "mge · jax-gradient" + split = _mind(tmp_path / "split", registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a.md": _themed("A", "mge", "jax-gradient"), + "feature/widgets/b.md": _themed("B", "mge", "interferometer"), + }) + assert _intake.auto_bundles(_intake.census(split))[0]["title"] == "mge" + + +def test_an_unthemed_prompt_falls_back_to_its_target(tmp_path): + """Themes are optional, so the old key has to keep working — and the two + kinds of pool sit side by side, ordered by their key text.""" + mind = _mind(tmp_path, registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a.md": _themed("Themed A", "mge"), + "bug/gadgets/b.md": _themed("Themed B", "mge", target="gadgets"), + "feature/widgets/c.md": _prompt("Plain C"), + "feature/widgets/d.md": _prompt("Plain D"), + }) + bundles = _intake.auto_bundles(_intake.census(mind)) + assert [b["slug"] for b in bundles] == ["auto-mge-1", "auto-widgets-1"] + assert [b["title"] for b in bundles] == ["mge", "widgets — bundle 1"] + assert [m["title"] for m in bundles[1]["members"]] == ["Plain C", "Plain D"] + + +def test_every_prompt_lands_in_at_most_one_auto_bundle(tmp_path): + """Themes are a list, but only the FIRST one groups — otherwise the same + task would be proposed from three cards and picked up twice.""" + mind = _mind(tmp_path, registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a.md": _themed("A", "mge", "jax-gradient"), + "feature/widgets/b.md": _themed("B", "mge", "jax-gradient"), + "feature/widgets/c.md": _themed("C", "jax-gradient", "mge"), + "feature/widgets/d.md": _themed("D", "jax-gradient", "dashboard"), + "feature/gadgets/e.md": _prompt("E").replace("Target: widgets", + "Target: gadgets"), + "feature/gadgets/f.md": _prompt("F").replace("Target: widgets", + "Target: gadgets"), + }) + bundles = _intake.auto_bundles(_intake.census(mind)) + paths = [m["path"] for b in bundles for m in b["members"]] + assert len(paths) == len(set(paths)) == 6 + assert [b["slug"] for b in bundles] == ["auto-gadgets-1", + "auto-jax-gradient-1", "auto-mge-1"] + + +def test_an_unknown_keyword_is_loud_on_the_card_and_counted_in_hygiene(tmp_path): + """The list must not rot into free-text tags, so a keyword `themes.md` + does not know still groups — visibly, the way an unregistered `Epic:` + slug does — and the page says how many prompts carry one.""" + mind = _mind(tmp_path, registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a.md": _themed("Odd one", "no-such-theme"), + "feature/widgets/b.md": _themed("Odd two", "no-such-theme", "mge"), + }) + c = _intake.census(mind) + assert [r["unknown_themes"] for r in c["records"]] == [["no-such-theme"]] * 2 + page = _page(mind) + assert "⚠️ theme(s) not in `themes.md`: no-such-theme" in page + assert "2 prompt(s) with unknown theme keyword(s)" in page + assert "draft/feature/widgets/a.md — unknown theme keyword(s): " \ + "no-such-theme" in page + html = _prose(_intake.render_dashboard_html(c)) + assert "⚠️ theme(s) not in themes.md: no-such-theme" in html + + +def test_a_mind_with_no_vocabulary_warns_about_nothing(tmp_path): + """A freshly-spawned Mind has an empty `themes.md`; shouting at every + keyword in its backlog would be noise, not hygiene.""" + mind = _mind(tmp_path, drafts={ + "feature/widgets/a.md": _themed("A", "whatever"), + "feature/widgets/b.md": _themed("B", "whatever")}) + c = _intake.census(mind) + assert c["theme_flags"] == [] + assert _intake.auto_bundles(c)[0]["slug"] == "auto-whatever-1" + assert "unknown theme keyword" not in _page(mind) + + +# The un-themed page must be byte-for-byte what it was before themes existed: +# 130-odd prompts carry no `Themes:` yet, and a grouping change that also +# reflowed every existing card would make the backfill diff unreadable. +_UNTHEMED_HEAD = ("widgets — bundle 1 — 2 task(s) · 4 pts · " + "auto — proposed") +_UNTHEMED_TABLE = """| Prompt | Difficulty | Priority | Status | +|--------|------------|----------|--------| +| Widget A | medium | normal | formalised | +| Widget B | medium | normal | formalised |""" + + +def test_an_unthemed_backlog_renders_exactly_as_it_did_before_themes(tmp_path): + mind = _mind(tmp_path, registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a.md": _prompt("Widget A"), + "feature/widgets/b.md": _prompt("Widget B")}) + section = _bundle_page(mind) + assert _UNTHEMED_HEAD in section + assert _UNTHEMED_TABLE in section + assert "| Prompt | Repo |" not in section and "themes.md" not in section + bundles = _intake.auto_bundles(_intake.census(mind)) + assert [b["slug"] for b in bundles] == ["auto-widgets-1"] + assert bundles[0]["title"] == "widgets — bundle 1" + html = _intake.render_dashboard_html(_intake.census(mind)) + section = html.split("

Bundles")[1].split("

")[0] + assert ("

" + "") in section + + +def test_formalising_writes_themes_under_repos_and_never_waits_for_one(tmp_path): + """Intake assigns the keywords at formalisation — but a prompt formalises + with or without them, and the bundler falls back to `Target:`.""" + text = "Speed up the @PyAutoArray MGE gradient path." + themed = _intake.analyse(text, "test", ["mge", "jax-gradient"]) + assert themed["themes"] == ["mge", "jax-gradient"] + assert ("Repos:\n- PyAutoArray\nThemes:\n- mge\n- jax-gradient\n" + "Difficulty:") in themed["header"] + bare = _intake.analyse(text, "test") + assert bare["themes"] == [] and "Themes:" not in bare["header"] + # A pasted header block that already carries the list keeps it. + pasted = _intake.analyse(_themed("Pasted", "mge"), "test") + assert pasted["themes"] == ["mge"] From eaa5a7d30457fed8de92e53b4daba5aa0f17c994 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 27 Aug 2026 21:12:26 -0400 Subject: [PATCH 2/2] test(intake): neutral repo name in formalisation fixture (tenant firewall) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Px3t8Ggy1PUmL9JdiLGip3 --- tests/test_intake_dashboard.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_intake_dashboard.py b/tests/test_intake_dashboard.py index 9814754..f74507c 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -1417,10 +1417,13 @@ def test_an_unthemed_backlog_renders_exactly_as_it_did_before_themes(tmp_path): def test_formalising_writes_themes_under_repos_and_never_waits_for_one(tmp_path): """Intake assigns the keywords at formalisation — but a prompt formalises with or without them, and the bundler falls back to `Target:`.""" - text = "Speed up the @PyAutoArray MGE gradient path." + # An organ repo, not a satellite one: the tenant firewall bars instance + # repo names from organ code, and a made-up name would resolve to no repo + # at all — leaving no `Repos:` block for the themes to land under. + text = "Speed up the @PyAutoMind MGE gradient path." themed = _intake.analyse(text, "test", ["mge", "jax-gradient"]) assert themed["themes"] == ["mge", "jax-gradient"] - assert ("Repos:\n- PyAutoArray\nThemes:\n- mge\n- jax-gradient\n" + assert ("Repos:\n- PyAutoMind\nThemes:\n- mge\n- jax-gradient\n" "Difficulty:") in themed["header"] bare = _intake.analyse(text, "test") assert bare["themes"] == [] and "Themes:" not in bare["header"]
PromptDifficultyPriority
PromptRepoDifficultyPriorityStatus
' - f'{_summary_label(_clip(r["title"], 70))}' + f'{_summary_label(r.get("target", "-"))}{_summary_label(r.get("difficulty", "-"))}{_summary_label(r.get("priority", "-"))}' @@ -1108,7 +1323,8 @@ def census(mind: Path) -> dict: 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, drift = [], [], [] + records, hygiene, drift, theme_flags = [], [], [], [] + vocab = parse_themes(mind) for wt in WORK_TYPES: folder = mind / "draft" / wt if not folder.is_dir(): @@ -1120,6 +1336,8 @@ def census(mind: Path) -> dict: rel = f.relative_to(mind) header = parse_header(text) missing = [h for h in HEADER_FIELDS if h not in header] + themes = parse_theme_list(text) + stray = unknown_themes(themes, vocab) try: phase = int(header.get("phase", "")) except ValueError: @@ -1141,6 +1359,10 @@ def census(mind: Path) -> dict: # Bundle membership a human PINNED in the prompt itself; auto # bundles never write here (see `auto_bundles`). "bundle": header.get("bundle", ""), + # What the work is ABOUT (`themes.md` vocabulary); the first + # keyword is the auto-bundler's grouping key. + "themes": themes, + "unknown_themes": stray, # `Filed:` normally; `Issued:` only on a prompt that has been # issued and moved back, which is still the later event. "date": _header_date(header), @@ -1149,6 +1371,9 @@ def census(mind: Path) -> dict: }) if len(missing) == len(HEADER_FIELDS): hygiene.append(f"{rel} — no metadata header (pre-dates intake)") + if stray: + theme_flags.append(f"{rel} — unknown theme keyword(s): " + + ", ".join(stray)) if _FIX_PR_RE.search(text): drift.append(f"{rel} — body records a fix PR, but the prompt " "never left draft/ (reconcile its lifecycle)") @@ -1223,6 +1448,8 @@ def _count(key): "in_flight": in_flight, "epics": parse_epics(mind / "epics.md"), "bundles": parse_bundles(mind / "bundles.md"), + "theme_vocab": vocab, + "theme_flags": theme_flags, "parked": parked, "planned": planned, "hygiene": hygiene, @@ -1693,14 +1920,30 @@ def render_dashboard(c: dict) -> str: L += _items([_bullet(r) for r in rows]) L += ["", "", ""] + # Hygiene is the page's only audit section: what a human should tidy, not + # what to pick. Each flag class is its own count line + `
` list. + blocks = [] if c["hygiene"]: - L += ["## Hygiene", "", - f"{len(c['hygiene'])} prompt(s) without a metadata header — they " - "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 += ["", "
"] + blocks.append( + [f"{len(c['hygiene'])} prompt(s) without a metadata header — they " + "show no facets above. Re-home or re-run intake on them when " + "touched.", "", + "
", "Headerless prompts", ""] + + [f"- `{h.split(' — ')[0]}`" for h in c["hygiene"]] + + ["", "
"]) + if c.get("theme_flags"): + blocks.append( + [f"{len(c['theme_flags'])} prompt(s) with unknown theme " + "keyword(s) — not in [`themes.md`](themes.md), so they group " + "loudly rather than silently. Correct the prompt, or add the " + "keyword to the vocabulary.", "", + "
", "Unknown theme keywords", ""] + + [f"- `{t}`" for t in c["theme_flags"]] + + ["", "
"]) + if blocks: + L += ["## Hygiene", ""] + for i, block in enumerate(blocks): + L += ([""] if i else []) + block boards = _board_links(c.get("home", "")) if boards: @@ -3142,6 +3385,9 @@ def main(argv=None): cl = sub.add_parser("classify", help="classify raw text or a file") cl.add_argument("text", nargs="*", help="raw idea text") cl.add_argument("--file", default="", help="read raw text from a file") + cl.add_argument("--themes", default="", + help="comma-separated Themes: keywords, primary first " + "(vocabulary: PyAutoMind/themes.md)") sub.add_parser("ideas", help="scan ideas.md and propose one prompt per bullet") @@ -3255,7 +3501,8 @@ def main(argv=None): if not text.strip(): print("intake: no input text to classify.", file=sys.stderr) return 4 - decision = analyse(text, source) + decision = analyse(text, source, [t for t in ( + getattr(a, "themes", "") or "").split(",") if t.strip()]) if a.apply: written = write_prompt(mind, decision, text, source) decision["written"] = written diff --git a/tests/test_intake_dashboard.py b/tests/test_intake_dashboard.py index 19173ee..9814754 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -1186,3 +1186,244 @@ def test_pinned_bundles_are_never_capped(tmp_path): assert len(cards) == _intake.BUNDLE_LIST_MAX + 1 # The footer counts AUTO bundles only — the pinned card is not a proposal. assert "Showing 8 of 12 auto bundles" in _bundle_page(mind) + + +# --------------------------------------------------------------------------- # +# themes: what the work is ABOUT, and the bundles keyed on it +# --------------------------------------------------------------------------- # +# `Target:` says where the code lives — a mechanical key, one worktree per repo, +# which made the proposals read as "three things that live in autoarray". A +# prompt's `Themes:` list says what the work is about, which is the useful +# grouping and is routinely cross-repo. The vocabulary is a markdown list in +# `PyAutoMind/themes.md`, so a human adds a theme without touching the Brain. +_THEMES = """# Themes + +The controlled vocabulary for a prompt's `Themes:` header. + +## Vocabulary + +- `mge`: Multi-Gaussian Expansion profiles, and fitting with them. +- `jax-gradient`: JAX autodiff — gradient correctness and gradient-based search. +- `interferometer`: Visibility-space datasets and their fits. +- `dashboard`: The Mind dashboard and its sibling boards. +""" + + +def _themed(title, *themes, target="widgets", **kw): + """A prompt carrying a `Themes:` list, in the same shape as `Repos:`.""" + body = _prompt(title, **kw).replace("Target: widgets", f"Target: {target}") + bullets = "".join(f"- {t}\n" for t in themes) + return body.replace("Difficulty:", f"Themes:\n{bullets}Difficulty:", 1) + + +def test_the_vocabulary_is_read_from_the_minds_own_markdown(tmp_path): + """`themes.md` is the source of truth — one editable markdown list, never + a second copy inside the renderer.""" + mind = _mind(tmp_path, registries={"themes.md": _THEMES}) + vocab = _intake.parse_themes(mind) + assert list(vocab) == ["mge", "jax-gradient", "interferometer", "dashboard"] + assert vocab["mge"].startswith("Multi-Gaussian") + assert _intake.parse_themes(tmp_path / "nowhere") == {} + + +def test_a_prompts_theme_list_keeps_the_order_it_was_written_in(tmp_path): + """The first bullet is the grouping key and the rest are affinity, so the + list is a sequence — parsing must never sort or de-order it.""" + text = _themed("Ordered", "jax-gradient", "mge", "mge") + assert _intake.parse_theme_list(text) == ["jax-gradient", "mge"] + assert _intake.parse_list_header(text, "Themes") == ["jax-gradient", "mge", + "mge"] + + +def test_a_primary_theme_pools_across_repos(tmp_path): + """The point of the whole feature: one bundle about MGE, not one bundle + per repo that MGE happens to touch.""" + mind = _mind(tmp_path, registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a.md": _themed("Widget MGE", "mge"), + "bug/gadgets/b.md": _themed("Gadget MGE", "mge", target="gadgets"), + }) + bundles = _intake.auto_bundles(_intake.census(mind)) + assert [b["slug"] for b in bundles] == ["auto-mge-1"] + assert bundles[0]["title"] == "mge" + assert [m["title"] for m in bundles[0]["members"]] == ["Gadget MGE", + "Widget MGE"] + assert {m["target"] for m in bundles[0]["members"]} == {"widgets", "gadgets"} + + +def test_a_theme_bundle_names_every_members_repo(tmp_path): + """A theme bundle is cross-repo by construction, so the members table has + to say where each task lives — a Target-keyed card never needs to, because + the column would be a constant.""" + mind = _mind(tmp_path, registries={"themes.md": _THEMES}, drafts={ + "feature/widgets/a.md": _themed("Widget MGE", "mge"), + "bug/gadgets/b.md": _themed("Gadget MGE", "mge", target="gadgets"), + "feature/doodads/c.md": _prompt("Plain C").replace("Target: widgets", + "Target: doodads"), + "feature/doodads/d.md": _prompt("Plain D").replace("Target: widgets", + "Target: doodads"), + }) + # Pools sort by key text, so the Target-keyed `doodads` card is first. + plain, themed = _bundle_page(mind).split("mge") + assert "| Prompt | Repo | Difficulty | Priority | Status |" in themed + assert "| gadgets |" in themed and "| widgets |" in themed + assert "| Prompt | Difficulty | Priority | Status |" in plain + assert "| Prompt | Repo |" not in plain + html = _intake.render_dashboard_html(_intake.census(mind)) + cards = html.split("

Bundles")[1].split("

")[0].split("
") + assert "

RepoRepo
PromptDifficultyPriorityStatus