feat(beads): integrate Beads Mode backend, MCP tools, and GUI support

This commit is contained in:
2026-05-06 13:48:47 -04:00
parent b1ddaa50f4
commit 2b66f3569b
17 changed files with 525 additions and 77 deletions
+36 -5
View File
@@ -19,6 +19,7 @@ from pathlib import Path, PureWindowsPath
from typing import Any, cast
from src import summarize
from src import project_manager
from src import beads_client
from src.file_cache import ASTParser
def find_next_increment(output_dir: Path, namespace: str) -> int:
@@ -197,7 +198,28 @@ def _build_files_section_from_items(file_items: list[dict[str, Any]]) -> str:
sections.append(f"### `{original}`\n\n```{lang}\n{content}\n```")
return "\n\n---\n\n".join(sections)
def build_markdown_from_items(file_items: list[dict[str, Any]], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False, aggregation_strategy: str = "auto") -> str:
def build_beads_section(base_dir: Path) -> str:
client = beads_client.BeadsClient(base_dir)
if not client.is_initialized():
return ""
beads = client.list_beads()
if not beads:
return ""
active = [b for b in beads if b.status == "active"]
completed = [b for b in beads if b.status == "completed"]
parts = []
parts.append("## Beads Mode: Progress Track")
if completed:
parts.append("### Completed Beads")
comp_list = ", ".join([f"`{b.title}`" for b in completed])
parts.append(comp_list)
if active:
parts.append("### Active Beads")
for b in active:
parts.append(f"- **{b.title}** ({b.id}): {b.description}")
return "\n\n".join(parts)
def build_markdown_from_items(file_items: list[dict[str, Any]], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False, aggregation_strategy: str = "auto", execution_mode: str = "standard", base_dir: Path | None = None) -> str:
"""Build markdown from pre-read file items instead of re-reading from disk."""
parts = []
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
@@ -213,7 +235,11 @@ def build_markdown_from_items(file_items: list[dict[str, Any]], screenshot_base_
parts.append("## Files\n\n" + _build_files_section_from_items(file_items))
if screenshots:
parts.append("## Screenshots\n\n" + build_screenshots_section(screenshot_base_dir, screenshots))
# DYNAMIC SUFFIX: History changes every turn, must go last
if execution_mode == "beads" and base_dir:
beads_md = build_beads_section(base_dir)
if beads_md:
parts.append(beads_md)
# DYNAMIC SUFFIX: History changes every turn, must go last
if history:
parts.append("## Discussion History\n\n" + build_discussion_section(history))
return "\n\n---\n\n".join(parts)
@@ -309,7 +335,7 @@ def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: P
parts.append("## Discussion History\n\n" + build_discussion_section(history))
return "\n\n---\n\n".join(parts)
def build_markdown(base_dir: Path, files: list[str | dict[str, Any]], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False) -> str:
def build_markdown(base_dir: Path, files: list[str | dict[str, Any]], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False, execution_mode: str = "standard") -> str:
parts = []
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
if files:
@@ -319,7 +345,11 @@ def build_markdown(base_dir: Path, files: list[str | dict[str, Any]], screenshot
parts.append("## Files\n\n" + build_files_section(base_dir, files))
if screenshots:
parts.append("## Screenshots\n\n" + build_screenshots_section(screenshot_base_dir, screenshots))
# DYNAMIC SUFFIX: History changes every turn, must go last
if execution_mode == "beads":
beads_md = build_beads_section(base_dir)
if beads_md:
parts.append(beads_md)
# DYNAMIC SUFFIX: History changes every turn, must go last
if history:
parts.append("## Discussion History\n\n" + build_discussion_section(history))
return "\n\n---\n\n".join(parts)
@@ -340,8 +370,9 @@ def run(config: dict[str, Any], aggregation_strategy: str = "auto") -> tuple[str
# Build file items once, then construct markdown from them (avoids double I/O)
file_items = build_file_items(base_dir, files)
summary_only = config.get("project", {}).get("summary_only", False)
execution_mode = config.get("project", {}).get("execution_mode", "standard")
markdown = build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history,
summary_only=summary_only, aggregation_strategy=aggregation_strategy)
summary_only=summary_only, aggregation_strategy=aggregation_strategy, execution_mode=execution_mode, base_dir=base_dir)
output_file.write_text(markdown, encoding="utf-8")
return markdown, output_file, file_items
+44 -2
View File
@@ -234,6 +234,7 @@ class AppController:
self.ui_project_git_dir: str = ""
self.ui_project_main_context: str = ""
self.ui_project_system_prompt: str = ""
self.ui_project_execution_mode: str = "native"
self.ui_gemini_cli_path: str = "gemini"
self.ui_word_wrap: bool = True
self.ui_auto_add_history: bool = False
@@ -954,6 +955,25 @@ class AppController:
elapsed = end_time - start_time
self._completed_ticket_count += 1
self._avg_ticket_time = ((self._avg_ticket_time * (self._completed_ticket_count - 1)) + elapsed) / self._completed_ticket_count
elif action == "bead_updated":
payload = task.get("payload", {})
bid = payload.get("bead_id")
status = payload.get("status")
if bid and status:
stream_id = "Tier 2"
msg = f"\n[BEAD UPDATE] {bid} -> status: {status}\n"
if stream_id not in self.mma_streams:
self.mma_streams[stream_id] = ""
self.mma_streams[stream_id] += msg
elif action == "bead_updated":
payload = task.get("payload", {})
bead_id = payload.get("bead_id")
status = payload.get("status")
stream_id = "Tier 2 (Tech Lead)"
if stream_id not in self.mma_streams:
self.mma_streams[stream_id] = ""
self.mma_streams[stream_id] += f"[BEAD UPDATE] {bead_id} -> status: {status}\n"
except Exception as e:
import traceback
sys.stderr.write(f"[DEBUG] Error executing GUI task: {e}\n{traceback.format_exc()}\n")
@@ -2367,8 +2387,8 @@ class AppController:
description=state.metadata.name,
tickets=tickets
)
# Keep dicts for UI table (or convert models.Ticket objects back to dicts if needed)
self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in tickets]
# Keep dicts for UI table
self._load_active_tickets()
# Load track-scoped history
history = project_manager.load_track_history(track_id, self.active_project_root)
with self._disc_entries_lock:
@@ -3143,4 +3163,26 @@ class AppController:
)
project_manager.save_track_state(self.active_track.id, state, self.active_project_root)
def _load_active_tickets(self) -> None:
"""Populates self.active_tickets based on the current execution mode."""
if getattr(self, "ui_project_execution_mode", "native") == "beads":
from src import beads_client
bclient = beads_client.BeadsClient(Path(self.active_project_root))
beads = bclient.list_beads()
self.active_tickets = []
for b in beads:
self.active_tickets.append({
"id": b.id,
"title": b.title,
"description": b.description,
"status": b.status,
"assigned_to": "tier3-worker",
"target_file": "",
"depends_on": []
})
else:
if self.active_track:
self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in self.active_track.tickets]
else:
self.active_tickets = []
+58
View File
@@ -0,0 +1,58 @@
from dataclasses import dataclass
from typing import List, Optional
from pathlib import Path
import json
@dataclass
class Bead:
id: str
title: str
description: str
status: str = "active"
class BeadsClient:
def __init__(self, working_dir: Path):
self.working_dir = Path(working_dir)
self.repo_dir = self.working_dir / ".beads_mock"
self.beads_file = self.repo_dir / "beads.json"
def init_repo(self) -> None:
"""Initialize the mock repository."""
self.repo_dir.mkdir(parents=True, exist_ok=True)
if not self.beads_file.exists():
self.beads_file.write_text("[]", encoding="utf-8")
def is_initialized(self) -> bool:
"""Check if the repository is initialized."""
return self.beads_file.exists()
def create_bead(self, title: str, description: str) -> str:
"""Create a new bead and return its ID."""
beads = self._read_beads()
bead_id = f"bead-{len(beads) + 1}"
bead = {"id": bead_id, "title": title, "description": description, "status": "active"}
beads.append(bead)
self._write_beads(beads)
return bead_id
def update_bead(self, bead_id: str, status: str) -> bool:
"""Update the status of an existing bead."""
beads = self._read_beads()
for bead in beads:
if bead["id"] == bead_id:
bead["status"] = status
self._write_beads(beads)
return True
return False
def list_beads(self) -> List[Bead]:
"""List all beads."""
return [Bead(**b) for b in self._read_beads()]
def _read_beads(self) -> List[dict]:
if not self.beads_file.exists():
return []
return json.loads(self.beads_file.read_text(encoding="utf-8"))
def _write_beads(self, beads: List[dict]) -> None:
self.beads_file.write_text(json.dumps(beads, indent=1), encoding="utf-8")
+97 -36
View File
@@ -1931,6 +1931,13 @@ class App:
proj_name = self.project.get("project", {}).get("name", Path(self.active_project_path).stem)
imgui.text_colored(C_IN, f"Active: {proj_name}")
imgui.separator()
imgui.text("Execution Mode")
modes = ["native", "beads"]
current_idx = modes.index(self.ui_project_execution_mode) if self.ui_project_execution_mode in modes else 0
ch, new_idx = imgui.combo("##exec_mode", current_idx, modes)
if ch:
self.ui_project_execution_mode = modes[new_idx]
imgui.separator()
imgui.text("Git Directory")
ch, self.ui_project_git_dir = imgui.input_text("##git_dir", self.ui_project_git_dir)
imgui.same_line()
@@ -4066,47 +4073,53 @@ def hello():
return
# Task 5.3: Dense Summary Line
track_name = self.active_track.description if self.active_track else "None"
if getattr(self, "ui_project_execution_mode", "native") == "beads":
track_name = "Beads Graph"
track_stats = {"percentage": 0.0, "completed": 0, "total": 0, "in_progress": 0, "blocked": 0, "todo": 0}
if self.active_track:
track_stats = project_manager.calculate_track_progress(self.active_track.tickets)
elif self.active_tickets:
track_stats = project_manager.calculate_track_progress(self.active_tickets)
total_cost = 0.0
for usage in self.mma_tier_usage.values():
model = usage.get('model', 'unknown')
in_t = usage.get('input', 0)
out_t = usage.get('output', 0)
total_cost += cost_tracker.estimate_cost(model, in_t, out_t)
total_cost = 0.0
for usage in self.mma_tier_usage.values():
model = usage.get('model', 'unknown')
in_t = usage.get('input', 0)
out_t = usage.get('output', 0)
total_cost += cost_tracker.estimate_cost(model, in_t, out_t)
imgui.text("Track:")
imgui.same_line()
imgui.text_colored(C_VAL, track_name)
imgui.same_line()
imgui.text(" | Status:")
imgui.same_line()
if self.mma_status == "paused":
c = imgui.ImVec4(1, 0.5, 0, 1)
if is_nerv: c = vec4(255, 152, 48)
imgui.text_colored(c, "PIPELINE PAUSED")
imgui.text("Track:")
imgui.same_line()
status_col = imgui.ImVec4(1, 1, 1, 1)
if self.mma_status == "idle": status_col = imgui.ImVec4(0.7, 0.7, 0.7, 1)
elif self.mma_status == "running": status_col = imgui.ImVec4(1, 1, 0, 1)
elif self.mma_status == "done": status_col = imgui.ImVec4(0, 1, 0, 1)
elif self.mma_status == "error": status_col = imgui.ImVec4(1, 0, 0, 1)
elif self.mma_status == "paused": status_col = imgui.ImVec4(1, 0.5, 0, 1)
if is_nerv:
if self.mma_status == "running": status_col = vec4(80, 255, 80) # DATA_GREEN
elif self.mma_status == "error": status_col = vec4(255, 72, 64) # ALERT_RED
imgui.text_colored(status_col, self.mma_status.upper())
imgui.same_line()
imgui.text(" | Cost:")
imgui.same_line()
imgui.text_colored(imgui.ImVec4(0, 1, 0, 1), f"${total_cost:,.4f}")
imgui.text_colored(C_VAL, track_name)
imgui.same_line()
imgui.text(" | Status:")
imgui.same_line()
if self.mma_status == "paused":
c = imgui.ImVec4(1, 0.5, 0, 1)
if is_nerv: c = vec4(255, 152, 48)
imgui.text_colored(c, "PIPELINE PAUSED")
imgui.same_line()
status_col = imgui.ImVec4(1, 1, 1, 1)
if self.mma_status == "idle": status_col = imgui.ImVec4(0.7, 0.7, 0.7, 1)
elif self.mma_status == "running": status_col = imgui.ImVec4(1, 1, 0, 1)
elif self.mma_status == "done": status_col = imgui.ImVec4(0, 1, 0, 1)
elif self.mma_status == "error": status_col = imgui.ImVec4(1, 0, 0, 1)
elif self.mma_status == "paused": status_col = imgui.ImVec4(1, 0.5, 0, 1)
if is_nerv:
if self.mma_status == "running": status_col = vec4(80, 255, 80) # DATA_GREEN
elif self.mma_status == "error": status_col = vec4(255, 72, 64) # ALERT_RED
imgui.text_colored(status_col, self.mma_status.upper())
imgui.same_line()
imgui.text(" | Cost:")
imgui.same_line()
imgui.text_colored(imgui.ImVec4(0, 1, 0, 1), f"${total_cost:,.4f}")
# Progress Bar
perc = track_stats["percentage"] / 100.0
p_color = imgui.ImVec2(0.0, 1.0) # WAIT WRONG TYPE
# Progress Bar
perc = track_stats["percentage"] / 100.0
p_color = imgui.ImVec4(0.0, 1.0, 0.0, 1.0)
if track_stats["percentage"] < 33:
p_color = imgui.ImVec4(1.0, 0.0, 0.0, 1.0)
@@ -4448,12 +4461,16 @@ def hello():
else:
imgui.text_disabled("Tier 4 stream is detached.")
imgui.end_tab_item()
if getattr(self, "ui_project_execution_mode", "native") == "beads":
if imgui.begin_tab_item("Beads")[0]:
self._render_beads_tab()
imgui.end_tab_item()
imgui.end_tab_bar()
def _render_task_dag_panel(self) -> None:
# 4. Task DAG Visualizer
imgui.text("Task DAG")
if self.active_track and self.node_editor_ctx:
if (self.active_track or self.active_tickets) and self.node_editor_ctx:
ed.set_current_editor(self.node_editor_ctx)
ed.begin('Visual DAG')
# Selection detection
@@ -4470,6 +4487,9 @@ def hello():
tid = str(t.get('id', '??'))
int_id = abs(hash(tid))
ed.begin_node(ed.NodeId(int_id))
if getattr(self, "ui_project_execution_mode", "native") == "beads":
imgui.text_colored(imgui.ImVec4(0, 1, 1, 1), "[B] ")
imgui.same_line()
imgui.text_colored(C_KEY, f"Ticket: {tid}")
status = t.get('status', 'todo')
s_col = C_VAL
@@ -4590,7 +4610,48 @@ def hello():
self._show_add_ticket_form = False
imgui.end_child()
else:
imgui.text_disabled("No active MMA track.")
imgui.text_disabled("No active MMA track or tickets.")
def _render_beads_tab(self) -> None:
imgui.text("Beads Graph (Dolt-backed)")
if imgui.button("Refresh Beads"):
pass
imgui.separator()
# Check for dolt/bd dependencies
dolt_path = shutil.which("dolt")
bd_path = shutil.which("bd")
if not dolt_path or not bd_path:
missing = []
if not dolt_path: missing.append("'dolt'")
if not bd_path: missing.append("'bd'")
imgui.text_colored(imgui.ImVec4(1, 0.5, 0, 1), f"Warning: {', '.join(missing)} not found in PATH.")
imgui.text_wrapped("Beads mode requires Dolt and the Beads (bd) CLI tools.")
if getattr(self, "ui_project_execution_mode", "native") == "beads":
try:
from src import beads_client
bclient = beads_client.BeadsClient(Path(self.active_project_root))
beads = bclient.list_beads()
if not beads:
imgui.text_disabled("No beads found.")
else:
if imgui.begin_table("beads_table", 3, imgui.TableFlags_.borders | imgui.TableFlags_.row_bg | imgui.TableFlags_.resizable):
imgui.table_setup_column("ID")
imgui.table_setup_column("Status")
imgui.table_setup_column("Title")
imgui.table_headers_row()
for b in beads:
imgui.table_next_row()
imgui.table_next_column()
imgui.text(str(b.id))
imgui.table_next_column()
imgui.text(str(b.status))
imgui.table_next_column()
imgui.text(str(b.title))
imgui.end_table()
except Exception as e:
imgui.text_colored(imgui.ImVec4(1, 0, 0, 1), f"Error loading beads: {e}")
def _render_tier_stream_panel(self, tier_key: str, stream_key: str | None) -> None:
if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_tier_stream_panel")
+66
View File
@@ -62,6 +62,7 @@ import ast
import subprocess
from src import summarize
from src import outline_tool
from src import beads_client
import urllib.request
import urllib.parse
from html.parser import HTMLParser
@@ -1282,6 +1283,31 @@ def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
return py_get_docstring(path, str(tool_input.get("name", "")))
if tool_name == "get_tree":
return get_tree(path, int(tool_input.get("max_depth", 2)))
# Beads tools
if tool_name.startswith("bd_"):
if not _primary_base_dir:
return "ERROR: no active workspace to run beads tools."
bclient = beads_client.BeadsClient(_primary_base_dir)
if tool_name == "bd_list":
beads = bclient.list_beads()
if not beads:
return "No beads found."
return "\n".join([f"ID: {b.id}, Status: {b.status}, Title: {b.title}" for b in beads])
elif tool_name == "bd_create":
title = str(tool_input.get("title", ""))
desc = str(tool_input.get("description", ""))
bid = bclient.create_bead(title, desc)
return f"Created bead: {bid}"
elif tool_name == "bd_update":
bid = str(tool_input.get("bead_id", ""))
status = str(tool_input.get("status", ""))
if bclient.update_bead(bid, status):
return f"Updated {bid} to status {status}"
return f"ERROR: bead {bid} not found."
elif tool_name == "bd_ready":
return "READY" if bclient.is_initialized() else "NOT_INITIALIZED"
return f"ERROR: unknown MCP tool '{tool_name}'"
async def async_dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
@@ -1967,6 +1993,46 @@ MCP_TOOL_SPECS: list[dict[str, Any]] = [
},
"required": ["path"]
}
},
{
"name": "bd_create",
"description": "Create a new Bead in the active Beads repository.",
"parameters": {
"type": "object",
"properties": {
"title": { "type": "string", "description": "Title of the Bead." },
"description": { "type": "string", "description": "Description of the Bead." }
},
"required": ["title", "description"]
}
},
{
"name": "bd_update",
"description": "Update an existing Bead.",
"parameters": {
"type": "object",
"properties": {
"bead_id": { "type": "string", "description": "ID of the Bead to update." },
"status": { "type": "string", "description": "New status for the Bead." }
},
"required": ["bead_id", "status"]
}
},
{
"name": "bd_list",
"description": "List all Beads in the active Beads repository.",
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "bd_ready",
"description": "Check if the Beads repository is initialized in the current workspace.",
"parameters": {
"type": "object",
"properties": {}
}
}
]
+1 -1
View File
@@ -97,7 +97,7 @@ def default_discussion() -> dict[str, Any]:
def default_project(name: str = "unnamed") -> dict[str, Any]:
return {
"project": {"name": name, "git_dir": "", "system_prompt": "", "main_context": ""},
"project": {"name": name, "git_dir": "", "system_prompt": "", "main_context": "", "execution_mode": "native"},
"output": {"output_dir": "./md_gen"},
"files": {"base_dir": ".", "paths": [], "tier_assignments": {}},
"screenshots": {"base_dir": ".", "paths": []},