| package task |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "log/slog" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "strings" |
| "time" |
| |
| "github.com/iomodo/staff/git" |
| "github.com/iomodo/staff/llm" |
| "github.com/iomodo/staff/tm" |
| "golang.org/x/text/cases" |
| "golang.org/x/text/language" |
| ) |
| |
| // SubtaskService handles subtask generation and management |
| type SubtaskService struct { |
| llmProvider llm.LLMProvider |
| taskManager tm.TaskManager |
| agentRoles []string // Available agent roles for assignment |
| prProvider git.PullRequestProvider // GitHub PR provider |
| githubOwner string |
| githubRepo string |
| cloneManager *git.CloneManager |
| logger *slog.Logger |
| } |
| |
| // NewSubtaskService creates a new subtask service |
| func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string, prProvider git.PullRequestProvider, githubOwner, githubRepo string, cloneManager *git.CloneManager, logger *slog.Logger) *SubtaskService { |
| if logger == nil { |
| logger = slog.Default() |
| } |
| return &SubtaskService{ |
| llmProvider: provider, |
| taskManager: taskManager, |
| agentRoles: agentRoles, |
| prProvider: prProvider, |
| githubOwner: githubOwner, |
| githubRepo: githubRepo, |
| cloneManager: cloneManager, |
| logger: logger, |
| } |
| } |
| |
| // ShouldGenerateSubtasks asks LLM whether a task needs subtasks based on existing agents |
| func (s *SubtaskService) ShouldGenerateSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskDecision, error) { |
| prompt := s.buildSubtaskDecisionPrompt(task) |
| |
| req := llm.ChatCompletionRequest{ |
| Model: "gpt-4", |
| Messages: []llm.Message{ |
| { |
| Role: llm.RoleSystem, |
| Content: s.getSubtaskDecisionSystemPrompt(), |
| }, |
| { |
| Role: llm.RoleUser, |
| Content: prompt, |
| }, |
| }, |
| MaxTokens: &[]int{1000}[0], |
| Temperature: &[]float64{0.3}[0], |
| } |
| |
| resp, err := s.llmProvider.ChatCompletion(ctx, req) |
| if err != nil { |
| return nil, fmt.Errorf("LLM decision failed: %w", err) |
| } |
| |
| if len(resp.Choices) == 0 { |
| return nil, fmt.Errorf("no response from LLM") |
| } |
| |
| // Parse the LLM response |
| decision, err := s.parseSubtaskDecision(resp.Choices[0].Message.Content) |
| if err != nil { |
| return nil, fmt.Errorf("failed to parse LLM decision: %w", err) |
| } |
| |
| return decision, nil |
| } |
| |
| // AnalyzeTaskForSubtasks uses LLM to analyze a task and propose subtasks |
| func (s *SubtaskService) AnalyzeTaskForSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskAnalysis, error) { |
| prompt := s.buildSubtaskAnalysisPrompt(task) |
| |
| req := llm.ChatCompletionRequest{ |
| Model: "gpt-4", |
| Messages: []llm.Message{ |
| { |
| Role: llm.RoleSystem, |
| Content: s.getSubtaskAnalysisSystemPrompt(), |
| }, |
| { |
| Role: llm.RoleUser, |
| Content: prompt, |
| }, |
| }, |
| MaxTokens: &[]int{4000}[0], |
| Temperature: &[]float64{0.3}[0], |
| } |
| |
| resp, err := s.llmProvider.ChatCompletion(ctx, req) |
| if err != nil { |
| return nil, fmt.Errorf("LLM analysis failed: %w", err) |
| } |
| |
| if len(resp.Choices) == 0 { |
| return nil, fmt.Errorf("no response from LLM") |
| } |
| |
| // Parse the LLM response |
| analysis, err := s.parseSubtaskAnalysis(resp.Choices[0].Message.Content, task.ID) |
| if err != nil { |
| return nil, fmt.Errorf("failed to parse LLM response: %w", err) |
| } |
| |
| return analysis, nil |
| } |
| |
| // getSubtaskDecisionSystemPrompt returns the system prompt for subtask decision |
| func (s *SubtaskService) getSubtaskDecisionSystemPrompt() string { |
| availableRoles := strings.Join(s.agentRoles, ", ") |
| |
| return fmt.Sprintf(`You are an expert project manager and task analyst. Your job is to determine whether a task needs to be broken down into subtasks. |
| |
| Currently available team roles and their capabilities: %s |
| |
| When evaluating a task, consider: |
| 1. Task complexity and scope |
| 2. Whether multiple specialized skills are needed |
| 3. If the task can be completed by a single agent with current capabilities |
| 4. Whether new agent roles might be needed for specialized skills |
| |
| Respond with a JSON object in this exact format: |
| { |
| "needs_subtasks": true/false, |
| "reasoning": "Clear explanation of why subtasks are or aren't needed", |
| "complexity_score": 5, |
| "required_skills": ["skill1", "skill2", "skill3"] |
| } |
| |
| Complexity score should be 1-10 where: |
| - 1-3: Simple tasks that can be handled by one agent |
| - 4-6: Moderate complexity, might benefit from subtasks |
| - 7-10: Complex tasks that definitely need breaking down |
| |
| Required skills should list all technical/domain skills needed to complete the task.`, availableRoles) |
| } |
| |
| // getSubtaskAnalysisSystemPrompt returns the system prompt for subtask analysis |
| func (s *SubtaskService) getSubtaskAnalysisSystemPrompt() string { |
| availableRoles := strings.Join(s.agentRoles, ", ") |
| |
| return fmt.Sprintf(`You are an expert project manager and technical architect. Your job is to analyze complex tasks and break them down into well-defined subtasks that can be assigned to specialized team members. |
| |
| Currently available team roles: %s |
| |
| When analyzing a task, you should: |
| 1. Understand the task requirements and scope |
| 2. Break it down into logical, manageable subtasks |
| 3. Assign each subtask to the most appropriate team role OR propose creating new agents |
| 4. Estimate effort and identify dependencies |
| 5. Provide a clear execution strategy |
| |
| If you need specialized skills not covered by existing roles, propose new agent creation. |
| |
| Respond with a JSON object in this exact format: |
| { |
| "analysis_summary": "Brief analysis of the task and approach", |
| "subtasks": [ |
| { |
| "title": "Subtask title", |
| "description": "Detailed description of what needs to be done", |
| "priority": "high|medium|low", |
| "assigned_to": "role_name", |
| "estimated_hours": 8, |
| "dependencies": ["subtask_index_1", "subtask_index_2"], |
| "required_skills": ["skill1", "skill2"] |
| } |
| ], |
| "agent_creations": [ |
| { |
| "role": "new_role_name", |
| "skills": ["specialized_skill1", "specialized_skill2"], |
| "description": "Description of what this agent does", |
| "justification": "Why this new agent is needed" |
| } |
| ], |
| "recommended_approach": "High-level strategy for executing these subtasks", |
| "estimated_total_hours": 40, |
| "risk_assessment": "Potential risks and mitigation strategies" |
| } |
| |
| For existing roles, use: %s |
| For new agents, propose appropriate role names and skill sets. |
| Dependencies should reference subtask indices (e.g., ["0", "1"] means depends on first and second subtasks).`, availableRoles, availableRoles) |
| } |
| |
| // buildSubtaskDecisionPrompt creates the user prompt for subtask decision |
| func (s *SubtaskService) buildSubtaskDecisionPrompt(task *tm.Task) string { |
| return fmt.Sprintf(`Please evaluate whether the following task needs to be broken down into subtasks: |
| |
| **Task Title:** %s |
| |
| **Description:** %s |
| |
| **Priority:** %s |
| |
| **Current Status:** %s |
| |
| Consider: |
| - Can this be completed by a single agent with existing capabilities? |
| - Does it require multiple specialized skills? |
| - Is the scope too large for one person? |
| - Are there logical components that could be parallelized? |
| |
| Provide your decision in the JSON format specified in the system prompt.`, |
| task.Title, |
| task.Description, |
| task.Priority, |
| task.Status) |
| } |
| |
| // buildSubtaskAnalysisPrompt creates the user prompt for LLM analysis |
| func (s *SubtaskService) buildSubtaskAnalysisPrompt(task *tm.Task) string { |
| return fmt.Sprintf(`Please analyze the following task and break it down into subtasks: |
| |
| **Task Title:** %s |
| |
| **Description:** %s |
| |
| **Priority:** %s |
| |
| **Current Status:** %s |
| |
| Please analyze this task and provide a detailed breakdown into subtasks. Consider: |
| - Technical complexity and requirements |
| - Logical task dependencies |
| - Appropriate skill sets needed for each subtask |
| - Risk factors and potential blockers |
| - Estimated effort for each component |
| |
| Provide the analysis in the JSON format specified in the system prompt.`, |
| task.Title, |
| task.Description, |
| task.Priority, |
| task.Status) |
| } |
| |
| // parseSubtaskDecision parses the LLM response into a SubtaskDecision struct |
| func (s *SubtaskService) parseSubtaskDecision(response string) (*tm.SubtaskDecision, error) { |
| // Try to extract JSON from the response |
| jsonStart := strings.Index(response, "{") |
| jsonEnd := strings.LastIndex(response, "}") |
| |
| if jsonStart == -1 || jsonEnd == -1 { |
| return nil, fmt.Errorf("no JSON found in LLM response") |
| } |
| |
| jsonStr := response[jsonStart : jsonEnd+1] |
| |
| var decision tm.SubtaskDecision |
| if err := json.Unmarshal([]byte(jsonStr), &decision); err != nil { |
| return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) |
| } |
| |
| return &decision, nil |
| } |
| |
| // parseSubtaskAnalysis parses the LLM response into a SubtaskAnalysis struct |
| func (s *SubtaskService) parseSubtaskAnalysis(response string, parentTaskID string) (*tm.SubtaskAnalysis, error) { |
| // Try to extract JSON from the response (LLM might wrap it in markdown) |
| jsonStart := strings.Index(response, "{") |
| jsonEnd := strings.LastIndex(response, "}") |
| |
| if jsonStart == -1 || jsonEnd == -1 { |
| return nil, fmt.Errorf("no JSON found in LLM response") |
| } |
| |
| jsonStr := response[jsonStart : jsonEnd+1] |
| |
| var rawAnalysis struct { |
| AnalysisSummary string `json:"analysis_summary"` |
| Subtasks []struct { |
| Title string `json:"title"` |
| Description string `json:"description"` |
| Priority string `json:"priority"` |
| AssignedTo string `json:"assigned_to"` |
| EstimatedHours int `json:"estimated_hours"` |
| Dependencies []string `json:"dependencies"` |
| RequiredSkills []string `json:"required_skills"` |
| } `json:"subtasks"` |
| AgentCreations []tm.AgentCreationProposal `json:"agent_creations"` |
| RecommendedApproach string `json:"recommended_approach"` |
| EstimatedTotalHours int `json:"estimated_total_hours"` |
| RiskAssessment string `json:"risk_assessment"` |
| } |
| |
| if err := json.Unmarshal([]byte(jsonStr), &rawAnalysis); err != nil { |
| return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) |
| } |
| |
| // Convert to our types |
| analysis := &tm.SubtaskAnalysis{ |
| ParentTaskID: parentTaskID, |
| AnalysisSummary: rawAnalysis.AnalysisSummary, |
| AgentCreations: rawAnalysis.AgentCreations, |
| RecommendedApproach: rawAnalysis.RecommendedApproach, |
| EstimatedTotalHours: rawAnalysis.EstimatedTotalHours, |
| RiskAssessment: rawAnalysis.RiskAssessment, |
| } |
| |
| // Convert subtasks |
| for _, st := range rawAnalysis.Subtasks { |
| priority := tm.PriorityMedium // default |
| switch strings.ToLower(st.Priority) { |
| case "high": |
| priority = tm.PriorityHigh |
| case "low": |
| priority = tm.PriorityLow |
| } |
| |
| subtask := tm.SubtaskProposal{ |
| Title: st.Title, |
| Description: st.Description, |
| Priority: priority, |
| AssignedTo: st.AssignedTo, |
| EstimatedHours: st.EstimatedHours, |
| Dependencies: st.Dependencies, |
| RequiredSkills: st.RequiredSkills, |
| } |
| |
| analysis.Subtasks = append(analysis.Subtasks, subtask) |
| } |
| |
| // Validate agent assignments and handle new agent creation |
| if err := s.validateAndHandleAgentAssignments(analysis); err != nil { |
| s.logger.Warn("Warning during agent assignment handling", slog.String("error", err.Error())) |
| } |
| |
| return analysis, nil |
| } |
| |
| // validateAndHandleAgentAssignments validates assignments and creates agent creation subtasks if needed |
| func (s *SubtaskService) validateAndHandleAgentAssignments(analysis *tm.SubtaskAnalysis) error { |
| // Collect all agent roles that will be available (existing + proposed new ones) |
| availableRoles := make(map[string]bool) |
| for _, role := range s.agentRoles { |
| availableRoles[role] = true |
| } |
| |
| // Add proposed new agent roles |
| for _, agentCreation := range analysis.AgentCreations { |
| availableRoles[agentCreation.Role] = true |
| |
| // Create a subtask for agent creation |
| agentCreationSubtask := tm.SubtaskProposal{ |
| Title: fmt.Sprintf("Create %s Agent", cases.Title(language.English).String(agentCreation.Role)), |
| Description: fmt.Sprintf("Create and configure a new %s agent with skills: %s. %s", agentCreation.Role, strings.Join(agentCreation.Skills, ", "), agentCreation.Justification), |
| Priority: tm.PriorityHigh, // Agent creation is high priority |
| AssignedTo: "ceo", // CEO creates new agents |
| EstimatedHours: 4, // Estimated time to set up new agent |
| Dependencies: []string{}, // No dependencies for agent creation |
| RequiredSkills: []string{"agent_configuration", "system_design"}, |
| } |
| |
| // Insert at the beginning so agent creation happens first |
| analysis.Subtasks = append([]tm.SubtaskProposal{agentCreationSubtask}, analysis.Subtasks...) |
| |
| // Update dependencies to account for the new subtask at index 0 |
| for i := 1; i < len(analysis.Subtasks); i++ { |
| for j, dep := range analysis.Subtasks[i].Dependencies { |
| // Convert dependency index and increment by 1 |
| if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 { |
| analysis.Subtasks[i].Dependencies[j] = fmt.Sprintf("%d", depIndex+1) |
| } |
| } |
| } |
| } |
| |
| // Now validate all assignments against available roles |
| defaultRole := "ceo" // fallback role |
| if len(s.agentRoles) > 0 { |
| defaultRole = s.agentRoles[0] |
| } |
| |
| for i := range analysis.Subtasks { |
| if !availableRoles[analysis.Subtasks[i].AssignedTo] { |
| s.logger.Warn("Unknown agent role for subtask, using default", |
| slog.String("unknown_role", analysis.Subtasks[i].AssignedTo), |
| slog.String("subtask_title", analysis.Subtasks[i].Title), |
| slog.String("assigned_role", defaultRole)) |
| analysis.Subtasks[i].AssignedTo = defaultRole |
| } |
| } |
| |
| return nil |
| } |
| |
| // parseDependencyIndex parses a dependency string to an integer index |
| func (s *SubtaskService) parseDependencyIndex(dep string) int { |
| var idx int |
| if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil { |
| return idx |
| } |
| return -1 // Invalid dependency format |
| } |
| |
| // isValidAgentRole checks if a role is in the available agent roles |
| func (s *SubtaskService) isValidAgentRole(role string) bool { |
| for _, availableRole := range s.agentRoles { |
| if availableRole == role { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // GenerateSubtaskPR creates a PR with the proposed subtasks |
| func (s *SubtaskService) GenerateSubtaskPR(ctx context.Context, analysis *tm.SubtaskAnalysis) (string, error) { |
| if s.prProvider == nil { |
| return "", fmt.Errorf("PR provider not configured") |
| } |
| |
| // Generate branch name for subtask proposal |
| branchName := fmt.Sprintf("subtasks/%s-proposal", analysis.ParentTaskID) |
| s.logger.Info("Creating subtask PR", slog.String("branch", branchName)) |
| |
| // Create Git branch and commit subtask proposal |
| if err := s.createSubtaskBranch(ctx, analysis, branchName); err != nil { |
| return "", fmt.Errorf("failed to create subtask branch: %w", err) |
| } |
| |
| // Generate PR content |
| prContent := s.generateSubtaskPRContent(analysis) |
| title := fmt.Sprintf("Subtask Proposal: %s", analysis.ParentTaskID) |
| |
| // Validate PR content |
| if title == "" { |
| return "", fmt.Errorf("PR title cannot be empty") |
| } |
| if prContent == "" { |
| return "", fmt.Errorf("PR description cannot be empty") |
| } |
| |
| // Determine base branch (try main first, fallback to master) |
| baseBranch := s.determineBaseBranch(ctx) |
| s.logger.Info("Using base branch", slog.String("base_branch", baseBranch)) |
| |
| // Create the pull request |
| options := git.PullRequestOptions{ |
| Title: title, |
| Description: prContent, |
| HeadBranch: branchName, |
| BaseBranch: baseBranch, |
| Labels: []string{"subtasks", "proposal", "ai-generated"}, |
| Draft: false, |
| } |
| |
| s.logger.Info("Creating PR with options", |
| slog.String("title", options.Title), |
| slog.String("head_branch", options.HeadBranch), |
| slog.String("base_branch", options.BaseBranch)) |
| |
| pr, err := s.prProvider.CreatePullRequest(ctx, options) |
| if err != nil { |
| return "", fmt.Errorf("failed to create PR: %w", err) |
| } |
| |
| prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", s.githubOwner, s.githubRepo, pr.Number) |
| s.logger.Info("Generated subtask proposal PR", slog.String("pr_url", prURL)) |
| |
| return prURL, nil |
| } |
| |
| // determineBaseBranch determines the correct base branch (main or master) |
| func (s *SubtaskService) determineBaseBranch(ctx context.Context) string { |
| if s.cloneManager == nil { |
| return "main" // default fallback |
| } |
| |
| // Get clone path to check branches |
| clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service") |
| if err != nil { |
| s.logger.Warn("Failed to get clone path for base branch detection", slog.String("error", err.Error())) |
| return "main" |
| } |
| |
| // Check if main branch exists |
| gitCmd := func(args ...string) *exec.Cmd { |
| return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...) |
| } |
| |
| // Try to checkout main branch |
| cmd := gitCmd("show-ref", "refs/remotes/origin/main") |
| if err := cmd.Run(); err == nil { |
| return "main" |
| } |
| |
| // Try to checkout master branch |
| cmd = gitCmd("show-ref", "refs/remotes/origin/master") |
| if err := cmd.Run(); err == nil { |
| return "master" |
| } |
| |
| // Default to main if neither can be detected |
| s.logger.Warn("Could not determine base branch, defaulting to 'main'") |
| return "main" |
| } |
| |
| // generateSubtaskFile creates the content for an individual subtask file |
| func (s *SubtaskService) generateSubtaskFile(subtask tm.SubtaskProposal, taskID, parentTaskID string) string { |
| var content strings.Builder |
| |
| // Generate YAML frontmatter |
| content.WriteString("---\n") |
| content.WriteString(fmt.Sprintf("id: %s\n", taskID)) |
| content.WriteString(fmt.Sprintf("title: %s\n", subtask.Title)) |
| content.WriteString(fmt.Sprintf("description: %s\n", subtask.Description)) |
| content.WriteString(fmt.Sprintf("assignee: %s\n", subtask.AssignedTo)) |
| content.WriteString(fmt.Sprintf("owner_id: %s\n", subtask.AssignedTo)) |
| content.WriteString(fmt.Sprintf("owner_name: %s\n", subtask.AssignedTo)) |
| content.WriteString("status: todo\n") |
| content.WriteString(fmt.Sprintf("priority: %s\n", strings.ToLower(string(subtask.Priority)))) |
| content.WriteString(fmt.Sprintf("parent_task_id: %s\n", parentTaskID)) |
| content.WriteString(fmt.Sprintf("estimated_hours: %d\n", subtask.EstimatedHours)) |
| content.WriteString(fmt.Sprintf("created_at: %s\n", time.Now().Format(time.RFC3339))) |
| content.WriteString(fmt.Sprintf("updated_at: %s\n", time.Now().Format(time.RFC3339))) |
| content.WriteString("completed_at: null\n") |
| content.WriteString("archived_at: null\n") |
| |
| // Add dependencies if any |
| if len(subtask.Dependencies) > 0 { |
| content.WriteString("dependencies:\n") |
| for _, dep := range subtask.Dependencies { |
| // Convert dependency index to actual subtask ID |
| if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 { |
| depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1) |
| content.WriteString(fmt.Sprintf(" - %s\n", depTaskID)) |
| } |
| } |
| } |
| |
| // Add required skills if any |
| if len(subtask.RequiredSkills) > 0 { |
| content.WriteString("required_skills:\n") |
| for _, skill := range subtask.RequiredSkills { |
| content.WriteString(fmt.Sprintf(" - %s\n", skill)) |
| } |
| } |
| |
| content.WriteString("---\n\n") |
| |
| // Add markdown content |
| content.WriteString("# Task Description\n\n") |
| content.WriteString(fmt.Sprintf("%s\n\n", subtask.Description)) |
| |
| if subtask.EstimatedHours > 0 { |
| content.WriteString("## Estimated Effort\n\n") |
| content.WriteString(fmt.Sprintf("**Estimated Hours:** %d\n\n", subtask.EstimatedHours)) |
| } |
| |
| if len(subtask.RequiredSkills) > 0 { |
| content.WriteString("## Required Skills\n\n") |
| for _, skill := range subtask.RequiredSkills { |
| content.WriteString(fmt.Sprintf("- %s\n", skill)) |
| } |
| content.WriteString("\n") |
| } |
| |
| if len(subtask.Dependencies) > 0 { |
| content.WriteString("## Dependencies\n\n") |
| content.WriteString("This task depends on the completion of:\n\n") |
| for _, dep := range subtask.Dependencies { |
| if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 { |
| depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1) |
| content.WriteString(fmt.Sprintf("- %s\n", depTaskID)) |
| } |
| } |
| content.WriteString("\n") |
| } |
| |
| content.WriteString("## Notes\n\n") |
| content.WriteString(fmt.Sprintf("This subtask was generated from parent task: %s\n", parentTaskID)) |
| content.WriteString("Generated by Staff AI Agent System\n\n") |
| |
| return content.String() |
| } |
| |
| // updateParentTaskAsCompleted updates the parent task file to mark it as completed |
| func (s *SubtaskService) updateParentTaskAsCompleted(taskFilePath string, analysis *tm.SubtaskAnalysis) error { |
| // Read the existing parent task file |
| content, err := os.ReadFile(taskFilePath) |
| if err != nil { |
| return fmt.Errorf("failed to read parent task file: %w", err) |
| } |
| |
| taskContent := string(content) |
| |
| // Find the YAML frontmatter boundaries |
| lines := strings.Split(taskContent, "\n") |
| var frontmatterStart, frontmatterEnd int = -1, -1 |
| |
| for i, line := range lines { |
| if line == "---" { |
| if frontmatterStart == -1 { |
| frontmatterStart = i |
| } else { |
| frontmatterEnd = i |
| break |
| } |
| } |
| } |
| |
| if frontmatterStart == -1 || frontmatterEnd == -1 { |
| return fmt.Errorf("invalid task file format: missing YAML frontmatter") |
| } |
| |
| // Update the frontmatter |
| now := time.Now().Format(time.RFC3339) |
| var updatedLines []string |
| |
| // Add lines before frontmatter |
| updatedLines = append(updatedLines, lines[:frontmatterStart+1]...) |
| |
| // Process frontmatter lines |
| for i := frontmatterStart + 1; i < frontmatterEnd; i++ { |
| line := lines[i] |
| if strings.HasPrefix(line, "status:") { |
| updatedLines = append(updatedLines, "status: completed") |
| } else if strings.HasPrefix(line, "updated_at:") { |
| updatedLines = append(updatedLines, fmt.Sprintf("updated_at: %s", now)) |
| } else if strings.HasPrefix(line, "completed_at:") { |
| updatedLines = append(updatedLines, fmt.Sprintf("completed_at: %s", now)) |
| } else { |
| updatedLines = append(updatedLines, line) |
| } |
| } |
| |
| // Add closing frontmatter and rest of content |
| updatedLines = append(updatedLines, lines[frontmatterEnd:]...) |
| |
| // Add subtask information to the task description |
| if frontmatterEnd+1 < len(lines) { |
| // Add subtask information |
| subtaskInfo := fmt.Sprintf("\n\n## Subtasks Created\n\nThis task has been broken down into %d subtasks:\n\n", len(analysis.Subtasks)) |
| for i, subtask := range analysis.Subtasks { |
| subtaskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1) |
| subtaskInfo += fmt.Sprintf("- **%s**: %s (assigned to %s)\n", subtaskID, subtask.Title, subtask.AssignedTo) |
| } |
| subtaskInfo += fmt.Sprintf("\n**Total Estimated Hours:** %d\n", analysis.EstimatedTotalHours) |
| subtaskInfo += fmt.Sprintf("**Completed:** %s - Task broken down into actionable subtasks\n", now) |
| |
| // Insert subtask info before any existing body content |
| updatedContent := strings.Join(updatedLines[:], "\n") + subtaskInfo |
| |
| // Write the updated content back to the file |
| if err := os.WriteFile(taskFilePath, []byte(updatedContent), 0644); err != nil { |
| return fmt.Errorf("failed to write updated parent task file: %w", err) |
| } |
| } |
| |
| s.logger.Info("Updated parent task to completed status", slog.String("task_id", analysis.ParentTaskID)) |
| return nil |
| } |
| |
| // generateSubtaskPRContent creates markdown content for the subtask proposal PR |
| func (s *SubtaskService) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string { |
| var content strings.Builder |
| |
| content.WriteString(fmt.Sprintf("# Subtasks Created for Task %s\n\n", analysis.ParentTaskID)) |
| content.WriteString(fmt.Sprintf("This PR creates **%d individual task files** in `/operations/tasks/` ready for agent assignment.\n\n", len(analysis.Subtasks))) |
| content.WriteString(fmt.Sprintf("✅ **Parent task `%s` has been marked as completed** - the complex task has been successfully broken down into actionable subtasks.\n\n", analysis.ParentTaskID)) |
| content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary)) |
| content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach)) |
| content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours)) |
| |
| // List the created task files |
| content.WriteString("## Created Task Files\n\n") |
| for i, subtask := range analysis.Subtasks { |
| taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1) |
| content.WriteString(fmt.Sprintf("### %d. `%s.md`\n", i+1, taskID)) |
| content.WriteString(fmt.Sprintf("- **Title:** %s\n", subtask.Title)) |
| content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo)) |
| content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority)) |
| content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours)) |
| content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description)) |
| } |
| |
| if analysis.RiskAssessment != "" { |
| content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment)) |
| } |
| |
| content.WriteString("## Proposed Subtasks\n\n") |
| |
| for i, subtask := range analysis.Subtasks { |
| content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title)) |
| content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo)) |
| content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority)) |
| content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours)) |
| |
| if len(subtask.Dependencies) > 0 { |
| deps := strings.Join(subtask.Dependencies, ", ") |
| content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps)) |
| } |
| |
| content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description)) |
| } |
| |
| content.WriteString("---\n") |
| content.WriteString("*Generated by Staff AI Agent System*\n\n") |
| content.WriteString("**Instructions:**\n") |
| content.WriteString("- Review the proposed subtasks\n") |
| content.WriteString("- Approve or request changes\n") |
| content.WriteString("- When merged, the subtasks will be automatically created and assigned\n") |
| |
| return content.String() |
| } |
| |
| // createSubtaskBranch creates a Git branch with subtask proposal content |
| func (s *SubtaskService) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName string) error { |
| if s.cloneManager == nil { |
| return fmt.Errorf("clone manager not configured") |
| } |
| |
| // Get a temporary clone for creating the subtask branch |
| clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service") |
| if err != nil { |
| return fmt.Errorf("failed to get clone path: %w", err) |
| } |
| |
| // All Git operations use the 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) |
| } |
| } |
| |
| // Pull latest changes |
| cmd = gitCmd("pull", "origin") |
| if err := cmd.Run(); err != nil { |
| s.logger.Warn("Failed to pull latest changes", slog.String("error", err.Error())) |
| } |
| |
| // Delete branch if it exists (cleanup from previous attempts) |
| cmd = gitCmd("branch", "-D", branchName) |
| _ = cmd.Run() // Ignore error if branch doesn't exist |
| |
| // Also delete remote tracking branch if it exists |
| cmd = gitCmd("push", "origin", "--delete", branchName) |
| _ = cmd.Run() // Ignore error if branch doesn't exist |
| |
| // Create and checkout new branch |
| cmd = gitCmd("checkout", "-b", branchName) |
| if err := cmd.Run(); err != nil { |
| return fmt.Errorf("failed to create branch: %w", err) |
| } |
| |
| // Create individual task files for each subtask |
| tasksDir := filepath.Join(clonePath, "operations", "tasks") |
| if err := os.MkdirAll(tasksDir, 0755); err != nil { |
| return fmt.Errorf("failed to create tasks directory: %w", err) |
| } |
| |
| var stagedFiles []string |
| |
| // Update parent task to mark as completed |
| parentTaskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", analysis.ParentTaskID)) |
| if err := s.updateParentTaskAsCompleted(parentTaskFile, analysis); err != nil { |
| return fmt.Errorf("failed to update parent task: %w", err) |
| } |
| |
| // Track parent task file for staging |
| parentRelativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", analysis.ParentTaskID)) |
| stagedFiles = append(stagedFiles, parentRelativeFile) |
| s.logger.Info("Updated parent task file", slog.String("file", parentRelativeFile)) |
| |
| // Create a file for each subtask |
| for i, subtask := range analysis.Subtasks { |
| taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1) |
| taskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", taskID)) |
| taskContent := s.generateSubtaskFile(subtask, taskID, analysis.ParentTaskID) |
| |
| if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { |
| return fmt.Errorf("failed to write subtask file %s: %w", taskID, err) |
| } |
| |
| // Track file for staging |
| relativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", taskID)) |
| stagedFiles = append(stagedFiles, relativeFile) |
| s.logger.Info("Created subtask file", slog.String("file", relativeFile)) |
| } |
| |
| // Stage all subtask files |
| for _, file := range stagedFiles { |
| cmd = gitCmd("add", file) |
| if err := cmd.Run(); err != nil { |
| return fmt.Errorf("failed to stage file %s: %w", file, err) |
| } |
| } |
| |
| // Commit changes |
| commitMsg := fmt.Sprintf("Create %d subtasks for task %s and mark parent as completed\n\nGenerated by Staff AI Agent System\n\nFiles modified:\n- %s.md (marked as completed)\n\nCreated individual task files:\n", |
| len(analysis.Subtasks), analysis.ParentTaskID, analysis.ParentTaskID) |
| |
| // Add list of created files to commit message |
| for i := range analysis.Subtasks { |
| taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1) |
| commitMsg += fmt.Sprintf("- %s.md\n", taskID) |
| } |
| |
| if len(analysis.AgentCreations) > 0 { |
| commitMsg += fmt.Sprintf("\nProposed %d new agents for specialized skills", len(analysis.AgentCreations)) |
| } |
| 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) |
| } |
| |
| s.logger.Info("Created subtask proposal branch", slog.String("branch", branchName)) |
| return nil |
| } |
| |
| // Close cleans up the service |
| func (s *SubtaskService) Close() error { |
| if s.llmProvider != nil { |
| return s.llmProvider.Close() |
| } |
| return nil |
| } |