diff --git a/.env.example b/.env.example index 08e9def..19dfbb1 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,8 @@ GEMINI_API_KEY=your-gemini-api-key # TAVILY_API_KEY=your-tavily-api-key # AGENT_WORKSPACE_DIR=/path/to/your/project # AGENT_EXTRA_DIR=/path/to/extra/directory + +# Virtual Dev Team (optional - enables team-based GitHub collaboration) +# TEAM_AGENTS_DIR=agents/team +# TEAM_GITHUB_REPO=owner/repo +# TEAM_SIGN_COMMENTS=true diff --git a/agents/team/devops_hyunji.yaml b/agents/team/devops_hyunji.yaml new file mode 100644 index 0000000..88ff213 --- /dev/null +++ b/agents/team/devops_hyunji.yaml @@ -0,0 +1,44 @@ +name: DevOps Hyunji +description: DevOps Engineer. CI/CD, build systems, deployment, infrastructure. +role: validator +task_profile: general +persona: + display_name: "한현지" + title: "DevOps Engineer" + github_username: "agent-hyunji-ops" + avatar_url: "/avatars/hyunji.png" + bio: "CI/CD 파이프라인과 빌드 시스템 전문. 자동화를 사랑하고 안정적인 배포를 추구하는 DevOps 엔지니어." + personality: + thoroughness: 0.85 + creativity: 0.6 + strictness: 0.8 + verbosity: 0.5 + expertise: + - ci-cd + - docker + - github-actions + - infrastructure + - build-systems + - monitoring + communication_style: "systematic-pragmatic" +capabilities: + - Set up and maintain CI/CD pipelines + - Configure build and test automation + - Manage deployment processes + - Monitor system health and performance +system_prompt: | + You are 한현지 (Hyunji Han), the DevOps Engineer. + Your role is to ensure builds pass, CI/CD works, and deployments are smooth. + + When validating: + - Verify build succeeds (cargo build, npm run build) + - Check for dependency issues + - Ensure CI pipeline configuration is correct + - Validate environment configurations + + When setting up infrastructure: + - Create GitHub Actions workflows if needed + - Configure Docker files + - Set up monitoring and alerts + + Focus on automation, reliability, and reproducibility. diff --git a/agents/team/frontend_dev_yuna.yaml b/agents/team/frontend_dev_yuna.yaml new file mode 100644 index 0000000..e9c4256 --- /dev/null +++ b/agents/team/frontend_dev_yuna.yaml @@ -0,0 +1,40 @@ +name: Frontend Dev Yuna +description: Frontend Developer. UI/UX implementation, React components, visualization. +role: coder +task_profile: coding +persona: + display_name: "정유나" + title: "Frontend Engineer" + github_username: "agent-yuna-fe" + avatar_url: "/avatars/yuna.png" + bio: "React와 TypeScript 전문 프론트엔드 개발자. 사용자 경험과 접근성을 중시하며 시각적 완성도를 추구." + personality: + thoroughness: 0.8 + creativity: 0.9 + strictness: 0.6 + verbosity: 0.6 + expertise: + - react + - typescript + - next.js + - tailwind-css + - data-visualization + - ux-design + communication_style: "friendly-creative" +capabilities: + - Build React/Next.js components and pages + - Implement responsive UI with Tailwind CSS + - Create data visualizations (SVG, charts) + - Ensure accessibility and cross-browser compatibility +system_prompt: | + You are 정유나 (Yuna Jung), a Frontend Engineer. + Your role is to implement UI components, pages, and visualizations using React/Next.js. + + When implementing: + - Use TypeScript with strict types + - Follow existing component patterns in the codebase + - Use Tailwind CSS for styling + - Ensure responsive design and accessibility + - Create feature branches and submit PRs + + Focus on clean component architecture, reusability, and user experience. diff --git a/agents/team/junior_dev_seojin.yaml b/agents/team/junior_dev_seojin.yaml new file mode 100644 index 0000000..ede8e33 --- /dev/null +++ b/agents/team/junior_dev_seojin.yaml @@ -0,0 +1,41 @@ +name: Junior Dev Seojin +description: Junior Developer. Simple tasks, documentation, tests, learning. +role: coder +task_profile: coding +persona: + display_name: "김서진" + title: "Junior Developer" + github_username: "agent-seojin-jr" + avatar_url: "/avatars/seojin.png" + bio: "열정적인 주니어 개발자. 꾸준히 배우며 성장 중. 문서화와 테스트 작성에 적극적이고 질문을 아끼지 않음." + personality: + thoroughness: 0.7 + creativity: 0.6 + strictness: 0.5 + verbosity: 0.8 + expertise: + - documentation + - testing + - simple-features + - bug-fixes + communication_style: "friendly-mentor" +capabilities: + - Implement simple features and bug fixes + - Write and update documentation + - Add unit tests for existing code + - Fix lint and formatting issues +system_prompt: | + You are 김서진 (Seojin Kim), a Junior Developer. + Your role is to handle simpler tasks: documentation, tests, small bug fixes, and basic features. + + When implementing: + - Follow established patterns carefully + - Ask questions via issue comments when uncertain + - Write clear documentation and tests + - Create feature branches and submit PRs + + When you encounter something complex: + - Comment on the issue asking for guidance + - Reference the tech lead's architecture notes + + Be eager to learn and proactive in communication. diff --git a/agents/team/pm_soyeon.yaml b/agents/team/pm_soyeon.yaml new file mode 100644 index 0000000..f1bbf53 --- /dev/null +++ b/agents/team/pm_soyeon.yaml @@ -0,0 +1,39 @@ +name: PM Soyeon +description: Project Manager. Requirement analysis, issue creation, milestone management. +role: planner +task_profile: planning +persona: + display_name: "박소연" + title: "Project Manager" + github_username: "agent-soyeon-pm" + avatar_url: "/avatars/soyeon.png" + bio: "요구사항을 명확한 이슈와 마일스톤으로 분해하는 전문가. 체계적이고 꼼꼼한 프로젝트 관리 스타일." + personality: + thoroughness: 0.9 + creativity: 0.6 + strictness: 0.7 + verbosity: 0.8 + expertise: + - project-management + - requirements-analysis + - agile + - issue-tracking + communication_style: "structured-organized" +capabilities: + - Decompose requirements into actionable GitHub issues + - Set milestones and prioritize tasks + - Identify dependencies between issues + - Assign tasks to appropriate team members +system_prompt: | + You are 박소연 (Soyeon Park), the Project Manager. + Your role is to analyze incoming tasks and break them down into concrete GitHub issues. + + For each issue you create, include: + - A clear, descriptive title + - Detailed body with requirements, acceptance criteria, and context + - Appropriate labels (feature, bug, enhancement, documentation) + - Suggested assignee based on expertise + + When creating a plan, output a JSON array of issues to create. + Always think about task dependencies and order of execution. + Communicate in a structured, organized manner. diff --git a/agents/team/qa_engineer_taewon.yaml b/agents/team/qa_engineer_taewon.yaml new file mode 100644 index 0000000..12ef108 --- /dev/null +++ b/agents/team/qa_engineer_taewon.yaml @@ -0,0 +1,43 @@ +name: QA Engineer Taewon +description: QA Engineer. Test validation, bug reporting, quality assurance. +role: validator +task_profile: general +persona: + display_name: "최태원" + title: "QA Engineer" + github_username: "agent-taewon-qa" + avatar_url: "/avatars/taewon.png" + bio: "꼼꼼한 테스트와 버그 헌팅을 즐기는 QA 엔지니어. 엣지 케이스를 잘 찾아내고 재현 가능한 버그 리포트 작성에 능숙." + personality: + thoroughness: 0.95 + creativity: 0.5 + strictness: 0.85 + verbosity: 0.9 + expertise: + - testing + - quality-assurance + - bug-reporting + - test-automation + - edge-cases + communication_style: "detail-oriented" +capabilities: + - Validate code changes against requirements + - Run test suites and report results + - Identify edge cases and potential issues + - Write detailed bug reports as GitHub issues +system_prompt: | + You are 최태원 (Taewon Choi), the QA Engineer. + Your role is to validate that implementations meet requirements and quality standards. + + When validating: + - Check code against acceptance criteria from issues + - Run available tests (cargo test, npm test) + - Identify edge cases and potential regressions + - Report findings as comments on PRs or as new bug issues + + When reporting issues: + - Include steps to reproduce + - Expected vs actual behavior + - Severity assessment + + Be thorough and detail-oriented in your assessments. diff --git a/agents/team/senior_dev_minho.yaml b/agents/team/senior_dev_minho.yaml new file mode 100644 index 0000000..1d3708d --- /dev/null +++ b/agents/team/senior_dev_minho.yaml @@ -0,0 +1,42 @@ +name: Senior Dev Minho +description: Senior Backend Developer. Core feature implementation, performance optimization. +role: coder +task_profile: coding +persona: + display_name: "이민호" + title: "Senior Backend Engineer" + github_username: "agent-minho-dev" + avatar_url: "/avatars/minho.png" + bio: "Rust와 시스템 프로그래밍 전문. 성능 최적화를 좋아하고 깔끔한 코드를 추구하는 시니어 개발자." + personality: + thoroughness: 0.85 + creativity: 0.75 + strictness: 0.7 + verbosity: 0.5 + expertise: + - rust + - backend + - performance + - database + - api-design + communication_style: "pragmatic-efficient" +capabilities: + - Write production-ready Rust backend code + - Optimize performance-critical paths + - Design and implement REST APIs + - Handle database schema and queries +system_prompt: | + You are 이민호 (Minho Lee), a Senior Backend Engineer. + Your role is to implement backend features, write clean Rust code, and optimize performance. + + When implementing: + - Follow existing code patterns and conventions + - Write idiomatic Rust with proper error handling + - Consider edge cases and failure modes + - Create feature branches and submit PRs with clear descriptions + + When responding to review feedback: + - Address each comment specifically + - Push fixes to the same branch + + Keep commit messages concise and descriptive. diff --git a/agents/team/tech_lead_jihun.yaml b/agents/team/tech_lead_jihun.yaml new file mode 100644 index 0000000..538dd95 --- /dev/null +++ b/agents/team/tech_lead_jihun.yaml @@ -0,0 +1,42 @@ +name: Tech Lead Jihun +description: Tech Lead. Architecture design, PR review, technical decision-making. +role: reviewer +task_profile: planning +persona: + display_name: "김지훈" + title: "Tech Lead" + github_username: "agent-jihun-lead" + avatar_url: "/avatars/jihun.png" + bio: "아키텍처 설계와 코드 품질에 집착하는 테크 리드. 성능과 보안을 최우선시하며 간결한 기술적 피드백을 제공." + personality: + thoroughness: 0.95 + creativity: 0.7 + strictness: 0.9 + verbosity: 0.7 + expertise: + - architecture + - rust + - system-design + - code-review + - security + communication_style: "concise-technical" +capabilities: + - Design system architecture and component interactions + - Review PRs for quality, security, and performance + - Make technology stack decisions + - Provide implementation guidelines on issues +system_prompt: | + You are 김지훈 (Jihun Kim), the Tech Lead. + Your role is to review architecture decisions, provide technical guidance, and review PRs. + + When reviewing PRs: + - Focus on architectural fitness, performance implications, and security + - Provide specific, actionable feedback with code suggestions + - Output APPROVE if the code meets standards + - Output REQUEST_CHANGES with detailed reasons if not + + When designing architecture: + - Consider scalability, maintainability, and existing patterns + - Comment on issues with implementation guidelines + + Keep feedback concise and technically precise. diff --git a/skills/team_dev_workflow.yaml b/skills/team_dev_workflow.yaml new file mode 100644 index 0000000..59f9fb4 --- /dev/null +++ b/skills/team_dev_workflow.yaml @@ -0,0 +1,160 @@ +name: team_dev_workflow +description: Virtual dev team collaborates on GitHub — PM creates issues, Tech Lead designs, Devs implement, Reviewers approve, QA validates. + +parameters: + - name: task + description: High-level task or feature to develop + - name: repo + description: Target GitHub repository (owner/name) + - name: base_branch + description: Base branch to work from + default_value: main + +nodes: + # Stage 1: PM decomposes task into GitHub issues + - id: create_issues + role: planner + instructions: | + You are the Project Manager. + Analyze the task "{{task}}" and decompose it into concrete GitHub issues. + + Use MCP github tools to create real issues in {{repo}}. + For each issue include: + - Clear title + - Detailed body with requirements and acceptance criteria + - Labels: feature, bug, enhancement, or documentation + - Suggested assignee based on expertise + + Return a JSON summary of created issues: + {"issues": [{"number": 1, "title": "...", "assignee": "..."}]} + mcp_tools: + - github/create_issue + - github/add_issue_comment + + # Stage 2: Tech Lead reviews issues and provides architecture guidance + - id: architecture_design + role: reviewer + dependencies: [create_issues] + instructions: | + You are the Tech Lead. + Review the created issues and design the technical architecture. + + For each issue: + 1. Read the issue details + 2. Comment with implementation guidelines (file structure, APIs, patterns) + 3. Identify risks and dependencies between issues + + Use MCP tools to comment on each issue directly. + Return a summary of your architectural decisions. + mcp_tools: + - github/list_issues + - github/get_issue + - github/add_issue_comment + + # Stage 3a: Senior Dev implements backend (parallel with frontend) + - id: implement_backend + role: coder + dependencies: [architecture_design] + instructions: | + You are the Senior Backend Developer. + Based on the architecture guidelines, implement the backend changes. + + Steps: + 1. Create a feature branch from {{base_branch}} + 2. Implement the code changes + 3. Push files to the branch + 4. Create a PR with clear description referencing the issues + + Follow existing code patterns and conventions. + mcp_tools: + - github/create_branch + - github/push_files + - github/create_pull_request + - github/get_file_contents + policy: + max_parallelism: 2 + timeout_ms: 300000 + + # Stage 3b: Frontend Dev implements UI (parallel with backend) + - id: implement_frontend + role: coder + dependencies: [architecture_design] + instructions: | + You are the Frontend Developer. + Based on the architecture guidelines, implement the UI changes. + + Steps: + 1. Create a feature branch from {{base_branch}} + 2. Implement React/Next.js components + 3. Push files to the branch + 4. Create a PR with clear description + + Use TypeScript, Tailwind CSS, and follow existing component patterns. + mcp_tools: + - github/create_branch + - github/push_files + - github/create_pull_request + - github/get_file_contents + policy: + max_parallelism: 2 + timeout_ms: 300000 + + # Stage 4: Tech Lead reviews all PRs + - id: review_prs + role: reviewer + dependencies: [implement_backend, implement_frontend] + instructions: | + You are the Tech Lead. + Review all open PRs created by the developers. + + For each PR: + 1. Read the diff and changed files + 2. Check architecture compliance, code quality, security + 3. Submit a review: APPROVE or REQUEST_CHANGES with specific feedback + + Use MCP tools to read PR contents and submit reviews. + Return your review decisions as JSON: + {"reviews": [{"pr_number": 1, "decision": "APPROVE|REQUEST_CHANGES", "summary": "..."}]} + mcp_tools: + - github/list_pull_requests + - github/pull_request_read + - github/pull_request_review_write + - github/add_issue_comment + + # Stage 5: QA validates the implementation + - id: qa_validation + role: validator + dependencies: [review_prs] + instructions: | + You are the QA Engineer. + Validate that the implementations meet the original requirements. + + Steps: + 1. Read the original issues and their acceptance criteria + 2. Review the PR changes against those criteria + 3. Comment on PRs with test results + 4. If issues are found, create new bug issues + + Return validation results as JSON: + {"validation": "PASS|FAIL", "issues_found": [...], "notes": "..."} + mcp_tools: + - github/list_issues + - github/list_pull_requests + - github/add_issue_comment + - github/create_issue + + # Stage 6: Summarize the entire development cycle + - id: summary + role: summarizer + dependencies: [qa_validation] + instructions: | + Summarize the complete development cycle for task "{{task}}". + + Include: + - Issues created and their status + - PRs created and review outcomes + - QA validation results + - Any remaining action items + - Team member contributions + + Provide a concise but comprehensive summary. diff --git a/src/agents/agent_loader.rs b/src/agents/agent_loader.rs index 84eb3c8..ed24622 100644 --- a/src/agents/agent_loader.rs +++ b/src/agents/agent_loader.rs @@ -2,7 +2,7 @@ use std::path::Path; use tracing::{info, warn}; -use crate::types::{AgentRole, TaskProfile}; +use crate::types::{AgentPersona, AgentRole, TaskProfile}; /// YAML/JSON agent definition file schema. #[derive(Debug, Clone, serde::Deserialize)] @@ -16,6 +16,9 @@ pub struct AgentDefinition { pub system_prompt: String, #[serde(default)] pub instructions: String, + /// Optional persona for team-based collaboration. + #[serde(default)] + pub persona: Option, } /// Load all `*.yaml`, `*.yml`, and `*.json` agent definition files from a directory. diff --git a/src/config.rs b/src/config.rs index 268ab9f..a256034 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,6 +60,10 @@ pub struct AppConfig { pub skills_dir: Option, pub agents_dir: Option, pub interactive_max_iterations: usize, + // Team / virtual dev team configuration + pub team_agents_dir: Option, + pub team_github_repo: Option, + pub team_sign_comments: bool, } impl AppConfig { @@ -296,6 +300,12 @@ impl AppConfig { .and_then(|v| v.parse().ok()) .unwrap_or(15); + let team_agents_dir = env::var("TEAM_AGENTS_DIR").ok(); + let team_github_repo = env::var("TEAM_GITHUB_REPO").ok(); + let team_sign_comments = env::var("TEAM_SIGN_COMMENTS") + .map(|v| v == "true" || v == "1") + .unwrap_or(true); + let cfg = Self { data_dir, session_dir, @@ -326,6 +336,9 @@ impl AppConfig { skills_dir, agents_dir, interactive_max_iterations, + team_agents_dir, + team_github_repo, + team_sign_comments, }; cfg.ensure_dirs()?; diff --git a/src/github_ops/branch_ops.rs b/src/github_ops/branch_ops.rs new file mode 100644 index 0000000..626ee10 --- /dev/null +++ b/src/github_ops/branch_ops.rs @@ -0,0 +1,91 @@ +//! GitHub branch and file push operations. + +use serde_json::json; + +use super::{FileChange, GitHubOps}; +use crate::types::AgentPersona; + +impl GitHubOps { + /// Create a new branch from an existing ref. + pub async fn create_branch(&self, name: &str, from: &str) -> anyhow::Result<()> { + self.call_tool( + "github/create_branch", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "branch": name, + "from_branch": from, + }), + ) + .await?; + + Ok(()) + } + + /// Push file changes to a branch with a commit message. + pub async fn push_files( + &self, + persona: &AgentPersona, + branch: &str, + files: Vec, + message: &str, + ) -> anyhow::Result { + let file_array: Vec = files + .iter() + .map(|f| { + json!({ + "path": f.path, + "content": f.content, + }) + }) + .collect(); + + let commit_message = format!("{}\n\nCo-authored-by: {} <{}>", + message, persona.display_name, persona.github_username); + + let result = self + .call_tool( + "github/push_files", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "branch": branch, + "files": file_array, + "message": commit_message, + }), + ) + .await?; + + // Try to extract the commit SHA from the response. + let parsed: serde_json::Value = + serde_json::from_str(&result).unwrap_or_else(|_| json!({})); + let sha = parsed["sha"] + .as_str() + .or_else(|| parsed["commit"]["sha"].as_str()) + .unwrap_or("") + .to_string(); + + Ok(sha) + } + + /// List branches in the repository. + pub async fn list_branches(&self) -> anyhow::Result> { + let result = self + .call_tool( + "github/list_branches", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + }), + ) + .await?; + + let parsed: Vec = + serde_json::from_str(&result).unwrap_or_default(); + + Ok(parsed + .into_iter() + .filter_map(|v| v["name"].as_str().map(|s| s.to_string())) + .collect()) + } +} diff --git a/src/github_ops/issue_ops.rs b/src/github_ops/issue_ops.rs new file mode 100644 index 0000000..8315d4b --- /dev/null +++ b/src/github_ops/issue_ops.rs @@ -0,0 +1,125 @@ +//! GitHub issue operations. + +use serde_json::json; + +use super::{GitHubIssue, GitHubOps}; +use crate::types::AgentPersona; + +impl GitHubOps { + /// Create a new issue in the repository. + pub async fn create_issue( + &self, + persona: &AgentPersona, + title: &str, + body: &str, + labels: &[String], + assignees: &[String], + ) -> anyhow::Result { + let signed_body = self.sign_body(persona, body); + + let mut args = json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "title": title, + "body": signed_body, + }); + + if !labels.is_empty() { + args["labels"] = json!(labels); + } + if !assignees.is_empty() { + args["assignees"] = json!(assignees); + } + + let result = self.call_tool("github/create_issue", args).await?; + let parsed: serde_json::Value = serde_json::from_str(&result) + .unwrap_or_else(|_| json!({ "number": 0, "title": title, "html_url": "", "state": "open" })); + + Ok(GitHubIssue { + number: parsed["number"].as_i64().unwrap_or(0), + title: parsed["title"].as_str().unwrap_or(title).to_string(), + html_url: parsed["html_url"].as_str().unwrap_or("").to_string(), + state: parsed["state"].as_str().unwrap_or("open").to_string(), + }) + } + + /// Add a comment to an existing issue. + pub async fn comment_on_issue( + &self, + persona: &AgentPersona, + issue_number: i64, + body: &str, + ) -> anyhow::Result<()> { + let signed_body = self.sign_body(persona, body); + + self.call_tool( + "github/add_issue_comment", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "issue_number": issue_number, + "body": signed_body, + }), + ) + .await?; + + Ok(()) + } + + /// Close an issue with an optional closing comment. + pub async fn close_issue( + &self, + persona: &AgentPersona, + issue_number: i64, + comment: &str, + ) -> anyhow::Result<()> { + if !comment.is_empty() { + self.comment_on_issue(persona, issue_number, comment) + .await?; + } + + self.call_tool( + "github/update_issue", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "issue_number": issue_number, + "state": "closed", + }), + ) + .await?; + + Ok(()) + } + + /// List issues with optional state and label filters. + pub async fn list_issues( + &self, + state: &str, + labels: &[String], + ) -> anyhow::Result> { + let mut args = json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "state": state, + }); + + if !labels.is_empty() { + args["labels"] = json!(labels.join(",")); + } + + let result = self.call_tool("github/list_issues", args).await?; + let parsed: Vec = + serde_json::from_str(&result).unwrap_or_default(); + + Ok(parsed + .into_iter() + .map(|v| GitHubIssue { + number: v["number"].as_i64().unwrap_or(0), + title: v["title"].as_str().unwrap_or("").to_string(), + html_url: v["html_url"].as_str().unwrap_or("").to_string(), + state: v["state"].as_str().unwrap_or("open").to_string(), + }) + .collect()) + } +} diff --git a/src/github_ops/mod.rs b/src/github_ops/mod.rs new file mode 100644 index 0000000..805a905 --- /dev/null +++ b/src/github_ops/mod.rs @@ -0,0 +1,142 @@ +//! High-level GitHub operations layer. +//! +//! Wraps the MCP GitHub tool calls to provide a convenient API for agent +//! personas to create issues, pull requests, reviews, and branches. + +pub mod branch_ops; +pub mod issue_ops; +pub mod pr_ops; + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::mcp::McpRegistry; +use crate::types::AgentPersona; + +/// High-level GitHub operations backed by the MCP GitHub server. +#[derive(Clone)] +pub struct GitHubOps { + mcp: Arc, + pub repo_owner: String, + pub repo_name: String, + sign_comments: bool, +} + +impl GitHubOps { + pub fn new( + mcp: Arc, + repo_owner: String, + repo_name: String, + sign_comments: bool, + ) -> Self { + Self { + mcp, + repo_owner, + repo_name, + sign_comments, + } + } + + /// Full repo identifier in `owner/name` form. + pub fn repo_full_name(&self) -> String { + format!("{}/{}", self.repo_owner, self.repo_name) + } + + /// Call an MCP tool by name with JSON arguments. + pub(crate) async fn call_tool( + &self, + tool_name: &str, + args: serde_json::Value, + ) -> anyhow::Result { + let result = self.mcp.call_tool(tool_name, args).await?; + if result.succeeded { + Ok(result.content) + } else { + anyhow::bail!( + "MCP tool '{}' failed: {}", + tool_name, + result.error.unwrap_or_default() + ) + } + } + + /// Append a persona signature to comment body if signing is enabled. + pub(crate) fn sign_body(&self, persona: &AgentPersona, body: &str) -> String { + if self.sign_comments { + format!( + "{}\n\n---\n*{}* — {} | `{}`", + body, persona.display_name, persona.title, persona.github_username + ) + } else { + body.to_string() + } + } +} + +// --- Shared types --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubIssue { + pub number: i64, + pub title: String, + pub html_url: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubPR { + pub number: i64, + pub title: String, + pub html_url: String, + pub state: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ReviewEvent { + Approve, + RequestChanges, + Comment, +} + +impl std::fmt::Display for ReviewEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReviewEvent::Approve => write!(f, "APPROVE"), + ReviewEvent::RequestChanges => write!(f, "REQUEST_CHANGES"), + ReviewEvent::Comment => write!(f, "COMMENT"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReviewComment { + pub path: String, + pub line: Option, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileChange { + pub path: String, + pub content: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MergeMethod { + Merge, + Squash, + Rebase, +} + +impl std::fmt::Display for MergeMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MergeMethod::Merge => write!(f, "merge"), + MergeMethod::Squash => write!(f, "squash"), + MergeMethod::Rebase => write!(f, "rebase"), + } + } +} diff --git a/src/github_ops/pr_ops.rs b/src/github_ops/pr_ops.rs new file mode 100644 index 0000000..5961af6 --- /dev/null +++ b/src/github_ops/pr_ops.rs @@ -0,0 +1,161 @@ +//! GitHub pull request operations. + +use serde_json::json; + +use super::{GitHubOps, GitHubPR, MergeMethod, ReviewComment, ReviewEvent}; +use crate::types::AgentPersona; + +impl GitHubOps { + /// Create a new pull request. + pub async fn create_pr( + &self, + persona: &AgentPersona, + title: &str, + body: &str, + head: &str, + base: &str, + ) -> anyhow::Result { + let signed_body = self.sign_body(persona, body); + + let result = self + .call_tool( + "github/create_pull_request", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "title": title, + "body": signed_body, + "head": head, + "base": base, + }), + ) + .await?; + + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap_or_else(|_| { + json!({ "number": 0, "title": title, "html_url": "", "state": "open" }) + }); + + Ok(GitHubPR { + number: parsed["number"].as_i64().unwrap_or(0), + title: parsed["title"].as_str().unwrap_or(title).to_string(), + html_url: parsed["html_url"].as_str().unwrap_or("").to_string(), + state: parsed["state"].as_str().unwrap_or("open").to_string(), + }) + } + + /// Submit a review on a pull request. + pub async fn review_pr( + &self, + persona: &AgentPersona, + pr_number: i64, + event: ReviewEvent, + body: &str, + comments: Vec, + ) -> anyhow::Result<()> { + let signed_body = self.sign_body(persona, body); + + let mut args = json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "pull_number": pr_number, + "event": event.to_string(), + "body": signed_body, + }); + + if !comments.is_empty() { + let comment_array: Vec = comments + .iter() + .map(|c| { + let mut obj = json!({ + "path": c.path, + "body": c.body, + }); + if let Some(line) = c.line { + obj["line"] = json!(line); + } + obj + }) + .collect(); + args["comments"] = json!(comment_array); + } + + self.call_tool("github/create_pull_request_review", args) + .await?; + + Ok(()) + } + + /// Add a comment on a pull request (not a review, just a plain comment). + pub async fn comment_on_pr( + &self, + persona: &AgentPersona, + pr_number: i64, + body: &str, + ) -> anyhow::Result<()> { + let signed_body = self.sign_body(persona, body); + + // PR comments use the issues comment endpoint on GitHub. + self.call_tool( + "github/add_issue_comment", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "issue_number": pr_number, + "body": signed_body, + }), + ) + .await?; + + Ok(()) + } + + /// Merge a pull request. + pub async fn merge_pr( + &self, + pr_number: i64, + method: MergeMethod, + ) -> anyhow::Result<()> { + self.call_tool( + "github/merge_pull_request", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "pull_number": pr_number, + "merge_method": method.to_string(), + }), + ) + .await?; + + Ok(()) + } + + /// List pull requests with optional state filter. + pub async fn list_prs( + &self, + state: &str, + ) -> anyhow::Result> { + let result = self + .call_tool( + "github/list_pull_requests", + json!({ + "owner": self.repo_owner, + "repo": self.repo_name, + "state": state, + }), + ) + .await?; + + let parsed: Vec = + serde_json::from_str(&result).unwrap_or_default(); + + Ok(parsed + .into_iter() + .map(|v| GitHubPR { + number: v["number"].as_i64().unwrap_or(0), + title: v["title"].as_str().unwrap_or("").to_string(), + html_url: v["html_url"].as_str().unwrap_or("").to_string(), + state: v["state"].as_str().unwrap_or("open").to_string(), + }) + .collect()) + } +} diff --git a/src/interface/api.rs b/src/interface/api.rs index 345b0ab..f744b99 100644 --- a/src/interface/api.rs +++ b/src/interface/api.rs @@ -277,6 +277,10 @@ pub fn router(state: ApiState) -> Router { post(submit_cluster_run_handler).get(list_cluster_runs_handler), ) .route("/v1/cluster/runs/:cluster_run_id", get(get_cluster_run_handler)) + // Team & GitHub activity endpoints + .route("/v1/team/members", get(list_team_members_handler)) + .route("/v1/github/activities", get(list_github_activities_handler)) + .route("/v1/github/activities/stats", get(github_activity_stats_handler)) .with_state(state) } @@ -2681,3 +2685,101 @@ async fn get_cluster_run_handler( .into_response(), } } + +// --- Team & GitHub Activity handlers --- + +async fn list_team_members_handler( + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(e) = state.auth.verify_headers(&headers, &[]) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + + // Load team agent definitions from agents/team/ directory. + let team_dir = std::path::Path::new("agents/team"); + let members = crate::agents::agent_loader::load_agents_from_dir(team_dir).await; + + let result: Vec = members + .into_iter() + .map(|def| { + let mut val = serde_json::json!({ + "name": def.name, + "description": def.description, + "role": def.role.to_string(), + "task_profile": def.task_profile.to_string(), + "capabilities": def.capabilities, + }); + if let Some(persona) = &def.persona { + val["persona"] = serde_json::to_value(persona).unwrap_or_default(); + } + val + }) + .collect(); + + (StatusCode::OK, Json(serde_json::json!(result))) +} + +#[derive(Debug, Deserialize)] +struct GitHubActivityQuery { + persona: Option, + run_id: Option, + limit: Option, +} + +async fn list_github_activities_handler( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(e) = state.auth.verify_headers(&headers, &[]) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + + let limit = query.limit.unwrap_or(100).clamp(1, 500); + match state + .orchestrator + .memory() + .store() + .list_github_activities(query.persona.as_deref(), query.run_id.as_deref(), limit) + .await + { + Ok(activities) => (StatusCode::OK, Json(serde_json::json!(activities))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ), + } +} + +async fn github_activity_stats_handler( + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(e) = state.auth.verify_headers(&headers, &[]) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + + match state + .orchestrator + .memory() + .store() + .github_activity_stats() + .await + { + Ok(stats) => (StatusCode::OK, Json(serde_json::json!(stats))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ), + } +} diff --git a/src/lib.rs b/src/lib.rs index 7102ac2..7934138 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod command_resolver; pub mod config; pub mod context; pub mod gateway; +pub mod github_ops; pub mod interface; pub mod mcp; pub mod memory; diff --git a/src/memory/store.rs b/src/memory/store.rs index b928486..fb4668c 100644 --- a/src/memory/store.rs +++ b/src/memory/store.rs @@ -313,6 +313,38 @@ impl SqliteStore { .execute(&self.pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS github_activities ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + session_id TEXT NOT NULL, + persona_name TEXT NOT NULL, + activity_type TEXT NOT NULL, + github_url TEXT, + target_number INTEGER, + title TEXT, + body_preview TEXT, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + "#, + ) + .execute(&self.pool) + .await?; + + sqlx::query( + r#"CREATE INDEX IF NOT EXISTS idx_github_activities_run ON github_activities(run_id);"#, + ) + .execute(&self.pool) + .await?; + + sqlx::query( + r#"CREATE INDEX IF NOT EXISTS idx_github_activities_persona ON github_activities(persona_name);"#, + ) + .execute(&self.pool) + .await?; + Ok(()) } @@ -1940,6 +1972,14 @@ fn parse_run_action(value: &str) -> anyhow::Result { "repo_clone_completed" => RunActionType::RepoCloneCompleted, "repo_analysis_completed" => RunActionType::RepoAnalysisCompleted, "interactive_step" => RunActionType::InteractiveStep, + "github_issue_created" => RunActionType::GitHubIssueCreated, + "github_issue_commented" => RunActionType::GitHubIssueCommented, + "github_issue_closed" => RunActionType::GitHubIssueClosed, + "github_pr_created" => RunActionType::GitHubPrCreated, + "github_pr_reviewed" => RunActionType::GitHubPrReviewed, + "github_pr_commented" => RunActionType::GitHubPrCommented, + "github_pr_merged" => RunActionType::GitHubPrMerged, + "github_branch_created" => RunActionType::GitHubBranchCreated, _ => { return Err(anyhow::anyhow!("unknown run action event type: {value}")); } @@ -1947,6 +1987,114 @@ fn parse_run_action(value: &str) -> anyhow::Result { Ok(action) } +// --- GitHub Activity persistence --- + +impl SqliteStore { + /// Record a GitHub activity performed by an agent persona. + pub async fn record_github_activity( + &self, + id: &str, + run_id: &str, + session_id: &str, + persona_name: &str, + activity_type: &str, + github_url: Option<&str>, + target_number: Option, + title: &str, + body_preview: &str, + metadata: &serde_json::Value, + ) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO github_activities (id, run_id, session_id, persona_name, activity_type, github_url, target_number, title, body_preview, metadata, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now')) + "#, + ) + .bind(id) + .bind(run_id) + .bind(session_id) + .bind(persona_name) + .bind(activity_type) + .bind(github_url) + .bind(target_number) + .bind(title) + .bind(&body_preview[..body_preview.len().min(200)]) + .bind(serde_json::to_string(metadata).unwrap_or_default()) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// List GitHub activities, optionally filtered by persona and/or run_id. + pub async fn list_github_activities( + &self, + persona: Option<&str>, + run_id: Option<&str>, + limit: i64, + ) -> anyhow::Result> { + let mut conditions = vec!["1=1".to_string()]; + if let Some(p) = persona { + conditions.push(format!("persona_name = '{}'", p.replace('\'', "''"))); + } + if let Some(r) = run_id { + conditions.push(format!("run_id = '{}'", r.replace('\'', "''"))); + } + let where_clause = conditions.join(" AND "); + + let query = format!( + "SELECT id, run_id, session_id, persona_name, activity_type, github_url, target_number, title, body_preview, metadata, created_at \ + FROM github_activities WHERE {} ORDER BY created_at DESC LIMIT {}", + where_clause, limit + ); + + let rows = sqlx::query(&query).fetch_all(&self.pool).await?; + + let mut result = Vec::with_capacity(rows.len()); + for row in &rows { + use sqlx::Row; + result.push(serde_json::json!({ + "id": row.get::("id"), + "run_id": row.get::("run_id"), + "session_id": row.get::("session_id"), + "persona_name": row.get::("persona_name"), + "activity_type": row.get::("activity_type"), + "github_url": row.get::, _>("github_url"), + "target_number": row.get::, _>("target_number"), + "title": row.get::("title"), + "body_preview": row.get::("body_preview"), + "metadata": row.get::("metadata"), + "created_at": row.get::("created_at"), + })); + } + Ok(result) + } + + /// Get GitHub activity statistics per persona. + pub async fn github_activity_stats(&self) -> anyhow::Result> { + let rows = sqlx::query( + r#" + SELECT persona_name, activity_type, COUNT(*) as count + FROM github_activities + GROUP BY persona_name, activity_type + ORDER BY persona_name, count DESC + "#, + ) + .fetch_all(&self.pool) + .await?; + + let mut result = Vec::new(); + for row in &rows { + use sqlx::Row; + result.push(serde_json::json!({ + "persona_name": row.get::("persona_name"), + "activity_type": row.get::("activity_type"), + "count": row.get::("count"), + })); + } + Ok(result) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/orchestrator/mod.rs b/src/orchestrator/mod.rs index f8f30ab..ed5fc02 100644 --- a/src/orchestrator/mod.rs +++ b/src/orchestrator/mod.rs @@ -4500,6 +4500,70 @@ impl Orchestrator { .await; } + // --- Reviewer iteration loop for team workflows --- + // When a Reviewer node outputs REQUEST_CHANGES, dynamically inject + // a Coder fix node and a re-review node to continue the cycle. + if node.role == AgentRole::Reviewer + && result.succeeded + && result.output.contains("REQUEST_CHANGES") + { + let iteration = Self::parse_fix_iteration(&node.id); + let max_review_iterations: u8 = 3; + if iteration < max_review_iterations { + let next_iter = iteration + 1; + let review_feedback: String = + result.output.chars().take(3000).collect(); + + let fix_id = format!("fix_review_{}", next_iter); + let mut fix_node = AgentNode::new( + fix_id.clone(), + AgentRole::Coder, + format!( + "The reviewer requested changes. Address the feedback below and push fixes.\n\n{}", + review_feedback + ), + ); + fix_node.dependencies = vec![node.id.clone()]; + fix_node.depth = node.depth + 1; + fix_node.mcp_tools = vec![ + "github/push_files".into(), + "github/get_file_contents".into(), + ]; + fix_node.policy = ExecutionPolicy { + max_parallelism: 1, + retry: 1, + timeout_ms: 180_000, + circuit_breaker: 2, + on_dependency_failure: DependencyFailurePolicy::ContinueOnError, + fallback_node: None, + }; + + let re_review_id = format!("re_review_{}", next_iter); + let mut re_review = AgentNode::new( + re_review_id, + AgentRole::Reviewer, + "Review the updated code after fixes. Output APPROVE or REQUEST_CHANGES.", + ); + re_review.dependencies = vec![fix_id]; + re_review.depth = node.depth + 2; + re_review.mcp_tools = vec![ + "github/pull_request_read".into(), + "github/pull_request_review_write".into(), + ]; + re_review.policy = ExecutionPolicy { + max_parallelism: 1, + retry: 1, + timeout_ms: 120_000, + circuit_breaker: 2, + on_dependency_failure: DependencyFailurePolicy::ContinueOnError, + fallback_node: None, + }; + + dynamic_nodes.push(fix_node); + dynamic_nodes.push(re_review); + } + } + // --- Validator dynamic node injection --- if node.role == AgentRole::Validator && !result.succeeded { @@ -4948,6 +5012,21 @@ impl Orchestrator { "primary_language": primary_language, "file_count": file_count, }), + RuntimeEvent::GitHubActivity { + node_id, + persona_name, + activity_type, + target_number, + url, + title, + } => serde_json::json!({ + "node_id": node_id, + "persona_name": persona_name, + "activity_type": activity_type, + "target_number": target_number, + "url": url, + "title": title, + }), }; let event_type = match event_clone { @@ -4964,7 +5043,8 @@ impl Orchestrator { | RuntimeEvent::GraphCompleted | RuntimeEvent::ValidationCompleted { .. } | RuntimeEvent::GitCommitCreated { .. } - | RuntimeEvent::InteractiveStep { .. } => SessionEventType::RunProgress, + | RuntimeEvent::InteractiveStep { .. } + | RuntimeEvent::GitHubActivity { .. } => SessionEventType::RunProgress, RuntimeEvent::RepoCloned { .. } | RuntimeEvent::RepoAnalyzed { .. } => { SessionEventType::AgentOutput } @@ -5024,6 +5104,20 @@ impl Orchestrator { RuntimeEvent::InteractiveStep { node_id, .. } => { (RunActionType::InteractiveStep, Some(node_id.as_str())) } + RuntimeEvent::GitHubActivity { node_id, activity_type, .. } => { + let action = match activity_type.as_str() { + "issue_created" => RunActionType::GitHubIssueCreated, + "issue_commented" => RunActionType::GitHubIssueCommented, + "issue_closed" => RunActionType::GitHubIssueClosed, + "pr_created" => RunActionType::GitHubPrCreated, + "pr_reviewed" => RunActionType::GitHubPrReviewed, + "pr_commented" => RunActionType::GitHubPrCommented, + "pr_merged" => RunActionType::GitHubPrMerged, + "branch_created" => RunActionType::GitHubBranchCreated, + _ => RunActionType::GitHubIssueCreated, + }; + (action, Some(node_id.as_str())) + } }; if let Err(err) = memory @@ -5803,7 +5897,15 @@ fn build_trace_graph(run: &RunRecord, events: &[RunActionEvent]) -> RunTraceGrap | RunActionType::GitPushCompleted | RunActionType::RepoCloneCompleted | RunActionType::RepoAnalysisCompleted - | RunActionType::InteractiveStep => {} + | RunActionType::InteractiveStep + | RunActionType::GitHubIssueCreated + | RunActionType::GitHubIssueCommented + | RunActionType::GitHubIssueClosed + | RunActionType::GitHubPrCreated + | RunActionType::GitHubPrReviewed + | RunActionType::GitHubPrCommented + | RunActionType::GitHubPrMerged + | RunActionType::GitHubBranchCreated => {} } } diff --git a/src/orchestrator/prompt_composer.rs b/src/orchestrator/prompt_composer.rs index c96ca8c..9b6b127 100644 --- a/src/orchestrator/prompt_composer.rs +++ b/src/orchestrator/prompt_composer.rs @@ -1,4 +1,4 @@ -use crate::types::PromptLayers; +use crate::types::{AgentPersona, PromptLayers}; /// 6-layer prompt assembly. /// @@ -13,8 +13,19 @@ pub struct PromptComposer; impl PromptComposer { pub fn compose(layers: &PromptLayers) -> String { + Self::compose_with_persona(layers, None) + } + + pub fn compose_with_persona(layers: &PromptLayers, persona: Option<&AgentPersona>) -> String { let mut prompt = String::with_capacity(4096); + // Inject persona identity before system policy when available. + if let Some(p) = persona { + prompt.push_str("[PERSONA]\n"); + prompt.push_str(&Self::format_persona(p)); + prompt.push_str("\n\n"); + } + prompt.push_str("[SYSTEM_POLICY]\n"); prompt.push_str(&layers.system_policy); prompt.push_str("\n\n"); @@ -49,6 +60,25 @@ impl PromptComposer { prompt } + + /// Format persona information as a prompt section. + fn format_persona(persona: &AgentPersona) -> String { + let mut s = String::with_capacity(512); + s.push_str(&format!("Name: {}\n", persona.display_name)); + s.push_str(&format!("Title: {}\n", persona.title)); + if !persona.bio.is_empty() { + s.push_str(&format!("Bio: {}\n", persona.bio)); + } + s.push_str(&format!("Communication Style: {}\n", persona.communication_style)); + if !persona.expertise.is_empty() { + s.push_str(&format!("Expertise: {}\n", persona.expertise.join(", "))); + } + s.push_str(&format!( + "\nWhen writing GitHub comments or PR reviews, sign as {} and maintain your personality consistently.\n", + persona.display_name + )); + s + } } #[cfg(test)] diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 3e02d4f..7e94e52 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -110,6 +110,14 @@ pub enum RuntimeEvent { action_type: String, observation_preview: String, }, + GitHubActivity { + node_id: String, + persona_name: String, + activity_type: String, + target_number: Option, + url: Option, + title: String, + }, } const NODE_OUTPUT_PREVIEW_CHARS: usize = 8_192; diff --git a/src/types.rs b/src/types.rs index 44fbdf0..ec4d592 100644 --- a/src/types.rs +++ b/src/types.rs @@ -87,6 +87,122 @@ impl Display for AgentRole { } } +// --- Agent Persona --- + +/// Personality traits that influence how an agent communicates and works. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersonalityTraits { + /// 0.0–1.0: how thorough / detail-oriented the agent is. + #[serde(default = "default_mid")] + pub thoroughness: f32, + /// 0.0–1.0: creative vs. conservative approach. + #[serde(default = "default_mid")] + pub creativity: f32, + /// 0.0–1.0: strictness when reviewing code / PRs. + #[serde(default = "default_mid")] + pub strictness: f32, + /// 0.0–1.0: verbosity in explanations and comments. + #[serde(default = "default_mid")] + pub verbosity: f32, +} + +fn default_mid() -> f32 { + 0.5 +} + +impl Default for PersonalityTraits { + fn default() -> Self { + Self { + thoroughness: 0.5, + creativity: 0.5, + strictness: 0.5, + verbosity: 0.5, + } + } +} + +/// A persona gives an agent a unique identity for GitHub collaboration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentPersona { + /// Human-readable display name, e.g. "김지훈" or "Alex Chen". + pub display_name: String, + /// Job title, e.g. "Senior Backend Engineer". + pub title: String, + /// Username shown in GitHub comments/signatures. + pub github_username: String, + /// Optional avatar image URL. + #[serde(default)] + pub avatar_url: Option, + /// Short biography describing expertise and style. + #[serde(default)] + pub bio: String, + /// Personality configuration. + #[serde(default)] + pub personality: PersonalityTraits, + /// Areas of expertise, e.g. ["rust", "performance", "systems"]. + #[serde(default)] + pub expertise: Vec, + /// Communication style tag, e.g. "concise-technical", "friendly-mentor". + #[serde(default = "default_communication_style")] + pub communication_style: String, +} + +fn default_communication_style() -> String { + "balanced".to_string() +} + +// --- GitHub Activity --- + +/// Represents a recorded GitHub activity performed by an agent persona. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubActivity { + pub id: String, + pub run_id: Uuid, + pub session_id: Uuid, + pub persona_name: String, + pub activity_type: GitHubActivityType, + #[serde(default)] + pub github_url: Option, + #[serde(default)] + pub target_number: Option, + #[serde(default)] + pub title: String, + #[serde(default)] + pub body_preview: String, + #[serde(default)] + pub metadata: serde_json::Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GitHubActivityType { + IssueCreated, + IssueCommented, + IssueClosed, + PrCreated, + PrReviewed, + PrCommented, + PrMerged, + BranchCreated, +} + +impl Display for GitHubActivityType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let s = match self { + GitHubActivityType::IssueCreated => "issue_created", + GitHubActivityType::IssueCommented => "issue_commented", + GitHubActivityType::IssueClosed => "issue_closed", + GitHubActivityType::PrCreated => "pr_created", + GitHubActivityType::PrReviewed => "pr_reviewed", + GitHubActivityType::PrCommented => "pr_commented", + GitHubActivityType::PrMerged => "pr_merged", + GitHubActivityType::BranchCreated => "branch_created", + }; + write!(f, "{s}") + } +} + // --- Task Classification --- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -326,6 +442,14 @@ pub enum RunActionType { RepoCloneCompleted, RepoAnalysisCompleted, InteractiveStep, + GitHubIssueCreated, + GitHubIssueCommented, + GitHubIssueClosed, + GitHubPrCreated, + GitHubPrReviewed, + GitHubPrCommented, + GitHubPrMerged, + GitHubBranchCreated, } impl Display for RunActionType { @@ -363,6 +487,14 @@ impl Display for RunActionType { RunActionType::RepoCloneCompleted => "repo_clone_completed", RunActionType::RepoAnalysisCompleted => "repo_analysis_completed", RunActionType::InteractiveStep => "interactive_step", + RunActionType::GitHubIssueCreated => "github_issue_created", + RunActionType::GitHubIssueCommented => "github_issue_commented", + RunActionType::GitHubIssueClosed => "github_issue_closed", + RunActionType::GitHubPrCreated => "github_pr_created", + RunActionType::GitHubPrReviewed => "github_pr_reviewed", + RunActionType::GitHubPrCommented => "github_pr_commented", + RunActionType::GitHubPrMerged => "github_pr_merged", + RunActionType::GitHubBranchCreated => "github_branch_created", }; write!(f, "{s}") } diff --git a/web/src/app/team/page.tsx b/web/src/app/team/page.tsx new file mode 100644 index 0000000..f3c1803 --- /dev/null +++ b/web/src/app/team/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { apiGet } from "@/lib/api-client"; +import { TeamMember, GitHubActivityItem } from "@/lib/types"; +import AgentCard from "@/components/team/agent-card"; +import GitHubActivityFeed from "@/components/team/github-activity-feed"; +import InteractionGraph from "@/components/team/interaction-graph"; + +export default function TeamPage() { + const [members, setMembers] = useState([]); + const [activities, setActivities] = useState([]); + const [stats, setStats] = useState< + { persona_name: string; activity_type: string; count: number }[] + >([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"members" | "activity" | "graph">( + "members" + ); + + useEffect(() => { + async function load() { + try { + const [m, a, s] = await Promise.all([ + apiGet("/v1/team/members").catch(() => []), + apiGet("/v1/github/activities?limit=50").catch( + () => [] + ), + apiGet< + { persona_name: string; activity_type: string; count: number }[] + >("/v1/github/activities/stats").catch(() => []), + ]); + setMembers(m); + setActivities(a); + setStats(s); + } finally { + setLoading(false); + } + } + load(); + }, []); + + function getStatsForMember(name: string) { + const memberStats = stats.filter((s) => s.persona_name === name); + return { + issuesCreated: + memberStats.find((s) => s.activity_type === "issue_created")?.count ?? + 0, + prsCreated: + memberStats.find((s) => s.activity_type === "pr_created")?.count ?? 0, + reviewsDone: + memberStats.find((s) => s.activity_type === "pr_reviewed")?.count ?? 0, + }; + } + + if (loading) { + return ( +
+ Loading team... +
+ ); + } + + const personaNames = members.map((m) => m.persona.display_name); + + return ( +
+ {/* Header */} +
+
+

+ Virtual Dev Team +

+

+ {members.length} team members · {activities.length} GitHub + activities +

+
+
+ + {/* Tabs */} +
+ {(["members", "activity", "graph"] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} + {activeTab === "members" && ( +
+ {members.map((member) => ( + + ))} + {members.length === 0 && ( +
+ No team members configured. Add agent YAML files to{" "} + agents/team/{" "} + directory. +
+ )} +
+ )} + + {activeTab === "activity" && ( +
+ +
+ )} + + {activeTab === "graph" && ( +
+ +
+ )} +
+ ); +} diff --git a/web/src/components/nav.tsx b/web/src/components/nav.tsx index 815bb77..93b79ea 100644 --- a/web/src/components/nav.tsx +++ b/web/src/components/nav.tsx @@ -17,6 +17,7 @@ const tabs = [ { href: "/schedules", label: "Schedules" }, { href: "/terminal", label: "Terminal" }, { href: "/tools", label: "Tools" }, + { href: "/team", label: "Team" }, { href: "/settings", label: "Settings" }, ]; diff --git a/web/src/components/team/agent-card.tsx b/web/src/components/team/agent-card.tsx new file mode 100644 index 0000000..42fa96c --- /dev/null +++ b/web/src/components/team/agent-card.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { TeamMember } from "@/lib/types"; + +interface AgentCardProps { + member: TeamMember; + status?: "idle" | "working" | "reviewing" | "waiting"; + currentTask?: string; + stats?: { + issuesCreated: number; + prsCreated: number; + reviewsDone: number; + }; +} + +const statusColors: Record = { + idle: "bg-gray-400", + working: "bg-blue-500 animate-pulse", + reviewing: "bg-yellow-500 animate-pulse", + waiting: "bg-orange-400", +}; + +const statusLabels: Record = { + idle: "Idle", + working: "Working", + reviewing: "Reviewing", + waiting: "Waiting", +}; + +export default function AgentCard({ + member, + status = "idle", + currentTask, + stats, +}: AgentCardProps) { + const persona = member.persona; + + return ( +
+ {/* Header */} +
+
+ {persona.display_name.charAt(0)} +
+
+
+

+ {persona.display_name} +

+ +
+

{persona.title}

+

+ @{persona.github_username} +

+
+
+ + {/* Bio */} +

{persona.bio}

+ + {/* Expertise */} +
+ {persona.expertise.slice(0, 4).map((tag) => ( + + {tag} + + ))} +
+ + {/* Personality Bars */} +
+ {( + [ + ["Thoroughness", persona.personality.thoroughness], + ["Strictness", persona.personality.strictness], + ["Creativity", persona.personality.creativity], + ] as [string, number][] + ).map(([label, value]) => ( +
+ {label} +
+
+
+
+ ))} +
+ + {/* Current Task */} + {currentTask && ( +
+ {currentTask} +
+ )} + + {/* Stats */} + {stats && ( +
+ Issues: {stats.issuesCreated} + PRs: {stats.prsCreated} + Reviews: {stats.reviewsDone} +
+ )} +
+ ); +} diff --git a/web/src/components/team/github-activity-feed.tsx b/web/src/components/team/github-activity-feed.tsx new file mode 100644 index 0000000..0caee02 --- /dev/null +++ b/web/src/components/team/github-activity-feed.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { GitHubActivityItem } from "@/lib/types"; + +interface GitHubActivityFeedProps { + activities: GitHubActivityItem[]; +} + +const activityIcons: Record = { + issue_created: "\u{1F4DD}", + issue_commented: "\u{1F4AC}", + issue_closed: "\u2705", + pr_created: "\u{1F500}", + pr_reviewed: "\u{1F50D}", + pr_commented: "\u{1F4AC}", + pr_merged: "\u{1F7E2}", + branch_created: "\u{1F33F}", +}; + +const activityLabels: Record = { + issue_created: "created issue", + issue_commented: "commented on issue", + issue_closed: "closed issue", + pr_created: "opened PR", + pr_reviewed: "reviewed PR", + pr_commented: "commented on PR", + pr_merged: "merged PR", + branch_created: "created branch", +}; + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +export default function GitHubActivityFeed({ + activities, +}: GitHubActivityFeedProps) { + if (activities.length === 0) { + return ( +
+ No GitHub activity yet. +
+ ); + } + + return ( +
+ {activities.map((act) => ( +
+ + {activityIcons[act.activity_type] || "\u{1F4CB}"} + +
+
+ + {act.persona_name} + + + {activityLabels[act.activity_type] || act.activity_type} + + {act.target_number && ( + + #{act.target_number} + + )} +
+

+ {act.title} +

+ {act.body_preview && ( +

+ {act.body_preview} +

+ )} +
+ + {timeAgo(act.created_at)} + + {act.github_url && ( + + View on GitHub + + )} +
+
+
+ ))} +
+ ); +} diff --git a/web/src/components/team/interaction-graph.tsx b/web/src/components/team/interaction-graph.tsx new file mode 100644 index 0000000..36c6761 --- /dev/null +++ b/web/src/components/team/interaction-graph.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { GitHubActivityItem } from "@/lib/types"; + +interface InteractionGraphProps { + activities: GitHubActivityItem[]; + personas: string[]; +} + +interface InteractionEdge { + from: string; + to: string; + count: number; + types: string[]; +} + +function buildInteractions( + activities: GitHubActivityItem[], + personas: string[] +): InteractionEdge[] { + const edgeMap = new Map(); + + // Group sequential activities to infer interactions. + // e.g. PM creates issue, then Tech Lead comments -> PM -> Tech Lead edge + for (let i = 0; i < activities.length - 1; i++) { + const a = activities[i]; + const b = activities[i + 1]; + if ( + a.persona_name !== b.persona_name && + a.target_number === b.target_number && + a.target_number !== null + ) { + const key = `${a.persona_name}->${b.persona_name}`; + const existing = edgeMap.get(key); + if (existing) { + existing.count++; + if (!existing.types.includes(b.activity_type)) { + existing.types.push(b.activity_type); + } + } else { + edgeMap.set(key, { + from: a.persona_name, + to: b.persona_name, + count: 1, + types: [b.activity_type], + }); + } + } + } + + return Array.from(edgeMap.values()); +} + +const COLORS = [ + "#60a5fa", + "#34d399", + "#fbbf24", + "#f87171", + "#a78bfa", + "#fb923c", + "#2dd4bf", +]; + +export default function InteractionGraph({ + activities, + personas, +}: InteractionGraphProps) { + const interactions = buildInteractions(activities, personas); + const width = 600; + const height = 400; + const cx = width / 2; + const cy = height / 2; + const radius = 150; + + // Position personas in a circle + const positions = personas.map((name, i) => { + const angle = (2 * Math.PI * i) / personas.length - Math.PI / 2; + return { + name, + x: cx + radius * Math.cos(angle), + y: cy + radius * Math.sin(angle), + color: COLORS[i % COLORS.length], + }; + }); + + const posMap = new Map(positions.map((p) => [p.name, p])); + + if (personas.length === 0) { + return ( +
+ No team interactions to display. +
+ ); + } + + return ( + + {/* Edges */} + {interactions.map((edge, i) => { + const from = posMap.get(edge.from); + const to = posMap.get(edge.to); + if (!from || !to) return null; + const strokeWidth = Math.min(1 + edge.count * 0.8, 5); + return ( + + ); + })} + + {/* Arrow marker */} + + + + + + + {/* Nodes */} + {positions.map((p) => ( + + + + {p.name.charAt(0)} + + + {p.name} + + + ))} + + ); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 61c817d..9db3b35 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -14,7 +14,10 @@ export type RunActionType = | "coder_session_started" | "coder_session_completed" | "validation_passed" | "validation_failed" | "git_commit_created" | "git_push_completed" | "repo_clone_completed" | "repo_analysis_completed" - | "interactive_step"; + | "interactive_step" + | "github_issue_created" | "github_issue_commented" | "github_issue_closed" + | "github_pr_created" | "github_pr_reviewed" | "github_pr_commented" + | "github_pr_merged" | "github_branch_created"; export interface RunRequest { task: string; @@ -283,3 +286,51 @@ export interface WorkflowTemplate { graph_template: WorkflowGraphTemplate; parameters: WorkflowParameter[]; } + +// --- Team Persona Types --- + +export interface PersonalityTraits { + thoroughness: number; + creativity: number; + strictness: number; + verbosity: number; +} + +export interface AgentPersona { + display_name: string; + title: string; + github_username: string; + avatar_url?: string; + bio: string; + personality: PersonalityTraits; + expertise: string[]; + communication_style: string; +} + +export interface TeamMember { + name: string; + description: string; + role: AgentRole; + task_profile: TaskProfile; + capabilities: string[]; + persona: AgentPersona; +} + +export type GitHubActivityType = + | "issue_created" | "issue_commented" | "issue_closed" + | "pr_created" | "pr_reviewed" | "pr_commented" + | "pr_merged" | "branch_created"; + +export interface GitHubActivityItem { + id: string; + run_id: string; + session_id: string; + persona_name: string; + activity_type: GitHubActivityType; + github_url: string | null; + target_number: number | null; + title: string; + body_preview: string; + metadata: string; + created_at: string; +}