From 38e7d5e58af4dc466913a301d0cda9e0928f50aa Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 27 Aug 2026 19:00:43 -0400 Subject: [PATCH] feat(intake): dashboard Bundles section + start_bundle skill (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Bundles view to the PyAutoMind dashboard: sets of independent prompts that make sense in one Fable-orchestrated session with Opus subagents. Every task still renders in its usual section; the bundle is an extra view, never a replacement, and no existing section changes. - `Bundle:` header key (mirrors `Epic:`); `parse_bundles()` reads the human-pinned registry `PyAutoMind/bundles.md`. - `auto_bundles()`: deterministic, render-only proposals — grouped by Target; excludes epic members, `Blocked-by:`, `Autonomy: human-required`, `Difficulty: too-large` and pinned members; first-fit under 8 size points (small=1/medium=2/large=4), <=4 members, <=1 large, min 2. - Renders between Backlog and Recent in md + html: members table, total size, one-tap orchestration prompt (copy button in HTML); pinned first, auto capped at 8 with a "showing N of M" footer; unknown slug warns on the card like epics. - New `skills/start_bundle` (one issue + one PR per member, one shared worktree per repo, architect plans / subagents implement) and a WORKFLOW.md paragraph under the delegation ladder. - 22 tests in test_intake_dashboard.py. Closes #309 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Px3t8Ggy1PUmL9JdiLGip3 --- agents/conductors/intake/AGENTS.md | 26 +- agents/conductors/intake/_intake.py | 423 +++++++++++++++++++++++++++- skills/WORKFLOW.md | 13 + skills/start_bundle/SKILL.md | 13 + skills/start_bundle/start_bundle.md | 93 ++++++ tests/test_intake_dashboard.py | 355 ++++++++++++++++++++++- 6 files changed, 916 insertions(+), 7 deletions(-) create mode 100644 skills/start_bundle/SKILL.md create mode 100644 skills/start_bundle/start_bundle.md diff --git a/agents/conductors/intake/AGENTS.md b/agents/conductors/intake/AGENTS.md index 12c4079..8f291c9 100644 --- a/agents/conductors/intake/AGENTS.md +++ b/agents/conductors/intake/AGENTS.md @@ -91,7 +91,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 **task** page — picks, in flight, parked, planned, backlog, recent, epics; `--apply` writes `PyAutoMind/dashboard.md`, `--check` exits 1 on drift | +| **dashboard** | `intake dashboard` | render the census as the Mind **task** page — picks, in flight, parked, planned, backlog, bundles, recent, epics; `--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/`), and pair live prompts that look like the same work filed twice; always read-only — retiring stays human | | **reconcile --repo** | `intake reconcile --repo [prefix]` | **also** read the target repo's source: identifiers the prompts name that exist upstream, and lines they quote that are **gone** — the two signals that see a prompt with no Mind-side trace. Opt-in; the default path is offline | @@ -99,8 +99,8 @@ schema — light structure over free-form prose. **Recent** is the one section laid out by *date* rather than by state: the 50 newest events on the **work in hand** — issued, parked, filed — merged across every live bucket (the `draft/` backlog included, which is most of them: -150 prompts against a handful of registry rows) and sitting between the Backlog -and the Epics. Epic members stay out, as they do in every pick list on the +150 prompts against a handful of registry rows) and sitting between the +Bundles and the Epics. Epic members stay out, as they do in every pick list on the page — they are worked in order through their epic, and a Recent row hands out a standalone `/start_dev`. It *holds* 50 and *shows* 10 (`RECENT_MAX` / `RECENT_PAGE`): the table is a glance, not a @@ -112,6 +112,26 @@ none of them can answer "what has been happening?". Dates come from the registry key that names the event (`issued:` / `parked:` / `filed:`, PyAutoMind REFERENCE.md "Task dates") or a prompt's own `Issued:` header. +**Bundles** read the backlog a second way — as *sessions* rather than as +tasks — and sit directly under it. A bundle is a set of **independent** prompts +worth running in one orchestrated session (`start_bundle`: one issue and one PR +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. +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 +rule as `PICK_LIST_MAX`. Pinned bundles are never capped. +Auto bundles are **render-only**: recomputed on every render and never written +back, so the nightly refresh commits no churn and a proposal that stops making +sense stops being proposed. `Blocked-by:` reads as unresolved whatever GitHub +says — this renderer makes no network call. + Shipped work is deliberately **not** in the feed and `complete/` is never opened to render it: the ledger is a thousand records deep and takes ~200 a month, so including it made the table a list of receipts — twenty things nobody diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 0316781..18e2c03 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -443,7 +443,9 @@ def parse_header(text: str) -> dict: Only scans the top of the file so a stray "Status:" deep in prose does not fire; first occurrence of each field wins. No YAML — the blessed convention. - `Epic:`/`Phase:` are optional epic-membership fields (dashboard grouping); + `Epic:`/`Phase:` are optional epic-membership fields (dashboard grouping), + `Bundle:` the same for a pinned bundle (`bundles.md`); `Blocked-by:` is the + declared gate the dashboard reads as "not startable on its own"; `Filed:`/`Issued:` are the prompt's own date, keyed by the state it was in when that happened (PyAutoMind REFERENCE.md "Task dates"). None are in HEADER_FIELDS, so their absence is never header hygiene. @@ -451,7 +453,7 @@ def parse_header(text: str) -> dict: fields = {} for line in text.splitlines()[:30]: m = re.match(r"(Type|Target|Difficulty|Autonomy|Priority|Status|" - r"Issued|Filed|Epic|Phase):\s*(\S.*)", + r"Issued|Filed|Epic|Phase|Bundle|Blocked-by):\s*(\S.*)", line.strip()) if m: fields.setdefault(m.group(1).lower(), m.group(2).strip()) @@ -614,6 +616,407 @@ def _epic_prompt(e: dict) -> str: return " ".join(parts) +# `bundles.md` — the Mind's registry of PINNED bundles: sets of INDEPENDENT +# prompts a human has decided are worth doing in one orchestrated session. +# Same H2-slug + `- key: value` shape as epics.md, plus a `members:` list of +# prompt paths written as ` - ` bullets (the active.md `repos:` idiom). +# +# A bundle is NOT an epic. An epic is ordered and phase-gated — one phase at a +# time, worked through its ledger — so its members are pulled OUT of every pick +# list. A bundle is a flat set of independent tasks that happen to suit one +# session: they can be worked in any order, so members stay in their usual +# sections and a bundle is an additional VIEW of the backlog, never a +# replacement for it. +_BUNDLE_FIELDS = ("title", "rationale", "status") + +# One member of a pinned bundle: an indented bullet under `- members:`. Top- +# level `- key: value` lines close the list (they match _REG_FIELD first). +_MEMBER_BULLET = re.compile(r"^\s+-\s+(\S+)") + + +def parse_bundles(path: Path) -> list: + """Parse `bundles.md` into `[{slug, title, members, rationale, status}]`. + + Tolerant like parse_epics: a slug alone still yields a record; absent file + -> empty list (a freshly-spawned Mind has no bundles). `- members:` opens + a list — every indented ` - ` bullet under it is one prompt path, + and the next top-level `- key:` field closes it. + """ + if not path.is_file(): + return [] + entries, cur, in_members = [], None, False + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + head = _REG_HEAD.match(line) + if head: + cur = {"slug": head.group(1), "members": [], "origin": "pinned"} + cur.update({k: "" for k in _BUNDLE_FIELDS}) + entries.append(cur) + in_members = False + continue + if cur is None: + continue + field = _REG_FIELD.match(line) + if field: + in_members = field.group(1) == "members" + if field.group(1) in _BUNDLE_FIELDS and not cur[field.group(1)]: + cur[field.group(1)] = field.group(2).strip() + continue + member = _MEMBER_BULLET.match(line) + if in_members and member: + cur["members"].append(member.group(1)) + return entries + + +# 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: +# small=1, medium=2, large=4 — and cap 8, which is exactly the two shapes the +# design names (1 large + 3 small = 7; 4 medium = 8) and nothing bigger. At +# most one large member, so the cap can never be spent on two of them. +# Unknown difficulty (`-`, the headerless prompts) counts as medium: the middle +# estimate, never the free one. +BUNDLE_SIZE_POINTS = {"small": 1, "medium": 2, "large": 4} +BUNDLE_UNKNOWN_POINTS = 2 +BUNDLE_POINT_CAP = 8 +BUNDLE_MAX_MEMBERS = 4 +BUNDLE_MAX_LARGE = 1 +BUNDLE_MIN_MEMBERS = 2 + +# How many AUTO bundles reach the page. Same rule as PICK_LIST_MAX below and +# for the same reason: a section is read to be picked from, and one card per +# repo in the Mind is an inventory, not a pick list. Pinned bundles are never +# capped — a human put them there. The cut is ranked, not arbitrary (see +# `bundle_cards`), and the footer says what was left off and how to keep it. +BUNDLE_LIST_MAX = 8 + + +def _bundle_points(rows: list) -> int: + """A bundle's total size in points (see BUNDLE_SIZE_POINTS).""" + return sum(BUNDLE_SIZE_POINTS.get(r.get("difficulty", "-"), + BUNDLE_UNKNOWN_POINTS) for r in rows) + + +def _auto_excluded(r: dict, pinned: set) -> str: + """Why a draft prompt cannot join an AUTO bundle — `''` when it can. + + A bundle is worked with the members running as independent, mostly + unattended subagent tasks, so anything that is not independently startable + stays out: epic members (worked in phase order through their epic), a + prompt a human already pinned or headed `Bundle:` (it belongs to that + bundle, not to a computed one), a declared `Blocked-by:` gate, a task whose + autonomy says a human must drive it, and `too-large` work that is a session + on its own. `Blocked-by:` reads as UNRESOLVED here whatever GitHub says: + this renderer never makes a network call (it runs bare in PyAutoMind's + `dashboard_refresh.yml`), and proposing a gated task is the more expensive + mistake. A prompt with no target folder (`-`) cannot be grouped by repo at + all, so it is not proposed either. + """ + if r.get("epic"): + return "epic member" + if r["path"] in pinned or r.get("bundle"): + return "pinned" + if (r.get("header") or {}).get("blocked-by"): + return "blocked-by" + if r.get("autonomy") == "human-required": + return "human-required" + if r.get("difficulty") == "too-large": + return "too-large" + if r.get("target", "-") == "-": + return "no target" + return "" + + +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. + """ + 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: + if len(pack) < BUNDLE_MIN_MEMBERS: + continue + n += 1 + out.append({ + "slug": f"auto-{target}-{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, + "points": _bundle_points(pack), + }) + return out + + +def bundle_prompt(b: dict) -> str: + """The one-tap orchestration prompt: run this whole bundle in one session. + + A procedure, not a snapshot — like `_epic_prompt`, everything that could go + stale (issue numbers, branch names, who is left) is worked out by the + session from the member prompts themselves. The contract it states is the + `start_bundle` skill's, in short form: one issue per member (the no-bulk- + issue rule still holds), one shared worktree per repo, per-task PRs so + `/prm` closes each member out unchanged. + """ + members = [m["path"] for m in b["members"]] + L = [f"You are the architect (Fable) for the PyAutoMind bundle " + f"'{b.get('title') or b['slug']}' — {len(members)} INDEPENDENT tasks " + "run in one orchestrated session.", + "", + "Members:"] + L += [f"- {p}" for p in members] + if b.get("rationale"): + L += ["", f"Why they are bundled: {b['rationale']}"] + L += [ + "", + "Contract (the `start_bundle` skill is the full body):", + "1. Read each member prompt above in full, and plan all of them " + "before editing anything. The members are independent — if any turns " + "out to depend on another, say so and drop it from the bundle.", + "2. Run `/start_dev ` for EACH member: one plan, one " + "issue, one registry entry per member. Never file them as a bulk " + "issue queue and never merge them into one issue.", + "3. One shared worktree per repo, not one per member: run " + "`/start_library` (or `/start_workspace`) once, naming the bundle as " + "the task and listing every member's repos. A worktree holds one " + "branch at a time, so inside it members are worked one at a time, " + "each on its own `feature/` branch cut from " + "`origin/main`; members in different repos may run in parallel.", + "4. Delegate the implementation of each member to an Opus subagent " + "via the Agent tool (`Agent(model=\"opus\", …)`), one subagent per " + "member, with the member's issue plan, the worktree path and the " + "branch to use. You plan, judge and talk to the user; the subagents " + "edit, test and report back.", + "5. Ship each member on its own: `/ship_library` or " + "`/ship_workspace`, ONE PR per task, so `/prm` closes each member out " + "unchanged. Never one PR for the bundle.", + "6. Report per member: issue, branch, PR, and pass/fail counts.", + ] + return "\n".join(L) + + +def bundle_cards(c: dict) -> list: + """Every bundle the page renders: pinned first (registry order), then auto. + + One place computes this so `render_dashboard` and its HTML twin cannot + drift apart. Pinned members are prompt paths in `bundles.md` plus any + prompt whose own header says `Bundle: `; a path that resolves to no + filed prompt still renders (as itself), because a bundle naming a prompt + that has moved is exactly the drift worth seeing. A `Bundle:` slug that is + in no registry entry gets its own card flagged `unknown` — the same + loud-not-silent treatment an unregistered `Epic:` gets. + + Auto bundles are then RANKED and capped at `BUNDLE_LIST_MAX`: most urgent + member first (a bundle is only as pickable as its most urgent task), then + the biggest session, then slug as a stable tie-break. Ranking before + cutting is the whole point — an alphabetical cut would show whichever + repos sort early rather than whichever sessions are worth running. + """ + by_path = {r["path"]: r for r in c.get("records") or []} + declared: dict = {} + for r in c.get("records") or []: + if r.get("bundle"): + declared.setdefault(r["bundle"], []).append(r) + + def _resolve(paths): + rows = [] + for p in paths: + rows.append(by_path.get(p) or { + "path": p, "title": p, "difficulty": "-", "priority": "-", + "status": "-", "work_type": "-", "target": "-", + "autonomy": "-", "missing": []}) + return rows + + cards = [] + for b in c.get("bundles") or []: + rows = _resolve(b["members"]) + seen = {r["path"] for r in rows} + 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, + "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)}) + auto = auto_bundles(c) + ranked = sorted(auto, key=lambda b: ( + min(PRIORITY_RANK.get(m.get("priority", "-"), 9) for m in b["members"]), + -b["points"], b["slug"])) + shown = ranked[:BUNDLE_LIST_MAX] + for b in shown: + # What the footer needs, carried on the cards rather than recomputed: + # both renderers ask the same question and must give the same answer. + b["auto_total"] = len(auto) + return cards + shown + + +def _bundle_footer(cards: list) -> str: + """`Showing 8 of 20 auto bundles …` — `''` when nothing was left off. + + A cut is only honest if the page says it happened, and pinning is the + answer to "but I wanted that one", so the line carries both. + """ + shown = [b for b in cards if b["origin"] == "auto"] + total = max((b.get("auto_total", 0) for b in shown), default=0) + if total <= len(shown): + return "" + return (f"Showing {len(shown)} of {total} auto bundles — pin one in " + "`bundles.md` to keep it on the page.") + + +def _bundle_head(b: dict) -> str: + """A bundle card's summary line: name, size, where it came from.""" + origin = "auto — proposed" if b["origin"] == "auto" else "pinned" + head = (f"{_summary_label(b.get('title') or b['slug'])} — " + f"{len(b['members'])} task(s) · {b['points']} pts · {origin}") + if b.get("status"): + head += f" — {_summary_label(_clip(b['status']))}" + return head + + +BUNDLE_TABLE_HEAD = ["| Prompt | 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 " + "member still gets its own issue and its own PR — so `/prm` closes each " + "one out unchanged. Not an epic: nothing here is ordered or phase-gated, " + "and every member also appears in its usual section above — a bundle is " + "an extra view of the backlog, never a replacement. Pinned bundles " + "are the human record in `bundles.md`; auto bundles are recomputed " + "from the backlog every time this page is rendered and are proposals, " + "never records.") + +BUNDLE_RUN_LABEL = ("Run this bundle — one session, one issue and one " + "PR per member") + + +def _bundle_rows_md(b: dict) -> list: + """A bundle's members as a table: what each one costs and where it stands. + + The page's other lists are rows-not-tables because a 133-row backlog has to + read on a phone (`_bullet`); a bundle has at most four members, and the + 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) + 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)))} |") + return rows + + +def _bundle_section(cards: list) -> list: + """The `## Bundles` section of `dashboard.md`.""" + L = ["## Bundles", "", + BUNDLE_BLURB + " Full record in [`bundles.md`](bundles.md).", ""] + for b in cards: + head = _bundle_head(b) + if b.get("unknown"): + head += " — ⚠️ not in `bundles.md`" + L += ["
", f"{head}", ""] + L += _items([_task_row(BUNDLE_RUN_LABEL, bundle_prompt(b))]) + if b.get("rationale"): + L += ["", _summary_label(b["rationale"])] + L += [""] + _bundle_rows_md(b) + ["", "
", ""] + footer = _bundle_footer(cards) + if footer: + L += [f"_{footer}_", ""] + return L + + +# Bundle members render as a real table on the Pages twin, and the shared board +# theme styles only `table.recent` — so the rule travels with the section +# rather than with the page head, which keeps the head byte-identical on a Mind +# that has no bundles at all. +_BUNDLE_CSS = """\ +table.bundle{width:100%;border-collapse:collapse;font-size:.95em; + margin:.1rem 0 .7rem} +table.bundle td,table.bundle th{border-bottom:1px solid var(--line); + padding:.35rem .5rem;text-align:left} +table.bundle th{color:var(--muted);font-weight:600;font-size:.85em} +table.bundle td.facet{white-space:nowrap;color:var(--muted);font-size:.85em} +""" + + +def _bundle_section_html(cards: list, blob: str) -> list: + """The Bundles section of `dashboard.html` — same cards, real copy buttons.""" + H = [f"", + f'

{_summary_label(BUNDLE_BLURB)}

'] + for b in cards: + head = _bundle_head(b) + if b.get("unknown"): + head += " — ⚠️ not in bundles.md" + H += ["
", f"{head}", + _html_task(BUNDLE_RUN_LABEL, bundle_prompt(b))] + if b.get("rationale"): + H.append(f'

{_summary_label(b["rationale"])}

') + H += ['', + "" + ""] + for r in b["members"]: + H += ["", + f'', + f'', + f'', + f'', + ""] + H += ["
PromptDifficultyPriorityStatus
' + f'{_summary_label(_clip(r["title"], 70))}{_summary_label(r.get("difficulty", "-"))}{_summary_label(r.get("priority", "-"))}' + f'{_summary_label(_clip(r.get("status", "-"), 40))}
", "
"] + footer = _bundle_footer(cards) + if footer: + H.append(f'

{_summary_label(footer)}

') + return H + + 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 "" @@ -735,6 +1138,9 @@ def census(mind: Path) -> dict: "status": header.get("status", "-"), "epic": header.get("epic", ""), "phase": phase, + # Bundle membership a human PINNED in the prompt itself; auto + # bundles never write here (see `auto_bundles`). + "bundle": header.get("bundle", ""), # `Filed:` normally; `Issued:` only on a prompt that has been # issued and moved back, which is still the later event. "date": _header_date(header), @@ -816,6 +1222,7 @@ def _count(key): "records": records, "in_flight": in_flight, "epics": parse_epics(mind / "epics.md"), + "bundles": parse_bundles(mind / "bundles.md"), "parked": parked, "planned": planned, "hygiene": hygiene, @@ -1232,6 +1639,14 @@ def render_dashboard(c: dict) -> str: L += _items([_bullet(r) for r in rows]) L += ["", "", ""] + # Bundles read the backlog ABOVE a second way — as sessions rather than + # as tasks — so they sit directly under it, before the page turns to + # "what has been happening". Members are not removed from anything above: + # a bundle is an extra view, never a replacement (unlike an epic). + cards = bundle_cards(c) + if cards: + L += _bundle_section(cards) + # Recency is orthogonal to state, so it gets its own table rather than a # column on any section above. A table, not the page's usual copy rows: # this section is read, not picked from — the task's own section is where @@ -1481,6 +1896,10 @@ def h2(title, src): H += [record_row(r) for r in rows] H += [""] + cards = bundle_cards(c) + if cards: + H += [h2("Bundles", "bundles.md")] + _bundle_section_html(cards, blob) + recent = c.get("recent") or [] if recent: H += ['' + h2("Recent", "dashboard.md#recent"), diff --git a/skills/WORKFLOW.md b/skills/WORKFLOW.md index de876df..f7cc06d 100644 --- a/skills/WORKFLOW.md +++ b/skills/WORKFLOW.md @@ -116,6 +116,19 @@ implementation phases (`start_library` / `start_workspace` source edits, script authoring, test writing, fix loops) to Opus as well, one subagent per coherent phase, using the same prompt contract below. +**Several tasks in one session — `start_bundle`.** The ladder above scales a +single task across tiers; a **bundle** scales one session across several +*independent* tasks. The PyAutoMind dashboard's Bundles section proposes them +(and `PyAutoMind/bundles.md` pins them); [`start_bundle/start_bundle.md`](start_bundle/start_bundle.md) +is the contract: the architect session reads every member, runs `/start_dev` +per member (one issue each — the no-bulk-issue rule is unchanged), creates +**one shared worktree per repo** for the whole bundle, delegates each member's +implementation to its own execution-tier subagent under the prompt contract +below, and ships **one PR per member** so `/prm` closes each out unchanged. +Within one repo the members are sequenced (a worktree holds one branch at a +time); across repos they may run in parallel. A bundle is never an epic — if +the members need an order, it belongs in `epics.md` instead. + **Stays in the judgment tier:** planning (`start_dev`), environment setup (`start_library`/`start_workspace`), release triage (`review_release`); identifying affected repos, drafting the commit message and full PR body diff --git a/skills/start_bundle/SKILL.md b/skills/start_bundle/SKILL.md new file mode 100644 index 0000000..74e93ba --- /dev/null +++ b/skills/start_bundle/SKILL.md @@ -0,0 +1,13 @@ +--- +name: start-bundle +description: Run a PyAutoMind bundle — a set of independent tasks worked in one orchestrated session, with one issue and one PR per member. Use when a dashboard Bundles card is copied in, or when several unrelated prompts in the same repo are to be done in one go. +--- + +# Start Bundle + +Follow [`start_bundle.md`](start_bundle.md) in this directory exactly — the +authoritative orchestration contract. Shared context, the capability ladder and +the subagent prompt contract are in [`../WORKFLOW.md`](../WORKFLOW.md). A bundle +is a set of INDEPENDENT tasks: every member still goes through `/start_dev` and +ships its own PR, so `/prm` closes each one out unchanged. Do not duplicate or +reinterpret the contract here — if it changes, edit `start_bundle.md`. diff --git a/skills/start_bundle/start_bundle.md b/skills/start_bundle/start_bundle.md new file mode 100644 index 0000000..8fed97b --- /dev/null +++ b/skills/start_bundle/start_bundle.md @@ -0,0 +1,93 @@ +# Start Bundle: Run Several Independent Tasks in One Session + +A **bundle** is a set of *independent* PyAutoMind prompts worked in one +orchestrated session. The session model is the **architect**: it plans and +judges, and delegates the implementation of each member to a subagent one rung +down the capability ladder (see [`WORKFLOW.md`](../WORKFLOW.md) — Fable > Opus > +Sonnet; a Fable session delegates to Opus). Bundles come from the PyAutoMind +dashboard's **Bundles** section — pinned entries in `PyAutoMind/bundles.md`, or +proposals it computes from the backlog — or from a human naming several prompts. + +**A bundle is not an epic.** An epic is ordered and phase-gated: one phase at a +time, through its ledger. A bundle has no order at all. If the members turn out +to depend on each other, it is not a bundle — say so, work the first one, and +file the rest as an epic or as ordinary backlog. + +**What a bundle does NOT change:** one prompt = one task = one issue = one PR. +The bundle is an orchestration convenience, never a batching of the record. + +## Steps + +### 1. Read every member first + +Read each member prompt in full *before* planning any of them. Confirm they are +independent (no member's plan needs another's merge) and that they belong to the +repos the bundle claims. Drop anything that is blocked, `Autonomy: +human-required`, or `Difficulty: too-large` — those are worked alone. Report the +membership you are actually taking on, and what you dropped and why. + +### 2. One `/start_dev` per member — never a bulk issue queue + +Run `/start_dev ` for **each** member: each gets its own +plan, its own GitHub issue and its own `PyAutoMind/active.md` entry. This is the +no-bulk-issue-series rule, unchanged — a bundle files the same issues a human +would have filed one at a time, in one sitting. Never merge members into one +issue, and never open issues for members you have not planned. + +### 3. One shared worktree per repo + +Create the worktree **once** for the whole bundle, not once per member: + +```bash +source PyAutoBrain/bin/worktree.sh +worktree_create [repo2 ...] +``` + +listing every repo any member touches, then follow `/start_library` (or +`/start_workspace` for workspace/tutorial members) for the registration and +activation steps. Register the claim in `active.md` under each member's task +entry, naming the shared `worktree:` path. + +A git worktree holds **one branch at a time**, so inside one repo the members +are worked **one at a time**, each on its own `feature/` branch cut +from `origin/main` — never all of them on one branch, which would collapse the +bundle into a single PR. Members whose repos do not overlap have separate +worktrees and may run in parallel. + +### 4. Delegate each member to a subagent + +One subagent per member (`Agent(model="opus", …)` from a Fable session; the +execution tier of [`WORKFLOW.md`](../WORKFLOW.md) otherwise), passing the +subagent prompt contract from that file: the worktree path, the repo list, the +branch to work on, the member's issue plan, and the instruction to stop and +report verbatim on failure rather than editing tests to pass. The architect +session keeps planning, judgment, registry updates and everything user-facing. + +Run subagents in parallel **only** where their branches are in different +worktrees. Within one repo, sequence them: implement → ship → cut the next +branch. + +### 5. Ship each member on its own + +`/ship_library` or `/ship_workspace` per member — **one PR per task**, so +`/prm` closes each member out with no bundle-specific handling anywhere. Library +members ship before the workspace members that depend on them (the library-first +gate is unchanged). + +### 6. Close out + +`/prm` per member, as usual. Then, if the bundle was **pinned** in +`PyAutoMind/bundles.md`, update or remove its entry (its `status:`, or the whole +entry once every member has shipped) and regenerate the dashboard +(`pyauto-brain intake --apply dashboard`). Auto bundles need no cleanup — they +are recomputed from the backlog on every render and disappear on their own. + +## Notes + +- A member that turns out to be much larger than its `Difficulty:` said is + removed from the bundle and finished on its own — never rushed to keep the + session tidy. +- Bundle membership never rewrites prompt files. `Bundle: ` in a prompt + header is written by a **human**; the dashboard's proposals stay on the page. +- Report per member at the end: issue, branch, PR, test pass/fail counts, and + anything left open. diff --git a/tests/test_intake_dashboard.py b/tests/test_intake_dashboard.py index 70726c3..19173ee 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -99,11 +99,14 @@ def test_every_backlog_prompt_is_one_collapsed_row_not_a_wide_table(tmp_path): "feature/widgets/two.md": _prompt("Feature two"), }) page = _page(mind) - backlog = page.split("## Backlog")[1] + # The Backlog SECTION, not "everything after Backlog": Bundles renders a + # members table directly below it (a bundle is a comparison of four rows, + # not a 133-row pick list), and that table is not a backlog regression. + backlog = page.split("## Backlog")[1].split("\n## ")[0] assert ('
📋 ' "Bug one — ") in backlog assert 'Feature two' in backlog - # The only table on the page is the 2-column where/count summary. + # No table in the backlog itself — a wide table is what this pins against. assert backlog.count("|") == 0, "the backlog must not render as tables" assert "bug — 1" in backlog, \ "long sections must be collapsible" @@ -835,3 +838,351 @@ def test_issued_beats_filed_on_a_prompt_carrying_both(tmp_path): rows = _intake.census( _mind(tmp_path, active={"sprocket.md": body}))["recent"] assert [(r["date"], r["event"]) for r in rows] == [("2026-08-19", "issued")] + + +# --------------------------------------------------------------------------- # +# bundles: several INDEPENDENT tasks in one orchestrated session +# --------------------------------------------------------------------------- # +# A bundle is the opposite of an epic. An epic is ordered and phase-gated, and +# its members are pulled out of every pick list; a bundle is a flat set whose +# members stay exactly where they were and gain a second, session-shaped view. +# Pinned bundles are the human record in `bundles.md`; auto bundles are computed +# at render time and never written anywhere — so these tests drive the renderer +# against a fixture Mind and assert on the page, never on a file. +_BUNDLES = """# Bundles + +## euclid-tidy +- title: Euclid pipeline tidy-up +- members: + - draft/feature/widgets/pinned_one.md + - draft/feature/widgets/pinned_two.md +- rationale: same reviewer, same afternoon +- status: proposed 2026-08-27 +""" + + +def _bundle_page(mind: Path) -> str: + return _page(mind).split("## Bundles")[1].split("\n## ")[0] + + +def _card_titles(section: str) -> list: + return re.findall(r"([^<]+) — \d+ task\(s\)", section) + + +def test_auto_bundles_group_by_target_repo(tmp_path): + """Independent tasks bundle only with tasks in the same repo — a session + that spans two repos is two worktrees and two sets of tests.""" + mind = _mind(tmp_path, drafts={ + "feature/widgets/a.md": _prompt("Widget A"), + "feature/widgets/b.md": _prompt("Widget B"), + "bug/gadgets/c.md": _prompt("Gadget C").replace("Target: widgets", + "Target: gadgets"), + "bug/gadgets/d.md": _prompt("Gadget D").replace("Target: widgets", + "Target: gadgets"), + }) + bundles = _intake.auto_bundles(_intake.census(mind)) + assert [b["slug"] for b in bundles] == ["auto-gadgets-1", "auto-widgets-1"] + assert [[m["title"] for m in b["members"]] for b in bundles] == [ + ["Gadget C", "Gadget D"], ["Widget A", "Widget B"]] + + +def test_a_lone_prompt_is_not_a_bundle(tmp_path): + """One task is a task. The minimum is two, or the section is just the + backlog again with extra words.""" + mind = _mind(tmp_path, drafts={"feature/widgets/only.md": _prompt("Only")}) + assert _intake.auto_bundles(_intake.census(mind)) == [] + assert "## Bundles" not in _page(mind) + + +def test_each_exclusion_keeps_a_prompt_out_of_the_auto_pool(tmp_path): + """Everything a bundle member must be: startable on its own, unblocked, + not already spoken for, and not a session in itself.""" + blocked = _prompt("Blocked one").replace( + "Status: formalised", "Status: formalised\nBlocked-by: Widgets#12") + mind = _mind(tmp_path, registries={"epics.md": _EPICS, + "bundles.md": _BUNDLES}, drafts={ + "feature/widgets/ok_one.md": _prompt("Fine one"), + "feature/widgets/ok_two.md": _prompt("Fine two"), + "feature/widgets/blocked.md": blocked, + "feature/widgets/human.md": _prompt("Human one", + autonomy="human-required"), + "feature/widgets/huge.md": _prompt("Huge one", difficulty="too-large"), + "feature/widgets/phase.md": _epic_prompt_body("Phase one", + "jax-profiling", 1), + "feature/widgets/pinned_one.md": _prompt("Pinned one"), + "feature/widgets/pinned_two.md": _prompt("Pinned two"), + "feature/widgets/headed.md": _prompt("Header-pinned").replace( + "Status: formalised", "Status: formalised\nBundle: euclid-tidy"), + }) + auto = _intake.auto_bundles(_intake.census(mind)) + assert [m["title"] for b in auto for m in b["members"]] == ["Fine one", + "Fine two"] + + +def test_a_declared_gate_reads_as_unresolved(tmp_path): + """The renderer makes no network call (it runs bare in the Mind's refresh + workflow), so a `Blocked-by:` is treated as still closed — proposing a + gated task is the more expensive mistake.""" + mind = _mind(tmp_path, drafts={ + "feature/widgets/a.md": _prompt("Open one"), + "feature/widgets/b.md": _prompt("Gated one").replace( + "Status: formalised", "Status: formalised\nBlocked-by: Widgets#1")}) + assert _intake.auto_bundles(_intake.census(mind)) == [] + + +def test_the_size_cap_starts_a_new_bundle(tmp_path): + """Points, not counts: one large task plus three small ones is a session; + a second large one is the next session.""" + drafts = {f"feature/widgets/s{i}.md": _prompt(f"Small {i}", + difficulty="small") + for i in range(3)} + drafts["feature/widgets/l1.md"] = _prompt("Large one", difficulty="large", + priority="high") + drafts["feature/widgets/l2.md"] = _prompt("Large two", difficulty="large", + priority="high") + drafts["feature/widgets/s9.md"] = _prompt("Small nine", difficulty="small", + priority="high") + bundles = _intake.auto_bundles(_intake.census(_mind(tmp_path, drafts=drafts))) + assert [[m["title"] for m in b["members"]] for b in bundles] == [ + ["Large one", "Small nine", "Small 0", "Small 1"], + ["Large two", "Small 2"]] + assert [b["points"] for b in bundles] == [7, 5] + for b in bundles: + assert b["points"] <= _intake.BUNDLE_POINT_CAP + assert len(b["members"]) <= _intake.BUNDLE_MAX_MEMBERS + assert sum(m["difficulty"] == "large" for m in b["members"]) <= 1 + + +def test_four_medium_tasks_are_one_bundle(tmp_path): + """The other shape the cap is drawn around (4 × medium = 8 points).""" + drafts = {f"feature/widgets/m{i}.md": _prompt(f"Medium {i}") + for i in range(4)} + bundles = _intake.auto_bundles(_intake.census(_mind(tmp_path, drafts=drafts))) + assert len(bundles) == 1 and bundles[0]["points"] == 8 + assert len(bundles[0]["members"]) == 4 + + +def test_auto_bundles_are_priority_ordered_and_deterministic(tmp_path): + """Most-pickable first, and the same input renders the same page — the + nightly re-render must not churn the section every time it runs.""" + mind = _mind(tmp_path, drafts={ + "feature/widgets/b_low.md": _prompt("Low one", priority="low"), + "feature/widgets/a_high.md": _prompt("High one", priority="high"), + "feature/widgets/c_high.md": _prompt("High two", priority="high"), + }) + c = _intake.census(mind) + assert [m["title"] for m in _intake.auto_bundles(c)[0]["members"]] == [ + "High one", "High two", "Low one"] + assert _intake.auto_bundles(c) == _intake.auto_bundles(_intake.census(mind)) + assert _bundle_page(mind) == _bundle_page(mind) + + +def test_a_bundle_member_still_appears_in_the_backlog(tmp_path): + """A bundle is an extra VIEW of the backlog, never a replacement — the + opposite of an epic, whose members leave every pick list.""" + mind = _mind(tmp_path, drafts={ + "feature/widgets/a.md": _prompt("Widget A", priority="high"), + "feature/widgets/b.md": _prompt("Widget B", priority="high")}) + page = _page(mind) + backlog = page.split("## Backlog")[1].split("\n## ")[0] + assert "Widget A" in backlog and "Widget B" in backlog + assert "Widget A" in page.split("## Start here")[1].split("## In flight")[0] + + +def test_pinned_bundles_come_first_and_carry_their_registry_prose(tmp_path): + """`bundles.md` is the human record; the proposals follow it.""" + mind = _mind(tmp_path, registries={"bundles.md": _BUNDLES}, drafts={ + "feature/widgets/pinned_one.md": _prompt("Pinned one"), + "feature/widgets/pinned_two.md": _prompt("Pinned two"), + "feature/widgets/loose_a.md": _prompt("Loose A"), + "feature/widgets/loose_b.md": _prompt("Loose B"), + }) + section = _bundle_page(mind) + assert _card_titles(section) == ["Euclid pipeline tidy-up", "widgets — bundle 1"] + assert "same reviewer, same afternoon" in section + assert "proposed 2026-08-27" in section + assert section.index("Pinned one") < section.index("Loose A") + assert "· pinned" in section and "· auto — proposed" in section + + +def test_a_pinned_member_leaves_the_auto_pool(tmp_path): + """A pinned prompt belongs to its bundle, not to a computed one.""" + mind = _mind(tmp_path, registries={"bundles.md": _BUNDLES}, drafts={ + "feature/widgets/pinned_one.md": _prompt("Pinned one"), + "feature/widgets/pinned_two.md": _prompt("Pinned two"), + }) + assert _intake.auto_bundles(_intake.census(mind)) == [] + + +def test_a_header_declared_member_joins_its_pinned_bundle(tmp_path): + """`Bundle: ` in a prompt header is the second way to pin — the + dashboard merges it into the registry entry's members.""" + mind = _mind(tmp_path, registries={"bundles.md": _BUNDLES}, drafts={ + "feature/widgets/pinned_one.md": _prompt("Pinned one"), + "feature/widgets/pinned_two.md": _prompt("Pinned two"), + "feature/widgets/headed.md": _prompt("Header-pinned").replace( + "Status: formalised", "Status: formalised\nBundle: euclid-tidy"), + }) + cards = _intake.bundle_cards(_intake.census(mind)) + assert [m["title"] for m in cards[0]["members"]] == [ + "Pinned one", "Pinned two", "Header-pinned"] + + +def test_a_member_of_an_unregistered_bundle_still_groups_loudly(tmp_path): + """A typo shows up on the page instead of silently rendering nothing — + the same treatment an unregistered `Epic:` slug gets.""" + mind = _mind(tmp_path, drafts={ + "feature/widgets/one.md": _prompt("Stray one").replace( + "Status: formalised", "Status: formalised\nBundle: no-such-bundle"), + }) + section = _bundle_page(mind) + assert "no-such-bundle" in section + assert "not in `bundles.md`" in section + assert "Stray one" in section + html = _intake.render_dashboard_html(_intake.census(mind)) + assert "not in bundles.md" in _prose(html) + + +def test_a_missing_member_prompt_still_renders(tmp_path): + """A pinned path that resolves to no filed prompt is exactly the drift + worth seeing — it renders as itself rather than vanishing.""" + mind = _mind(tmp_path, registries={"bundles.md": _BUNDLES}, drafts={ + "feature/widgets/pinned_one.md": _prompt("Pinned one")}) + section = _bundle_page(mind) + assert "draft/feature/widgets/pinned_two.md" in section + + +def test_the_bundle_prompt_states_the_orchestration_contract(tmp_path): + """The 📋 payload is the whole contract: one issue and one PR per member, + one shared worktree per repo, execution delegated a rung down.""" + mind = _mind(tmp_path, drafts={ + "feature/widgets/a.md": _prompt("Widget A"), + "feature/widgets/b.md": _prompt("Widget B")}) + prompt = _intake.bundle_prompt(_intake.auto_bundles(_intake.census(mind))[0]) + assert "architect (Fable)" in prompt + assert "draft/feature/widgets/a.md" in prompt + assert "/start_dev " in prompt + assert "one issue" in prompt and "bulk issue queue" in prompt + assert "One shared worktree per repo" in prompt + assert "Opus subagent" in prompt + assert "ONE PR per task" in prompt + assert "/prm" in prompt + assert "/ship_library" in prompt + + +def test_bundles_sit_between_backlog_and_recent_on_both_pages(tmp_path): + """Bundles read the backlog a second way, so they sit under it — and the + page still turns to Recent afterwards.""" + mind = _mind(tmp_path, drafts={ + "feature/widgets/a.md": _prompt("Widget A").replace( + "Status: formalised", "Status: formalised\nFiled: 2026-08-20"), + "feature/widgets/b.md": _prompt("Widget B").replace( + "Status: formalised", "Status: formalised\nFiled: 2026-08-21")}) + page = _page(mind) + assert page.index("## Backlog") < page.index("## Bundles") \ + < page.index("## Recent") + html = _intake.render_dashboard_html(_intake.census(mind)) + assert html.index("

Backlog") < html.index("

Bundles") \ + < html.index("

Recent") + section = html.split("

Bundles")[1].split("

")[0] + assert '' in section + assert '