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
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)
cat examples/brainstorm_notification_system.md | discussion-parser | jq .
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

View File

@ -41,12 +41,32 @@ discussions ui --tui
## Installation
```bash
# Clone and install
# Clone and install (SmartTools installed automatically from git)
git clone https://gitea.brrd.tech/rob/orchestrated-discussions.git
cd orchestrated-discussions
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]"
```
@ -77,7 +97,7 @@ docker-compose run --rm shell
- Python 3.10+
- [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
@ -123,6 +143,7 @@ User/CLI/UI
| `discussions comment <file> <text>` | Add a human comment |
| `discussions participants` | List available participant SmartTools |
| `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 |
### Examples
@ -145,6 +166,12 @@ discussions comment my-feature.md "I agree with the concerns" --vote changes
# Check consensus status
discussions status my-feature.md
# Find orphaned diagrams (not referenced by any discussion)
discussions cleanup
# Delete orphaned diagrams (with confirmation)
discussions cleanup --delete
```
## Templates

View File

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

View File

@ -244,6 +244,114 @@ def cmd_advance(args) -> int:
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:
"""Launch the interactive UI (GUI by default, TUI with --tui)."""
# 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.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
p_ui = subparsers.add_parser("ui", help="Launch interactive UI")
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._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):
"""Refresh the list of discussions."""
self.discussions_list = []
@ -869,6 +902,7 @@ class DiscussionGUI:
"""
cmd, env = self._get_artifact_editor_cmd()
if not cmd:
self._add_output("Error: Artifact editor not found")
return None
cmd.extend(["--output", str(file_path)])
@ -881,6 +915,14 @@ class DiscussionGUI:
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
for line in result.stdout.splitlines():
if line.startswith("ARTIFACT_SAVED:"):
@ -888,6 +930,7 @@ class DiscussionGUI:
return None
except Exception as e:
self._add_output(f"Error launching artifact editor: {e}")
return None
def _create_new_artifact(self):
@ -900,6 +943,19 @@ class DiscussionGUI:
self._show_error("No discussion loaded")
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
diagrams_dir = self.current_discussion.path.parent / "diagrams"
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).
"""
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
# Create diagrams directory if needed
@ -2046,8 +2116,14 @@ class DiscussionGUI:
slug = title.lower().replace(" ", "-")
slug = "".join(c for c in slug if c.isalnum() or c == "-")
# Determine output directory
output_dir = self.discussions_dir if self.discussions_dir else Path.cwd()
# Get output directory from the location field
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"
# Check if file exists
@ -2076,12 +2152,29 @@ class DiscussionGUI:
except Exception as 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,
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_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_text("Template:", color=(150, 200, 255))
template_items = templates + ["+ Create New Template..."]