feat: Add cleanup command and improve artifact editor integration

Features:
- Add `discussions cleanup` command to find orphaned diagrams
  - Scans all discussions for DIAGRAM: references
  - Lists unreferenced files in diagrams/ folders with sizes
  - --delete flag removes orphans with confirmation prompt
- New Discussion dialog now shows and allows changing save location
- Add Browse button to select output directory for new discussions

Fixes:
- Fix silent failure when artifact-editor not installed (now shows error)
- Fix silent failure when artifact-editor missing GUI deps (PyQt6)
- Add error logging for subprocess failures in artifact editor

Dependencies:
- smarttools now installed automatically from git URL
- artifact-editor included in [gui] extra
- PyQt6/QScintilla included for artifact-editor GUI support

Also:
- Add diagrams/ to .gitignore (generated test artifacts)
- Update README with simplified installation instructions
- Document cleanup command in README and CLAUDE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2025-12-30 20:07:44 -04:00
parent c592b93597
commit a39253c893
6 changed files with 255 additions and 7 deletions

3
.gitignore vendored
View File

@ -49,3 +49,6 @@ htmlcov/
# Discussion files created during testing # Discussion files created during testing
test_*.md test_*.md
# Generated diagrams (keep examples/diagrams/ for documentation)
diagrams/

View File

@ -23,6 +23,12 @@ pytest --cov=discussions
# Test SmartTools directly (Unix philosophy - test tools independently) # Test SmartTools directly (Unix philosophy - test tools independently)
cat examples/brainstorm_notification_system.md | discussion-parser | jq . cat examples/brainstorm_notification_system.md | discussion-parser | jq .
cat examples/brainstorm_notification_system.md | discussion-parser | discussion-vote-counter cat examples/brainstorm_notification_system.md | discussion-parser | discussion-vote-counter
# Find orphaned diagrams (not referenced by any discussion)
discussions cleanup
# Delete orphaned diagrams with confirmation
discussions cleanup --delete
``` ```
## Architecture ## Architecture

View File

@ -41,12 +41,32 @@ discussions ui --tui
## Installation ## Installation
```bash ```bash
# Clone and install # Clone and install (SmartTools installed automatically from git)
git clone https://gitea.brrd.tech/rob/orchestrated-discussions.git git clone https://gitea.brrd.tech/rob/orchestrated-discussions.git
cd orchestrated-discussions cd orchestrated-discussions
pip install -e ".[dev]" pip install -e ".[dev]"
# For TUI support only # With GUI support (includes artifact-editor for diagram creation)
pip install -e ".[gui]"
```
### From local checkouts (for development)
```bash
# Clone all projects
git clone https://gitea.brrd.tech/rob/SmartTools.git ~/PycharmProjects/SmartTools
git clone https://gitea.brrd.tech/rob/artifact-editor.git ~/PycharmProjects/artifact-editor
git clone https://gitea.brrd.tech/rob/orchestrated-discussions.git ~/PycharmProjects/orchestrated-discussions
# Install in order (editable mode)
pip install -e ~/PycharmProjects/SmartTools
pip install -e ~/PycharmProjects/artifact-editor
pip install -e "~/PycharmProjects/orchestrated-discussions[dev]"
```
### Minimal install (TUI only, no artifact editor)
```bash
pip install -e ".[tui]" pip install -e ".[tui]"
``` ```
@ -77,7 +97,7 @@ docker-compose run --rm shell
- Python 3.10+ - Python 3.10+
- [SmartTools](https://gitea.brrd.tech/rob/SmartTools) (installed automatically) - [SmartTools](https://gitea.brrd.tech/rob/SmartTools) (installed automatically)
- At least one AI CLI tool (Claude, Codex, etc.) - At least one AI provider configured in SmartTools (Claude, Codex, Gemini, etc.)
## How It Works ## How It Works
@ -123,6 +143,7 @@ User/CLI/UI
| `discussions comment <file> <text>` | Add a human comment | | `discussions comment <file> <text>` | Add a human comment |
| `discussions participants` | List available participant SmartTools | | `discussions participants` | List available participant SmartTools |
| `discussions advance <file> --phase <id>` | Advance to a specific phase | | `discussions advance <file> --phase <id>` | Advance to a specific phase |
| `discussions cleanup [directory]` | Find orphaned diagrams not referenced by any discussion |
| `discussions ui [directory]` | Launch interactive UI | | `discussions ui [directory]` | Launch interactive UI |
### Examples ### Examples
@ -145,6 +166,12 @@ discussions comment my-feature.md "I agree with the concerns" --vote changes
# Check consensus status # Check consensus status
discussions status my-feature.md discussions status my-feature.md
# Find orphaned diagrams (not referenced by any discussion)
discussions cleanup
# Delete orphaned diagrams (with confirmation)
discussions cleanup --delete
``` ```
## Templates ## Templates

View File

@ -28,17 +28,22 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"PyYAML>=6.0", "PyYAML>=6.0",
"smarttools>=0.1.0", "smarttools @ git+https://gitea.brrd.tech/rob/SmartTools.git",
] ]
[project.optional-dependencies] [project.optional-dependencies]
tui = [ tui = [
"urwid>=2.1.0", "urwid>=2.1.0",
] ]
gui = [
"dearpygui>=2.0.0",
"artifact-editor @ git+https://gitea.brrd.tech/rob/artifact-editor.git",
]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"pytest-cov>=4.0", "pytest-cov>=4.0",
"urwid>=2.1.0", "urwid>=2.1.0",
"dearpygui>=2.0.0",
] ]
[project.scripts] [project.scripts]

View File

@ -244,6 +244,114 @@ def cmd_advance(args) -> int:
return 0 return 0
def cmd_cleanup(args) -> int:
"""Find and optionally delete orphaned diagrams."""
from .discussion import Discussion
from .markers import extract_diagrams
import re
directory = Path(args.directory) if args.directory else Path.cwd()
if not directory.exists():
print(f"Error: Directory not found: {directory}")
return 1
# Find all discussion files
discussions = list(directory.glob("**/*.discussion.md"))
# Collect all referenced diagrams from all discussions
referenced = set()
for disc_path in discussions:
try:
d = Discussion.load(disc_path)
for comment in d.comments:
diagrams = extract_diagrams(comment.body)
for diagram in diagrams:
# Resolve relative to discussion file
abs_path = (disc_path.parent / diagram.path).resolve()
referenced.add(abs_path)
except Exception as e:
print(f"Warning: Could not parse {disc_path}: {e}")
# Find all diagram directories
diagram_dirs = set()
# Check for diagrams folders alongside discussions
for disc_path in discussions:
diagrams_dir = disc_path.parent / "diagrams"
if diagrams_dir.exists():
diagram_dirs.add(diagrams_dir)
# Also check top-level diagrams folder
top_diagrams = directory / "diagrams"
if top_diagrams.exists():
diagram_dirs.add(top_diagrams)
# Scan for any diagrams/ folders recursively (catches orphaned folders with no discussions)
for diagrams_dir in directory.glob("**/diagrams"):
if diagrams_dir.is_dir():
diagram_dirs.add(diagrams_dir)
if not diagram_dirs:
print("No diagram directories found")
return 0
# Find all diagram files
diagram_extensions = {'.puml', '.plantuml', '.mmd', '.mermaid', '.svg', '.png', '.scad'}
all_diagrams = set()
for diagrams_dir in diagram_dirs:
for ext in diagram_extensions:
for f in diagrams_dir.glob(f"*{ext}"):
all_diagrams.add(f.resolve())
# Find orphans
orphans = all_diagrams - referenced
if not orphans:
print(f"No orphaned diagrams found ({len(all_diagrams)} diagrams, all referenced)")
return 0
print(f"Found {len(orphans)} orphaned diagram(s) out of {len(all_diagrams)} total:\n")
# Group by directory for display
by_dir = {}
for orphan in sorted(orphans):
parent = orphan.parent
if parent not in by_dir:
by_dir[parent] = []
by_dir[parent].append(orphan)
for parent, files in sorted(by_dir.items()):
try:
rel_parent = parent.relative_to(directory)
except ValueError:
rel_parent = parent
print(f" {rel_parent}/")
for f in files:
size = f.stat().st_size
size_str = f"{size:,} bytes" if size < 1024 else f"{size/1024:.1f} KB"
print(f" {f.name} ({size_str})")
if args.delete:
print()
confirm = input(f"Delete {len(orphans)} orphaned file(s)? [y/N] ")
if confirm.lower() == 'y':
deleted = 0
for orphan in orphans:
try:
orphan.unlink()
deleted += 1
except Exception as e:
print(f" Error deleting {orphan.name}: {e}")
print(f"Deleted {deleted} file(s)")
else:
print("Cancelled")
else:
print(f"\nRun with --delete to remove these files")
return 0
def cmd_ui(args) -> int: def cmd_ui(args) -> int:
"""Launch the interactive UI (GUI by default, TUI with --tui).""" """Launch the interactive UI (GUI by default, TUI with --tui)."""
# Determine if path is a file or directory # Determine if path is a file or directory
@ -340,6 +448,12 @@ def main(argv: list[str] = None) -> int:
p_advance.add_argument("--phase", help="Target phase ID") p_advance.add_argument("--phase", help="Target phase ID")
p_advance.set_defaults(func=cmd_advance) p_advance.set_defaults(func=cmd_advance)
# 'cleanup' command
p_cleanup = subparsers.add_parser("cleanup", help="Find orphaned diagrams")
p_cleanup.add_argument("directory", nargs="?", help="Directory to scan (default: current)")
p_cleanup.add_argument("--delete", "-d", action="store_true", help="Delete orphaned files (with confirmation)")
p_cleanup.set_defaults(func=cmd_cleanup)
# 'ui' command # 'ui' command
p_ui = subparsers.add_parser("ui", help="Launch interactive UI") p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
p_ui.add_argument("path", nargs="?", help="Discussion file (.md) or directory to browse") p_ui.add_argument("path", nargs="?", help="Discussion file (.md) or directory to browse")

View File

@ -534,6 +534,39 @@ class DiscussionGUI:
self.discussions_dir = Path(folder_path) self.discussions_dir = Path(folder_path)
self._refresh_discussions() self._refresh_discussions()
def _browse_for_folder(self, target_input_tag: str):
"""Show folder browser and update a target input field with selected path.
Args:
target_input_tag: Tag of the input_text widget to update with selection
"""
# Create a one-time folder dialog for this specific purpose
dialog_tag = f"browse_folder_{target_input_tag}"
def on_select(sender, app_data):
folder_path = app_data.get("file_path_name", "")
if folder_path and dpg.does_item_exist(target_input_tag):
dpg.set_value(target_input_tag, folder_path)
dpg.delete_item(dialog_tag)
def on_cancel(sender, app_data):
dpg.delete_item(dialog_tag)
# Get current value to start from
current = dpg.get_value(target_input_tag) if dpg.does_item_exist(target_input_tag) else str(Path.cwd())
with dpg.file_dialog(
directory_selector=True,
show=True,
callback=on_select,
cancel_callback=on_cancel,
tag=dialog_tag,
default_path=current,
width=700,
height=400
):
pass
def _refresh_discussions(self): def _refresh_discussions(self):
"""Refresh the list of discussions.""" """Refresh the list of discussions."""
self.discussions_list = [] self.discussions_list = []
@ -869,6 +902,7 @@ class DiscussionGUI:
""" """
cmd, env = self._get_artifact_editor_cmd() cmd, env = self._get_artifact_editor_cmd()
if not cmd: if not cmd:
self._add_output("Error: Artifact editor not found")
return None return None
cmd.extend(["--output", str(file_path)]) cmd.extend(["--output", str(file_path)])
@ -881,6 +915,14 @@ class DiscussionGUI:
env=env, env=env,
) )
# Log any stderr output for debugging
if result.stderr:
self._add_output(f"Artifact editor: {result.stderr[:200]}")
# Check return code
if result.returncode != 0:
self._add_output(f"Artifact editor exited with code {result.returncode}")
# Parse ARTIFACT_SAVED:path from stdout # Parse ARTIFACT_SAVED:path from stdout
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
if line.startswith("ARTIFACT_SAVED:"): if line.startswith("ARTIFACT_SAVED:"):
@ -888,6 +930,7 @@ class DiscussionGUI:
return None return None
except Exception as e: except Exception as e:
self._add_output(f"Error launching artifact editor: {e}")
return None return None
def _create_new_artifact(self): def _create_new_artifact(self):
@ -900,6 +943,19 @@ class DiscussionGUI:
self._show_error("No discussion loaded") self._show_error("No discussion loaded")
return return
# Check if artifact editor is available before proceeding
cmd, env = self._get_artifact_editor_cmd()
if not cmd:
self._show_error(
"Artifact Editor not installed.\n\n"
"Install from ~/PycharmProjects/artifact-editor:\n"
" cd ~/PycharmProjects/artifact-editor\n"
" ./install.sh\n\n"
"Or install with pip:\n"
" pip install -e ~/PycharmProjects/artifact-editor"
)
return
# Create diagrams directory if needed # Create diagrams directory if needed
diagrams_dir = self.current_discussion.path.parent / "diagrams" diagrams_dir = self.current_discussion.path.parent / "diagrams"
diagrams_dir.mkdir(exist_ok=True) diagrams_dir.mkdir(exist_ok=True)
@ -1712,6 +1768,20 @@ class DiscussionGUI:
to the comment only after we know the actual saved path (user may change format). to the comment only after we know the actual saved path (user may change format).
""" """
if not self.current_discussion: if not self.current_discussion:
self._show_error("No discussion loaded. Open a discussion first.")
return
# Check if artifact editor is available before proceeding
cmd, env = self._get_artifact_editor_cmd()
if not cmd:
self._show_error(
"Artifact Editor not installed.\n\n"
"Install from ~/PycharmProjects/artifact-editor:\n"
" cd ~/PycharmProjects/artifact-editor\n"
" ./install.sh\n\n"
"Or install with pip:\n"
" pip install -e ~/PycharmProjects/artifact-editor"
)
return return
# Create diagrams directory if needed # Create diagrams directory if needed
@ -2046,8 +2116,14 @@ class DiscussionGUI:
slug = title.lower().replace(" ", "-") slug = title.lower().replace(" ", "-")
slug = "".join(c for c in slug if c.isalnum() or c == "-") slug = "".join(c for c in slug if c.isalnum() or c == "-")
# Determine output directory # Get output directory from the location field
output_dir = self.discussions_dir if self.discussions_dir else Path.cwd() output_dir = Path(dpg.get_value("new_disc_location"))
if not output_dir.exists():
try:
output_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
self._show_error(f"Cannot create directory: {e}")
return
output_path = output_dir / f"{slug}.discussion.md" output_path = output_dir / f"{slug}.discussion.md"
# Check if file exists # Check if file exists
@ -2076,12 +2152,29 @@ class DiscussionGUI:
except Exception as e: except Exception as e:
self._show_error(f"Failed to create discussion: {e}") self._show_error(f"Failed to create discussion: {e}")
# Determine initial output directory
if self.current_discussion:
initial_dir = self.current_discussion.path.parent
else:
initial_dir = self.discussions_dir if self.discussions_dir else Path.cwd()
with dpg.window(label="New Discussion", tag=window_tag, with dpg.window(label="New Discussion", tag=window_tag,
width=550, height=520, pos=[400, 150], no_collapse=True): width=550, height=550, pos=[400, 150], no_collapse=True):
dpg.add_text("Title:", color=(150, 200, 255)) dpg.add_text("Title:", color=(150, 200, 255))
dpg.add_input_text(tag="new_disc_title", width=-1, hint="Enter discussion title") dpg.add_input_text(tag="new_disc_title", width=-1, hint="Enter discussion title")
dpg.add_spacer(height=5)
dpg.add_text("Location:", color=(150, 200, 255))
with dpg.group(horizontal=True):
dpg.add_input_text(
tag="new_disc_location",
default_value=str(initial_dir),
width=-80,
readonly=False
)
dpg.add_button(label="Browse", callback=lambda: self._browse_for_folder("new_disc_location"))
dpg.add_spacer(height=10) dpg.add_spacer(height=10)
dpg.add_text("Template:", color=(150, 200, 255)) dpg.add_text("Template:", color=(150, 200, 255))
template_items = templates + ["+ Create New Template..."] template_items = templates + ["+ Create New Template..."]