commit 3b0c0339f7f15ed5fec7b669215d1b47d83fca33 Author: rob Date: Mon Dec 8 07:57:43 2025 -0400 Initial project structure for Orchestrated Discussions - Core modules: markers, voting, participant, discussion, runner, cli - Bundled participants: architect, security, pragmatist, etc. - Example discussion file demonstrating format - Comprehensive design document - Basic test suite for markers and voting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..635baeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# mypy +.mypy_cache/ + +# Local config +.env +*.local.yaml + +# Discussion files created during testing +test_*.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ad0985 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Orchestrated Discussions + +**Multi-agent AI discussion orchestration with voting and phases.** + +Conduct structured discussions between multiple AI personas, each with distinct perspectives, expertise, and voting behavior. + +```bash +# Create a discussion +discussions new "Add user authentication" --template feature + +# Run a turn with specific participants +discussions turn auth-discussion.md @architect @security @pragmatist + +# Check status +discussions status auth-discussion.md +# Output: Phase: initial_feedback, Votes: READY: 1, CHANGES: 2 + +# Interactive mode +discussions ui auth-discussion.md +``` + +## Installation + +```bash +pip install orchestrated-discussions + +# For TUI support +pip install orchestrated-discussions[tui] +``` + +### Requirements + +- Python 3.10+ +- [SmartTools](https://github.com/rob/smarttools) (installed automatically) +- At least one AI CLI tool (Claude, Codex, OpenCode, etc.) + +## Quick Start + +```bash +# Create your first discussion +discussions new "My Feature" --template feature + +# See bundled participants +discussions participants list + +# Run a turn +discussions turn my-feature.md @architect @pragmatist + +# Add your own comment +discussions comment my-feature.md "I think we should..." --vote READY +``` + +## How It Works + +1. **Discussions** are markdown files with structured comments +2. **Participants** are AI personas with distinct perspectives (architect, security, pragmatist, etc.) +3. **Phases** guide the discussion through stages (feedback → review → vote) +4. **Votes** (READY/CHANGES/REJECT) determine consensus +5. **Markers** (Q:, TODO:, DECISION:) capture structured information + +## Documentation + +- [Design Document](docs/DESIGN.md) - Full architecture and implementation details +- [API Reference](docs/API.md) - Python API documentation +- [Participant Guide](docs/PARTICIPANTS.md) - Creating custom personas + +## Project Context + +This is part of a three-project ecosystem: + +1. **SmartTools** - AI provider abstraction and tool execution +2. **Orchestrated Discussions** (this project) - Multi-agent conversation orchestration +3. **CascadingDev** - Git-driven automation (uses both above) + +## License + +MIT diff --git a/config/default_participants.yaml b/config/default_participants.yaml new file mode 100644 index 0000000..2b9d7be --- /dev/null +++ b/config/default_participants.yaml @@ -0,0 +1,239 @@ +# Default Participants for Orchestrated Discussions +# These personas are bundled with the package and can be customized per-project + +schema_version: "1.0" + +# Voting participants - these cast READY/CHANGES/REJECT votes +voting_participants: + - name: AI-Moderator + alias: moderator + role: Discussion Facilitator + personality: | + You are AI-Moderator, a neutral discussion facilitator who keeps conversations + productive and on-track. + + Your role: + - Guide the discussion through phases + - Summarize progress and key points + - Identify when consensus is near or blocked + - Call for votes when appropriate + - Ensure all perspectives are heard + + Perspective: + - Stay neutral - don't advocate for technical positions + - Focus on process, not content + - Help resolve conflicts constructively + - Keep the discussion moving forward + + expertise: + - Process facilitation + - Consensus building + - Conflict resolution + - Project management + concerns: + - "Are we making progress?" + - "Do we have consensus?" + - "Are all concerns being addressed?" + provider_hint: claude-sonnet + + - name: AI-Architect + alias: architect + role: Systems Architect + personality: | + You are AI-Architect (also known as Chen), a senior systems architect with deep + expertise in distributed systems, design patterns, and long-term technical strategy. + + Your role: + - Think in systems, patterns, and architectural principles + - Consider scalability, maintainability, and evolution over time + - Identify architectural risks and technical debt implications + - Suggest well-established patterns and proven approaches + - Balance ideal architecture with practical constraints + + Perspective: + - You think 2-5 years ahead, not just the immediate implementation + - You value modularity, separation of concerns, and clean boundaries + - You prefer boring, proven technology over cutting-edge experiments + - You call out when shortcuts will create architectural debt + + expertise: + - System design + - Scalability + - Technical debt + - Architectural patterns + - API design + concerns: + - "How does this fit the overall architecture?" + - "Will this scale?" + - "What's the long-term maintenance burden?" + provider_hint: claude-sonnet + + - name: AI-Security + alias: security + role: Security Specialist + personality: | + You are AI-Security (also known as Steve), a security specialist who identifies + vulnerabilities, threat vectors, and security best practices. + + Your role: + - Identify security risks and vulnerabilities + - Suggest mitigations and security controls + - Consider threat models and attack surfaces + - Ensure compliance with security best practices + - Balance security with usability + + Perspective: + - Assume malicious actors will try to exploit the system + - Consider both external and internal threats + - Think about data protection and privacy + - Focus on practical, implementable security measures + + expertise: + - Vulnerability assessment + - Threat modeling + - Authentication & authorization + - Data protection + - Input validation + concerns: + - "What are the security implications?" + - "How could this be exploited?" + - "Are we handling sensitive data properly?" + provider_hint: claude-sonnet + + - name: AI-Pragmatist + alias: pragmatist + role: Shipping Pragmatist + personality: | + You are AI-Pragmatist (also known as Maya), a shipping-focused engineer who + advocates for practical solutions and incremental delivery. + + Your role: + - Advocate for simpler solutions + - Identify over-engineering and scope creep + - Suggest MVP approaches + - Balance quality with delivery speed + - Challenge unnecessary complexity + + Perspective: + - "Done is better than perfect when it's good enough" + - Ship early, iterate often + - Complexity is the enemy of delivery + - Technical debt is acceptable if managed + - Users need features, not architectural purity + + expertise: + - MVP scoping + - Shipping velocity + - Trade-off analysis + - Iterative development + concerns: + - "Can we ship this incrementally?" + - "Are we over-engineering this?" + - "What's the simplest thing that could work?" + provider_hint: claude-sonnet + + - name: AI-Perfectionist + alias: perfectionist + role: Quality Champion + personality: | + You are AI-Perfectionist (also known as Alex), a quality-obsessed engineer who + advocates for code excellence and comprehensive testing. + + Your role: + - Advocate for code quality and best practices + - Ensure adequate test coverage + - Push for clear documentation + - Identify maintainability issues + - Balance quality with practicality + + Perspective: + - "Code is read 10x more than written - optimize for clarity" + - Technical debt compounds over time + - Tests are not optional + - Documentation is part of the deliverable + + expertise: + - Code quality + - Testing strategies + - Documentation + - Code review + - Developer experience + concerns: + - "Is this maintainable?" + - "Do we have adequate tests?" + - "Is the code clear and well-documented?" + provider_hint: claude-sonnet + + - name: AI-Designer + alias: designer + role: UX Designer + personality: | + You are AI-Designer (also known as Eva), a user experience designer who + advocates for usability, accessibility, and user-centered design. + + Your role: + - Advocate for user needs + - Ensure accessibility compliance + - Consider the full user journey + - Push for intuitive interfaces + - Balance aesthetics with functionality + + Perspective: + - Users should not need documentation + - Accessibility is not optional + - Design for the edge cases + - Consistency builds trust + + expertise: + - User experience + - Accessibility (WCAG) + - Visual design + - User research + - Interaction design + concerns: + - "Is this intuitive for users?" + - "Does this meet accessibility standards?" + - "How will this look and feel?" + provider_hint: claude-sonnet + +# Background participants - provide tools/research, do not vote +background_participants: + - name: AI-Researcher + alias: researcher + role: Research Assistant + personality: | + You are AI-Researcher, a thorough research assistant who provides + cited sources, documentation, and background information. + + Your role: + - Find relevant documentation and examples + - Research best practices and prior art + - Provide context and background + - Cite sources when possible + + capabilities: + - Web research + - Documentation lookup + - Best practice identification + - Citation tracking + provider_hint: claude-haiku + + - name: AI-Visualizer + alias: visualizer + role: Diagram Generator + personality: | + You are AI-Visualizer, a diagram specialist who creates PlantUML + diagrams to visualize architecture, flows, and relationships. + + Your role: + - Create clear, informative diagrams + - Visualize system architecture + - Illustrate data flows and sequences + - Help clarify complex relationships + + capabilities: + - PlantUML diagrams + - Architecture diagrams + - Sequence diagrams + - Class diagrams + provider_hint: claude-haiku diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..1139cc6 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,1082 @@ +# Orchestrated Discussions - Design Document + +## Overview + +**Orchestrated Discussions** is a standalone Python library and CLI for conducting structured multi-agent AI conversations. It enables humans to facilitate discussions between multiple AI personas, each with distinct perspectives, expertise, and voting behavior. + +### Project Context + +This project is part of a three-project ecosystem: + +1. **SmartTools** (`~/PycharmProjects/SmartTools`) - Foundation layer + - AI provider abstraction with fallback chains + - YAML-based tool definitions + - Multi-step execution pipelines + - CLI and TUI interfaces + - **Status**: Mostly complete + +2. **Orchestrated Discussions** (this project) - Conversation layer + - Multi-agent discussion orchestration + - Phase-based workflows with voting + - Turn management and @mentions + - **Status**: In design/early development + +3. **CascadingDev** (`~/PycharmProjects/CascadingDev`) - Integration layer + - Git-driven automation with cascading rules + - Pre-commit hook orchestration + - Will be refactored to depend on SmartTools + Orchestrated Discussions + - **Status**: Functional but entangled; needs refactoring after dependencies are ready + +### Design Goals + +1. **Standalone utility** - Works without git, CascadingDev, or any specific workflow +2. **SmartTools integration** - Uses SmartTools for AI provider abstraction +3. **Extensible personas** - Users can create/customize discussion participants +4. **Multiple interfaces** - CLI, TUI, and Python API +5. **Append-only discussions** - Markdown files that grow, never shrink +6. **Portable state** - Discussion files are self-contained and human-readable + +--- + +## Architecture + +### Dependency Graph + +``` +┌─────────────────────────────────────┐ +│ User Application │ +│ (CascadingDev, custom scripts) │ +└─────────────────┬───────────────────┘ + │ +┌─────────────────▼───────────────────┐ +│ Orchestrated Discussions │ +│ - Discussion state management │ +│ - Phase/turn orchestration │ +│ - Voting and consensus │ +│ - @mention routing │ +└─────────────────┬───────────────────┘ + │ +┌─────────────────▼───────────────────┐ +│ SmartTools │ +│ - AI provider abstraction │ +│ - Tool execution engine │ +│ - Provider fallback chains │ +└─────────────────────────────────────┘ +``` + +### Module Structure + +``` +orchestrated-discussions/ +├── src/discussions/ +│ ├── __init__.py # Package exports +│ ├── cli.py # CLI entry point (discussions command) +│ ├── discussion.py # Discussion class and state management +│ ├── participant.py # Participant definitions and loading +│ ├── markers.py # Marker parsing (Q:, TODO:, VOTE:, etc.) +│ ├── turns.py # Turn management and @mention routing +│ ├── phases.py # Phase definitions and transitions +│ ├── voting.py # Vote counting and consensus logic +│ ├── runner.py # Main orchestration engine +│ └── ui/ +│ ├── __init__.py +│ └── tui.py # Optional urwid-based TUI +│ +├── config/ +│ └── default_participants.yaml # Bundled persona definitions +│ +├── examples/ +│ ├── feature_discussion.md # Example: feature planning +│ ├── code_review.md # Example: code review +│ └── architecture_decision.md # Example: ADR discussion +│ +├── tests/ +│ ├── test_markers.py +│ ├── test_voting.py +│ ├── test_phases.py +│ └── test_runner.py +│ +├── docs/ +│ └── DESIGN.md # This document +│ +├── pyproject.toml # Package configuration +└── README.md # User documentation +``` + +--- + +## Core Concepts + +### 1. Discussion + +A **Discussion** is a markdown file containing: +- Metadata (title, phase, status) +- Context/description +- Participant comments with optional votes +- Structured markers (questions, decisions, todos) + +```markdown + + + + + + +# Feature X Implementation + +## Context +We need to implement feature X that allows users to... + +## Requirements +- Must support... +- Should integrate with... + +--- + +Name: AI-Architect +Looking at this from a systems perspective, I have concerns about... + +The proposed approach could lead to tight coupling between... + +Q: Have we considered using the adapter pattern here? + +VOTE: CHANGES + +--- + +Name: AI-Pragmatist +I think we're overcomplicating this. The simplest approach would be... + +VOTE: READY + +--- +``` + +### 2. Participant + +A **Participant** is an AI persona with: +- **Name**: Display name (e.g., "AI-Architect", "AI-Security") +- **Alias**: Short mention name (e.g., "architect", "security") +- **Role**: Brief role description +- **Personality**: System prompt defining perspective and behavior +- **Expertise**: List of expertise areas +- **Concerns**: What this participant watches for +- **Vote behavior**: Whether this participant votes (voting vs background) + +Participants are stored as YAML files and can optionally be backed by SmartTools. + +```yaml +# config/participants/architect.yaml +name: AI-Architect +alias: architect +role: Systems Architect +personality: | + You are AI-Architect (also known as Chen), a senior systems architect with deep + expertise in distributed systems, design patterns, and long-term technical strategy. + + Your perspective: + - Think 2-5 years ahead, not just immediate implementation + - Value modularity, separation of concerns, and clean boundaries + - Prefer boring, proven technology over cutting-edge experiments + - Call out when shortcuts will create architectural debt + + When responding: + - Consider scalability, maintainability, and evolution over time + - Identify architectural risks and technical debt implications + - Suggest well-established patterns and proven approaches + - Balance ideal architecture with practical constraints + +expertise: + - System design + - Scalability + - Technical debt + - Architectural patterns + - API design + +concerns: + - "How does this fit the overall architecture?" + - "Will this scale?" + - "What's the long-term maintenance burden?" + - "Are we creating unnecessary coupling?" + +type: voting # voting | background +provider_hint: claude-sonnet # Preferred AI provider +``` + +### 3. Markers + +**Markers** are structured annotations recognized by the system: + +| Marker | Description | Example | +|--------|-------------|---------| +| `VOTE:` | Participant's vote | `VOTE: READY` | +| `Q:` | Question | `Q: Have we considered caching?` | +| `TODO:` | Action item | `TODO: Research rate limiting options` | +| `DECISION:` | Recorded decision | `DECISION: We will use PostgreSQL` | +| `ASSIGNED:` | Claimed task | `ASSIGNED: @rob will write the spec` | +| `DONE:` | Completed task | `DONE: Spec written and reviewed` | +| `CONCERN:` | Raised concern | `CONCERN: Security implications unclear` | +| `@alias` | Mention participant | `@architect what do you think?` | + +### 4. Phases + +**Phases** define the stages of a discussion. Each phase has: +- **ID**: Unique identifier +- **Title**: Human-readable name +- **Instructions**: What participants should focus on +- **Voting mode**: Whether votes are collected in this phase +- **Auto-trigger**: Condition for automatic advancement +- **Next phase**: What phase follows + +```yaml +phases: + - id: initial_feedback + title: Initial Feedback + instructions: | + Share your initial thoughts on the proposal. Focus on: + - Overall feasibility + - Major concerns or risks + - Questions that need answering + voting_mode: false + auto_trigger: all_mentioned_responded + next: detailed_review + + - id: detailed_review + title: Detailed Review + instructions: | + Provide detailed feedback from your area of expertise. + Raise specific concerns and suggest alternatives. + voting_mode: false + auto_trigger: null # Manual advancement + next: consensus_vote + + - id: consensus_vote + title: Consensus Vote + instructions: | + Cast your final vote based on the discussion. + - READY: Approve to proceed + - CHANGES: Needs modifications (specify what) + - REJECT: Fundamental issues (explain why) + voting_mode: true + auto_trigger: null + next: null # Terminal phase +``` + +### 5. Voting + +**Voting** determines discussion outcomes: + +| Vote | Meaning | +|------|---------| +| `READY` | Approve - no blocking concerns | +| `CHANGES` | Conditional approval - needs specified modifications | +| `REJECT` | Block - fundamental issues that must be resolved | + +**Consensus rules** (configurable): +- `threshold_ready`: Fraction of READY votes needed (default: 0.67) +- `threshold_reject`: Any REJECT blocks by default (threshold: 0.01) +- `human_required`: Whether human approval is mandatory + +### 6. Turns + +A **Turn** is one round of participant responses. The orchestrator: +1. Identifies who should respond (mentioned participants or all) +2. Provides context (discussion content, phase instructions) +3. Collects responses (comment + optional vote) +4. Appends responses to discussion file +5. Checks for phase transitions + +--- + +## Integration with SmartTools + +### Option A: Direct Import (Preferred) + +```python +# discussions/runner.py +from smarttools.providers import call_provider, load_providers +from smarttools.tool import load_tool, run_tool + +def get_participant_response(participant, context, callout): + """Get AI response for a participant.""" + + # Build prompt from participant personality + context + prompt = f""" +{participant.personality} + +## Current Discussion +{context} + +## Your Task +{callout if callout else "Provide your perspective on the discussion."} + +Respond with JSON: +{{"comment": "your markdown comment", "vote": "READY|CHANGES|REJECT|null"}} +""" + + # Use SmartTools provider + result = call_provider(participant.provider_hint, prompt) + + if result.success: + return parse_response(result.text) + else: + return None +``` + +### Option B: Participants as SmartTools + +Each participant can be a SmartTool, allowing users to customize via SmartTools' TUI: + +```yaml +# ~/.smarttools/discussion-architect/config.yaml +name: discussion-architect +description: Systems architect for discussions +category: Discussion +arguments: + - flag: --context + variable: context + description: Discussion content + - flag: --callout + variable: callout + default: "" + description: Specific question or request +steps: + - type: prompt + prompt: | + You are AI-Architect, a senior systems architect... + + ## Discussion Context + {context} + + ## Request + {callout} + + Respond with JSON: {"comment": "...", "vote": "READY|CHANGES|REJECT|null"} + provider: claude-sonnet + output_var: response +output: "{response}" +``` + +### Provider Configuration + +SmartTools manages provider configuration in `~/.smarttools/providers.yaml`: + +```yaml +providers: + - name: claude-sonnet + command: "claude -p --model sonnet" + description: "Balanced quality/speed" + - name: claude-haiku + command: "claude -p --model haiku" + description: "Fast, good for simple responses" + - name: opencode-deepseek + command: "$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat" + description: "Best value, cheap and accurate" +``` + +--- + +## CLI Interface + +### Commands + +```bash +# Create a new discussion +discussions new "Feature X" --template feature +discussions new "API Redesign" --template architecture-decision + +# List discussions +discussions list +discussions list --status open + +# Show discussion status +discussions status feature-x.md +discussions status feature-x.md --verbose + +# Run a turn (invoke specific participants) +discussions turn feature-x.md @architect @security +discussions turn feature-x.md @all # All registered participants + +# Advance phase manually +discussions advance feature-x.md +discussions advance feature-x.md --to detailed_review + +# Add human comment +discussions comment feature-x.md "I agree with the architect's concerns." +discussions comment feature-x.md --vote READY "Looks good to me." + +# Participant management +discussions participants list +discussions participants add architect --from-file persona.yaml +discussions participants add custom-reviewer --interactive +discussions participants remove old-participant + +# Interactive TUI +discussions ui feature-x.md +discussions ui # Opens discussion browser +``` + +### Example Session + +```bash +$ discussions new "Add user authentication" --template feature +Created: discussions/add-user-authentication.md + +$ discussions turn add-user-authentication.md @architect @security @pragmatist +Invoking AI-Architect... +Invoking AI-Security... +Invoking AI-Pragmatist... + +Discussion updated with 3 new comments. +Votes: READY: 1, CHANGES: 2, REJECT: 0 + +$ discussions status add-user-authentication.md +Discussion: Add user authentication +Phase: initial_feedback +Status: OPEN + +Participants (3 responded): + AI-Architect: CHANGES - concerned about session management + AI-Security: CHANGES - needs threat model + AI-Pragmatist: READY - MVP approach is fine + +Open Questions (2): + Q: Should we use JWT or session cookies? (@architect) + Q: What's our token expiry policy? (@security) + +$ discussions comment add-user-authentication.md "Let's use JWT with 1hr expiry." +Added comment from Human (Rob). + +$ discussions advance add-user-authentication.md +Advanced to phase: detailed_review +``` + +--- + +## Python API + +```python +from discussions import Discussion, Participant, Runner + +# Load or create a discussion +discussion = Discussion.load("feature-x.md") +# or +discussion = Discussion.create( + title="Feature X", + template="feature", + context="We need to implement..." +) + +# Access discussion state +print(discussion.title) # "Feature X" +print(discussion.phase) # "initial_feedback" +print(discussion.status) # "OPEN" +print(discussion.participants) # ["architect", "security", "pragmatist"] + +# Get structured data +print(discussion.votes) # {"AI-Architect": "CHANGES", ...} +print(discussion.questions) # [{"text": "...", "author": "...", "status": "open"}] +print(discussion.decisions) # [{"text": "...", "author": "...", "supporters": [...]}] + +# Run a turn +runner = Runner() +responses = runner.run_turn( + discussion=discussion, + participants=["architect", "security"], + callout="Please review the updated proposal." +) + +# Add human comment +discussion.add_comment( + author="Rob", + text="I agree with the security concerns.", + vote="CHANGES" +) + +# Check consensus +if discussion.has_consensus(): + print(f"Consensus reached: {discussion.consensus_result}") + +# Save changes +discussion.save() +``` + +--- + +## Discussion File Format + +### Header Block + +```markdown + + + + + + + +``` + +### Content Sections + +```markdown +# Feature X Implementation + +## Context +[Description of what's being discussed] + +## Requirements +- [Requirement 1] +- [Requirement 2] + +## Constraints +- [Constraint 1] +``` + +### Comment Blocks + +```markdown +--- + +Name: AI-Architect +[Markdown content - the participant's comment] + +Can include: +- Multiple paragraphs +- Code blocks +- Lists +- Q: questions +- TODO: action items +- DECISION: decisions + +VOTE: CHANGES + +--- +``` + +### Phase Markers + +```markdown + + +``` + +The `VOTE-RESET` marker indicates that votes before this point should not be counted for the current phase. + +--- + +## Configuration + +### Project Configuration + +```yaml +# ~/.config/discussions/config.yaml (or discussions.yaml in project root) +default_participants: + - architect + - security + - pragmatist + +default_template: feature + +consensus: + threshold_ready: 0.67 + threshold_reject: 0.01 + human_required: true + +providers: + default: claude-sonnet + fallback: + - opencode-deepseek + - claude-haiku + +output: + directory: ./discussions # Where to store discussion files + summary_files: true # Generate .sum.md files +``` + +### Participant Registry + +```yaml +# ~/.config/discussions/participants.yaml +participants: + - name: AI-Architect + alias: architect + role: Systems Architect + personality: | + You are a senior systems architect... + type: voting + provider_hint: claude-sonnet + + - name: AI-Security + alias: security + role: Security Specialist + personality: | + You are a security specialist... + type: voting + provider_hint: claude-sonnet + + - name: AI-Researcher + alias: researcher + role: Research Assistant + personality: | + You are a research assistant... + type: background # Does not vote + provider_hint: claude-haiku +``` + +--- + +## Bundled Participants + +The following participants are bundled with the package: + +### Voting Participants + +| Name | Alias | Role | Perspective | +|------|-------|------|-------------| +| AI-Moderator | `moderator` | Discussion Facilitator | Keeps discussions productive, summarizes, calls votes | +| AI-Architect | `architect` | Systems Architect | Long-term thinking, scalability, patterns | +| AI-Security | `security` | Security Specialist | Threat modeling, vulnerabilities, mitigations | +| AI-Pragmatist | `pragmatist` | Shipping Pragmatist | MVP mindset, practical tradeoffs | +| AI-Perfectionist | `perfectionist` | Quality Champion | Code quality, testing, documentation | +| AI-Designer | `designer` | UX Designer | User experience, accessibility | + +### Background Participants (Non-voting) + +| Name | Alias | Role | Capability | +|------|-------|------|------------| +| AI-Researcher | `researcher` | Research Assistant | Web research, documentation lookup | +| AI-Visualizer | `visualizer` | Diagram Generator | PlantUML diagrams | + +--- + +## Templates + +### Feature Discussion Template + +```markdown + + + + + + + + +# {title} + +## Context +[Describe the feature and why it's needed] + +## Requirements +- [ ] [Requirement 1] +- [ ] [Requirement 2] + +## Open Questions +- [Question 1] + +## Constraints +- [Constraint 1] + +--- + +*Discussion begins below. Participants will be @mentioned to provide feedback.* +``` + +### Code Review Template + +```markdown + + + + + + + + +# Code Review: {title} + +## Changes +[Summary of changes or link to diff] + +## Areas of Focus +- [ ] Architecture +- [ ] Security +- [ ] Performance +- [ ] Testing +- [ ] Documentation + +--- + +*Review comments below.* +``` + +### Architecture Decision Record (ADR) Template + +```markdown + + + + + + + + +# ADR: {title} + +## Status +PROPOSED + +## Context +[Why is this decision needed?] + +## Options Considered + +### Option A: [Name] +- Pros: ... +- Cons: ... + +### Option B: [Name] +- Pros: ... +- Cons: ... + +## Decision +[To be determined through discussion] + +## Consequences +[To be determined] + +--- + +*Discussion begins below.* +``` + +--- + +## Implementation Plan + +### Phase 1: Core Library (MVP) + +1. **`markers.py`** - Parse VOTE:, Q:, TODO:, etc. +2. **`discussion.py`** - Discussion class with load/save +3. **`participant.py`** - Participant loading from YAML +4. **`voting.py`** - Vote counting and consensus +5. **`runner.py`** - Basic turn execution using SmartTools + +**Deliverable**: Python API that can load discussions, run turns, and save results. + +### Phase 2: CLI + +1. **`cli.py`** - Implement commands: new, status, turn, comment, advance +2. **Templates** - Bundled discussion templates +3. **Participants** - Bundled persona definitions + +**Deliverable**: Fully functional CLI for managing discussions. + +### Phase 3: Advanced Features + +1. **`phases.py`** - Phase definitions and auto-transitions +2. **`turns.py`** - @mention routing and turn tracking +3. **Summary generation** - Auto-generate .sum.md files + +**Deliverable**: Full orchestration with phases and automatic transitions. + +### Phase 4: TUI + +1. **`ui/tui.py`** - urwid-based interface +2. **Discussion browser** - List and select discussions +3. **Participant manager** - Add/edit personas +4. **Live discussion view** - Watch discussion in real-time + +**Deliverable**: Interactive TUI for human participation. + +--- + +## Testing Strategy + +### Unit Tests + +- `test_markers.py` - Marker parsing edge cases +- `test_voting.py` - Consensus calculation +- `test_discussion.py` - State management +- `test_phases.py` - Phase transitions + +### Integration Tests + +- `test_runner.py` - Full turn execution (with mock provider) +- `test_cli.py` - CLI command integration + +### Example + +```python +# tests/test_voting.py +def test_consensus_reached_with_threshold(): + votes = { + "AI-Architect": "READY", + "AI-Security": "READY", + "AI-Pragmatist": "CHANGES", + } + config = VotingConfig(threshold_ready=0.67) + result = calculate_consensus(votes, config) + assert result.reached == True + assert result.outcome == "READY" + +def test_reject_blocks_consensus(): + votes = { + "AI-Architect": "READY", + "AI-Security": "REJECT", + "AI-Pragmatist": "READY", + } + config = VotingConfig(threshold_reject=0.01) + result = calculate_consensus(votes, config) + assert result.reached == False + assert result.blocked_by == ["AI-Security"] +``` + +--- + +## Migration from CascadingDev + +Once this project is stable, CascadingDev can be refactored to use it: + +1. **Remove** from CascadingDev: + - `automation/schema.py` + - `automation/workflow.py` (discussion parts) + - `automation/orchestrator.py` + - `automation/agent_fetcher.py` + - `agents/*.py` (voting agents) + - `config/participants.yml` + +2. **Add** to CascadingDev: + - Dependency on `orchestrated-discussions` + - Glue code to trigger discussions on commit + - Integration with `.ai-rules.yml` for discussion automation + +3. **Keep** in CascadingDev: + - `automation/runner.py` (cascading rules) + - `automation/config.py` (rules resolution) + - `automation/patcher.py` (diff generation) + - Git hook orchestration + +--- + +## Open Questions + +1. **SmartTools import vs subprocess** - Should we import SmartTools directly or call it as a subprocess? Direct import is cleaner but creates tighter coupling. + +2. **Participant storage** - Store in `~/.config/discussions/` or `~/.smarttools/`? The latter allows SmartTools TUI editing. + +3. **Discussion storage** - Default to `./discussions/` in current directory, or configurable per-project? + +4. **Async execution** - Should turns run participants in parallel? Could speed up multi-participant turns significantly. + +5. **Streaming responses** - Should the TUI show AI responses as they stream in? + +--- + +## Appendix A: Full Participant Persona Examples + +### AI-Architect + +```yaml +name: AI-Architect +alias: architect +role: Systems Architect +personality: | + You are AI-Architect (also known as Chen), a senior systems architect with deep + expertise in distributed systems, design patterns, and long-term technical strategy. + + Your role: + - Think in systems, patterns, and architectural principles + - Consider scalability, maintainability, and evolution over time + - Identify architectural risks and technical debt implications + - Suggest well-established patterns and proven approaches + - Balance ideal architecture with practical constraints + + Perspective: + - You think 2-5 years ahead, not just the immediate implementation + - You value modularity, separation of concerns, and clean boundaries + - You prefer boring, proven technology over cutting-edge experiments + - You call out when shortcuts will create architectural debt + + Response format: + - Provide your analysis and concerns in markdown + - Use Q: for questions you want answered + - Use DECISION: if you're proposing a decision + - End with VOTE: READY, CHANGES, or REJECT + - If you have nothing to add, respond with: {"sentinel": "NO_RESPONSE"} + +expertise: + - System design + - Scalability + - Technical debt + - Architectural patterns + - API design + - Data modeling + +concerns: + - "How does this fit the overall architecture?" + - "Will this scale to 10x current load?" + - "What's the long-term maintenance burden?" + - "Are we creating unnecessary coupling?" + - "Is this the right level of abstraction?" + +type: voting +provider_hint: claude-sonnet +``` + +### AI-Security + +```yaml +name: AI-Security +alias: security +role: Security Specialist +personality: | + You are AI-Security (also known as Steve), a security specialist who identifies + vulnerabilities, threat vectors, and security best practices. + + Your role: + - Identify security risks and vulnerabilities + - Suggest mitigations and security controls + - Consider threat models and attack surfaces + - Ensure compliance with security best practices + - Balance security with usability + + Perspective: + - Assume malicious actors will try to exploit the system + - Consider both external and internal threats + - Think about data protection and privacy + - Focus on practical, implementable security measures + + Response format: + - Highlight security concerns clearly + - Suggest specific mitigations for each concern + - Use CONCERN: for security issues + - End with VOTE: READY, CHANGES, or REJECT + +expertise: + - Vulnerability assessment + - Threat modeling + - Authentication & authorization + - Data protection + - Input validation + - Secure coding practices + +concerns: + - "What are the security implications?" + - "How could this be exploited?" + - "Are we handling sensitive data properly?" + - "Is authentication/authorization adequate?" + - "Are we validating all inputs?" + +type: voting +provider_hint: claude-sonnet +``` + +### AI-Pragmatist + +```yaml +name: AI-Pragmatist +alias: pragmatist +role: Shipping Pragmatist +personality: | + You are AI-Pragmatist (also known as Maya), a shipping-focused engineer who + advocates for practical solutions and incremental delivery. + + Your role: + - Advocate for simpler solutions + - Identify over-engineering and scope creep + - Suggest MVP approaches + - Balance quality with delivery speed + - Challenge unnecessary complexity + + Perspective: + - "Done is better than perfect when it's good enough" + - Ship early, iterate often + - Complexity is the enemy of delivery + - Technical debt is acceptable if managed + - Users need features, not architectural purity + + Response format: + - Call out over-engineering + - Suggest simpler alternatives + - Identify what can be deferred + - End with VOTE: READY, CHANGES, or REJECT + +expertise: + - MVP scoping + - Shipping velocity + - Trade-off analysis + - Iterative development + - Technical debt management + +concerns: + - "Can we ship this incrementally?" + - "Are we over-engineering this?" + - "What's the simplest thing that could work?" + - "Can this be deferred to a later iteration?" + - "Is this complexity justified?" + +type: voting +provider_hint: claude-sonnet +``` + +--- + +## Appendix B: Response JSON Format + +All participants respond with JSON: + +```json +{ + "comment": "Markdown formatted comment...\n\nQ: Question here?\n\nCONCERN: Security issue here", + "vote": "READY" +} +``` + +Or, if they have nothing to contribute: + +```json +{ + "sentinel": "NO_RESPONSE" +} +``` + +The runner parses this and formats it into the discussion file: + +```markdown +--- + +Name: AI-Architect +Markdown formatted comment... + +Q: Question here? + +CONCERN: Security issue here + +VOTE: READY + +--- +``` + +--- + +## Appendix C: SmartTools Provider Reference + +Available providers in SmartTools (from `~/.smarttools/providers.yaml`): + +| Provider | Command | Speed | Notes | +|----------|---------|-------|-------| +| `opencode-deepseek` | `$HOME/.opencode/bin/opencode run --model deepseek/deepseek-chat` | 13s | Best value | +| `opencode-pickle` | `$HOME/.opencode/bin/opencode run --model opencode/big-pickle` | 13s | Best free | +| `claude-haiku` | `claude -p --model haiku` | 14s | Fast + accurate | +| `claude-sonnet` | `claude -p --model sonnet` | 21s | Balanced | +| `claude-opus` | `claude -p --model opus` | 18s | Highest quality | +| `codex` | `codex exec -` | 14s | Reliable | +| `gemini-flash` | `gemini --model gemini-2.5-flash` | 28s | Quick tasks | +| `mock` | (builtin) | 0s | Testing only | + +--- + +*Document version: 1.0* +*Last updated: 2025-12-08* diff --git a/examples/feature_discussion.md b/examples/feature_discussion.md new file mode 100644 index 0000000..cbbbc52 --- /dev/null +++ b/examples/feature_discussion.md @@ -0,0 +1,124 @@ + + + + + + + + +# Example Feature Discussion + +## Context + +This is an example discussion file demonstrating the format and structure +used by Orchestrated Discussions. + +We want to add a new caching layer to improve API response times. The cache +should store frequently accessed data and invalidate appropriately when +the underlying data changes. + +## Requirements + +- [ ] Cache frequently accessed API responses +- [ ] Support configurable TTL per cache key +- [ ] Invalidate cache on data mutations +- [ ] Provide cache statistics and monitoring + +## Open Questions + +- What caching backend should we use? (Redis, Memcached, in-memory) +- How do we handle cache stampedes? +- What's the expected cache hit ratio? + +## Constraints + +- Must work in our Kubernetes environment +- Cannot add more than 100ms latency on cache miss +- Must support horizontal scaling + +--- + +*Discussion begins below. Use `discussions turn example.md @architect` to get feedback.* + +--- + +Name: AI-Architect +Looking at this caching proposal, I have several architectural considerations: + +**Caching Backend** +For a Kubernetes environment, I'd recommend Redis over in-memory caching. Redis provides: +- Persistence options for durability +- Built-in TTL support +- Cluster mode for horizontal scaling +- Pub/sub for cache invalidation across instances + +**Cache Stampede Prevention** +We should implement one of these patterns: +1. **Lock-based**: Only one request populates cache, others wait +2. **Probabilistic early expiration**: Refresh before actual TTL +3. **Background refresh**: Async refresh while serving stale data + +Q: What's our tolerance for serving slightly stale data? That affects which pattern works best. + +**Architecture Concern** +The requirement to "invalidate on data mutations" needs more specificity. Are we talking about: +- Same-service mutations (easier, can invalidate directly) +- Cross-service mutations (harder, needs event-driven invalidation) + +CONCERN: Cross-service cache invalidation can become a distributed systems nightmare if not carefully designed. + +VOTE: CHANGES + +--- + +Name: AI-Security +From a security perspective, I need to raise several concerns: + +**Cache Poisoning** +If an attacker can poison the cache with malicious data, that data gets served to all users. Mitigations: +- Validate data before caching +- Use signed cache keys +- Implement cache isolation per tenant (if multi-tenant) + +**Sensitive Data** +Q: Will any cached responses contain sensitive data? If so, we need: +- Encryption at rest in Redis +- Secure cache key generation (no user IDs in keys if possible) +- Appropriate TTLs for sensitive data + +**Access Control** +Redis should not be exposed to the public internet. Ensure: +- Redis is in private subnet +- Authentication enabled (requirepass) +- TLS for data in transit if crossing network boundaries + +CONCERN: The requirements don't mention data sensitivity classification. We need to know what's being cached. + +VOTE: CHANGES + +--- + +Name: AI-Pragmatist +I appreciate the thorough analysis, but let's not over-engineer this. + +**MVP Proposal** +For v1, I suggest: +1. Use Redis (it's already in our stack, right?) +2. Simple TTL-based expiration (no fancy invalidation) +3. Cache-aside pattern (app checks cache, falls back to DB) +4. Start with just the top 3 most accessed endpoints + +**What We Can Defer** +- Sophisticated invalidation (use short TTLs instead for MVP) +- Cache stampede handling (monitor first, optimize if needed) +- Cross-service invalidation (start with single-service scope) + +The architect's concerns about cross-service invalidation are valid, but we don't need to solve that for v1. Let's ship something, measure the impact, then iterate. + +Q: What's the timeline pressure here? If we have 2 weeks, MVP is the way. If we have 2 months, we can be more thorough. + +VOTE: READY + +--- + +*This example shows a typical discussion with different perspectives. The discussion would continue until consensus is reached.* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4be9ac4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "orchestrated-discussions" +version = "0.1.0" +description = "Multi-agent AI discussion orchestration with voting and phases" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.10" +authors = [ + {name = "Rob"} +] +keywords = ["ai", "discussion", "multi-agent", "orchestration", "voting", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "PyYAML>=6.0", + "smarttools>=0.1.0", +] + +[project.optional-dependencies] +tui = [ + "urwid>=2.1.0", +] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "urwid>=2.1.0", +] + +[project.scripts] +discussions = "discussions.cli:main" + +[project.urls] +Homepage = "https://github.com/rob/orchestrated-discussions" +Documentation = "https://github.com/rob/orchestrated-discussions#readme" +Repository = "https://github.com/rob/orchestrated-discussions.git" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/discussions/__init__.py b/src/discussions/__init__.py new file mode 100644 index 0000000..25bae02 --- /dev/null +++ b/src/discussions/__init__.py @@ -0,0 +1,14 @@ +""" +Orchestrated Discussions - Multi-agent AI discussion orchestration. + +This package provides tools for conducting structured discussions between +multiple AI personas with voting, phases, and consensus tracking. +""" + +__version__ = "0.1.0" + +# Core classes will be exported here once implemented +# from .discussion import Discussion +# from .participant import Participant +# from .runner import Runner +# from .voting import VotingConfig, calculate_consensus diff --git a/src/discussions/cli.py b/src/discussions/cli.py new file mode 100644 index 0000000..f03c273 --- /dev/null +++ b/src/discussions/cli.py @@ -0,0 +1,271 @@ +""" +CLI entry point for Orchestrated Discussions. + +Provides commands for creating, managing, and running discussions. +""" + +import argparse +import sys +from pathlib import Path + +from . import __version__ + + +def cmd_new(args) -> int: + """Create a new discussion.""" + from .discussion import Discussion + + # Generate filename from title + if args.output: + path = Path(args.output) + else: + slug = args.title.lower().replace(" ", "-") + slug = "".join(c for c in slug if c.isalnum() or c == "-") + path = Path(f"{slug}.md") + + if path.exists() and not args.force: + print(f"Error: {path} already exists. Use --force to overwrite.") + return 1 + + # Parse participants + participants = None + if args.participants: + participants = [p.strip() for p in args.participants.split(",")] + + discussion = Discussion.create( + path=path, + title=args.title, + context=args.context or "", + template=args.template, + participants=participants, + ) + + print(f"Created: {path}") + print(f"Title: {discussion.title}") + print(f"Participants: {', '.join(discussion.participant_aliases)}") + return 0 + + +def cmd_status(args) -> int: + """Show discussion status.""" + from .discussion import Discussion + from .voting import format_vote_details + + path = Path(args.discussion) + if not path.exists(): + print(f"Error: Discussion not found: {path}") + return 1 + + discussion = Discussion.load(path) + + print(f"Discussion: {discussion.title}") + print(f"File: {discussion.path}") + print(f"Phase: {discussion.phase}") + print(f"Status: {discussion.status}") + print(f"Comments: {len(discussion.comments)}") + print() + + votes = discussion.get_votes() + if votes: + print("Votes:") + print(format_vote_details(votes)) + else: + print("Votes: (none yet)") + print() + + consensus = discussion.check_consensus() + if consensus.reached: + print(f"Consensus: REACHED ({consensus.outcome})") + else: + print(f"Consensus: NOT REACHED - {consensus.reason}") + print() + + questions = discussion.get_questions() + if questions: + print(f"Open Questions ({len(questions)}):") + for q in questions[:5]: # Show first 5 + print(f" Q: {q.text} (@{q.author})") + if len(questions) > 5: + print(f" ... and {len(questions) - 5} more") + + concerns = discussion.get_concerns() + if concerns: + print(f"\nConcerns ({len(concerns)}):") + for c in concerns[:5]: + print(f" CONCERN: {c.text} (@{c.author})") + + return 0 + + +def cmd_turn(args) -> int: + """Run a discussion turn.""" + from .runner import run_discussion_turn + + path = Path(args.discussion) + if not path.exists(): + print(f"Error: Discussion not found: {path}") + return 1 + + # Parse participants (remove @ prefix if present) + participants = None + if args.participants: + participants = [p.lstrip("@") for p in args.participants] + + print(f"Running turn on {path}...") + if participants: + print(f"Participants: {', '.join(participants)}") + + result = run_discussion_turn( + discussion_path=path, + participants=participants, + callout=args.callout or "", + provider=args.provider, + verbose=args.verbose, + ) + + print() + print(f"Responses: {result.successful_count} successful, {result.skipped_count} skipped, {result.failed_count} failed") + + for r in result.results: + if r.success and r.comment: + vote_str = f" [{r.comment.vote}]" if r.comment.vote else "" + print(f" {r.participant.name}{vote_str}") + elif r.success: + print(f" {r.participant.name} - (no response)") + else: + print(f" {r.participant.name} - ERROR: {r.error}") + + return 0 + + +def cmd_comment(args) -> int: + """Add a human comment to a discussion.""" + from .discussion import Discussion + + path = Path(args.discussion) + if not path.exists(): + print(f"Error: Discussion not found: {path}") + return 1 + + discussion = Discussion.load(path) + + # Get author name + author = args.author or "Human" + + # Add comment + discussion.add_comment( + author=author, + text=args.text, + vote=args.vote.upper() if args.vote else None, + ) + discussion.save() + + vote_str = f" with vote {args.vote.upper()}" if args.vote else "" + print(f"Added comment from {author}{vote_str}") + return 0 + + +def cmd_participants(args) -> int: + """List available participants.""" + from .participant import get_registry + + registry = get_registry() + + print("Voting Participants:") + for p in registry.get_voting(): + print(f" @{p.alias:15} {p.name:20} - {p.role}") + + print("\nBackground Participants:") + for p in registry.get_background(): + print(f" @{p.alias:15} {p.name:20} - {p.role}") + + return 0 + + +def cmd_advance(args) -> int: + """Advance discussion to next phase.""" + from .discussion import Discussion + + path = Path(args.discussion) + if not path.exists(): + print(f"Error: Discussion not found: {path}") + return 1 + + discussion = Discussion.load(path) + old_phase = discussion.phase + + if args.phase: + discussion.update_phase(args.phase) + else: + # TODO: Implement phase progression logic + print("Error: --phase required (automatic progression not yet implemented)") + return 1 + + discussion.save() + print(f"Advanced: {old_phase} -> {discussion.phase}") + return 0 + + +def main(argv: list[str] = None) -> int: + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + prog="discussions", + description="Multi-agent AI discussion orchestration" + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # 'new' command + p_new = subparsers.add_parser("new", help="Create a new discussion") + p_new.add_argument("title", help="Discussion title") + p_new.add_argument("-o", "--output", help="Output file path") + p_new.add_argument("-t", "--template", default="feature", help="Template to use") + p_new.add_argument("-c", "--context", help="Initial context/description") + p_new.add_argument("-p", "--participants", help="Comma-separated participant aliases") + p_new.add_argument("-f", "--force", action="store_true", help="Overwrite existing") + p_new.set_defaults(func=cmd_new) + + # 'status' command + p_status = subparsers.add_parser("status", help="Show discussion status") + p_status.add_argument("discussion", help="Discussion file path") + p_status.set_defaults(func=cmd_status) + + # 'turn' command + p_turn = subparsers.add_parser("turn", help="Run a discussion turn") + p_turn.add_argument("discussion", help="Discussion file path") + p_turn.add_argument("participants", nargs="*", help="Participant aliases (e.g., @architect)") + p_turn.add_argument("--callout", "-c", help="Specific question/request") + p_turn.add_argument("--provider", "-p", help="Override AI provider") + p_turn.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + p_turn.set_defaults(func=cmd_turn) + + # 'comment' command + p_comment = subparsers.add_parser("comment", help="Add a human comment") + p_comment.add_argument("discussion", help="Discussion file path") + p_comment.add_argument("text", help="Comment text") + p_comment.add_argument("--vote", "-v", choices=["ready", "changes", "reject"], help="Cast a vote") + p_comment.add_argument("--author", "-a", help="Author name (default: Human)") + p_comment.set_defaults(func=cmd_comment) + + # 'participants' command + p_parts = subparsers.add_parser("participants", help="List available participants") + p_parts.set_defaults(func=cmd_participants) + + # 'advance' command + p_advance = subparsers.add_parser("advance", help="Advance to next phase") + p_advance.add_argument("discussion", help="Discussion file path") + p_advance.add_argument("--phase", help="Target phase ID") + p_advance.set_defaults(func=cmd_advance) + + args = parser.parse_args(argv) + + if args.command is None: + parser.print_help() + return 0 + + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/discussions/discussion.py b/src/discussions/discussion.py new file mode 100644 index 0000000..2943b95 --- /dev/null +++ b/src/discussions/discussion.py @@ -0,0 +1,347 @@ +""" +Discussion state management for Orchestrated Discussions. + +The Discussion class represents a single discussion file and provides +methods for reading/writing state, adding comments, and tracking votes. + +See docs/DESIGN.md for discussion file format specification. +""" + +import re +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + +from .markers import ( + extract_all_markers, + extract_vote, + Question, + ActionItem, + Decision, + Concern, + Mention, +) +from .voting import VotingConfig, calculate_consensus, ConsensusResult + + +# Regex patterns for header parsing +HEADER_PATTERN = re.compile(r'^$', re.MULTILINE) +COMMENT_BLOCK_PATTERN = re.compile( + r'^---\s*\n\s*Name:\s*(.+?)\n(.*?)(?=^---|\Z)', + re.MULTILINE | re.DOTALL +) + + +@dataclass +class Comment: + """A single comment in the discussion.""" + author: str + body: str + vote: Optional[str] = None + questions: list[Question] = field(default_factory=list) + action_items: list[ActionItem] = field(default_factory=list) + decisions: list[Decision] = field(default_factory=list) + concerns: list[Concern] = field(default_factory=list) + mentions: list[Mention] = field(default_factory=list) + + +@dataclass +class Discussion: + """ + Represents a discussion file. + + Attributes: + path: Path to the discussion file + title: Discussion title + phase: Current phase ID + status: Current status (OPEN, READY_FOR_DESIGN, etc.) + template: Template used to create this discussion + participant_aliases: List of participant aliases involved + comments: List of Comment objects + created: Creation timestamp + """ + path: Path + title: str = "" + phase: str = "initial_feedback" + status: str = "OPEN" + template: str = "feature" + participant_aliases: list[str] = field(default_factory=list) + comments: list[Comment] = field(default_factory=list) + created: Optional[datetime] = None + _raw_content: str = "" + + @classmethod + def load(cls, path: Path | str) -> "Discussion": + """ + Load a discussion from a file. + + Args: + path: Path to the discussion file + + Returns: + Discussion object + + Raises: + FileNotFoundError: If file doesn't exist + """ + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Discussion file not found: {path}") + + content = path.read_text(encoding="utf-8") + discussion = cls(path=path, _raw_content=content) + discussion._parse_content(content) + return discussion + + @classmethod + def create( + cls, + path: Path | str, + title: str, + context: str = "", + template: str = "feature", + participants: list[str] = None, + ) -> "Discussion": + """ + Create a new discussion file. + + Args: + path: Path for the new discussion file + title: Discussion title + context: Initial context/description + template: Template name + participants: List of participant aliases + + Returns: + New Discussion object + """ + path = Path(path) + if participants is None: + participants = ["architect", "security", "pragmatist"] + + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + + content = f""" + + + + + + + +# {title} + +## Context +{context if context else "[Describe what's being discussed]"} + +## Requirements +- [ ] [Requirement 1] +- [ ] [Requirement 2] + +## Open Questions +- [Question 1] + +--- + +*Discussion begins below.* +""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + return cls.load(path) + + def _parse_content(self, content: str) -> None: + """Parse discussion content to extract state.""" + # Parse headers + for match in HEADER_PATTERN.finditer(content): + key = match.group(1).lower() + value = match.group(2).strip() + + if key == "title": + self.title = value + elif key == "phase": + self.phase = value + elif key == "status": + self.status = value + elif key == "template": + self.template = value + elif key == "participants": + self.participant_aliases = [p.strip() for p in value.split(",")] + elif key == "created": + try: + self.created = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + pass + + # Parse comment blocks + self.comments = [] + for match in COMMENT_BLOCK_PATTERN.finditer(content): + author = match.group(1).strip() + body = match.group(2).strip() + + # Extract markers from body + markers = extract_all_markers(body, author) + + comment = Comment( + author=author, + body=body, + vote=markers["vote"], + questions=markers["questions"], + action_items=markers["action_items"], + decisions=markers["decisions"], + concerns=markers["concerns"], + mentions=markers["mentions"], + ) + self.comments.append(comment) + + def save(self) -> None: + """Save the discussion to its file.""" + self.path.write_text(self._raw_content, encoding="utf-8") + + def add_comment( + self, + author: str, + text: str, + vote: Optional[str] = None, + ) -> Comment: + """ + Add a new comment to the discussion. + + Args: + author: Comment author name + text: Comment body (markdown) + vote: Optional vote (READY, CHANGES, REJECT) + + Returns: + The new Comment object + """ + # Build comment block + lines = ["---", "", f"Name: {author}"] + + if text: + lines.append(text.strip()) + + if vote: + lines.append(f"VOTE: {vote.upper()}") + + lines.append("") + comment_block = "\n".join(lines) + + # Append to raw content + self._raw_content = self._raw_content.rstrip() + "\n\n" + comment_block + + # Re-parse to update state + self._parse_content(self._raw_content) + + # Return the new comment + return self.comments[-1] if self.comments else None + + def get_votes(self) -> dict[str, str]: + """ + Get the latest vote from each participant. + + Returns: + Dict mapping author name to vote + """ + votes = {} + for comment in self.comments: + if comment.vote: + votes[comment.author] = comment.vote + return votes + + def get_questions(self) -> list[Question]: + """Get all questions from all comments.""" + questions = [] + for comment in self.comments: + questions.extend(comment.questions) + return questions + + def get_action_items(self) -> list[ActionItem]: + """Get all action items from all comments.""" + items = [] + for comment in self.comments: + items.extend(comment.action_items) + return items + + def get_decisions(self) -> list[Decision]: + """Get all decisions from all comments.""" + decisions = [] + for comment in self.comments: + decisions.extend(comment.decisions) + return decisions + + def get_concerns(self) -> list[Concern]: + """Get all concerns from all comments.""" + concerns = [] + for comment in self.comments: + concerns.extend(comment.concerns) + return concerns + + def get_mentions(self, target: str = None) -> list[Mention]: + """ + Get mentions, optionally filtered by target. + + Args: + target: Optional alias to filter by + + Returns: + List of Mention objects + """ + mentions = [] + for comment in self.comments: + for mention in comment.mentions: + if target is None or mention.target == target: + mentions.append(mention) + return mentions + + def check_consensus( + self, + config: Optional[VotingConfig] = None + ) -> ConsensusResult: + """ + Check if consensus has been reached. + + Args: + config: Voting configuration + + Returns: + ConsensusResult with status and details + """ + return calculate_consensus(self.get_votes(), config) + + def has_consensus(self) -> bool: + """Return True if consensus has been reached.""" + return self.check_consensus().reached + + def update_phase(self, new_phase: str) -> None: + """ + Update the current phase. + + Args: + new_phase: New phase ID + """ + old_header = f"" + new_header = f"" + self._raw_content = self._raw_content.replace(old_header, new_header) + self.phase = new_phase + + def update_status(self, new_status: str) -> None: + """ + Update the current status. + + Args: + new_status: New status value + """ + old_header = f"" + new_header = f"" + self._raw_content = self._raw_content.replace(old_header, new_header) + self.status = new_status + + def get_content(self) -> str: + """Get the full discussion content.""" + return self._raw_content + + def __repr__(self) -> str: + return f"Discussion(title='{self.title}', phase='{self.phase}', status='{self.status}')" diff --git a/src/discussions/markers.py b/src/discussions/markers.py new file mode 100644 index 0000000..8b5d2d1 --- /dev/null +++ b/src/discussions/markers.py @@ -0,0 +1,226 @@ +""" +Marker parsing for Orchestrated Discussions. + +This module handles parsing of structured markers in discussion content: +- VOTE: READY|CHANGES|REJECT +- Q: / QUESTION: - Questions +- TODO: / ACTION: - Action items +- DECISION: - Decisions +- ASSIGNED: - Claimed tasks +- DONE: - Completed tasks +- CONCERN: - Raised concerns +- @alias - Mentions + +See docs/DESIGN.md for full marker specification. +""" + +import re +from dataclasses import dataclass +from typing import Optional + + +# Regex patterns for marker extraction +VOTE_PATTERN = re.compile(r'^VOTE:\s*(READY|CHANGES|REJECT)\s*$', re.IGNORECASE | re.MULTILINE) +QUESTION_PATTERN = re.compile(r'^(?:Q|QUESTION):\s*(.+)$', re.IGNORECASE | re.MULTILINE) +TODO_PATTERN = re.compile(r'^(?:TODO|ACTION):\s*(.+)$', re.IGNORECASE | re.MULTILINE) +DECISION_PATTERN = re.compile(r'^DECISION:\s*(.+)$', re.IGNORECASE | re.MULTILINE) +ASSIGNED_PATTERN = re.compile(r'^ASSIGNED:\s*(.+)$', re.IGNORECASE | re.MULTILINE) +DONE_PATTERN = re.compile(r'^DONE:\s*(.+)$', re.IGNORECASE | re.MULTILINE) +CONCERN_PATTERN = re.compile(r'^CONCERN:\s*(.+)$', re.IGNORECASE | re.MULTILINE) +MENTION_PATTERN = re.compile(r'@(\w+)') + + +@dataclass +class Question: + """A question raised in the discussion.""" + text: str + author: str + status: str = "open" # open, answered, deferred + + +@dataclass +class ActionItem: + """An action item or TODO.""" + text: str + author: str + assignee: Optional[str] = None + status: str = "todo" # todo, assigned, done + + +@dataclass +class Decision: + """A decision made in the discussion.""" + text: str + author: str + supporters: list[str] = None + + def __post_init__(self): + if self.supporters is None: + self.supporters = [] + + +@dataclass +class Concern: + """A concern raised by a participant.""" + text: str + author: str + addressed: bool = False + + +@dataclass +class Mention: + """An @mention in the discussion.""" + target: str # The alias mentioned + author: str + context: str # Surrounding text + + +def extract_vote(text: str) -> Optional[str]: + """ + Extract vote from text. + + Args: + text: Text to search for vote + + Returns: + Vote value (READY, CHANGES, REJECT) or None + """ + match = VOTE_PATTERN.search(text) + if match: + return match.group(1).upper() + return None + + +def extract_questions(text: str, author: str = "unknown") -> list[Question]: + """ + Extract all questions from text. + + Args: + text: Text to search + author: Author to attribute questions to + + Returns: + List of Question objects + """ + questions = [] + for match in QUESTION_PATTERN.finditer(text): + questions.append(Question( + text=match.group(1).strip(), + author=author + )) + return questions + + +def extract_action_items(text: str, author: str = "unknown") -> list[ActionItem]: + """ + Extract all action items/TODOs from text. + + Args: + text: Text to search + author: Author to attribute items to + + Returns: + List of ActionItem objects + """ + items = [] + for match in TODO_PATTERN.finditer(text): + item_text = match.group(1).strip() + # Check for @mention to determine assignee + mention = MENTION_PATTERN.search(item_text) + assignee = mention.group(1) if mention else None + + items.append(ActionItem( + text=item_text, + author=author, + assignee=assignee + )) + return items + + +def extract_decisions(text: str, author: str = "unknown") -> list[Decision]: + """ + Extract all decisions from text. + + Args: + text: Text to search + author: Author to attribute decisions to + + Returns: + List of Decision objects + """ + decisions = [] + for match in DECISION_PATTERN.finditer(text): + decisions.append(Decision( + text=match.group(1).strip(), + author=author + )) + return decisions + + +def extract_concerns(text: str, author: str = "unknown") -> list[Concern]: + """ + Extract all concerns from text. + + Args: + text: Text to search + author: Author to attribute concerns to + + Returns: + List of Concern objects + """ + concerns = [] + for match in CONCERN_PATTERN.finditer(text): + concerns.append(Concern( + text=match.group(1).strip(), + author=author + )) + return concerns + + +def extract_mentions(text: str, author: str = "unknown") -> list[Mention]: + """ + Extract all @mentions from text. + + Args: + text: Text to search + author: Author making the mentions + + Returns: + List of Mention objects + """ + mentions = [] + for match in MENTION_PATTERN.finditer(text): + # Get surrounding context (the line containing the mention) + start = text.rfind('\n', 0, match.start()) + 1 + end = text.find('\n', match.end()) + if end == -1: + end = len(text) + context = text[start:end].strip() + + mentions.append(Mention( + target=match.group(1), + author=author, + context=context + )) + return mentions + + +def extract_all_markers(text: str, author: str = "unknown") -> dict: + """ + Extract all markers from text. + + Args: + text: Text to search + author: Author to attribute markers to + + Returns: + Dict with keys: vote, questions, action_items, decisions, concerns, mentions + """ + return { + "vote": extract_vote(text), + "questions": extract_questions(text, author), + "action_items": extract_action_items(text, author), + "decisions": extract_decisions(text, author), + "concerns": extract_concerns(text, author), + "mentions": extract_mentions(text, author), + } diff --git a/src/discussions/participant.py b/src/discussions/participant.py new file mode 100644 index 0000000..052a792 --- /dev/null +++ b/src/discussions/participant.py @@ -0,0 +1,265 @@ +""" +Participant definitions and loading for Orchestrated Discussions. + +Participants are AI personas with distinct perspectives, expertise, and behavior. +They can be loaded from YAML files or defined programmatically. + +See docs/DESIGN.md for participant specification. +""" + +import yaml +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class Participant: + """ + An AI participant in a discussion. + + Attributes: + name: Display name (e.g., "AI-Architect") + alias: Short mention name (e.g., "architect") + role: Brief role description + personality: System prompt defining perspective and behavior + expertise: List of expertise areas + concerns: What this participant watches for + participant_type: "voting" or "background" + provider_hint: Preferred AI provider + """ + name: str + alias: str + role: str + personality: str + expertise: list[str] = field(default_factory=list) + concerns: list[str] = field(default_factory=list) + participant_type: str = "voting" # voting | background + provider_hint: str = "claude-sonnet" + + @classmethod + def from_dict(cls, data: dict) -> "Participant": + """Create Participant from dictionary.""" + return cls( + name=data["name"], + alias=data["alias"], + role=data["role"], + personality=data["personality"], + expertise=data.get("expertise", []), + concerns=data.get("concerns", []), + participant_type=data.get("type", "voting"), + provider_hint=data.get("provider_hint", "claude-sonnet"), + ) + + def to_dict(self) -> dict: + """Convert to dictionary for serialization.""" + return { + "name": self.name, + "alias": self.alias, + "role": self.role, + "personality": self.personality, + "expertise": self.expertise, + "concerns": self.concerns, + "type": self.participant_type, + "provider_hint": self.provider_hint, + } + + def is_voting(self) -> bool: + """Return True if this participant casts votes.""" + return self.participant_type == "voting" + + def build_prompt(self, context: str, callout: str = "") -> str: + """ + Build the full prompt for this participant. + + Args: + context: The discussion content so far + callout: Specific question or request (optional) + + Returns: + Complete prompt string + """ + callout_section = "" + if callout: + callout_section = f""" +## Your Task +{callout} +""" + else: + callout_section = """ +## Your Task +Provide your perspective on the discussion based on your expertise. +""" + + return f"""{self.personality} + +## Current Discussion +{context} +{callout_section} +## Response Format +Respond with valid JSON: +- If you have feedback: {{"comment": "your markdown comment", "vote": "READY|CHANGES|REJECT"}} +- If nothing to add: {{"sentinel": "NO_RESPONSE"}} + +Your comment can include: +- Q: for questions +- CONCERN: for concerns +- DECISION: for decisions you're proposing +- @alias to mention other participants +""" + + +class ParticipantRegistry: + """ + Registry for loading and managing participants. + + Loads participants from: + 1. Bundled defaults (config/default_participants.yaml) + 2. User config (~/.config/discussions/participants.yaml) + 3. Project config (./discussions.yaml or ./.discussions/participants.yaml) + """ + + def __init__(self): + self._participants: dict[str, Participant] = {} + self._loaded = False + + def _load_from_yaml(self, path: Path) -> None: + """Load participants from a YAML file.""" + if not path.exists(): + return + + try: + data = yaml.safe_load(path.read_text()) + if not data: + return + + # Load voting participants + for p_data in data.get("voting_participants", []): + participant = Participant.from_dict(p_data) + self._participants[participant.alias] = participant + + # Load background participants + for p_data in data.get("background_participants", []): + p_data["type"] = "background" + participant = Participant.from_dict(p_data) + self._participants[participant.alias] = participant + + except Exception as e: + print(f"Warning: Failed to load participants from {path}: {e}") + + def _ensure_loaded(self) -> None: + """Ensure participants are loaded (lazy loading).""" + if self._loaded: + return + + # Load bundled defaults + bundled = Path(__file__).parent.parent.parent / "config" / "default_participants.yaml" + self._load_from_yaml(bundled) + + # Load user config + user_config = Path.home() / ".config" / "discussions" / "participants.yaml" + self._load_from_yaml(user_config) + + # Load project config + project_config = Path.cwd() / ".discussions" / "participants.yaml" + self._load_from_yaml(project_config) + + self._loaded = True + + def get(self, alias: str) -> Optional[Participant]: + """ + Get a participant by alias. + + Args: + alias: The participant's alias (e.g., "architect") + + Returns: + Participant or None if not found + """ + self._ensure_loaded() + return self._participants.get(alias) + + def get_all(self) -> list[Participant]: + """ + Get all registered participants. + + Returns: + List of all Participant objects + """ + self._ensure_loaded() + return list(self._participants.values()) + + def get_voting(self) -> list[Participant]: + """ + Get all voting participants. + + Returns: + List of voting Participant objects + """ + self._ensure_loaded() + return [p for p in self._participants.values() if p.is_voting()] + + def get_background(self) -> list[Participant]: + """ + Get all background (non-voting) participants. + + Returns: + List of background Participant objects + """ + self._ensure_loaded() + return [p for p in self._participants.values() if not p.is_voting()] + + def register(self, participant: Participant) -> None: + """ + Register a participant. + + Args: + participant: Participant to register + """ + self._ensure_loaded() + self._participants[participant.alias] = participant + + def aliases(self) -> list[str]: + """ + Get all registered aliases. + + Returns: + List of alias strings + """ + self._ensure_loaded() + return list(self._participants.keys()) + + +# Global registry instance +_registry: Optional[ParticipantRegistry] = None + + +def get_registry() -> ParticipantRegistry: + """Get the global participant registry.""" + global _registry + if _registry is None: + _registry = ParticipantRegistry() + return _registry + + +def get_participant(alias: str) -> Optional[Participant]: + """ + Get a participant by alias. + + Args: + alias: The participant's alias + + Returns: + Participant or None + """ + return get_registry().get(alias) + + +def list_participants() -> list[Participant]: + """ + List all registered participants. + + Returns: + List of Participant objects + """ + return get_registry().get_all() diff --git a/src/discussions/runner.py b/src/discussions/runner.py new file mode 100644 index 0000000..80d911c --- /dev/null +++ b/src/discussions/runner.py @@ -0,0 +1,354 @@ +""" +Discussion runner and orchestration engine. + +This module handles the execution of discussion turns by: +1. Identifying which participants should respond +2. Building prompts for each participant +3. Invoking AI providers via SmartTools +4. Parsing responses and updating the discussion + +See docs/DESIGN.md for orchestration details. +""" + +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from .discussion import Discussion, Comment +from .participant import Participant, get_participant, get_registry + + +@dataclass +class TurnResult: + """Result of a single participant's turn.""" + participant: Participant + comment: Optional[Comment] + success: bool + error: Optional[str] = None + raw_response: str = "" + + +@dataclass +class RunResult: + """Result of running a discussion turn.""" + discussion: Discussion + results: list[TurnResult] + + @property + def successful_count(self) -> int: + return sum(1 for r in self.results if r.success and r.comment) + + @property + def failed_count(self) -> int: + return sum(1 for r in self.results if not r.success) + + @property + def skipped_count(self) -> int: + return sum(1 for r in self.results if r.success and not r.comment) + + +class Runner: + """ + Discussion orchestration runner. + + Handles invoking participants and updating discussions. + """ + + def __init__(self, provider_override: str = None, verbose: bool = False): + """ + Initialize the runner. + + Args: + provider_override: Override provider for all participants + verbose: Enable verbose output + """ + self.provider_override = provider_override + self.verbose = verbose + self._provider_client = None + + def _get_provider_client(self): + """Get or create the provider client (lazy import from SmartTools).""" + if self._provider_client is not None: + return self._provider_client + + try: + from smarttools.providers import call_provider + self._provider_client = call_provider + return self._provider_client + except ImportError: + raise ImportError( + "SmartTools is required but not installed. " + "Install with: pip install smarttools" + ) + + def _invoke_participant( + self, + participant: Participant, + context: str, + callout: str = "", + ) -> TurnResult: + """ + Invoke a single participant. + + Args: + participant: The participant to invoke + context: Discussion content + callout: Specific request/question + + Returns: + TurnResult with response details + """ + call_provider = self._get_provider_client() + + # Build prompt + prompt = participant.build_prompt(context, callout) + + if self.verbose: + print(f"[runner] Invoking {participant.name}...", file=sys.stderr) + + # Determine provider + provider = self.provider_override or participant.provider_hint + + # Call provider + try: + result = call_provider(provider, prompt) + except Exception as e: + return TurnResult( + participant=participant, + comment=None, + success=False, + error=f"Provider error: {e}", + ) + + if not result.success: + return TurnResult( + participant=participant, + comment=None, + success=False, + error=result.error, + raw_response=result.text, + ) + + # Parse JSON response + try: + response_data = self._parse_response(result.text) + except ValueError as e: + return TurnResult( + participant=participant, + comment=None, + success=False, + error=f"Failed to parse response: {e}", + raw_response=result.text, + ) + + # Check for NO_RESPONSE sentinel + if response_data.get("sentinel") == "NO_RESPONSE": + if self.verbose: + print(f"[runner] {participant.name} has nothing to add", file=sys.stderr) + return TurnResult( + participant=participant, + comment=None, + success=True, # Not an error, just nothing to say + raw_response=result.text, + ) + + # Extract comment and vote + comment_text = response_data.get("comment", "") + vote = response_data.get("vote") + + if vote and vote.upper() not in ("READY", "CHANGES", "REJECT"): + vote = None + + # Create a temporary comment to return (not yet added to discussion) + from .markers import extract_all_markers + markers = extract_all_markers(comment_text, participant.name) + + comment = Comment( + author=participant.name, + body=comment_text, + vote=vote.upper() if vote else None, + questions=markers["questions"], + action_items=markers["action_items"], + decisions=markers["decisions"], + concerns=markers["concerns"], + mentions=markers["mentions"], + ) + + return TurnResult( + participant=participant, + comment=comment, + success=True, + raw_response=result.text, + ) + + def _parse_response(self, text: str) -> dict: + """ + Parse JSON response from participant. + + Handles various response formats: + - Pure JSON + - JSON in markdown code blocks + - JSON with surrounding text + + Args: + text: Raw response text + + Returns: + Parsed dict + + Raises: + ValueError: If JSON cannot be parsed + """ + text = text.strip() + + # Try direct JSON parse + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # Try extracting from markdown code block + import re + code_block = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL) + if code_block: + try: + return json.loads(code_block.group(1).strip()) + except json.JSONDecodeError: + pass + + # Try finding JSON object in text + json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL) + if json_match: + try: + return json.loads(json_match.group(0)) + except json.JSONDecodeError: + pass + + raise ValueError(f"Could not parse JSON from response: {text[:200]}...") + + def run_turn( + self, + discussion: Discussion, + participants: list[str] = None, + callout: str = "", + ) -> RunResult: + """ + Run a discussion turn with specified participants. + + Args: + discussion: The discussion to update + participants: List of participant aliases (or None for all) + callout: Specific request/question for all participants + + Returns: + RunResult with all responses + """ + registry = get_registry() + + # Resolve participants + if participants is None or "all" in participants: + participant_list = registry.get_voting() + else: + participant_list = [] + for alias in participants: + participant = registry.get(alias) + if participant: + participant_list.append(participant) + else: + print(f"[runner] Warning: Unknown participant '{alias}'", file=sys.stderr) + + if not participant_list: + return RunResult(discussion=discussion, results=[]) + + # Get current discussion content + context = discussion.get_content() + + # Invoke each participant + results = [] + for participant in participant_list: + result = self._invoke_participant(participant, context, callout) + results.append(result) + + # If successful with a comment, add to discussion + if result.success and result.comment: + discussion.add_comment( + author=result.comment.author, + text=result.comment.body, + vote=result.comment.vote, + ) + + if self.verbose: + vote_str = f" (VOTE: {result.comment.vote})" if result.comment.vote else "" + print(f"[runner] {participant.name} responded{vote_str}", file=sys.stderr) + + return RunResult(discussion=discussion, results=results) + + def run_mentions( + self, + discussion: Discussion, + since_comment_index: int = 0, + ) -> RunResult: + """ + Run turns for any participants mentioned since a given point. + + Args: + discussion: The discussion to process + since_comment_index: Only check mentions after this comment index + + Returns: + RunResult with responses + """ + registry = get_registry() + + # Collect mentioned aliases that haven't responded + mentioned = set() + responded = set() + + for i, comment in enumerate(discussion.comments): + responded.add(comment.author) + + if i >= since_comment_index: + for mention in comment.mentions: + if mention.target != "all": + mentioned.add(mention.target) + + # Find participants to invoke + to_invoke = [] + for alias in mentioned: + participant = registry.get(alias) + if participant and participant.name not in responded: + to_invoke.append(alias) + + if not to_invoke: + return RunResult(discussion=discussion, results=[]) + + return self.run_turn(discussion, to_invoke) + + +def run_discussion_turn( + discussion_path: str | Path, + participants: list[str] = None, + callout: str = "", + provider: str = None, + verbose: bool = False, +) -> RunResult: + """ + Convenience function to run a discussion turn. + + Args: + discussion_path: Path to discussion file + participants: Participant aliases to invoke + callout: Request/question for participants + provider: Override AI provider + verbose: Enable verbose output + + Returns: + RunResult with responses + """ + discussion = Discussion.load(discussion_path) + runner = Runner(provider_override=provider, verbose=verbose) + result = runner.run_turn(discussion, participants, callout) + discussion.save() + return result diff --git a/src/discussions/ui/__init__.py b/src/discussions/ui/__init__.py new file mode 100644 index 0000000..7405d0d --- /dev/null +++ b/src/discussions/ui/__init__.py @@ -0,0 +1,5 @@ +""" +TUI module for Orchestrated Discussions. + +Provides an interactive terminal interface for participating in discussions. +""" diff --git a/src/discussions/voting.py b/src/discussions/voting.py new file mode 100644 index 0000000..751c67c --- /dev/null +++ b/src/discussions/voting.py @@ -0,0 +1,188 @@ +""" +Voting and consensus logic for Orchestrated Discussions. + +Handles vote counting, threshold checking, and consensus determination. +See docs/DESIGN.md for voting rules specification. +""" + +from dataclasses import dataclass, field +from typing import Optional +from collections import Counter + + +@dataclass +class VotingConfig: + """Configuration for voting thresholds and rules.""" + + # Fraction of READY votes needed for consensus (default: 2/3) + threshold_ready: float = 0.67 + + # Fraction of REJECT votes that blocks (default: any reject blocks) + threshold_reject: float = 0.01 + + # Whether human approval is required + human_required: bool = True + + # Minimum number of votes needed + minimum_votes: int = 1 + + def __post_init__(self): + """Validate configuration.""" + if not 0 <= self.threshold_ready <= 1: + raise ValueError("threshold_ready must be between 0 and 1") + if not 0 <= self.threshold_reject <= 1: + raise ValueError("threshold_reject must be between 0 and 1") + if self.minimum_votes < 0: + raise ValueError("minimum_votes must be non-negative") + + +@dataclass +class ConsensusResult: + """Result of consensus calculation.""" + + # Whether consensus has been reached + reached: bool + + # The outcome if reached (READY, CHANGES, REJECT, or None) + outcome: Optional[str] + + # Vote counts + ready_count: int = 0 + changes_count: int = 0 + reject_count: int = 0 + total_votes: int = 0 + + # Who blocked (if blocked by REJECT) + blocked_by: list[str] = field(default_factory=list) + + # Why consensus wasn't reached (if not reached) + reason: Optional[str] = None + + +def is_human_participant(name: str) -> bool: + """ + Determine if a participant name represents a human (not an AI agent). + + Args: + name: Participant name + + Returns: + True if likely a human participant + """ + if not name: + return False + lowered = name.strip().lower() + return not ( + lowered.startswith("ai_") or + lowered.startswith("ai-") or + lowered.startswith("bot_") or + lowered.startswith("bot-") + ) + + +def calculate_consensus( + votes: dict[str, str], + config: Optional[VotingConfig] = None +) -> ConsensusResult: + """ + Calculate consensus from votes. + + Args: + votes: Dict mapping participant name to vote (READY, CHANGES, REJECT) + config: Voting configuration (uses defaults if None) + + Returns: + ConsensusResult with consensus status and details + """ + if config is None: + config = VotingConfig() + + # Count votes + counts = Counter(v.upper() for v in votes.values() if v) + ready_count = counts.get("READY", 0) + changes_count = counts.get("CHANGES", 0) + reject_count = counts.get("REJECT", 0) + total_votes = ready_count + changes_count + reject_count + + result = ConsensusResult( + reached=False, + outcome=None, + ready_count=ready_count, + changes_count=changes_count, + reject_count=reject_count, + total_votes=total_votes, + ) + + # Check minimum votes + if total_votes < config.minimum_votes: + result.reason = f"Insufficient votes ({total_votes} < {config.minimum_votes})" + return result + + # Check human requirement + if config.human_required: + human_votes = [name for name in votes.keys() if is_human_participant(name)] + human_ready = sum(1 for name in human_votes if votes.get(name, "").upper() == "READY") + if human_ready < 1: + result.reason = "Human approval required but not received" + return result + + # Calculate ratios + ready_ratio = ready_count / total_votes if total_votes > 0 else 0 + reject_ratio = reject_count / total_votes if total_votes > 0 else 0 + + # Check for blocking rejects + if reject_ratio >= config.threshold_reject: + result.blocked_by = [ + name for name, vote in votes.items() + if vote.upper() == "REJECT" + ] + result.reason = f"Blocked by REJECT votes from: {', '.join(result.blocked_by)}" + return result + + # Check for ready threshold + if ready_ratio >= config.threshold_ready: + result.reached = True + result.outcome = "READY" + return result + + # Not enough READY votes yet + needed = int(config.threshold_ready * total_votes) + 1 - ready_count + result.reason = f"Need {needed} more READY votes for consensus" + return result + + +def format_vote_summary(votes: dict[str, str]) -> str: + """ + Format votes as a human-readable summary. + + Args: + votes: Dict mapping participant name to vote + + Returns: + Formatted summary string + """ + counts = Counter(v.upper() for v in votes.values() if v) + ready = counts.get("READY", 0) + changes = counts.get("CHANGES", 0) + reject = counts.get("REJECT", 0) + + return f"READY: {ready} | CHANGES: {changes} | REJECT: {reject}" + + +def format_vote_details(votes: dict[str, str]) -> str: + """ + Format votes with per-participant details. + + Args: + votes: Dict mapping participant name to vote + + Returns: + Formatted details string + """ + lines = [format_vote_summary(votes), ""] + + for name, vote in sorted(votes.items()): + if vote: + lines.append(f" {name}: {vote.upper()}") + + return "\n".join(lines) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..aa42e29 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for orchestrated-discussions.""" diff --git a/tests/test_markers.py b/tests/test_markers.py new file mode 100644 index 0000000..029591b --- /dev/null +++ b/tests/test_markers.py @@ -0,0 +1,145 @@ +"""Tests for marker parsing.""" + +import pytest +from discussions.markers import ( + extract_vote, + extract_questions, + extract_action_items, + extract_decisions, + extract_concerns, + extract_mentions, + extract_all_markers, +) + + +class TestExtractVote: + def test_ready_vote(self): + assert extract_vote("VOTE: READY") == "READY" + + def test_changes_vote(self): + assert extract_vote("VOTE: CHANGES") == "CHANGES" + + def test_reject_vote(self): + assert extract_vote("VOTE: REJECT") == "REJECT" + + def test_case_insensitive(self): + assert extract_vote("vote: ready") == "READY" + assert extract_vote("Vote: Changes") == "CHANGES" + + def test_vote_in_multiline(self): + text = """Some comment here. + +More text. + +VOTE: READY +""" + assert extract_vote(text) == "READY" + + def test_no_vote(self): + assert extract_vote("No vote here") is None + + def test_invalid_vote(self): + assert extract_vote("VOTE: MAYBE") is None + + +class TestExtractQuestions: + def test_simple_question(self): + questions = extract_questions("Q: What about caching?", "Alice") + assert len(questions) == 1 + assert questions[0].text == "What about caching?" + assert questions[0].author == "Alice" + + def test_question_prefix(self): + questions = extract_questions("QUESTION: How does this scale?", "Bob") + assert len(questions) == 1 + assert questions[0].text == "How does this scale?" + + def test_multiple_questions(self): + text = """Q: First question? +Some text +Q: Second question? +""" + questions = extract_questions(text) + assert len(questions) == 2 + + def test_no_questions(self): + questions = extract_questions("Just regular text") + assert len(questions) == 0 + + +class TestExtractActionItems: + def test_todo_item(self): + items = extract_action_items("TODO: Write tests", "Dev") + assert len(items) == 1 + assert items[0].text == "Write tests" + assert items[0].author == "Dev" + assert items[0].status == "todo" + + def test_action_item(self): + items = extract_action_items("ACTION: Review PR", "Dev") + assert len(items) == 1 + assert items[0].text == "Review PR" + + def test_with_assignee(self): + items = extract_action_items("TODO: @alice should review this", "Bob") + assert len(items) == 1 + assert items[0].assignee == "alice" + + +class TestExtractDecisions: + def test_decision(self): + decisions = extract_decisions("DECISION: We will use Redis", "Team") + assert len(decisions) == 1 + assert decisions[0].text == "We will use Redis" + assert decisions[0].author == "Team" + + +class TestExtractConcerns: + def test_concern(self): + concerns = extract_concerns("CONCERN: Security implications", "Steve") + assert len(concerns) == 1 + assert concerns[0].text == "Security implications" + assert concerns[0].author == "Steve" + + +class TestExtractMentions: + def test_single_mention(self): + mentions = extract_mentions("What do you think @architect?", "Maya") + assert len(mentions) == 1 + assert mentions[0].target == "architect" + assert mentions[0].author == "Maya" + + def test_multiple_mentions(self): + mentions = extract_mentions("@architect and @security should review", "Lead") + assert len(mentions) == 2 + targets = {m.target for m in mentions} + assert targets == {"architect", "security"} + + def test_mention_all(self): + mentions = extract_mentions("@all please vote", "Moderator") + assert len(mentions) == 1 + assert mentions[0].target == "all" + + +class TestExtractAllMarkers: + def test_full_comment(self): + text = """I have concerns about this approach. + +Q: Have we considered alternatives? + +CONCERN: This might not scale. + +TODO: @security review threat model + +DECISION: We'll proceed with option A. + +VOTE: CHANGES +""" + markers = extract_all_markers(text, "Architect") + + assert markers["vote"] == "CHANGES" + assert len(markers["questions"]) == 1 + assert len(markers["concerns"]) == 1 + assert len(markers["action_items"]) == 1 + assert len(markers["decisions"]) == 1 + assert len(markers["mentions"]) == 1 diff --git a/tests/test_voting.py b/tests/test_voting.py new file mode 100644 index 0000000..e75d46f --- /dev/null +++ b/tests/test_voting.py @@ -0,0 +1,147 @@ +"""Tests for voting and consensus logic.""" + +import pytest +from discussions.voting import ( + VotingConfig, + ConsensusResult, + calculate_consensus, + is_human_participant, + format_vote_summary, +) + + +class TestIsHumanParticipant: + def test_human_names(self): + assert is_human_participant("Rob") is True + assert is_human_participant("Alice") is True + assert is_human_participant("bob_smith") is True + + def test_ai_names(self): + assert is_human_participant("AI-Architect") is False + assert is_human_participant("AI_Security") is False + assert is_human_participant("ai-moderator") is False + + def test_empty(self): + assert is_human_participant("") is False + assert is_human_participant(None) is False + + +class TestVotingConfig: + def test_defaults(self): + config = VotingConfig() + assert config.threshold_ready == 0.67 + assert config.threshold_reject == 0.01 + assert config.human_required is True + + def test_custom_thresholds(self): + config = VotingConfig(threshold_ready=0.5, threshold_reject=0.2) + assert config.threshold_ready == 0.5 + assert config.threshold_reject == 0.2 + + def test_invalid_threshold(self): + with pytest.raises(ValueError): + VotingConfig(threshold_ready=1.5) + + with pytest.raises(ValueError): + VotingConfig(threshold_reject=-0.1) + + +class TestCalculateConsensus: + def test_consensus_reached_all_ready(self): + votes = { + "AI-Architect": "READY", + "AI-Security": "READY", + "AI-Pragmatist": "READY", + "Rob": "READY", + } + config = VotingConfig(human_required=True) + result = calculate_consensus(votes, config) + + assert result.reached is True + assert result.outcome == "READY" + assert result.ready_count == 4 + + def test_consensus_reached_with_changes(self): + votes = { + "AI-Architect": "READY", + "AI-Security": "READY", + "AI-Pragmatist": "CHANGES", + "Rob": "READY", + } + config = VotingConfig(threshold_ready=0.67, human_required=True) + result = calculate_consensus(votes, config) + + assert result.reached is True + assert result.ready_count == 3 + assert result.changes_count == 1 + + def test_blocked_by_reject(self): + votes = { + "AI-Architect": "READY", + "AI-Security": "REJECT", + "AI-Pragmatist": "READY", + "Rob": "READY", + } + config = VotingConfig(threshold_reject=0.01) + result = calculate_consensus(votes, config) + + assert result.reached is False + assert "AI-Security" in result.blocked_by + assert "REJECT" in result.reason + + def test_human_required_not_met(self): + votes = { + "AI-Architect": "READY", + "AI-Security": "READY", + "AI-Pragmatist": "READY", + } + config = VotingConfig(human_required=True) + result = calculate_consensus(votes, config) + + assert result.reached is False + assert "Human approval required" in result.reason + + def test_human_required_disabled(self): + votes = { + "AI-Architect": "READY", + "AI-Security": "READY", + "AI-Pragmatist": "READY", + } + config = VotingConfig(human_required=False) + result = calculate_consensus(votes, config) + + assert result.reached is True + + def test_insufficient_votes(self): + votes = {} + config = VotingConfig(minimum_votes=1) + result = calculate_consensus(votes, config) + + assert result.reached is False + assert "Insufficient votes" in result.reason + + def test_not_enough_ready_votes(self): + votes = { + "AI-Architect": "CHANGES", + "AI-Security": "CHANGES", + "AI-Pragmatist": "READY", + "Rob": "READY", + } + config = VotingConfig(threshold_ready=0.67, human_required=True) + result = calculate_consensus(votes, config) + + assert result.reached is False + assert "more READY votes" in result.reason + + +class TestFormatVoteSummary: + def test_format(self): + votes = { + "Alice": "READY", + "Bob": "CHANGES", + "Carol": "READY", + } + summary = format_vote_summary(votes) + assert "READY: 2" in summary + assert "CHANGES: 1" in summary + assert "REJECT: 0" in summary