task: task-1753636924-a1d4c708 - created
Change-Id: Ic78528c47ae38114b9b7504f1c4a76f95e93eb13
diff --git a/server/agent/simple_manager.go b/server/agent/simple_manager.go
new file mode 100644
index 0000000..27d522d
--- /dev/null
+++ b/server/agent/simple_manager.go
@@ -0,0 +1,565 @@
+package agent
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/iomodo/staff/assignment"
+ "github.com/iomodo/staff/config"
+ "github.com/iomodo/staff/git"
+ "github.com/iomodo/staff/llm"
+ "github.com/iomodo/staff/tm"
+)
+
+// SimpleAgent represents a simplified AI agent for MVP
+type SimpleAgent struct {
+ Name string
+ Role string
+ Model string
+ SystemPrompt string
+ Provider llm.LLMProvider
+ MaxTokens *int
+ Temperature *float64
+}
+
+// SimpleAgentManager manages multiple AI agents with basic Git operations
+type SimpleAgentManager struct {
+ config *config.Config
+ agents map[string]*SimpleAgent
+ taskManager tm.TaskManager
+ autoAssigner *assignment.AutoAssigner
+ prProvider git.PullRequestProvider
+ cloneManager *git.CloneManager
+ isRunning map[string]bool
+ stopChannels map[string]chan struct{}
+}
+
+// NewSimpleAgentManager creates a simplified agent manager
+func NewSimpleAgentManager(cfg *config.Config, taskManager tm.TaskManager) (*SimpleAgentManager, error) {
+ // Create auto-assigner
+ autoAssigner := assignment.NewAutoAssigner(cfg.Agents)
+
+ // Create GitHub PR provider
+ githubConfig := git.GitHubConfig{
+ Token: cfg.GitHub.Token,
+ }
+ prProvider := git.NewGitHubPullRequestProvider(cfg.GitHub.Owner, cfg.GitHub.Repo, githubConfig)
+
+ // Create clone manager for per-agent Git repositories
+ repoURL := fmt.Sprintf("https://github.com/%s/%s.git", cfg.GitHub.Owner, cfg.GitHub.Repo)
+ workspacePath := filepath.Join(".", "workspace")
+ cloneManager := git.NewCloneManager(repoURL, workspacePath)
+
+ manager := &SimpleAgentManager{
+ config: cfg,
+ agents: make(map[string]*SimpleAgent),
+ taskManager: taskManager,
+ autoAssigner: autoAssigner,
+ prProvider: prProvider,
+ cloneManager: cloneManager,
+ isRunning: make(map[string]bool),
+ stopChannels: make(map[string]chan struct{}),
+ }
+
+ // Initialize agents
+ if err := manager.initializeAgents(); err != nil {
+ return nil, fmt.Errorf("failed to initialize agents: %w", err)
+ }
+
+ return manager, nil
+}
+
+// initializeAgents creates agent instances from configuration
+func (am *SimpleAgentManager) initializeAgents() error {
+ for _, agentConfig := range am.config.Agents {
+ agent, err := am.createAgent(agentConfig)
+ if err != nil {
+ return fmt.Errorf("failed to create agent %s: %w", agentConfig.Name, err)
+ }
+ am.agents[agentConfig.Name] = agent
+ }
+ return nil
+}
+
+// createAgent creates a single agent instance
+func (am *SimpleAgentManager) createAgent(agentConfig config.AgentConfig) (*SimpleAgent, error) {
+ // Load system prompt
+ systemPrompt, err := am.loadSystemPrompt(agentConfig.SystemPromptFile)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load system prompt: %w", err)
+ }
+
+ // Create LLM provider
+ llmConfig := llm.Config{
+ Provider: llm.ProviderOpenAI,
+ APIKey: am.config.OpenAI.APIKey,
+ BaseURL: am.config.OpenAI.BaseURL,
+ Timeout: am.config.OpenAI.Timeout,
+ }
+
+ provider, err := llm.CreateProvider(llmConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create LLM provider: %w", err)
+ }
+
+ agent := &SimpleAgent{
+ Name: agentConfig.Name,
+ Role: agentConfig.Role,
+ Model: agentConfig.Model,
+ SystemPrompt: systemPrompt,
+ Provider: provider,
+ MaxTokens: agentConfig.MaxTokens,
+ Temperature: agentConfig.Temperature,
+ }
+
+ return agent, nil
+}
+
+// loadSystemPrompt loads the system prompt from file
+func (am *SimpleAgentManager) loadSystemPrompt(filePath string) (string, error) {
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to read system prompt file %s: %w", filePath, err)
+ }
+ return string(content), nil
+}
+
+// StartAgent starts an agent to process tasks in a loop
+func (am *SimpleAgentManager) StartAgent(agentName string, loopInterval time.Duration) error {
+ agent, exists := am.agents[agentName]
+ if !exists {
+ return fmt.Errorf("agent %s not found", agentName)
+ }
+
+ if am.isRunning[agentName] {
+ return fmt.Errorf("agent %s is already running", agentName)
+ }
+
+ stopChan := make(chan struct{})
+ am.stopChannels[agentName] = stopChan
+ am.isRunning[agentName] = true
+
+ go am.runAgentLoop(agent, loopInterval, stopChan)
+
+ log.Printf("Started agent %s (%s) with %s model", agentName, agent.Role, agent.Model)
+ return nil
+}
+
+// StopAgent stops a running agent
+func (am *SimpleAgentManager) StopAgent(agentName string) error {
+ if !am.isRunning[agentName] {
+ return fmt.Errorf("agent %s is not running", agentName)
+ }
+
+ close(am.stopChannels[agentName])
+ delete(am.stopChannels, agentName)
+ am.isRunning[agentName] = false
+
+ log.Printf("Stopped agent %s", agentName)
+ return nil
+}
+
+// runAgentLoop runs the main processing loop for an agent
+func (am *SimpleAgentManager) runAgentLoop(agent *SimpleAgent, interval time.Duration, stopChan <-chan struct{}) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-stopChan:
+ log.Printf("Agent %s stopping", agent.Name)
+ return
+ case <-ticker.C:
+ if err := am.processAgentTasks(agent); err != nil {
+ log.Printf("Error processing tasks for agent %s: %v", agent.Name, err)
+ }
+ }
+ }
+}
+
+// processAgentTasks processes all assigned tasks for an agent
+func (am *SimpleAgentManager) processAgentTasks(agent *SimpleAgent) error {
+ // Get tasks assigned to this agent
+ tasks, err := am.taskManager.GetTasksByAssignee(agent.Name)
+ if err != nil {
+ return fmt.Errorf("failed to get tasks for agent %s: %w", agent.Name, err)
+ }
+
+ for _, task := range tasks {
+ if task.Status == tm.StatusPending || task.Status == tm.StatusInProgress {
+ if err := am.processTask(agent, task); err != nil {
+ log.Printf("Error processing task %s: %v", task.ID, err)
+ // Mark task as failed
+ task.Status = tm.StatusFailed
+ if err := am.taskManager.UpdateTask(task); err != nil {
+ log.Printf("Error updating failed task %s: %v", task.ID, err)
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// processTask processes a single task with an agent
+func (am *SimpleAgentManager) processTask(agent *SimpleAgent, task *tm.Task) error {
+ ctx := context.Background()
+
+ log.Printf("Agent %s processing task %s: %s", agent.Name, task.ID, task.Title)
+
+ // Mark task as in progress
+ task.Status = tm.StatusInProgress
+ if err := am.taskManager.UpdateTask(task); err != nil {
+ return fmt.Errorf("failed to update task status: %w", err)
+ }
+
+ // Generate solution using LLM
+ solution, err := am.generateSolution(ctx, agent, task)
+ if err != nil {
+ return fmt.Errorf("failed to generate solution: %w", err)
+ }
+
+ // Create Git branch and commit solution
+ branchName := am.generateBranchName(task)
+ if err := am.createAndCommitSolution(branchName, task, solution, agent); err != nil {
+ return fmt.Errorf("failed to commit solution: %w", err)
+ }
+
+ // Create pull request
+ prURL, err := am.createPullRequest(ctx, task, solution, agent, branchName)
+ if err != nil {
+ return fmt.Errorf("failed to create pull request: %w", err)
+ }
+
+ // Update task as completed
+ task.Status = tm.StatusCompleted
+ task.Solution = solution
+ task.PullRequestURL = prURL
+ task.CompletedAt = &time.Time{}
+ *task.CompletedAt = time.Now()
+
+ if err := am.taskManager.UpdateTask(task); err != nil {
+ return fmt.Errorf("failed to update completed task: %w", err)
+ }
+
+ log.Printf("Task %s completed by agent %s. PR: %s", task.ID, agent.Name, prURL)
+ return nil
+}
+
+// generateSolution uses the agent's LLM to generate a solution
+func (am *SimpleAgentManager) generateSolution(ctx context.Context, agent *SimpleAgent, task *tm.Task) (string, error) {
+ prompt := am.buildTaskPrompt(task)
+
+ req := llm.ChatCompletionRequest{
+ Model: agent.Model,
+ Messages: []llm.Message{
+ {
+ Role: llm.RoleSystem,
+ Content: agent.SystemPrompt,
+ },
+ {
+ Role: llm.RoleUser,
+ Content: prompt,
+ },
+ },
+ MaxTokens: agent.MaxTokens,
+ Temperature: agent.Temperature,
+ }
+
+ resp, err := agent.Provider.ChatCompletion(ctx, req)
+ if err != nil {
+ return "", fmt.Errorf("LLM request failed: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return "", fmt.Errorf("no response from LLM")
+ }
+
+ return resp.Choices[0].Message.Content, nil
+}
+
+// buildTaskPrompt creates a detailed prompt for the LLM
+func (am *SimpleAgentManager) buildTaskPrompt(task *tm.Task) string {
+ return fmt.Sprintf(`Task: %s
+
+Priority: %s
+Description: %s
+
+Please provide a complete solution for this task. Include:
+1. Detailed implementation plan
+2. Code changes needed (if applicable)
+3. Files to be created or modified
+4. Testing considerations
+5. Any dependencies or prerequisites
+
+Your response should be comprehensive and actionable.`,
+ task.Title,
+ task.Priority,
+ task.Description)
+}
+
+// generateBranchName creates a Git branch name for the task
+func (am *SimpleAgentManager) generateBranchName(task *tm.Task) string {
+ // Clean title for use in branch name
+ cleanTitle := strings.ToLower(task.Title)
+ cleanTitle = strings.ReplaceAll(cleanTitle, " ", "-")
+ cleanTitle = strings.ReplaceAll(cleanTitle, "/", "-")
+ // Remove special characters
+ var result strings.Builder
+ for _, r := range cleanTitle {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
+ result.WriteRune(r)
+ }
+ }
+ cleanTitle = result.String()
+
+ // Limit length
+ if len(cleanTitle) > 40 {
+ cleanTitle = cleanTitle[:40]
+ }
+
+ return fmt.Sprintf("%s%s-%s", am.config.Git.BranchPrefix, task.ID, cleanTitle)
+}
+
+// createAndCommitSolution creates a Git branch and commits the solution using per-agent clones
+// Each agent works in its own Git clone, eliminating concurrency issues
+func (am *SimpleAgentManager) createAndCommitSolution(branchName string, task *tm.Task, solution string, agent *SimpleAgent) error {
+ ctx := context.Background()
+
+ // Get agent's dedicated Git clone
+ clonePath, err := am.cloneManager.GetAgentClonePath(agent.Name)
+ if err != nil {
+ return fmt.Errorf("failed to get agent clone: %w", err)
+ }
+
+ log.Printf("Agent %s working in clone: %s", agent.Name, clonePath)
+
+ // Refresh the clone with latest changes
+ if err := am.cloneManager.RefreshAgentClone(agent.Name); err != nil {
+ log.Printf("Warning: Failed to refresh clone for agent %s: %v", agent.Name, err)
+ }
+
+ // All Git operations use the agent's clone directory
+ gitCmd := func(args ...string) *exec.Cmd {
+ return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
+ }
+
+ // Ensure we're on main branch before creating new branch
+ cmd := gitCmd("checkout", "main")
+ if err := cmd.Run(); err != nil {
+ // Try master branch if main doesn't exist
+ cmd = gitCmd("checkout", "master")
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to checkout main/master branch: %w", err)
+ }
+ }
+
+ // Create branch
+ cmd = gitCmd("checkout", "-b", branchName)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to create branch: %w", err)
+ }
+
+ // Create solution file in agent's clone
+ solutionDir := filepath.Join(clonePath, "tasks", "solutions")
+ if err := os.MkdirAll(solutionDir, 0755); err != nil {
+ return fmt.Errorf("failed to create solution directory: %w", err)
+ }
+
+ solutionFile := filepath.Join(solutionDir, fmt.Sprintf("%s-solution.md", task.ID))
+ solutionContent := fmt.Sprintf(`# Solution for Task: %s
+
+**Agent:** %s (%s)
+**Model:** %s
+**Completed:** %s
+
+## Task Description
+%s
+
+## Solution
+%s
+
+---
+*Generated by Staff AI Agent System*
+`, task.Title, agent.Name, agent.Role, agent.Model, time.Now().Format(time.RFC3339), task.Description, solution)
+
+ if err := os.WriteFile(solutionFile, []byte(solutionContent), 0644); err != nil {
+ return fmt.Errorf("failed to write solution file: %w", err)
+ }
+
+ // Stage files
+ relativeSolutionFile := filepath.Join("tasks", "solutions", fmt.Sprintf("%s-solution.md", task.ID))
+ cmd = gitCmd("add", relativeSolutionFile)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to stage files: %w", err)
+ }
+
+ // Commit changes
+ commitMsg := am.buildCommitMessage(task, agent)
+ cmd = gitCmd("commit", "-m", commitMsg)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to commit: %w", err)
+ }
+
+ // Push branch
+ cmd = gitCmd("push", "-u", "origin", branchName)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to push branch: %w", err)
+ }
+
+ log.Printf("Agent %s successfully pushed branch %s", agent.Name, branchName)
+ return nil
+}
+
+// buildCommitMessage creates a commit message from template
+func (am *SimpleAgentManager) buildCommitMessage(task *tm.Task, agent *SimpleAgent) string {
+ template := am.config.Git.CommitMessageTemplate
+
+ replacements := map[string]string{
+ "{task_id}": task.ID,
+ "{task_title}": task.Title,
+ "{agent_name}": agent.Name,
+ "{solution}": "See solution file for details",
+ }
+
+ result := template
+ for placeholder, value := range replacements {
+ result = strings.ReplaceAll(result, placeholder, value)
+ }
+
+ return result
+}
+
+// createPullRequest creates a GitHub pull request
+func (am *SimpleAgentManager) createPullRequest(ctx context.Context, task *tm.Task, solution string, agent *SimpleAgent, branchName string) (string, error) {
+ title := fmt.Sprintf("Task %s: %s", task.ID, task.Title)
+
+ // Build PR description from template
+ description := am.buildPRDescription(task, solution, agent)
+
+ options := git.PullRequestOptions{
+ Title: title,
+ Description: description,
+ HeadBranch: branchName,
+ BaseBranch: "main",
+ Labels: []string{"ai-generated", "staff-agent", strings.ToLower(agent.Role)},
+ Draft: false,
+ }
+
+ pr, err := am.prProvider.CreatePullRequest(ctx, options)
+ if err != nil {
+ return "", fmt.Errorf("failed to create PR: %w", err)
+ }
+
+ return fmt.Sprintf("https://github.com/%s/%s/pull/%d", am.config.GitHub.Owner, am.config.GitHub.Repo, pr.Number), nil
+}
+
+// buildPRDescription creates PR description from template
+func (am *SimpleAgentManager) buildPRDescription(task *tm.Task, solution string, agent *SimpleAgent) string {
+ template := am.config.Git.PRTemplate
+
+ // Truncate solution for PR if too long
+ truncatedSolution := solution
+ if len(solution) > 1000 {
+ truncatedSolution = solution[:1000] + "...\n\n*See solution file for complete details*"
+ }
+
+ replacements := map[string]string{
+ "{task_id}": task.ID,
+ "{task_title}": task.Title,
+ "{task_description}": task.Description,
+ "{agent_name}": fmt.Sprintf("%s (%s)", agent.Name, agent.Role),
+ "{priority}": string(task.Priority),
+ "{solution}": truncatedSolution,
+ "{files_changed}": fmt.Sprintf("- `tasks/solutions/%s-solution.md`", task.ID),
+ }
+
+ result := template
+ for placeholder, value := range replacements {
+ result = strings.ReplaceAll(result, placeholder, value)
+ }
+
+ return result
+}
+
+// AutoAssignTask automatically assigns a task to the best matching agent
+func (am *SimpleAgentManager) AutoAssignTask(taskID string) error {
+ task, err := am.taskManager.GetTask(taskID)
+ if err != nil {
+ return fmt.Errorf("failed to get task: %w", err)
+ }
+
+ agentName, err := am.autoAssigner.AssignTask(task)
+ if err != nil {
+ return fmt.Errorf("failed to auto-assign task: %w", err)
+ }
+
+ task.Assignee = agentName
+ if err := am.taskManager.UpdateTask(task); err != nil {
+ return fmt.Errorf("failed to update task assignment: %w", err)
+ }
+
+ explanation := am.autoAssigner.GetRecommendationExplanation(task, agentName)
+ log.Printf("Auto-assigned task %s to %s: %s", taskID, agentName, explanation)
+
+ return nil
+}
+
+// GetAgentStatus returns the status of all agents
+func (am *SimpleAgentManager) GetAgentStatus() map[string]SimpleAgentStatus {
+ status := make(map[string]SimpleAgentStatus)
+
+ for name, agent := range am.agents {
+ status[name] = SimpleAgentStatus{
+ Name: agent.Name,
+ Role: agent.Role,
+ Model: agent.Model,
+ IsRunning: am.isRunning[name],
+ }
+ }
+
+ return status
+}
+
+// SimpleAgentStatus represents the status of an agent
+type SimpleAgentStatus struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ Model string `json:"model"`
+ IsRunning bool `json:"is_running"`
+}
+
+// IsAgentRunning checks if an agent is currently running
+func (am *SimpleAgentManager) IsAgentRunning(agentName string) bool {
+ return am.isRunning[agentName]
+}
+
+// Close shuts down the agent manager
+func (am *SimpleAgentManager) Close() error {
+ // Stop all running agents
+ for agentName := range am.isRunning {
+ if am.isRunning[agentName] {
+ am.StopAgent(agentName)
+ }
+ }
+
+ // Close all LLM providers
+ for _, agent := range am.agents {
+ if err := agent.Provider.Close(); err != nil {
+ log.Printf("Error closing provider for agent %s: %v", agent.Name, err)
+ }
+ }
+
+ // Cleanup all agent Git clones
+ if err := am.cloneManager.CleanupAllClones(); err != nil {
+ log.Printf("Error cleaning up agent clones: %v", err)
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/server/assignment/auto_assignment.go b/server/assignment/auto_assignment.go
new file mode 100644
index 0000000..ed01be8
--- /dev/null
+++ b/server/assignment/auto_assignment.go
@@ -0,0 +1,237 @@
+package assignment
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/iomodo/staff/config"
+ "github.com/iomodo/staff/tm"
+)
+
+// AutoAssigner handles intelligent task assignment to agents
+type AutoAssigner struct {
+ agents []config.AgentConfig
+}
+
+// NewAutoAssigner creates a new auto-assignment system
+func NewAutoAssigner(agents []config.AgentConfig) *AutoAssigner {
+ return &AutoAssigner{
+ agents: agents,
+ }
+}
+
+// AssignmentScore represents how well an agent matches a task
+type AssignmentScore struct {
+ AgentName string
+ Score float64
+ Reasons []string
+}
+
+// AssignTask automatically assigns a task to the best matching agent
+func (a *AutoAssigner) AssignTask(task *tm.Task) (string, error) {
+ if len(a.agents) == 0 {
+ return "", fmt.Errorf("no agents available for assignment")
+ }
+
+ scores := a.calculateScores(task)
+ if len(scores) == 0 {
+ return "", fmt.Errorf("no suitable agent found for task")
+ }
+
+ // Sort by score (highest first)
+ sort.Slice(scores, func(i, j int) bool {
+ return scores[i].Score > scores[j].Score
+ })
+
+ bestMatch := scores[0]
+ if bestMatch.Score == 0 {
+ // No good match, assign to CEO as fallback
+ return "ceo", nil
+ }
+
+ return bestMatch.AgentName, nil
+}
+
+// GetAssignmentRecommendations returns ranked recommendations for task assignment
+func (a *AutoAssigner) GetAssignmentRecommendations(task *tm.Task) []AssignmentScore {
+ scores := a.calculateScores(task)
+
+ // Sort by score (highest first)
+ sort.Slice(scores, func(i, j int) bool {
+ return scores[i].Score > scores[j].Score
+ })
+
+ return scores
+}
+
+// calculateScores calculates assignment scores for all agents
+func (a *AutoAssigner) calculateScores(task *tm.Task) []AssignmentScore {
+ scores := make([]AssignmentScore, 0, len(a.agents))
+
+ taskText := strings.ToLower(task.Title + " " + task.Description)
+ taskKeywords := extractKeywords(taskText)
+
+ for _, agent := range a.agents {
+ score := &AssignmentScore{
+ AgentName: agent.Name,
+ Score: 0,
+ Reasons: make([]string, 0),
+ }
+
+ // Score based on task type keywords
+ score.Score += a.scoreTaskTypes(agent.TaskTypes, taskKeywords, score)
+
+ // Score based on capabilities
+ score.Score += a.scoreCapabilities(agent.Capabilities, taskKeywords, score)
+
+ // Score based on task priority and agent model
+ score.Score += a.scorePriorityModelMatch(task.Priority, agent.Model, score)
+
+ // Score based on explicit agent mention in task
+ score.Score += a.scoreExplicitMention(agent.Name, agent.Role, taskText, score)
+
+ scores = append(scores, *score)
+ }
+
+ return scores
+}
+
+// scoreTaskTypes scores based on task type matching
+func (a *AutoAssigner) scoreTaskTypes(agentTypes []string, taskKeywords []string, score *AssignmentScore) float64 {
+ typeScore := 0.0
+
+ for _, agentType := range agentTypes {
+ for _, keyword := range taskKeywords {
+ if strings.Contains(keyword, agentType) || strings.Contains(agentType, keyword) {
+ typeScore += 3.0
+ score.Reasons = append(score.Reasons, fmt.Sprintf("matches task type: %s", agentType))
+ }
+ }
+ }
+
+ return typeScore
+}
+
+// scoreCapabilities scores based on capability matching
+func (a *AutoAssigner) scoreCapabilities(capabilities []string, taskKeywords []string, score *AssignmentScore) float64 {
+ capScore := 0.0
+
+ for _, capability := range capabilities {
+ for _, keyword := range taskKeywords {
+ if strings.Contains(keyword, capability) || strings.Contains(capability, keyword) {
+ capScore += 2.0
+ score.Reasons = append(score.Reasons, fmt.Sprintf("has capability: %s", capability))
+ }
+ }
+ }
+
+ return capScore
+}
+
+// scorePriorityModelMatch scores based on priority and model sophistication
+func (a *AutoAssigner) scorePriorityModelMatch(priority tm.TaskPriority, model string, score *AssignmentScore) float64 {
+ priorityScore := 0.0
+
+ // High priority tasks prefer more capable models
+ if priority == tm.PriorityHigh && strings.Contains(model, "gpt-4") {
+ priorityScore += 1.0
+ score.Reasons = append(score.Reasons, "high priority task matches advanced model")
+ }
+
+ // Medium/low priority can use efficient models
+ if priority != tm.PriorityHigh && strings.Contains(model, "gpt-3.5") {
+ priorityScore += 0.5
+ score.Reasons = append(score.Reasons, "priority matches model efficiency")
+ }
+
+ return priorityScore
+}
+
+// scoreExplicitMention scores based on explicit agent/role mentions
+func (a *AutoAssigner) scoreExplicitMention(agentName, agentRole, taskText string, score *AssignmentScore) float64 {
+ mentionScore := 0.0
+
+ // Check for explicit agent name mention
+ if strings.Contains(taskText, agentName) {
+ mentionScore += 5.0
+ score.Reasons = append(score.Reasons, "explicitly mentioned by name")
+ }
+
+ // Check for role mention
+ roleLower := strings.ToLower(agentRole)
+ if strings.Contains(taskText, roleLower) {
+ mentionScore += 4.0
+ score.Reasons = append(score.Reasons, "role mentioned in task")
+ }
+
+ return mentionScore
+}
+
+// extractKeywords extracts relevant keywords from task text
+func extractKeywords(text string) []string {
+ // Simple keyword extraction - split by common delimiters
+ words := strings.FieldsFunc(text, func(c rune) bool {
+ return c == ' ' || c == ',' || c == '.' || c == ':' || c == ';' || c == '\n' || c == '\t'
+ })
+
+ // Filter out common stop words and short words
+ stopWords := map[string]bool{
+ "the": true, "a": true, "an": true, "and": true, "or": true, "but": true,
+ "in": true, "on": true, "at": true, "to": true, "for": true, "of": true,
+ "with": true, "by": true, "is": true, "are": true, "was": true, "were": true,
+ "be": true, "been": true, "have": true, "has": true, "had": true, "do": true,
+ "does": true, "did": true, "will": true, "would": true, "could": true, "should": true,
+ }
+
+ keywords := make([]string, 0)
+ for _, word := range words {
+ word = strings.ToLower(strings.TrimSpace(word))
+ if len(word) > 2 && !stopWords[word] {
+ keywords = append(keywords, word)
+ }
+ }
+
+ return keywords
+}
+
+// ValidateAssignment checks if an assignment is valid
+func (a *AutoAssigner) ValidateAssignment(agentName string, task *tm.Task) error {
+ // Check if agent exists
+ for _, agent := range a.agents {
+ if agent.Name == agentName {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("agent '%s' not found", agentName)
+}
+
+// GetAgentCapabilities returns the capabilities of a specific agent
+func (a *AutoAssigner) GetAgentCapabilities(agentName string) ([]string, error) {
+ for _, agent := range a.agents {
+ if agent.Name == agentName {
+ return agent.Capabilities, nil
+ }
+ }
+
+ return nil, fmt.Errorf("agent '%s' not found", agentName)
+}
+
+// GetRecommendationExplanation returns a human-readable explanation for assignment
+func (a *AutoAssigner) GetRecommendationExplanation(task *tm.Task, agentName string) string {
+ recommendations := a.GetAssignmentRecommendations(task)
+
+ for _, rec := range recommendations {
+ if rec.AgentName == agentName {
+ if len(rec.Reasons) == 0 {
+ return fmt.Sprintf("Agent %s assigned (score: %.1f)", agentName, rec.Score)
+ }
+
+ reasons := strings.Join(rec.Reasons, ", ")
+ return fmt.Sprintf("Agent %s assigned (score: %.1f) because: %s", agentName, rec.Score, reasons)
+ }
+ }
+
+ return fmt.Sprintf("Agent %s assigned (manual override)", agentName)
+}
\ No newline at end of file
diff --git a/server/cmd/commands/assign_task.go b/server/cmd/commands/assign_task.go
new file mode 100644
index 0000000..4bb6f75
--- /dev/null
+++ b/server/cmd/commands/assign_task.go
@@ -0,0 +1,43 @@
+package commands
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+var assignTaskCmd = &cobra.Command{
+ Use: "assign-task [task-id] [agent-name]",
+ Short: "Assign a task to an agent",
+ Long: `Assign an existing task to a specific agent.
+
+Examples:
+ staff assign-task task-1234567890-abcd1234 backend-engineer
+ staff assign-task task-1234567890-abcd1234 frontend-engineer`,
+ Args: cobra.ExactArgs(2),
+ RunE: runAssignTask,
+}
+
+func runAssignTask(cmd *cobra.Command, args []string) error {
+ taskID := args[0]
+ agentName := args[1]
+
+ // Get the task
+ task, err := taskManager.GetTask(taskID)
+ if err != nil {
+ return fmt.Errorf("failed to get task: %w", err)
+ }
+
+ // Assign the task
+ task.Assignee = agentName
+ if err := taskManager.UpdateTask(task); err != nil {
+ return fmt.Errorf("failed to assign task: %w", err)
+ }
+
+ fmt.Printf("Task %s assigned to %s successfully!\n", taskID, agentName)
+ fmt.Printf("Title: %s\n", task.Title)
+ fmt.Printf("Priority: %s\n", task.Priority)
+ fmt.Printf("Status: %s\n", task.Status)
+
+ return nil
+}
\ No newline at end of file
diff --git a/server/cmd/commands/cleanup_clones.go b/server/cmd/commands/cleanup_clones.go
new file mode 100644
index 0000000..344c4f3
--- /dev/null
+++ b/server/cmd/commands/cleanup_clones.go
@@ -0,0 +1,53 @@
+package commands
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+var cleanupClonesCmd = &cobra.Command{
+ Use: "cleanup-clones",
+ Short: "Clean up all agent Git clones",
+ Long: `Remove all agent Git clone directories to free up disk space.
+
+This command will:
+- Stop any running agents
+- Remove all agent-specific Git clone directories
+- Free up disk space used by clones
+
+Examples:
+ staff cleanup-clones`,
+ RunE: runCleanupClones,
+}
+
+// Note: Command is added in root.go init() function
+
+func runCleanupClones(cmd *cobra.Command, args []string) error {
+ if agentManager == nil {
+ return fmt.Errorf("agent manager not initialized")
+ }
+
+ // Stop all running agents first
+ fmt.Println("Stopping all running agents...")
+ for _, agent := range cfg.Agents {
+ if agentManager.IsAgentRunning(agent.Name) {
+ if err := agentManager.StopAgent(agent.Name); err != nil {
+ fmt.Printf("Warning: Failed to stop agent %s: %v\n", agent.Name, err)
+ } else {
+ fmt.Printf("Stopped agent: %s\n", agent.Name)
+ }
+ }
+ }
+
+ // Cleanup all clones by closing the agent manager
+ // This will trigger the cleanup automatically
+ if err := agentManager.Close(); err != nil {
+ return fmt.Errorf("failed to cleanup agent clones: %w", err)
+ }
+
+ fmt.Println("โ
All agent Git clones have been cleaned up successfully!")
+ fmt.Println("๐ก Clones will be recreated automatically when agents start working on tasks")
+
+ return nil
+}
\ No newline at end of file
diff --git a/server/cmd/commands/config_check.go b/server/cmd/commands/config_check.go
new file mode 100644
index 0000000..4813ee8
--- /dev/null
+++ b/server/cmd/commands/config_check.go
@@ -0,0 +1,81 @@
+package commands
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+var configCheckCmd = &cobra.Command{
+ Use: "config-check",
+ Short: "Check configuration validity",
+ Long: `Check the current configuration for errors and display settings.
+
+Examples:
+ staff config-check`,
+ RunE: runConfigCheck,
+}
+
+func runConfigCheck(cmd *cobra.Command, args []string) error {
+ fmt.Println("Configuration Check:")
+ fmt.Println("==================")
+
+ // Check OpenAI configuration
+ if cfg.OpenAI.APIKey == "" {
+ fmt.Println("โ OpenAI API key is missing")
+ } else {
+ fmt.Printf("โ
OpenAI API key configured (ends with: ...%s)\n", cfg.OpenAI.APIKey[len(cfg.OpenAI.APIKey)-4:])
+ }
+
+ if cfg.OpenAI.BaseURL == "" {
+ fmt.Println("โน๏ธ OpenAI Base URL using default")
+ } else {
+ fmt.Printf("โน๏ธ OpenAI Base URL: %s\n", cfg.OpenAI.BaseURL)
+ }
+
+ // Check GitHub configuration
+ if cfg.GitHub.Token == "" {
+ fmt.Println("โ GitHub token is missing")
+ } else {
+ fmt.Printf("โ
GitHub token configured (ends with: ...%s)\n", cfg.GitHub.Token[len(cfg.GitHub.Token)-4:])
+ }
+
+ if cfg.GitHub.Owner == "" {
+ fmt.Println("โ GitHub owner is missing")
+ } else {
+ fmt.Printf("โ
GitHub owner: %s\n", cfg.GitHub.Owner)
+ }
+
+ if cfg.GitHub.Repo == "" {
+ fmt.Println("โ GitHub repo is missing")
+ } else {
+ fmt.Printf("โ
GitHub repo: %s\n", cfg.GitHub.Repo)
+ }
+
+ // Check agents configuration
+ fmt.Printf("\nAgents: %d configured\n", len(cfg.Agents))
+ for i, agent := range cfg.Agents {
+ temp := 0.7
+ if agent.Temperature != nil {
+ temp = *agent.Temperature
+ }
+ fmt.Printf(" %d. %s (model: %s, temp: %.1f)\n", i+1, agent.Name, agent.Model, temp)
+ }
+
+ // Check task manager
+ if taskManager == nil {
+ fmt.Println("โ Task manager not initialized")
+ } else {
+ fmt.Println("โ
Task manager initialized")
+ }
+
+ // Check agent manager
+ if agentManager == nil {
+ fmt.Println("โ Agent manager not initialized")
+ } else {
+ fmt.Println("โ
Agent manager initialized")
+ }
+
+ fmt.Println("\nConfiguration check complete!")
+ return nil
+}
\ No newline at end of file
diff --git a/server/cmd/commands/create_task.go b/server/cmd/commands/create_task.go
new file mode 100644
index 0000000..4bb57d9
--- /dev/null
+++ b/server/cmd/commands/create_task.go
@@ -0,0 +1,89 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/iomodo/staff/tm"
+ "github.com/spf13/cobra"
+)
+
+var createTaskCmd = &cobra.Command{
+ Use: "create-task [title]",
+ Short: "Create a new task",
+ Long: `Create a new task with specified title, description, and priority.
+
+Examples:
+ staff create-task "Add user authentication"
+ staff create-task "Fix login bug" --description "Users cannot log in with Google OAuth" --priority high --assignee backend-engineer`,
+ Args: cobra.ExactArgs(1),
+ RunE: runCreateTask,
+}
+
+var (
+ taskDescription string
+ taskPriority string
+ taskAssignee string
+ taskDueDate string
+)
+
+func init() {
+ createTaskCmd.Flags().StringVarP(&taskDescription, "description", "d", "", "Task description")
+ createTaskCmd.Flags().StringVarP(&taskPriority, "priority", "p", "medium", "Task priority (low, medium, high)")
+ createTaskCmd.Flags().StringVarP(&taskAssignee, "assignee", "a", "", "Agent to assign the task to")
+ createTaskCmd.Flags().StringVar(&taskDueDate, "due", "", "Due date (RFC3339 format, e.g., 2024-01-15T10:00:00Z)")
+}
+
+func runCreateTask(cmd *cobra.Command, args []string) error {
+ title := args[0]
+
+ // Validate priority
+ priority := tm.TaskPriority(taskPriority)
+ if priority != tm.PriorityLow && priority != tm.PriorityMedium && priority != tm.PriorityHigh {
+ return fmt.Errorf("invalid priority: %s (must be low, medium, or high)", taskPriority)
+ }
+
+ // Parse due date if provided
+ var dueDate *time.Time
+ if taskDueDate != "" {
+ parsed, err := time.Parse(time.RFC3339, taskDueDate)
+ if err != nil {
+ return fmt.Errorf("invalid due date format: %s (expected RFC3339)", taskDueDate)
+ }
+ dueDate = &parsed
+ }
+
+ // Create task request
+ req := &tm.TaskCreateRequest{
+ Title: title,
+ Description: taskDescription,
+ OwnerID: "user", // MVP: single user
+ Priority: priority,
+ DueDate: dueDate,
+ }
+
+ // Create the task
+ task, err := taskManager.CreateTask(context.Background(), req)
+ if err != nil {
+ return fmt.Errorf("failed to create task: %w", err)
+ }
+
+ fmt.Printf("Task created successfully!\n")
+ fmt.Printf("ID: %s\n", task.ID)
+ fmt.Printf("Title: %s\n", task.Title)
+ fmt.Printf("Priority: %s\n", task.Priority)
+ fmt.Printf("Status: %s\n", task.Status)
+
+ // Auto-assign if assignee is specified
+ if taskAssignee != "" {
+ task.Assignee = taskAssignee
+ if err := taskManager.UpdateTask(task); err != nil {
+ fmt.Printf("Warning: Failed to assign task to %s: %v\n", taskAssignee, err)
+ } else {
+ fmt.Printf("Assigned to: %s\n", taskAssignee)
+ }
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/server/cmd/commands/list_agents.go b/server/cmd/commands/list_agents.go
new file mode 100644
index 0000000..7d0ac86
--- /dev/null
+++ b/server/cmd/commands/list_agents.go
@@ -0,0 +1,63 @@
+package commands
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+var listAgentsCmd = &cobra.Command{
+ Use: "list-agents",
+ Short: "List all configured agents",
+ Long: `List all configured agents with their settings and status.
+
+Examples:
+ staff list-agents`,
+ RunE: runListAgents,
+}
+
+func runListAgents(cmd *cobra.Command, args []string) error {
+ if len(cfg.Agents) == 0 {
+ fmt.Println("No agents configured")
+ return nil
+ }
+
+ fmt.Printf("Found %d configured agents:\n\n", len(cfg.Agents))
+
+ // Display agents in table format
+ fmt.Printf("%-20s %-15s %-12s %-10s %-30s\n", "Name", "Model", "Temperature", "Status", "Role/Description")
+ fmt.Printf("%s\n", strings.Repeat("-", 90))
+
+ for _, agent := range cfg.Agents {
+ status := "stopped"
+ if agentManager.IsAgentRunning(agent.Name) {
+ status = "running"
+ }
+
+ role := agent.Role
+ if role == "" {
+ role = "general"
+ }
+ if len(role) > 27 {
+ role = role[:27] + "..."
+ }
+
+ temp := 0.7
+ if agent.Temperature != nil {
+ temp = *agent.Temperature
+ }
+
+ fmt.Printf("%-20s %-15s %-12.1f %-10s %-30s\n",
+ agent.Name,
+ agent.Model,
+ temp,
+ status,
+ role)
+ }
+
+ fmt.Printf("\nUse 'staff start-agent <agent-name>' to start an agent\n")
+ fmt.Printf("Use 'staff stop-agent <agent-name>' to stop a running agent\n")
+
+ return nil
+}
\ No newline at end of file
diff --git a/server/cmd/commands/list_tasks.go b/server/cmd/commands/list_tasks.go
new file mode 100644
index 0000000..09cc20b
--- /dev/null
+++ b/server/cmd/commands/list_tasks.go
@@ -0,0 +1,109 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/iomodo/staff/tm"
+ "github.com/spf13/cobra"
+)
+
+var listTasksCmd = &cobra.Command{
+ Use: "list-tasks",
+ Short: "List all tasks",
+ Long: `List all tasks with optional filtering by status, priority, or assignee.
+
+Examples:
+ staff list-tasks
+ staff list-tasks --status todo
+ staff list-tasks --priority high
+ staff list-tasks --assignee backend-engineer`,
+ RunE: runListTasks,
+}
+
+var (
+ filterStatus string
+ filterPriority string
+ filterAssignee string
+ pageSize int = 20
+ pageNum int = 0
+)
+
+func init() {
+ listTasksCmd.Flags().StringVar(&filterStatus, "status", "", "Filter by status (todo, in_progress, completed, archived)")
+ listTasksCmd.Flags().StringVar(&filterPriority, "priority", "", "Filter by priority (low, medium, high)")
+ listTasksCmd.Flags().StringVar(&filterAssignee, "assignee", "", "Filter by assignee")
+ listTasksCmd.Flags().IntVar(&pageSize, "page-size", 20, "Number of tasks per page")
+ listTasksCmd.Flags().IntVar(&pageNum, "page", 0, "Page number (0-based)")
+}
+
+func runListTasks(cmd *cobra.Command, args []string) error {
+ // Build filter
+ filter := &tm.TaskFilter{}
+
+ if filterStatus != "" {
+ status := tm.TaskStatus(filterStatus)
+ filter.Status = &status
+ }
+
+ if filterPriority != "" {
+ priority := tm.TaskPriority(filterPriority)
+ filter.Priority = &priority
+ }
+
+ // Get tasks
+ taskList, err := taskManager.ListTasks(context.Background(), filter, pageNum, pageSize)
+ if err != nil {
+ return fmt.Errorf("failed to list tasks: %w", err)
+ }
+
+ // Filter by assignee if specified (not in TaskFilter interface yet)
+ var filteredTasks []*tm.Task
+ if filterAssignee != "" {
+ for _, task := range taskList.Tasks {
+ if task.Assignee == filterAssignee {
+ filteredTasks = append(filteredTasks, task)
+ }
+ }
+ } else {
+ filteredTasks = taskList.Tasks
+ }
+
+ // Display results
+ if len(filteredTasks) == 0 {
+ fmt.Println("No tasks found")
+ return nil
+ }
+
+ fmt.Printf("Found %d tasks (page %d/%d)\n\n", len(filteredTasks), pageNum+1, (taskList.TotalCount+pageSize-1)/pageSize)
+
+ // Display tasks in table format
+ fmt.Printf("%-20s %-10s %-10s %-15s %-50s\n", "ID", "Status", "Priority", "Assignee", "Title")
+ fmt.Printf("%s\n", strings.Repeat("-", 110))
+
+ for _, task := range filteredTasks {
+ assignee := task.Assignee
+ if assignee == "" {
+ assignee = "unassigned"
+ }
+
+ title := task.Title
+ if len(title) > 47 {
+ title = title[:47] + "..."
+ }
+
+ fmt.Printf("%-20s %-10s %-10s %-15s %-50s\n",
+ task.ID,
+ string(task.Status),
+ string(task.Priority),
+ assignee,
+ title)
+ }
+
+ if taskList.HasMore {
+ fmt.Printf("\nUse --page %d to see more tasks\n", pageNum+1)
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/server/cmd/commands/root.go b/server/cmd/commands/root.go
index 088e7ff..165d109 100644
--- a/server/cmd/commands/root.go
+++ b/server/cmd/commands/root.go
@@ -1,62 +1,88 @@
package commands
import (
+ "fmt"
"log/slog"
"os"
- "os/signal"
- "syscall"
- "github.com/iomodo/staff/server"
+ "github.com/iomodo/staff/agent"
+ "github.com/iomodo/staff/config"
+ "github.com/iomodo/staff/git"
+ "github.com/iomodo/staff/tm"
+ "github.com/iomodo/staff/tm/git_tm"
"github.com/spf13/cobra"
)
// Command is an abstraction of the cobra Command
type Command = cobra.Command
+// Global variables for the MVP
+var (
+ agentManager *agent.SimpleAgentManager
+ taskManager tm.TaskManager
+ cfg *config.Config
+)
+
// Run function starts the application
func Run(args []string) error {
rootCmd.SetArgs(args)
return rootCmd.Execute()
}
-// rootCmd is a command to run the server.
+// rootCmd is the main command for Staff MVP
var rootCmd = &cobra.Command{
- Use: "server",
- Short: "Runs a server",
- Long: `Runs a server. Killing the process will stop the server`,
- RunE: serverCmdF,
+ Use: "staff",
+ Short: "Staff - AI Multi-Agent Development System",
+ Long: `Staff MVP - AI agents that autonomously handle development tasks and create GitHub PRs.
+
+Examples:
+ staff create-task "Add user authentication" --priority high --agent backend-engineer
+ staff start-agent backend-engineer
+ staff list-tasks
+ staff list-agents`,
+ PersistentPreRunE: initializeApp,
}
-func serverCmdF(_ *cobra.Command, _ []string) error {
- srv, err := runServer()
- if err != nil {
- return err
+func init() {
+ // Add all commands
+ rootCmd.AddCommand(createTaskCmd)
+ rootCmd.AddCommand(assignTaskCmd)
+ rootCmd.AddCommand(startAgentCmd)
+ rootCmd.AddCommand(stopAgentCmd)
+ rootCmd.AddCommand(listTasksCmd)
+ rootCmd.AddCommand(listAgentsCmd)
+ rootCmd.AddCommand(configCheckCmd)
+ rootCmd.AddCommand(cleanupClonesCmd)
+ rootCmd.AddCommand(versionCmd)
+}
+
+// initializeApp loads configuration and sets up managers
+func initializeApp(cmd *cobra.Command, args []string) error {
+ // Skip initialization for help and version commands
+ if cmd.Name() == "help" || cmd.Name() == "version" {
+ return nil
}
- defer srv.Shutdown()
- // wait for kill signal before attempting to gracefully shutdown
- // the running service
- interruptChan := make(chan os.Signal, 1)
- signal.Notify(interruptChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
- <-interruptChan
- return nil
-}
+ // Load configuration
+ var err error
+ cfg, err = config.LoadConfigWithEnvOverrides("config.yaml")
+ if err != nil {
+ return fmt.Errorf("failed to load config: %w", err)
+ }
-func runServer() (*server.Server, error) {
+ // Initialize task manager
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
- Level: slog.LevelDebug,
+ Level: slog.LevelInfo,
}))
- srv, err := server.NewServer(logger)
+ gitInterface := git.DefaultGit(".")
+ taskManager = git_tm.NewGitTaskManagerWithLogger(gitInterface, ".", logger)
+
+ // Initialize agent manager
+ agentManager, err = agent.NewSimpleAgentManager(cfg, taskManager)
if err != nil {
- logger.Error(err.Error())
- return nil, err
+ return fmt.Errorf("failed to initialize agent manager: %w", err)
}
- serverErr := srv.Start()
- if serverErr != nil {
- logger.Error(serverErr.Error())
- return nil, serverErr
- }
- return srv, nil
+ return nil
}
diff --git a/server/cmd/commands/start_agent.go b/server/cmd/commands/start_agent.go
new file mode 100644
index 0000000..8c951c8
--- /dev/null
+++ b/server/cmd/commands/start_agent.go
@@ -0,0 +1,84 @@
+package commands
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+var startAgentCmd = &cobra.Command{
+ Use: "start-agent [agent-name]",
+ Short: "Start an agent to process tasks",
+ Long: `Start a specific agent to continuously process assigned tasks.
+
+The agent will:
+1. Check for assigned tasks every 30 seconds
+2. Process tasks using the configured LLM
+3. Create GitHub PRs for solutions
+4. Mark tasks as completed
+
+Examples:
+ staff start-agent backend-engineer
+ staff start-agent frontend-engineer`,
+ Args: cobra.ExactArgs(1),
+ RunE: runStartAgent,
+}
+
+var (
+ agentInterval time.Duration = 30 * time.Second
+)
+
+func init() {
+ startAgentCmd.Flags().DurationVar(&agentInterval, "interval", 30*time.Second, "Task check interval")
+}
+
+func runStartAgent(cmd *cobra.Command, args []string) error {
+ agentName := args[0]
+
+ // Check if agent exists in configuration
+ var agentExists bool
+ for _, agent := range cfg.Agents {
+ if agent.Name == agentName {
+ agentExists = true
+ break
+ }
+ }
+
+ if !agentExists {
+ return fmt.Errorf("agent '%s' not found in configuration", agentName)
+ }
+
+ fmt.Printf("Starting agent: %s\n", agentName)
+ fmt.Printf("Task check interval: %v\n", agentInterval)
+ fmt.Printf("Press Ctrl+C to stop the agent\n\n")
+
+ // Set up signal handling for graceful shutdown
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ <-sigChan
+ fmt.Printf("\nReceived shutdown signal, stopping agent...\n")
+ cancel()
+ }()
+
+ // Start the agent
+ err := agentManager.StartAgent(agentName, agentInterval)
+ if err != nil {
+ return fmt.Errorf("failed to start agent: %w", err)
+ }
+
+ // Wait for context cancellation (Ctrl+C)
+ <-ctx.Done()
+
+ fmt.Printf("Agent %s stopped\n", agentName)
+ return nil
+}
\ No newline at end of file
diff --git a/server/cmd/commands/stop_agent.go b/server/cmd/commands/stop_agent.go
new file mode 100644
index 0000000..e80d527
--- /dev/null
+++ b/server/cmd/commands/stop_agent.go
@@ -0,0 +1,31 @@
+package commands
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+var stopAgentCmd = &cobra.Command{
+ Use: "stop-agent [agent-name]",
+ Short: "Stop a running agent",
+ Long: `Stop a specific running agent.
+
+Examples:
+ staff stop-agent backend-engineer
+ staff stop-agent frontend-engineer`,
+ Args: cobra.ExactArgs(1),
+ RunE: runStopAgent,
+}
+
+func runStopAgent(cmd *cobra.Command, args []string) error {
+ agentName := args[0]
+
+ err := agentManager.StopAgent(agentName)
+ if err != nil {
+ return fmt.Errorf("failed to stop agent: %w", err)
+ }
+
+ fmt.Printf("Agent %s stopped successfully\n", agentName)
+ return nil
+}
\ No newline at end of file
diff --git a/server/cmd/commands/version.go b/server/cmd/commands/version.go
new file mode 100644
index 0000000..b6f2720
--- /dev/null
+++ b/server/cmd/commands/version.go
@@ -0,0 +1,27 @@
+package commands
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ Version = "0.1.0"
+ BuildDate = "2024-01-15"
+ GitCommit = "mvp-build"
+)
+
+var versionCmd = &cobra.Command{
+ Use: "version",
+ Short: "Show version information",
+ Long: `Display the current version of Staff MVP.`,
+ Run: runVersion,
+}
+
+func runVersion(cmd *cobra.Command, args []string) {
+ fmt.Printf("Staff MVP v%s\n", Version)
+ fmt.Printf("Built: %s\n", BuildDate)
+ fmt.Printf("Commit: %s\n", GitCommit)
+ fmt.Printf("AI Multi-Agent Development System\n")
+}
\ No newline at end of file
diff --git a/server/config.yaml b/server/config.yaml
new file mode 100644
index 0000000..2bd6482
--- /dev/null
+++ b/server/config.yaml
@@ -0,0 +1,43 @@
+# Staff MVP Configuration
+# This is a minimal config for testing the MVP
+
+openai:
+ api_key: "${OPENAI_API_KEY}"
+ base_url: ""
+ timeout: "60s"
+
+github:
+ token: "${GITHUB_TOKEN}"
+ owner: "shota" # Replace with your GitHub username
+ repo: "staff" # Replace with your repository name
+
+git:
+ branch_prefix: "task/"
+ commit_message_template: "Task {task_id}: {task_title} by {agent_name}"
+ pr_template: |
+ ## Task: {task_title}
+
+ **Priority:** {priority}
+ **Task ID:** {task_id}
+ **Agent:** {agent_name}
+
+ ### Description
+ {task_description}
+
+ ### Solution
+ {solution}
+
+ ### Files Changed
+ {files_changed}
+
+ ---
+ *This PR was automatically generated by Staff AI Agent System*
+
+# Simplified agent configuration for MVP testing
+agents:
+ - name: "backend-engineer"
+ role: "Backend Engineer"
+ model: "gpt-4"
+ temperature: 0.3
+ max_tokens: 4000
+ system_prompt_file: "operations/agents/backend-engineer/system.md"
\ No newline at end of file
diff --git a/server/config/config.go b/server/config/config.go
new file mode 100644
index 0000000..84441d2
--- /dev/null
+++ b/server/config/config.go
@@ -0,0 +1,234 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Config represents the Staff MVP configuration
+type Config struct {
+ OpenAI OpenAIConfig `yaml:"openai"`
+ GitHub GitHubConfig `yaml:"github"`
+ Agents []AgentConfig `yaml:"agents"`
+ Tasks TasksConfig `yaml:"tasks"`
+ Git GitConfig `yaml:"git"`
+}
+
+// OpenAIConfig represents OpenAI provider configuration
+type OpenAIConfig struct {
+ APIKey string `yaml:"api_key"`
+ Model string `yaml:"model"`
+ BaseURL string `yaml:"base_url"`
+ Timeout time.Duration `yaml:"timeout"`
+ MaxRetries int `yaml:"max_retries"`
+}
+
+// GitHubConfig represents GitHub integration configuration
+type GitHubConfig struct {
+ Token string `yaml:"token"`
+ Owner string `yaml:"owner"`
+ Repo string `yaml:"repo"`
+}
+
+// AgentConfig represents individual agent configuration
+type AgentConfig struct {
+ Name string `yaml:"name"`
+ Role string `yaml:"role"`
+ Model string `yaml:"model"`
+ SystemPromptFile string `yaml:"system_prompt_file"`
+ Capabilities []string `yaml:"capabilities"` // For auto-assignment
+ TaskTypes []string `yaml:"task_types"` // Types of tasks this agent handles
+ MaxTokens *int `yaml:"max_tokens"` // Model-specific token limits
+ Temperature *float64 `yaml:"temperature"` // Model creativity setting
+}
+
+// TasksConfig represents task management configuration
+type TasksConfig struct {
+ StoragePath string `yaml:"storage_path"`
+ CompletedPath string `yaml:"completed_path"`
+}
+
+// GitConfig represents Git operation configuration
+type GitConfig struct {
+ BranchPrefix string `yaml:"branch_prefix"`
+ CommitMessageTemplate string `yaml:"commit_message_template"`
+ PRTemplate string `yaml:"pr_template"`
+}
+
+// LoadConfig loads configuration from a YAML file
+func LoadConfig(configPath string) (*Config, error) {
+ // Read the config file
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read config file: %w", err)
+ }
+
+ // Parse YAML
+ var config Config
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ return nil, fmt.Errorf("failed to parse config YAML: %w", err)
+ }
+
+ // Apply defaults
+ config = applyDefaults(config)
+
+ // Validate configuration
+ if err := validateConfig(config); err != nil {
+ return nil, fmt.Errorf("invalid configuration: %w", err)
+ }
+
+ return &config, nil
+}
+
+// LoadConfigWithEnvOverrides loads config with environment variable overrides
+func LoadConfigWithEnvOverrides(configPath string) (*Config, error) {
+ config, err := LoadConfig(configPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Override with environment variables if present
+ if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
+ config.OpenAI.APIKey = apiKey
+ }
+ if githubToken := os.Getenv("GITHUB_TOKEN"); githubToken != "" {
+ config.GitHub.Token = githubToken
+ }
+ if owner := os.Getenv("GITHUB_OWNER"); owner != "" {
+ config.GitHub.Owner = owner
+ }
+ if repo := os.Getenv("GITHUB_REPO"); repo != "" {
+ config.GitHub.Repo = repo
+ }
+
+ // Re-validate after env overrides
+ if err := validateConfig(*config); err != nil {
+ return nil, fmt.Errorf("invalid configuration after env overrides: %w", err)
+ }
+
+ return config, nil
+}
+
+// applyDefaults applies default values to configuration
+func applyDefaults(config Config) Config {
+ // OpenAI defaults
+ if config.OpenAI.Model == "" {
+ config.OpenAI.Model = "gpt-4"
+ }
+ if config.OpenAI.BaseURL == "" {
+ config.OpenAI.BaseURL = "https://api.openai.com/v1"
+ }
+ if config.OpenAI.Timeout == 0 {
+ config.OpenAI.Timeout = 30 * time.Second
+ }
+ if config.OpenAI.MaxRetries == 0 {
+ config.OpenAI.MaxRetries = 3
+ }
+
+ // Tasks defaults
+ if config.Tasks.StoragePath == "" {
+ config.Tasks.StoragePath = "tasks/"
+ }
+ if config.Tasks.CompletedPath == "" {
+ config.Tasks.CompletedPath = "tasks/completed/"
+ }
+
+ // Git defaults
+ if config.Git.BranchPrefix == "" {
+ config.Git.BranchPrefix = "task/"
+ }
+ if config.Git.CommitMessageTemplate == "" {
+ config.Git.CommitMessageTemplate = "Task {task_id}: {task_title}\n\n{solution}\n\nGenerated by Staff AI Agent: {agent_name}"
+ }
+ if config.Git.PRTemplate == "" {
+ config.Git.PRTemplate = `## Task: {task_title}
+
+**Task ID:** {task_id}
+**Agent:** {agent_name}
+**Priority:** {priority}
+
+### Description
+{task_description}
+
+### Solution
+{solution}
+
+### Files Changed
+{files_changed}
+
+---
+*Generated by Staff AI Multi-Agent System*`
+ }
+
+ // Agent defaults
+ for i := range config.Agents {
+ if config.Agents[i].Model == "" {
+ config.Agents[i].Model = config.OpenAI.Model
+ }
+ }
+
+ return config
+}
+
+// validateConfig validates the configuration
+func validateConfig(config Config) error {
+ // Validate OpenAI config
+ if config.OpenAI.APIKey == "" {
+ return fmt.Errorf("openai.api_key is required")
+ }
+ if config.OpenAI.Model == "" {
+ return fmt.Errorf("openai.model is required")
+ }
+
+ // Validate GitHub config
+ if config.GitHub.Token == "" {
+ return fmt.Errorf("github.token is required")
+ }
+ if config.GitHub.Owner == "" {
+ return fmt.Errorf("github.owner is required")
+ }
+ if config.GitHub.Repo == "" {
+ return fmt.Errorf("github.repo is required")
+ }
+
+ // Validate agents
+ if len(config.Agents) == 0 {
+ return fmt.Errorf("at least one agent must be configured")
+ }
+
+ for i, agent := range config.Agents {
+ if agent.Name == "" {
+ return fmt.Errorf("agent[%d].name is required", i)
+ }
+ if agent.Role == "" {
+ return fmt.Errorf("agent[%d].role is required", i)
+ }
+ if agent.SystemPromptFile == "" {
+ return fmt.Errorf("agent[%d].system_prompt_file is required", i)
+ }
+ }
+
+ return nil
+}
+
+// GetAgentByName returns an agent config by name
+func (c *Config) GetAgentByName(name string) (*AgentConfig, error) {
+ for _, agent := range c.Agents {
+ if agent.Name == name {
+ return &agent, nil
+ }
+ }
+ return nil, fmt.Errorf("agent not found: %s", name)
+}
+
+// ListAgentNames returns a list of all configured agent names
+func (c *Config) ListAgentNames() []string {
+ names := make([]string, len(c.Agents))
+ for i, agent := range c.Agents {
+ names[i] = agent.Name
+ }
+ return names
+}
\ No newline at end of file
diff --git a/server/config/openai_test.go b/server/config/openai_test.go
new file mode 100644
index 0000000..bd53e9c
--- /dev/null
+++ b/server/config/openai_test.go
@@ -0,0 +1,202 @@
+package config
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/iomodo/staff/llm"
+ "github.com/iomodo/staff/llm/openai"
+)
+
+// TestOpenAIIntegration tests the OpenAI integration with real API calls
+// This test requires OPENAI_API_KEY environment variable to be set
+func TestOpenAIIntegration(t *testing.T) {
+ apiKey := os.Getenv("OPENAI_API_KEY")
+ if apiKey == "" {
+ t.Skip("OPENAI_API_KEY not set, skipping OpenAI integration test")
+ }
+
+ // Create OpenAI config
+ config := llm.Config{
+ Provider: llm.ProviderOpenAI,
+ APIKey: apiKey,
+ BaseURL: "https://api.openai.com/v1",
+ }
+
+ // Create OpenAI provider
+ factory := &openai.OpenAIFactory{}
+ provider, err := factory.CreateProvider(config)
+ if err != nil {
+ t.Fatalf("Failed to create OpenAI provider: %v", err)
+ }
+ defer provider.Close()
+
+ // Test chat completion
+ t.Run("ChatCompletion", func(t *testing.T) {
+ req := llm.ChatCompletionRequest{
+ Model: "gpt-3.5-turbo",
+ Messages: []llm.Message{
+ {
+ Role: llm.RoleSystem,
+ Content: "You are a helpful assistant.",
+ },
+ {
+ Role: llm.RoleUser,
+ Content: "Hello! Just say 'Hello from OpenAI' and nothing else.",
+ },
+ },
+ }
+
+ resp, err := provider.ChatCompletion(context.Background(), req)
+ if err != nil {
+ t.Fatalf("ChatCompletion failed: %v", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ t.Fatal("No choices returned")
+ }
+
+ message := resp.Choices[0].Message
+ if message.Content == "" {
+ t.Fatal("Empty response content")
+ }
+
+ t.Logf("OpenAI Response: %s", message.Content)
+ })
+
+ // Test embeddings
+ t.Run("Embeddings", func(t *testing.T) {
+ req := llm.EmbeddingRequest{
+ Model: "text-embedding-ada-002",
+ Input: "Hello, world!",
+ }
+
+ resp, err := provider.CreateEmbeddings(context.Background(), req)
+ if err != nil {
+ t.Fatalf("CreateEmbeddings failed: %v", err)
+ }
+
+ if len(resp.Data) == 0 {
+ t.Fatal("No embeddings returned")
+ }
+
+ embedding := resp.Data[0]
+ if len(embedding.Embedding) == 0 {
+ t.Fatal("Empty embedding vector")
+ }
+
+ t.Logf("Embedding dimensions: %d", len(embedding.Embedding))
+ })
+}
+
+// TestConfigurationLoading tests the configuration loading functionality
+func TestConfigurationLoading(t *testing.T) {
+ // Create a temporary config file
+ configContent := `
+openai:
+ api_key: "test-key"
+ model: "gpt-4"
+
+github:
+ token: "test-token"
+ owner: "test-owner"
+ repo: "test-repo"
+
+agents:
+ - name: "ceo"
+ role: "CEO"
+ system_prompt_file: "operations/agents/ceo/system.md"
+
+tasks:
+ storage_path: "tasks/"
+`
+
+ // Write temp config file
+ tmpFile, err := os.CreateTemp("", "staff-config-*.yaml")
+ if err != nil {
+ t.Fatalf("Failed to create temp file: %v", err)
+ }
+ defer os.Remove(tmpFile.Name())
+
+ if _, err := tmpFile.WriteString(configContent); err != nil {
+ t.Fatalf("Failed to write config: %v", err)
+ }
+ tmpFile.Close()
+
+ // Test loading config
+ config, err := LoadConfig(tmpFile.Name())
+ if err != nil {
+ t.Fatalf("Failed to load config: %v", err)
+ }
+
+ // Validate loaded config
+ if config.OpenAI.APIKey != "test-key" {
+ t.Errorf("Expected API key 'test-key', got '%s'", config.OpenAI.APIKey)
+ }
+
+ if config.OpenAI.Model != "gpt-4" {
+ t.Errorf("Expected model 'gpt-4', got '%s'", config.OpenAI.Model)
+ }
+
+ if len(config.Agents) != 1 {
+ t.Errorf("Expected 1 agent, got %d", len(config.Agents))
+ }
+
+ if config.Agents[0].Name != "ceo" {
+ t.Errorf("Expected agent name 'ceo', got '%s'", config.Agents[0].Name)
+ }
+}
+
+// TestEnvironmentOverrides tests environment variable overrides
+func TestEnvironmentOverrides(t *testing.T) {
+ // Set environment variables
+ os.Setenv("OPENAI_API_KEY", "env-openai-key")
+ os.Setenv("GITHUB_TOKEN", "env-github-token")
+ defer func() {
+ os.Unsetenv("OPENAI_API_KEY")
+ os.Unsetenv("GITHUB_TOKEN")
+ }()
+
+ // Create a temporary config file
+ configContent := `
+openai:
+ api_key: "config-key"
+
+github:
+ token: "config-token"
+ owner: "test-owner"
+ repo: "test-repo"
+
+agents:
+ - name: "ceo"
+ role: "CEO"
+ system_prompt_file: "operations/agents/ceo/system.md"
+`
+
+ tmpFile, err := os.CreateTemp("", "staff-config-*.yaml")
+ if err != nil {
+ t.Fatalf("Failed to create temp file: %v", err)
+ }
+ defer os.Remove(tmpFile.Name())
+
+ if _, err := tmpFile.WriteString(configContent); err != nil {
+ t.Fatalf("Failed to write config: %v", err)
+ }
+ tmpFile.Close()
+
+ // Test loading config with env overrides
+ config, err := LoadConfigWithEnvOverrides(tmpFile.Name())
+ if err != nil {
+ t.Fatalf("Failed to load config: %v", err)
+ }
+
+ // Verify environment overrides
+ if config.OpenAI.APIKey != "env-openai-key" {
+ t.Errorf("Expected env API key 'env-openai-key', got '%s'", config.OpenAI.APIKey)
+ }
+
+ if config.GitHub.Token != "env-github-token" {
+ t.Errorf("Expected env GitHub token 'env-github-token', got '%s'", config.GitHub.Token)
+ }
+}
\ No newline at end of file
diff --git a/server/git/CONCURRENCY_README.md b/server/git/CONCURRENCY_README.md
new file mode 100644
index 0000000..1cbe184
--- /dev/null
+++ b/server/git/CONCURRENCY_README.md
@@ -0,0 +1,172 @@
+# Git Concurrency Solution: Per-Agent Repository Clones
+
+## Problem Statement
+
+Git is not thread-safe, which creates critical race conditions when multiple AI agents try to perform Git operations concurrently:
+
+- **Repository Corruption**: Multiple agents modifying the same `.git` folder simultaneously
+- **Branch Conflicts**: Agents creating branches with the same names or overwriting each other's work
+- **Push Failures**: Concurrent pushes causing merge conflicts and failed operations
+- **Index Lock Errors**: Git index.lock conflicts when multiple processes access the repository
+
+## Solution: Per-Agent Git Clones
+
+Instead of using mutexes (which would serialize all Git operations and hurt performance), we give each agent its own Git repository clone:
+
+```
+workspace/
+โโโ agent-backend-engineer/ # Backend engineer's clone
+โ โโโ .git/
+โ โโโ tasks/
+โ โโโ ...
+โโโ agent-frontend-engineer/ # Frontend engineer's clone
+โ โโโ .git/
+โ โโโ tasks/
+โ โโโ ...
+โโโ agent-qa-engineer/ # QA engineer's clone
+ โโโ .git/
+ โโโ tasks/
+ โโโ ...
+```
+
+## Key Benefits
+
+### ๐ **True Concurrency**
+- Multiple agents can work simultaneously without blocking each other
+- No waiting for Git lock releases
+- Scales to hundreds of concurrent agents
+
+### ๐ก๏ธ **Complete Isolation**
+- Each agent has its own `.git` directory and working tree
+- No shared state or race conditions
+- Agent failures don't affect other agents
+
+### ๐ **Automatic Synchronization**
+- Each clone automatically pulls latest changes before creating branches
+- All branches push to the same remote repository
+- PRs are created against the main repository
+
+### ๐งน **Easy Cleanup**
+- `staff cleanup-clones` removes all agent workspaces
+- Clones are recreated on-demand when agents start working
+- No manual Git state management required
+
+## Implementation Details
+
+### CloneManager (`git/clone_manager.go`)
+
+```go
+type CloneManager struct {
+ baseRepoURL string // Source repository URL
+ workspacePath string // Base workspace directory
+ agentClones map[string]string // agent name -> clone path
+ mu sync.RWMutex // Thread-safe map access
+}
+```
+
+**Key Methods:**
+- `GetAgentClonePath(agentName)` - Get/create agent's clone directory
+- `RefreshAgentClone(agentName)` - Pull latest changes for an agent
+- `CleanupAgentClone(agentName)` - Remove specific agent's clone
+- `CleanupAllClones()` - Remove all agent clones
+
+### Agent Integration
+
+Each agent's Git operations are automatically routed to its dedicated clone:
+
+```go
+// Get agent's dedicated Git clone
+clonePath, err := am.cloneManager.GetAgentClonePath(agent.Name)
+if err != nil {
+ return fmt.Errorf("failed to get agent clone: %w", err)
+}
+
+// All Git operations use the agent's clone directory
+gitCmd := func(args ...string) *exec.Cmd {
+ return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
+}
+```
+
+## Workflow Example
+
+1. **Agent Starts Task**:
+ ```bash
+ Agent backend-engineer gets task: "Add user authentication"
+ Creating clone: workspace/agent-backend-engineer/
+ ```
+
+2. **Concurrent Operations**:
+ ```bash
+ # These happen simultaneously without conflicts:
+ Agent backend-engineer: git clone -> workspace/agent-backend-engineer/
+ Agent frontend-engineer: git clone -> workspace/agent-frontend-engineer/
+ Agent qa-engineer: git clone -> workspace/agent-qa-engineer/
+ ```
+
+3. **Branch Creation**:
+ ```bash
+ # Each agent creates branches in their own clone:
+ backend-engineer: git checkout -b task-123-auth-backend
+ frontend-engineer: git checkout -b task-124-auth-ui
+ qa-engineer: git checkout -b task-125-auth-tests
+ ```
+
+4. **Concurrent Pushes**:
+ ```bash
+ # All agents push to origin simultaneously:
+ git push -u origin task-123-auth-backend # โ
Success
+ git push -u origin task-124-auth-ui # โ
Success
+ git push -u origin task-125-auth-tests # โ
Success
+ ```
+
+## Management Commands
+
+### List Agent Clones
+```bash
+staff list-agents # Shows which agents are running and their clone status
+```
+
+### Cleanup All Clones
+```bash
+staff cleanup-clones # Removes all agent workspace directories
+```
+
+### Monitor Disk Usage
+```bash
+du -sh workspace/ # Check total workspace disk usage
+```
+
+## Resource Considerations
+
+### Disk Space
+- Each clone uses ~repository size (typically 10-100MB per agent)
+- For 10 agents with 50MB repo = ~500MB total
+- Use `staff cleanup-clones` to free space when needed
+
+### Network Usage
+- Initial clone downloads full repository
+- Subsequent `git pull` operations are incremental
+- All agents share the same remote repository
+
+### Performance
+- Clone creation: ~2-5 seconds per agent (one-time cost)
+- Git operations: Full speed, no waiting for locks
+- Parallel processing: Linear scalability with agent count
+
+## Comparison to Alternatives
+
+| Solution | Concurrency | Complexity | Performance | Risk |
+|----------|-------------|------------|-------------|------|
+| **Per-Agent Clones** | โ
Full | ๐ก Medium | โ
High | ๐ข Low |
+| Global Git Mutex | โ None | ๐ข Low | โ Poor | ๐ก Medium |
+| File Locking | ๐ก Limited | ๐ด High | ๐ก Medium | ๐ด High |
+| Separate Repositories | โ
Full | ๐ด Very High | โ
High | ๐ด High |
+
+## Future Enhancements
+
+- **Lazy Cleanup**: Auto-remove unused clones after N days
+- **Clone Sharing**: Share clones between agents with similar tasks
+- **Compressed Clones**: Use `git clone --depth=1` for space efficiency
+- **Remote Workspaces**: Support for distributed agent execution
+
+The per-agent clone solution provides the optimal balance of performance, safety, and maintainability for concurrent AI agent operations.
\ No newline at end of file
diff --git a/server/git/clone_manager.go b/server/git/clone_manager.go
new file mode 100644
index 0000000..afedd65
--- /dev/null
+++ b/server/git/clone_manager.go
@@ -0,0 +1,160 @@
+package git
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+)
+
+// CloneManager manages separate Git repository clones for each agent
+// This eliminates Git concurrency issues by giving each agent its own working directory
+type CloneManager struct {
+ baseRepoURL string
+ workspacePath string
+ agentClones map[string]string // agent name -> clone path
+ mu sync.RWMutex
+}
+
+// NewCloneManager creates a new CloneManager
+func NewCloneManager(baseRepoURL, workspacePath string) *CloneManager {
+ return &CloneManager{
+ baseRepoURL: baseRepoURL,
+ workspacePath: workspacePath,
+ agentClones: make(map[string]string),
+ }
+}
+
+// GetAgentClonePath returns the Git clone path for a specific agent
+// Creates the clone if it doesn't exist
+func (cm *CloneManager) GetAgentClonePath(agentName string) (string, error) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ // Check if clone already exists
+ if clonePath, exists := cm.agentClones[agentName]; exists {
+ // Verify the clone still exists on disk
+ if _, err := os.Stat(clonePath); err == nil {
+ return clonePath, nil
+ }
+ // Remove stale entry if directory doesn't exist
+ delete(cm.agentClones, agentName)
+ }
+
+ // Create new clone for the agent
+ clonePath := filepath.Join(cm.workspacePath, fmt.Sprintf("agent-%s", agentName))
+
+ // Ensure workspace directory exists
+ if err := os.MkdirAll(cm.workspacePath, 0755); err != nil {
+ return "", fmt.Errorf("failed to create workspace directory: %w", err)
+ }
+
+ // Remove existing clone directory if it exists
+ if err := os.RemoveAll(clonePath); err != nil {
+ return "", fmt.Errorf("failed to remove existing clone: %w", err)
+ }
+
+ // Clone the repository
+ if err := cm.cloneRepository(clonePath); err != nil {
+ return "", fmt.Errorf("failed to clone repository for agent %s: %w", agentName, err)
+ }
+
+ // Store the clone path
+ cm.agentClones[agentName] = clonePath
+
+ return clonePath, nil
+}
+
+// cloneRepository performs the actual Git clone operation
+func (cm *CloneManager) cloneRepository(clonePath string) error {
+ ctx := context.Background()
+
+ // Clone the repository
+ cmd := exec.CommandContext(ctx, "git", "clone", cm.baseRepoURL, clonePath)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("git clone failed: %w", err)
+ }
+
+ return nil
+}
+
+// RefreshAgentClone pulls the latest changes for an agent's clone
+func (cm *CloneManager) RefreshAgentClone(agentName string) error {
+ cm.mu.RLock()
+ clonePath, exists := cm.agentClones[agentName]
+ cm.mu.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("no clone exists for agent %s", agentName)
+ }
+
+ ctx := context.Background()
+
+ // Change to clone directory and pull latest changes
+ cmd := exec.CommandContext(ctx, "git", "-C", clonePath, "pull", "origin")
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to pull latest changes for agent %s: %w", agentName, err)
+ }
+
+ return nil
+}
+
+// CleanupAgentClone removes the clone directory for an agent
+func (cm *CloneManager) CleanupAgentClone(agentName string) error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ clonePath, exists := cm.agentClones[agentName]
+ if !exists {
+ return nil // Already cleaned up
+ }
+
+ // Remove the clone directory
+ if err := os.RemoveAll(clonePath); err != nil {
+ return fmt.Errorf("failed to remove clone for agent %s: %w", agentName, err)
+ }
+
+ // Remove from tracking
+ delete(cm.agentClones, agentName)
+
+ return nil
+}
+
+// CleanupAllClones removes all agent clone directories
+func (cm *CloneManager) CleanupAllClones() error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ var errors []error
+
+ for agentName, clonePath := range cm.agentClones {
+ if err := os.RemoveAll(clonePath); err != nil {
+ errors = append(errors, fmt.Errorf("failed to remove clone for agent %s: %w", agentName, err))
+ }
+ }
+
+ // Clear all tracked clones
+ cm.agentClones = make(map[string]string)
+
+ if len(errors) > 0 {
+ return fmt.Errorf("cleanup errors: %v", errors)
+ }
+
+ return nil
+}
+
+// GetAllAgentClones returns a map of all agent clones
+func (cm *CloneManager) GetAllAgentClones() map[string]string {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ // Return a copy to avoid race conditions
+ result := make(map[string]string)
+ for agent, path := range cm.agentClones {
+ result[agent] = path
+ }
+
+ return result
+}
\ No newline at end of file
diff --git a/server/git/mutex.go b/server/git/mutex.go
new file mode 100644
index 0000000..21bc25f
--- /dev/null
+++ b/server/git/mutex.go
@@ -0,0 +1,40 @@
+package git
+
+import (
+ "sync"
+)
+
+// GitMutex provides thread-safe access to Git operations
+// Since Git is not thread-safe, we need to serialize all Git operations
+// across all agents to prevent repository corruption and race conditions
+type GitMutex struct {
+ mu sync.Mutex
+}
+
+// NewGitMutex creates a new GitMutex instance
+func NewGitMutex() *GitMutex {
+ return &GitMutex{}
+}
+
+// Lock acquires the Git operation lock
+// This ensures only one agent can perform Git operations at a time
+func (gm *GitMutex) Lock() {
+ gm.mu.Lock()
+}
+
+// Unlock releases the Git operation lock
+func (gm *GitMutex) Unlock() {
+ gm.mu.Unlock()
+}
+
+// WithLock executes a function while holding the Git lock
+// This is a convenience method to ensure proper lock/unlock pattern
+func (gm *GitMutex) WithLock(fn func() error) error {
+ gm.Lock()
+ defer gm.Unlock()
+ return fn()
+}
+
+// Global Git mutex instance - shared across all agents
+// This ensures no concurrent Git operations across the entire application
+var GlobalGitMutex = NewGitMutex()
\ No newline at end of file
diff --git a/server/operations/agents/backend-engineer/system.md b/server/operations/agents/backend-engineer/system.md
new file mode 100644
index 0000000..1d481b4
--- /dev/null
+++ b/server/operations/agents/backend-engineer/system.md
@@ -0,0 +1,40 @@
+# Backend Engineer Agent System Prompt
+
+You are a skilled Backend Engineer specializing in building robust, scalable server-side applications and APIs.
+
+## Your Role
+- Design and implement backend systems, APIs, and databases
+- Write clean, maintainable, and well-tested Go code
+- Focus on performance, security, and scalability
+- Follow best practices for API design and database interactions
+
+## Technical Expertise
+- **Languages**: Go (primary), Python, SQL
+- **Databases**: PostgreSQL, MySQL, Redis
+- **APIs**: RESTful APIs, GraphQL, gRPC
+- **Infrastructure**: Docker, Kubernetes, CI/CD
+- **Testing**: Unit tests, integration tests, benchmarks
+
+## Task Processing Guidelines
+1. **Analyze the task requirements thoroughly**
+2. **Design the solution architecture**
+3. **Implement the code with proper error handling**
+4. **Include comprehensive tests**
+5. **Document the implementation and usage**
+
+## Code Quality Standards
+- Write idiomatic Go code following Go conventions
+- Include proper error handling and logging
+- Add comprehensive comments for complex logic
+- Ensure code is testable and maintainable
+- Follow SOLID principles and clean architecture
+
+## Response Format
+When solving tasks, provide:
+1. **Analysis**: Brief analysis of the requirements
+2. **Architecture**: High-level design approach
+3. **Implementation**: Complete, working code
+4. **Tests**: Unit tests and examples
+5. **Documentation**: Usage instructions and API docs
+
+Always prioritize reliability, performance, and maintainability in your solutions.
\ No newline at end of file
diff --git a/server/tasks/task-1753623184-58260673.md b/server/tasks/task-1753623184-58260673.md
index fee17eb..d92637b 100644
--- a/server/tasks/task-1753623184-58260673.md
+++ b/server/tasks/task-1753623184-58260673.md
@@ -1,4 +1,5 @@
---
+assignee: backend-engineer
created_at: "2025-07-27T17:33:04+04:00"
description: Create a simple test to verify the MVP system works
id: task-1753623184-58260673
@@ -7,7 +8,7 @@
priority: high
status: todo
title: Test MVP functionality
-updated_at: "2025-07-27T17:33:04+04:00"
+updated_at: "2025-07-27T17:38:01+04:00"
---
# Task Description
diff --git a/server/tasks/task-1753636924-a1d4c708.md b/server/tasks/task-1753636924-a1d4c708.md
new file mode 100644
index 0000000..994b4e9
--- /dev/null
+++ b/server/tasks/task-1753636924-a1d4c708.md
@@ -0,0 +1,17 @@
+---
+assignee: ""
+created_at: "2025-07-27T21:22:04+04:00"
+description: This is a test task to verify the MVP is working
+id: task-1753636924-a1d4c708
+owner_id: user
+owner_name: user
+priority: high
+status: todo
+title: Test task for MVP
+updated_at: "2025-07-27T21:22:04+04:00"
+---
+
+# Task Description
+
+This is a test task to verify the MVP is working
+
diff --git a/server/tm/git_tm/example.go b/server/tm/git_tm/example.go
index 3c2816d..e1b0412 100644
--- a/server/tm/git_tm/example.go
+++ b/server/tm/git_tm/example.go
@@ -43,7 +43,7 @@
logger.Info("Created task", slog.String("id", task.ID))
// Get the task
- retrievedTask, err := taskManager.GetTask(ctx, task.ID)
+ retrievedTask, err := taskManager.GetTask(task.ID)
if err != nil {
logger.Error("Failed to get task", slog.String("error", err.Error()))
os.Exit(1)
diff --git a/server/tm/git_tm/git_task_manager.go b/server/tm/git_tm/git_task_manager.go
index edfb476..b1aa39c 100644
--- a/server/tm/git_tm/git_task_manager.go
+++ b/server/tm/git_tm/git_task_manager.go
@@ -118,6 +118,7 @@
"description": task.Description,
"owner_id": task.Owner.ID,
"owner_name": task.Owner.Name,
+ "assignee": task.Assignee,
"status": task.Status,
"priority": task.Priority,
"created_at": task.CreatedAt.Format(time.RFC3339),
@@ -188,6 +189,9 @@
if ownerName, ok := frontmatter["owner_name"].(string); ok {
task.Owner.Name = ownerName
}
+ if assignee, ok := frontmatter["assignee"].(string); ok {
+ task.Assignee = assignee
+ }
if status, ok := frontmatter["status"].(string); ok {
task.Status = tm.TaskStatus(status)
}
@@ -353,97 +357,74 @@
}
// GetTask retrieves a task by ID
-func (gtm *GitTaskManager) GetTask(ctx context.Context, id string) (*tm.Task, error) {
+func (gtm *GitTaskManager) GetTask(id string) (*tm.Task, error) {
return gtm.readTaskFile(id)
}
// UpdateTask updates an existing task
-func (gtm *GitTaskManager) UpdateTask(ctx context.Context, id string, req *tm.TaskUpdateRequest) (*tm.Task, error) {
- // Read existing task
- task, err := gtm.readTaskFile(id)
+func (gtm *GitTaskManager) UpdateTask(task *tm.Task) error {
+ // Set update time
+ task.UpdatedAt = time.Now()
+
+ // Write task to file
+ return gtm.writeTaskFile(task)
+}
+
+// readAllTasks reads all task files from disk
+func (gtm *GitTaskManager) readAllTasks() ([]*tm.Task, error) {
+ taskFiles, err := gtm.listTaskFiles()
if err != nil {
return nil, err
}
-
- // Update fields
- updated := false
- if req.Title != nil {
- task.Title = *req.Title
- updated = true
- }
- if req.Description != nil {
- task.Description = *req.Description
- updated = true
- }
- if req.OwnerID != nil {
- task.Owner.ID = *req.OwnerID
- // Get owner name from user service
- if ownerName, err := gtm.userService.GetUserName(*req.OwnerID); err == nil {
- task.Owner.Name = ownerName
- } else {
- gtm.logger.Warn("Failed to get owner name, using ID", slog.String("ownerID", *req.OwnerID), slog.String("error", err.Error()))
- task.Owner.Name = *req.OwnerID
- }
- updated = true
- }
- if req.Status != nil {
- task.Status = *req.Status
- updated = true
- }
- if req.Priority != nil {
- task.Priority = *req.Priority
- updated = true
- }
- if req.DueDate != nil {
- task.DueDate = req.DueDate
- updated = true
- }
-
- if !updated {
- return task, nil
- }
-
- // Update timestamps
- task.UpdatedAt = time.Now()
-
- // Handle status-specific timestamps
- if req.Status != nil {
- switch *req.Status {
- case tm.StatusCompleted:
- if task.CompletedAt == nil {
- now := time.Now()
- task.CompletedAt = &now
+
+ var tasks []*tm.Task
+ for _, taskFile := range taskFiles {
+ // Extract task ID from filename (task-{id}.md)
+ filename := filepath.Base(taskFile)
+ if strings.HasPrefix(filename, "task-") && strings.HasSuffix(filename, ".md") {
+ taskID := strings.TrimSuffix(strings.TrimPrefix(filename, "task-"), ".md")
+ task, err := gtm.readTaskFile(taskID)
+ if err != nil {
+ gtm.logger.Warn("Failed to read task file", slog.String("file", taskFile), slog.String("error", err.Error()))
+ continue
}
- case tm.StatusArchived:
- if task.ArchivedAt == nil {
- now := time.Now()
- task.ArchivedAt = &now
- }
+ tasks = append(tasks, task)
}
}
+
+ return tasks, nil
+}
- // Write updated task
- if err := gtm.writeTaskFile(task); err != nil {
+// GetTasksByAssignee retrieves tasks assigned to a specific agent (MVP method)
+func (gtm *GitTaskManager) GetTasksByAssignee(assignee string) ([]*tm.Task, error) {
+ // Read all tasks and filter by assignee
+ tasks, err := gtm.readAllTasks()
+ if err != nil {
return nil, err
}
-
- // Commit to git
- if err := gtm.commitTaskChange(id, "updated", task.Owner); err != nil {
- return nil, err
+
+ var assignedTasks []*tm.Task
+ for _, task := range tasks {
+ if task.Assignee == assignee {
+ assignedTasks = append(assignedTasks, task)
+ }
}
-
- return task, nil
+
+ return assignedTasks, nil
}
// ArchiveTask archives a task
func (gtm *GitTaskManager) ArchiveTask(ctx context.Context, id string) error {
- status := tm.StatusArchived
- req := &tm.TaskUpdateRequest{
- Status: &status,
+ task, err := gtm.GetTask(id)
+ if err != nil {
+ return err
}
-
- _, err := gtm.UpdateTask(ctx, id, req)
- return err
+
+ task.Status = tm.StatusArchived
+ now := time.Now()
+ task.ArchivedAt = &now
+
+ return gtm.UpdateTask(task)
}
// ListTasks lists tasks with filtering and pagination
@@ -544,22 +525,38 @@
// StartTask starts a task (changes status to in_progress)
func (gtm *GitTaskManager) StartTask(ctx context.Context, id string) (*tm.Task, error) {
- status := tm.StatusInProgress
- req := &tm.TaskUpdateRequest{
- Status: &status,
+ task, err := gtm.GetTask(id)
+ if err != nil {
+ return nil, err
}
-
- return gtm.UpdateTask(ctx, id, req)
+
+ task.Status = tm.StatusInProgress
+
+ err = gtm.UpdateTask(task)
+ if err != nil {
+ return nil, err
+ }
+
+ return task, nil
}
// CompleteTask completes a task (changes status to completed)
func (gtm *GitTaskManager) CompleteTask(ctx context.Context, id string) (*tm.Task, error) {
- status := tm.StatusCompleted
- req := &tm.TaskUpdateRequest{
- Status: &status,
+ task, err := gtm.GetTask(id)
+ if err != nil {
+ return nil, err
}
-
- return gtm.UpdateTask(ctx, id, req)
+
+ task.Status = tm.StatusCompleted
+ now := time.Now()
+ task.CompletedAt = &now
+
+ err = gtm.UpdateTask(task)
+ if err != nil {
+ return nil, err
+ }
+
+ return task, nil
}
// GetTasksByOwner gets tasks for a specific owner
diff --git a/server/tm/git_tm/git_task_manager_test.go b/server/tm/git_tm/git_task_manager_test.go
index 033e11c..084200d 100644
--- a/server/tm/git_tm/git_task_manager_test.go
+++ b/server/tm/git_tm/git_task_manager_test.go
@@ -449,8 +449,7 @@
require.NoError(t, err)
// Get the task
- ctx := context.Background()
- retrievedTask, err := gtm.GetTask(ctx, task.ID)
+ retrievedTask, err := gtm.GetTask(task.ID)
assert.NoError(t, err)
assert.NotNil(t, retrievedTask)
assert.Equal(t, task.ID, retrievedTask.ID)
@@ -463,8 +462,7 @@
gtm, _ := createTestTaskManager(t, tempDir)
- ctx := context.Background()
- task, err := gtm.GetTask(ctx, "non-existent-task")
+ task, err := gtm.GetTask("non-existent-task")
assert.Error(t, err)
assert.Nil(t, task)
assert.Equal(t, tm.ErrTaskNotFound, err)
@@ -508,15 +506,22 @@
newPriority := tm.PriorityHigh
newOwnerID := "user456"
- req := &tm.TaskUpdateRequest{
- Title: &newTitle,
- Description: &newDescription,
- Status: &newStatus,
- Priority: &newPriority,
- OwnerID: &newOwnerID,
- }
-
- updatedTask, err := gtm.UpdateTask(ctx, originalTask.ID, req)
+ // Get task and update fields
+ taskToUpdate, err := gtm.GetTask(originalTask.ID)
+ assert.NoError(t, err)
+
+ taskToUpdate.Title = newTitle
+ taskToUpdate.Description = newDescription
+ taskToUpdate.Status = newStatus
+ taskToUpdate.Priority = newPriority
+ taskToUpdate.Owner.ID = newOwnerID
+ taskToUpdate.Owner.Name = newOwnerID
+
+ err = gtm.UpdateTask(taskToUpdate)
+ assert.NoError(t, err)
+
+ // Get updated task to verify
+ updatedTask, err := gtm.GetTask(originalTask.ID)
assert.NoError(t, err)
assert.NotNil(t, updatedTask)
@@ -545,16 +550,14 @@
gtm, _ := createTestTaskManager(t, tempDir)
- ctx := context.Background()
- newTitle := "Updated Title"
- req := &tm.TaskUpdateRequest{
- Title: &newTitle,
+ // Try to update non-existent task
+ fakeTask := &tm.Task{
+ ID: "non-existent-task",
+ Title: "Updated Title",
}
- task, err := gtm.UpdateTask(ctx, "non-existent-task", req)
+ err := gtm.UpdateTask(fakeTask)
assert.Error(t, err)
- assert.Nil(t, task)
- assert.Equal(t, tm.ErrTaskNotFound, err)
}
func TestUpdateTaskNoChanges(t *testing.T) {
@@ -583,15 +586,16 @@
err = gtm.writeTaskFile(originalTask)
require.NoError(t, err)
- // Update with no changes
- ctx := context.Background()
- req := &tm.TaskUpdateRequest{}
+ // Update with no changes (just call UpdateTask with same task)
+ err = gtm.UpdateTask(originalTask)
+ assert.NoError(t, err)
- updatedTask, err := gtm.UpdateTask(ctx, originalTask.ID, req)
+ // Get updated task to verify
+ updatedTask, err := gtm.GetTask(originalTask.ID)
assert.NoError(t, err)
assert.NotNil(t, updatedTask)
- // Verify no changes were made
+ // Verify no changes were made to content
assert.Equal(t, originalTask.Title, updatedTask.Title)
assert.Equal(t, originalTask.Description, updatedTask.Description)
assert.Equal(t, originalTask.Status, updatedTask.Status)
@@ -625,27 +629,31 @@
err = gtm.writeTaskFile(task)
require.NoError(t, err)
- ctx := context.Background()
-
// Test completing a task
- completedStatus := tm.StatusCompleted
- req := &tm.TaskUpdateRequest{
- Status: &completedStatus,
- }
+ task.Status = tm.StatusCompleted
+ now := time.Now()
+ task.CompletedAt = &now
- updatedTask, err := gtm.UpdateTask(ctx, task.ID, req)
+ err = gtm.UpdateTask(task)
+ assert.NoError(t, err)
+
+ // Get updated task to verify
+ updatedTask, err := gtm.GetTask(task.ID)
assert.NoError(t, err)
assert.NotNil(t, updatedTask)
assert.Equal(t, tm.StatusCompleted, updatedTask.Status)
assert.NotNil(t, updatedTask.CompletedAt)
- // Test archiving a task
- archivedStatus := tm.StatusArchived
- req = &tm.TaskUpdateRequest{
- Status: &archivedStatus,
- }
+ // Test archiving a task
+ task.Status = tm.StatusArchived
+ now = time.Now()
+ task.ArchivedAt = &now
- updatedTask, err = gtm.UpdateTask(ctx, task.ID, req)
+ err = gtm.UpdateTask(task)
+ assert.NoError(t, err)
+
+ // Get updated task to verify
+ updatedTask, err = gtm.GetTask(task.ID)
assert.NoError(t, err)
assert.NotNil(t, updatedTask)
assert.Equal(t, tm.StatusArchived, updatedTask.Status)
@@ -684,7 +692,7 @@
assert.NoError(t, err)
// Verify task was archived
- archivedTask, err := gtm.GetTask(ctx, task.ID)
+ archivedTask, err := gtm.GetTask(task.ID)
assert.NoError(t, err)
assert.Equal(t, tm.StatusArchived, archivedTask.Status)
assert.NotNil(t, archivedTask.ArchivedAt)
diff --git a/server/tm/interface.go b/server/tm/interface.go
index 93b0f29..e3fa926 100644
--- a/server/tm/interface.go
+++ b/server/tm/interface.go
@@ -8,8 +8,8 @@
type TaskManager interface {
// Task operations
CreateTask(ctx context.Context, req *TaskCreateRequest) (*Task, error)
- GetTask(ctx context.Context, id string) (*Task, error)
- UpdateTask(ctx context.Context, id string, req *TaskUpdateRequest) (*Task, error)
+ GetTask(taskID string) (*Task, error) // Simplified for MVP
+ UpdateTask(task *Task) error // Simplified for MVP
ArchiveTask(ctx context.Context, id string) error
ListTasks(ctx context.Context, filter *TaskFilter, page, pageSize int) (*TaskList, error)
@@ -19,6 +19,7 @@
// Task queries
GetTasksByOwner(ctx context.Context, ownerID string, page, pageSize int) (*TaskList, error)
+ GetTasksByAssignee(assignee string) ([]*Task, error) // For MVP auto-assignment
GetTasksByStatus(ctx context.Context, status TaskStatus, page, pageSize int) (*TaskList, error)
GetTasksByPriority(ctx context.Context, priority TaskPriority, page, pageSize int) (*TaskList, error)
}
diff --git a/server/tm/types.go b/server/tm/types.go
index 781d63d..052ba4a 100644
--- a/server/tm/types.go
+++ b/server/tm/types.go
@@ -9,8 +9,10 @@
const (
StatusToDo TaskStatus = "todo"
+ StatusPending TaskStatus = "pending" // For MVP compatibility
StatusInProgress TaskStatus = "in_progress"
StatusCompleted TaskStatus = "completed"
+ StatusFailed TaskStatus = "failed" // For error handling
StatusArchived TaskStatus = "archived"
)
@@ -31,17 +33,20 @@
// Task represents a single task in the system
type Task struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Description string `json:"description"`
- Owner Owner `json:"owner"`
- Status TaskStatus `json:"status"`
- Priority TaskPriority `json:"priority"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DueDate *time.Time `json:"due_date,omitempty"`
- CompletedAt *time.Time `json:"completed_at,omitempty"`
- ArchivedAt *time.Time `json:"archived_at,omitempty"`
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Owner Owner `json:"owner"`
+ Assignee string `json:"assignee,omitempty"` // For MVP auto-assignment
+ Status TaskStatus `json:"status"`
+ Priority TaskPriority `json:"priority"`
+ Solution string `json:"solution,omitempty"` // Generated solution
+ PullRequestURL string `json:"pull_request_url,omitempty"` // GitHub PR URL
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DueDate *time.Time `json:"due_date,omitempty"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ ArchivedAt *time.Time `json:"archived_at,omitempty"`
}
// TaskFilter represents filters for querying tasks