From de024965a03bd19dbeb326975cc987fcf539fb36 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 26 Jan 2026 00:40:52 -0400 Subject: [PATCH] Add dev setup script and restructure dependencies for editable installs - Add scripts/dev-setup.sh for setting up development environment with editable installs of interdependent projects (cmdforge, ramble, artifact-editor, orchestrated-discussions) - Restructure pyproject.toml dependencies: top-level app specifies git URLs, libraries use name-only deps for compatibility with editable installs - Add artifact-editor as explicit dependency (transitive through discussions) - Various model, parser, and widget enhancements Co-Authored-By: Claude Opus 4.5 --- pyproject.toml | 7 +- scripts/dev-setup.sh | 89 ++++++++++++++ src/development_hub/models/goal.py | 19 +++ src/development_hub/models/todo.py | 26 ++++- src/development_hub/parsers/base.py | 18 +++ src/development_hub/parsers/goals_parser.py | 29 +++++ src/development_hub/parsers/todos_parser.py | 31 ++++- .../views/dashboard/audit_worker.py | 31 ++++- .../widgets/collapsible_section.py | 109 ++++++++++++++++-- 9 files changed, 337 insertions(+), 22 deletions(-) create mode 100755 scripts/dev-setup.sh diff --git a/pyproject.toml b/pyproject.toml index f2f422b..29adcc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,12 @@ dependencies = [ "PySide6>=6.4.0", "pyte>=0.8.0", "pyyaml>=6.0", - "orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git", - "ramble @ git+https://gitea.brrd.tech/rob/ramble.git", + # Git dependencies - top-level app specifies where to get internal packages + # Libraries use name-only deps so editable installs work during development "cmdforge @ git+https://gitea.brrd.tech/rob/CmdForge.git", + "ramble @ git+https://gitea.brrd.tech/rob/ramble.git", + "artifact-editor @ git+https://gitea.brrd.tech/rob/artifact-editor.git", + "orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git", ] [project.optional-dependencies] diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100755 index 0000000..4a55434 --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Development setup script for development-hub +# +# This script installs all interdependent projects as editable packages, +# allowing you to develop them in parallel without reinstalling. +# +# Usage: +# ./scripts/dev-setup.sh # Full setup with venv creation +# ./scripts/dev-setup.sh --deps # Just reinstall editable deps (faster) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +PROJECTS_ROOT="$(dirname "$PROJECT_DIR")" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log() { + echo -e "${BLUE}==>${NC} $1" +} + +success() { + echo -e "${GREEN}==>${NC} $1" +} + +# Check if we're just updating deps +DEPS_ONLY=false +if [ "$1" = "--deps" ]; then + DEPS_ONLY=true +fi + +cd "$PROJECT_DIR" + +if [ "$DEPS_ONLY" = false ]; then + # Create venv if it doesn't exist + if [ ! -d ".venv" ]; then + log "Creating virtual environment..." + python3 -m venv .venv + fi +fi + +# Activate venv +log "Activating virtual environment..." +source .venv/bin/activate + +if [ "$DEPS_ONLY" = false ]; then + # Upgrade pip + log "Upgrading pip..." + pip install --upgrade pip +fi + +# Install editable packages in dependency order (base packages first) +# Using --no-deps to avoid git URL conflicts, then installing other deps +log "Installing cmdforge (base layer)..." +pip install -e "$PROJECTS_ROOT/CmdForge" --no-deps +pip install PyYAML requests PySide6 NodeGraphQt setuptools + +log "Installing ramble..." +pip install -e "$PROJECTS_ROOT/ramble" --no-deps + +log "Installing artifact-editor..." +pip install -e "$PROJECTS_ROOT/artifact-editor" --no-deps +pip install QScintilla + +log "Installing orchestrated-discussions[gui]..." +pip install -e "$PROJECTS_ROOT/orchestrated-discussions[gui]" --no-deps +pip install dearpygui sounddevice numpy urwid + +log "Installing development-hub..." +pip install -e "$PROJECT_DIR" --no-deps +pip install pyte + +if [ "$DEPS_ONLY" = false ]; then + # Install dev dependencies + log "Installing dev dependencies..." + pip install pytest pytest-qt pytest-cov +fi + +success "Development environment ready!" +echo "" +echo "Installed packages (editable):" +pip list | grep -E "(cmdforge|ramble|artifact-editor|orchestrated-discussions|development-hub)" | sed 's/^/ /' +echo "" +echo "To activate: source .venv/bin/activate" +echo "To run: development-hub" diff --git a/src/development_hub/models/goal.py b/src/development_hub/models/goal.py index 8268ad9..fc33d76 100644 --- a/src/development_hub/models/goal.py +++ b/src/development_hub/models/goal.py @@ -45,6 +45,23 @@ class Deliverable: return cls(name=name, status=status) +@dataclass +class LinkedDocument: + """A document linked to a milestone (plan, spec, notes, etc.).""" + path: str # Relative path to document + title: str = "" # Display title (derived from path if empty) + doc_type: str = "plan" # "plan", "spec", "notes", "discussion" + + @property + def display_title(self) -> str: + """Get display title, falling back to filename.""" + if self.title: + return self.title + # Extract filename without extension + from pathlib import Path + return Path(self.path).stem.replace("-", " ").replace("_", " ").title() + + @dataclass class Milestone: """A milestone with deliverables and progress tracking.""" @@ -56,6 +73,8 @@ class Milestone: deliverables: list[Deliverable] = field(default_factory=list) notes: str = "" description: str = "" # Free-form description text + plan_path: str | None = None # Path to linked plan document + documents: list[LinkedDocument] = field(default_factory=list) # Additional linked docs @property def is_complete(self) -> bool: diff --git a/src/development_hub/models/todo.py b/src/development_hub/models/todo.py index ec41c52..3c8d914 100644 --- a/src/development_hub/models/todo.py +++ b/src/development_hub/models/todo.py @@ -15,6 +15,8 @@ class Todo: tags: list[str] = field(default_factory=list) # from #tag in text completed_date: str | None = None blocker_reason: str | None = None # For blocked items + phase: str | None = None # from [Phase 1] prefix or #phase-1 tag + notes: str | None = None # Additional context, shown as tooltip @property def priority_order(self) -> int: @@ -33,9 +35,21 @@ class Todo: self.blocker_reason = None def to_markdown(self) -> str: - """Convert to markdown checkbox format.""" + """Convert to markdown checkbox format. + + Returns a string that may be multiple lines if notes are present: + - [ ] Task text @M4 + > Notes about this task + """ checkbox = "[x]" if self.completed else "[ ]" - parts = [f"- {checkbox} {self.text}"] + + # Include phase prefix if present + if self.phase: + text_part = f"[{self.phase}] {self.text}" + else: + text_part = self.text + + parts = [f"- {checkbox} {text_part}"] if self.milestone: parts.append(f"@{self.milestone}") @@ -51,7 +65,13 @@ class Todo: if self.blocker_reason: parts.append(f"- {self.blocker_reason}") - return " ".join(parts) + result = " ".join(parts) + + # Add notes as indented blockquote line + if self.notes: + result += f"\n > {self.notes}" + + return result @dataclass diff --git a/src/development_hub/parsers/base.py b/src/development_hub/parsers/base.py index 0ef7365..cf114fe 100644 --- a/src/development_hub/parsers/base.py +++ b/src/development_hub/parsers/base.py @@ -251,6 +251,24 @@ class BaseParser: return date, text.strip() return None, text + @staticmethod + def extract_phase(text: str) -> tuple[str | None, str]: + """Extract [Phase N] or [Phase Name] prefix from text. + + Args: + text: Text potentially starting with [Phase 1] or similar + + Returns: + Tuple of (phase string or None, text without phase prefix) + """ + # Match [Phase X] at the start of the text + match = re.match(r"^\[([^\]]+)\]\s*", text) + if match: + phase = match.group(1) + text = text[match.end():].strip() + return phase, text + return None, text + @staticmethod def parse_table(lines: list[str]) -> list[tuple[str, ...]]: """Parse a markdown table. diff --git a/src/development_hub/parsers/goals_parser.py b/src/development_hub/parsers/goals_parser.py index a6e72b7..29da19d 100644 --- a/src/development_hub/parsers/goals_parser.py +++ b/src/development_hub/parsers/goals_parser.py @@ -9,6 +9,7 @@ from development_hub.models.goal import ( GoalList, Milestone, Deliverable, + LinkedDocument, MilestoneStatus, DeliverableStatus, ) @@ -211,6 +212,8 @@ class MilestonesParser(BaseParser): deliverables = [] notes = "" description_lines = [] + plan_path = None + documents = [] lines = content.split("\n") table_lines = [] @@ -242,6 +245,21 @@ class MilestonesParser(BaseParser): notes = notes_match.group(1).strip() continue + # Parse **Plan**: path/to/file.md + plan_match = re.match(r"\*\*Plan\*\*:\s*(.+)", line_stripped) + if plan_match: + plan_path = plan_match.group(1).strip() + continue + + # Parse **Documents**: path1, path2, ... (optional multi-doc field) + docs_match = re.match(r"\*\*Documents\*\*:\s*(.+)", line_stripped) + if docs_match: + doc_paths = [p.strip() for p in docs_match.group(1).split(",")] + for path in doc_paths: + if path: + documents.append(LinkedDocument(path=path)) + continue + # Parse deliverables table if line_stripped.startswith("|"): in_table = True @@ -270,6 +288,8 @@ class MilestonesParser(BaseParser): deliverables=deliverables, notes=notes, description=" ".join(description_lines), + plan_path=plan_path, + documents=documents, ) def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]: @@ -407,6 +427,15 @@ class MilestonesParser(BaseParser): if milestone.notes: lines.append(f"**Notes**: {milestone.notes}") + # Plan path + if milestone.plan_path: + lines.append(f"**Plan**: {milestone.plan_path}") + + # Additional documents + if milestone.documents: + doc_paths = ", ".join(d.path for d in milestone.documents) + lines.append(f"**Documents**: {doc_paths}") + # Description (after fields, before table) if milestone.description: lines.append("") diff --git a/src/development_hub/parsers/todos_parser.py b/src/development_hub/parsers/todos_parser.py index 21adbfd..959c7ae 100644 --- a/src/development_hub/parsers/todos_parser.py +++ b/src/development_hub/parsers/todos_parser.py @@ -16,6 +16,7 @@ class TodosParser(BaseParser): Expected format: ## Active Tasks / High Priority / Medium Priority / Low Priority - [ ] Task description @project #tag + > Optional notes on indented line ## Completed - [x] Done task (2026-01-06) @@ -38,10 +39,25 @@ class TodosParser(BaseParser): current_priority = "medium" table_lines = [] in_table = False + pending_todo = None # Track todo waiting for possible notes - for line in self.body.split("\n"): + lines = self.body.split("\n") + for line in lines: line_stripped = line.strip() + # Check for indented note line (follows a todo) + if pending_todo and line.startswith(" ") and line_stripped.startswith(">"): + # Extract note text (remove leading > and whitespace) + note_text = line_stripped[1:].strip() + pending_todo.notes = note_text + todo_list.add_todo(pending_todo) + pending_todo = None + continue + elif pending_todo: + # Previous line was a todo but this isn't a note - add the todo + todo_list.add_todo(pending_todo) + pending_todo = None + # Detect section headers if line_stripped.startswith("## ") or line_stripped.startswith("### "): # Save any pending table @@ -93,7 +109,12 @@ class TodosParser(BaseParser): if line_stripped.startswith("- ["): todo = self._parse_todo_line(line_stripped, current_priority, current_section) if todo: - todo_list.add_todo(todo) + # Don't add yet - wait to see if next line has notes + pending_todo = todo + + # Handle any remaining pending todo + if pending_todo: + todo_list.add_todo(pending_todo) # Handle any remaining table if in_table and table_lines: @@ -105,7 +126,7 @@ class TodosParser(BaseParser): """Parse a single todo line. Args: - line: Line like "- [ ] Task @M1 @project #tag" + line: Line like "- [ ] [Phase 1] Task @M1 @project #tag" priority: Current priority level section: Current section name @@ -117,6 +138,9 @@ class TodosParser(BaseParser): if not text: return None + # Extract phase prefix first (e.g., [Phase 1]) + phase, text = self.extract_phase(text) + # Extract metadata (milestone first, then project) milestone, text = self.extract_milestone_tag(text) project, text = self.extract_project_tag(text) @@ -156,6 +180,7 @@ class TodosParser(BaseParser): tags=tags, completed_date=date, blocker_reason=blocker_reason if section == "blocked" else None, + phase=phase, ) # Handle blocked items diff --git a/src/development_hub/views/dashboard/audit_worker.py b/src/development_hub/views/dashboard/audit_worker.py index d55db76..13be74e 100644 --- a/src/development_hub/views/dashboard/audit_worker.py +++ b/src/development_hub/views/dashboard/audit_worker.py @@ -1,5 +1,6 @@ """Background worker for running goals audit.""" +import json import subprocess from pathlib import Path @@ -14,10 +15,18 @@ class AuditWorker(QObject): finished = Signal(str, bool) # output, success error = Signal(str) - def __init__(self, project_key: str, project_path: Path | None = None): + def __init__( + self, + project_name: str, + goals_path: Path, + milestones_path: Path | None = None, + project_dir: Path | None = None, + ): super().__init__() - self.project_key = project_key # e.g. "development-hub" or "global" - self.project_path = project_path + self.project_name = project_name + self.goals_path = goals_path + self.milestones_path = milestones_path + self.project_dir = project_dir self._process: subprocess.Popen | None = None self._cancelled = False @@ -27,6 +36,16 @@ class AuditWorker(QObject): if not cmdforge_path: cmdforge_path = Path("cmdforge") + # Build JSON input for the tool + input_data = { + "project_name": self.project_name, + "goals_path": str(self.goals_path), + } + if self.milestones_path: + input_data["milestones_path"] = str(self.milestones_path) + if self.project_dir: + input_data["project_dir"] = str(self.project_dir) + try: self._process = subprocess.Popen( [str(cmdforge_path), "run", "audit-goals"], @@ -34,11 +53,11 @@ class AuditWorker(QObject): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - cwd=str(self.project_path) if self.project_path and self.project_path.exists() else None, + cwd=str(self.project_dir) if self.project_dir and self.project_dir.exists() else None, ) - # Pass project key to stdin (tool expects project name, not file content) - stdout, stderr = self._process.communicate(input=self.project_key) + # Pass JSON input to tool + stdout, stderr = self._process.communicate(input=json.dumps(input_data)) if self._cancelled: return diff --git a/src/development_hub/widgets/collapsible_section.py b/src/development_hub/widgets/collapsible_section.py index 3af2763..86b7120 100644 --- a/src/development_hub/widgets/collapsible_section.py +++ b/src/development_hub/widgets/collapsible_section.py @@ -761,6 +761,9 @@ class MilestoneWidget(QFrame): todo_added = Signal(str, str, str) # (text, priority, milestone_id) - for adding new todos todo_start_discussion = Signal(object) # (todo) - for starting discussion from todo todo_edited = Signal(object, str, str) # (todo, old_text, new_text) - for inline editing + milestone_start_discussion = Signal(object) # (milestone) - for starting discussion from milestone + milestone_import_plan = Signal(object) # (milestone) - for importing a plan to this milestone + milestone_view_plan = Signal(object, str) # (milestone, plan_path) - for viewing linked plan def __init__( self, @@ -848,6 +851,14 @@ class MilestoneWidget(QFrame): self.target_label.setStyleSheet("color: #888888; font-size: 11px;") content_layout.addWidget(self.target_label) + # Plan link (if set) + if self.milestone.plan_path: + self.plan_link = QLabel(f'📄 View Implementation Plan') + self.plan_link.setStyleSheet("font-size: 11px;") + self.plan_link.setOpenExternalLinks(False) + self.plan_link.linkActivated.connect(self._on_view_plan_clicked) + content_layout.addWidget(self.plan_link) + # Deliverables list self.deliverables_container = QWidget() self.deliverables_layout = QVBoxLayout(self.deliverables_container) @@ -1005,7 +1016,9 @@ class MilestoneWidget(QFrame): """) def _load_deliverables(self): - """Load deliverable/todo widgets.""" + """Load deliverable/todo widgets, grouped by phase if applicable.""" + import re + # Clear existing while self.deliverables_layout.count(): item = self.deliverables_layout.takeAt(0) @@ -1014,14 +1027,49 @@ class MilestoneWidget(QFrame): # Show todos if available (preferred mode), otherwise show deliverables if self._todos: + # Group todos by phase + phases = {} for todo in self._todos: - # Show priority badge instead of milestone (since milestone is obvious) - widget = TodoItemWidget(todo, show_priority=True) - widget.toggled.connect(self._on_todo_toggled_internal) - widget.deleted.connect(self._on_todo_deleted_internal) - widget.start_discussion.connect(self.todo_start_discussion.emit) - widget.edited.connect(self.todo_edited.emit) - self.deliverables_layout.addWidget(widget) + phase_key = todo.phase or "_ungrouped" + phases.setdefault(phase_key, []).append(todo) + + # Sort phase names: "Phase 1" before "Phase 2", then alphabetic + def phase_sort_key(name): + if name == "_ungrouped": + return (999, "") # Ungrouped at end + match = re.search(r'\d+', name) + return (int(match.group()) if match else 500, name) + + sorted_phases = sorted(phases.keys(), key=phase_sort_key) + + # If there's only one phase (or no phases), don't show phase headers + show_phase_headers = len(sorted_phases) > 1 or ( + len(sorted_phases) == 1 and sorted_phases[0] != "_ungrouped" + ) + + for phase_name in sorted_phases: + phase_todos = phases[phase_name] + + # Add phase header if needed + if show_phase_headers and phase_name != "_ungrouped": + phase_header = QLabel(f"▸ {phase_name}") + phase_header.setStyleSheet( + "color: #888888; font-size: 11px; font-weight: bold; " + "padding: 4px 0px 2px 0px;" + ) + self.deliverables_layout.addWidget(phase_header) + + # Add todos for this phase + for todo in phase_todos: + widget = TodoItemWidget(todo, show_priority=True) + widget.toggled.connect(self._on_todo_toggled_internal) + widget.deleted.connect(self._on_todo_deleted_internal) + widget.start_discussion.connect(self.todo_start_discussion.emit) + widget.edited.connect(self.todo_edited.emit) + # Set tooltip with notes if available + if todo.notes: + widget.setToolTip(todo.notes) + self.deliverables_layout.addWidget(widget) else: # Legacy: Add deliverables for deliverable in self.milestone.deliverables: @@ -1078,3 +1126,48 @@ class MilestoneWidget(QFrame): self._update_progress() self._update_status_icon() self._load_deliverables() + + def _on_view_plan_clicked(self, link): + """Handle view plan link click.""" + if self.milestone.plan_path: + self.milestone_view_plan.emit(self.milestone, self.milestone.plan_path) + + def contextMenuEvent(self, event): + """Show context menu on right-click.""" + menu = QMenu(self) + menu.setStyleSheet(""" + QMenu { + background-color: #2d2d2d; + border: 1px solid #3d3d3d; + padding: 4px; + } + QMenu::item { + padding: 8px 24px; + color: #e0e0e0; + } + QMenu::item:selected { + background-color: #3d6a99; + } + """) + + # Start Discussion action + discuss_action = menu.addAction("Start Discussion...") + discuss_action.triggered.connect( + lambda: self.milestone_start_discussion.emit(self.milestone) + ) + + # Import Plan action + import_action = menu.addAction("Import Plan...") + import_action.triggered.connect( + lambda: self.milestone_import_plan.emit(self.milestone) + ) + + # View Plan action (if plan exists) + if self.milestone.plan_path: + menu.addSeparator() + view_action = menu.addAction("View Plan") + view_action.triggered.connect( + lambda: self.milestone_view_plan.emit(self.milestone, self.milestone.plan_path) + ) + + menu.exec(event.globalPos())