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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-26 00:40:52 -04:00
parent 20818956b3
commit de024965a0
9 changed files with 337 additions and 22 deletions

View File

@ -13,9 +13,12 @@ dependencies = [
"PySide6>=6.4.0", "PySide6>=6.4.0",
"pyte>=0.8.0", "pyte>=0.8.0",
"pyyaml>=6.0", "pyyaml>=6.0",
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git", # Git dependencies - top-level app specifies where to get internal packages
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git", # Libraries use name-only deps so editable installs work during development
"cmdforge @ git+https://gitea.brrd.tech/rob/CmdForge.git", "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] [project.optional-dependencies]

89
scripts/dev-setup.sh Executable file
View File

@ -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"

View File

@ -45,6 +45,23 @@ class Deliverable:
return cls(name=name, status=status) 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 @dataclass
class Milestone: class Milestone:
"""A milestone with deliverables and progress tracking.""" """A milestone with deliverables and progress tracking."""
@ -56,6 +73,8 @@ class Milestone:
deliverables: list[Deliverable] = field(default_factory=list) deliverables: list[Deliverable] = field(default_factory=list)
notes: str = "" notes: str = ""
description: str = "" # Free-form description text 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 @property
def is_complete(self) -> bool: def is_complete(self) -> bool:

View File

@ -15,6 +15,8 @@ class Todo:
tags: list[str] = field(default_factory=list) # from #tag in text tags: list[str] = field(default_factory=list) # from #tag in text
completed_date: str | None = None completed_date: str | None = None
blocker_reason: str | None = None # For blocked items 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 @property
def priority_order(self) -> int: def priority_order(self) -> int:
@ -33,9 +35,21 @@ class Todo:
self.blocker_reason = None self.blocker_reason = None
def to_markdown(self) -> str: 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 "[ ]" 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: if self.milestone:
parts.append(f"@{self.milestone}") parts.append(f"@{self.milestone}")
@ -51,7 +65,13 @@ class Todo:
if self.blocker_reason: if self.blocker_reason:
parts.append(f"- {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 @dataclass

View File

@ -251,6 +251,24 @@ class BaseParser:
return date, text.strip() return date, text.strip()
return None, text 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 @staticmethod
def parse_table(lines: list[str]) -> list[tuple[str, ...]]: def parse_table(lines: list[str]) -> list[tuple[str, ...]]:
"""Parse a markdown table. """Parse a markdown table.

View File

@ -9,6 +9,7 @@ from development_hub.models.goal import (
GoalList, GoalList,
Milestone, Milestone,
Deliverable, Deliverable,
LinkedDocument,
MilestoneStatus, MilestoneStatus,
DeliverableStatus, DeliverableStatus,
) )
@ -211,6 +212,8 @@ class MilestonesParser(BaseParser):
deliverables = [] deliverables = []
notes = "" notes = ""
description_lines = [] description_lines = []
plan_path = None
documents = []
lines = content.split("\n") lines = content.split("\n")
table_lines = [] table_lines = []
@ -242,6 +245,21 @@ class MilestonesParser(BaseParser):
notes = notes_match.group(1).strip() notes = notes_match.group(1).strip()
continue 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 # Parse deliverables table
if line_stripped.startswith("|"): if line_stripped.startswith("|"):
in_table = True in_table = True
@ -270,6 +288,8 @@ class MilestonesParser(BaseParser):
deliverables=deliverables, deliverables=deliverables,
notes=notes, notes=notes,
description=" ".join(description_lines), description=" ".join(description_lines),
plan_path=plan_path,
documents=documents,
) )
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]: def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
@ -407,6 +427,15 @@ class MilestonesParser(BaseParser):
if milestone.notes: if milestone.notes:
lines.append(f"**Notes**: {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) # Description (after fields, before table)
if milestone.description: if milestone.description:
lines.append("") lines.append("")

View File

@ -16,6 +16,7 @@ class TodosParser(BaseParser):
Expected format: Expected format:
## Active Tasks / High Priority / Medium Priority / Low Priority ## Active Tasks / High Priority / Medium Priority / Low Priority
- [ ] Task description @project #tag - [ ] Task description @project #tag
> Optional notes on indented line
## Completed ## Completed
- [x] Done task (2026-01-06) - [x] Done task (2026-01-06)
@ -38,10 +39,25 @@ class TodosParser(BaseParser):
current_priority = "medium" current_priority = "medium"
table_lines = [] table_lines = []
in_table = False 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() 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 # Detect section headers
if line_stripped.startswith("## ") or line_stripped.startswith("### "): if line_stripped.startswith("## ") or line_stripped.startswith("### "):
# Save any pending table # Save any pending table
@ -93,7 +109,12 @@ class TodosParser(BaseParser):
if line_stripped.startswith("- ["): if line_stripped.startswith("- ["):
todo = self._parse_todo_line(line_stripped, current_priority, current_section) todo = self._parse_todo_line(line_stripped, current_priority, current_section)
if todo: 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 # Handle any remaining table
if in_table and table_lines: if in_table and table_lines:
@ -105,7 +126,7 @@ class TodosParser(BaseParser):
"""Parse a single todo line. """Parse a single todo line.
Args: Args:
line: Line like "- [ ] Task @M1 @project #tag" line: Line like "- [ ] [Phase 1] Task @M1 @project #tag"
priority: Current priority level priority: Current priority level
section: Current section name section: Current section name
@ -117,6 +138,9 @@ class TodosParser(BaseParser):
if not text: if not text:
return None return None
# Extract phase prefix first (e.g., [Phase 1])
phase, text = self.extract_phase(text)
# Extract metadata (milestone first, then project) # Extract metadata (milestone first, then project)
milestone, text = self.extract_milestone_tag(text) milestone, text = self.extract_milestone_tag(text)
project, text = self.extract_project_tag(text) project, text = self.extract_project_tag(text)
@ -156,6 +180,7 @@ class TodosParser(BaseParser):
tags=tags, tags=tags,
completed_date=date, completed_date=date,
blocker_reason=blocker_reason if section == "blocked" else None, blocker_reason=blocker_reason if section == "blocked" else None,
phase=phase,
) )
# Handle blocked items # Handle blocked items

View File

@ -1,5 +1,6 @@
"""Background worker for running goals audit.""" """Background worker for running goals audit."""
import json
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@ -14,10 +15,18 @@ class AuditWorker(QObject):
finished = Signal(str, bool) # output, success finished = Signal(str, bool) # output, success
error = Signal(str) 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__() super().__init__()
self.project_key = project_key # e.g. "development-hub" or "global" self.project_name = project_name
self.project_path = project_path self.goals_path = goals_path
self.milestones_path = milestones_path
self.project_dir = project_dir
self._process: subprocess.Popen | None = None self._process: subprocess.Popen | None = None
self._cancelled = False self._cancelled = False
@ -27,6 +36,16 @@ class AuditWorker(QObject):
if not cmdforge_path: if not cmdforge_path:
cmdforge_path = Path("cmdforge") 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: try:
self._process = subprocess.Popen( self._process = subprocess.Popen(
[str(cmdforge_path), "run", "audit-goals"], [str(cmdforge_path), "run", "audit-goals"],
@ -34,11 +53,11 @@ class AuditWorker(QObject):
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, 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) # Pass JSON input to tool
stdout, stderr = self._process.communicate(input=self.project_key) stdout, stderr = self._process.communicate(input=json.dumps(input_data))
if self._cancelled: if self._cancelled:
return return

View File

@ -761,6 +761,9 @@ class MilestoneWidget(QFrame):
todo_added = Signal(str, str, str) # (text, priority, milestone_id) - for adding new todos 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_start_discussion = Signal(object) # (todo) - for starting discussion from todo
todo_edited = Signal(object, str, str) # (todo, old_text, new_text) - for inline editing 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__( def __init__(
self, self,
@ -848,6 +851,14 @@ class MilestoneWidget(QFrame):
self.target_label.setStyleSheet("color: #888888; font-size: 11px;") self.target_label.setStyleSheet("color: #888888; font-size: 11px;")
content_layout.addWidget(self.target_label) content_layout.addWidget(self.target_label)
# Plan link (if set)
if self.milestone.plan_path:
self.plan_link = QLabel(f'📄 <a href="#" style="color: #4a9eff;">View Implementation Plan</a>')
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 # Deliverables list
self.deliverables_container = QWidget() self.deliverables_container = QWidget()
self.deliverables_layout = QVBoxLayout(self.deliverables_container) self.deliverables_layout = QVBoxLayout(self.deliverables_container)
@ -1005,7 +1016,9 @@ class MilestoneWidget(QFrame):
""") """)
def _load_deliverables(self): def _load_deliverables(self):
"""Load deliverable/todo widgets.""" """Load deliverable/todo widgets, grouped by phase if applicable."""
import re
# Clear existing # Clear existing
while self.deliverables_layout.count(): while self.deliverables_layout.count():
item = self.deliverables_layout.takeAt(0) item = self.deliverables_layout.takeAt(0)
@ -1014,13 +1027,48 @@ class MilestoneWidget(QFrame):
# Show todos if available (preferred mode), otherwise show deliverables # Show todos if available (preferred mode), otherwise show deliverables
if self._todos: if self._todos:
# Group todos by phase
phases = {}
for todo in self._todos: for todo in self._todos:
# Show priority badge instead of milestone (since milestone is obvious) 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 = TodoItemWidget(todo, show_priority=True)
widget.toggled.connect(self._on_todo_toggled_internal) widget.toggled.connect(self._on_todo_toggled_internal)
widget.deleted.connect(self._on_todo_deleted_internal) widget.deleted.connect(self._on_todo_deleted_internal)
widget.start_discussion.connect(self.todo_start_discussion.emit) widget.start_discussion.connect(self.todo_start_discussion.emit)
widget.edited.connect(self.todo_edited.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) self.deliverables_layout.addWidget(widget)
else: else:
# Legacy: Add deliverables # Legacy: Add deliverables
@ -1078,3 +1126,48 @@ class MilestoneWidget(QFrame):
self._update_progress() self._update_progress()
self._update_status_icon() self._update_status_icon()
self._load_deliverables() 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())