blob: ba6a4c44cca227221ba701a1e10d00ba70e8e876 [file] [log] [blame]
iomododea44b02025-07-29 12:55:25 +04001package task
iomodod9ff8da2025-07-28 11:42:22 +04002
3import (
4 "context"
5 "encoding/json"
6 "fmt"
iomodo62da94a2025-07-28 19:01:55 +04007 "log/slog"
iomodo443b20a2025-07-28 15:24:05 +04008 "os"
9 "os/exec"
10 "path/filepath"
iomodod9ff8da2025-07-28 11:42:22 +040011 "strings"
iomodo443b20a2025-07-28 15:24:05 +040012 "time"
iomodod9ff8da2025-07-28 11:42:22 +040013
iomodo443b20a2025-07-28 15:24:05 +040014 "github.com/iomodo/staff/git"
iomodod9ff8da2025-07-28 11:42:22 +040015 "github.com/iomodo/staff/llm"
16 "github.com/iomodo/staff/tm"
iomodoaf998792025-07-28 19:05:18 +040017 "golang.org/x/text/cases"
18 "golang.org/x/text/language"
iomodod9ff8da2025-07-28 11:42:22 +040019)
20
21// SubtaskService handles subtask generation and management
22type SubtaskService struct {
iomodoaf998792025-07-28 19:05:18 +040023 llmProvider llm.LLMProvider
24 taskManager tm.TaskManager
25 agentRoles []string // Available agent roles for assignment
26 prProvider git.PullRequestProvider // GitHub PR provider
27 githubOwner string
28 githubRepo string
29 cloneManager *git.CloneManager
30 logger *slog.Logger
iomodod9ff8da2025-07-28 11:42:22 +040031}
32
33// NewSubtaskService creates a new subtask service
iomodo62da94a2025-07-28 19:01:55 +040034func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string, prProvider git.PullRequestProvider, githubOwner, githubRepo string, cloneManager *git.CloneManager, logger *slog.Logger) *SubtaskService {
35 if logger == nil {
36 logger = slog.Default()
37 }
iomodod9ff8da2025-07-28 11:42:22 +040038 return &SubtaskService{
iomodo443b20a2025-07-28 15:24:05 +040039 llmProvider: provider,
40 taskManager: taskManager,
41 agentRoles: agentRoles,
42 prProvider: prProvider,
43 githubOwner: githubOwner,
44 githubRepo: githubRepo,
45 cloneManager: cloneManager,
iomodo62da94a2025-07-28 19:01:55 +040046 logger: logger,
iomodod9ff8da2025-07-28 11:42:22 +040047 }
48}
49
iomodo5c99a442025-07-28 14:23:52 +040050// ShouldGenerateSubtasks asks LLM whether a task needs subtasks based on existing agents
51func (s *SubtaskService) ShouldGenerateSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskDecision, error) {
52 prompt := s.buildSubtaskDecisionPrompt(task)
iomodoaf998792025-07-28 19:05:18 +040053
iomodo5c99a442025-07-28 14:23:52 +040054 req := llm.ChatCompletionRequest{
55 Model: "gpt-4",
56 Messages: []llm.Message{
57 {
58 Role: llm.RoleSystem,
59 Content: s.getSubtaskDecisionSystemPrompt(),
60 },
61 {
62 Role: llm.RoleUser,
63 Content: prompt,
64 },
65 },
66 MaxTokens: &[]int{1000}[0],
67 Temperature: &[]float64{0.3}[0],
68 }
69
70 resp, err := s.llmProvider.ChatCompletion(ctx, req)
71 if err != nil {
72 return nil, fmt.Errorf("LLM decision failed: %w", err)
73 }
74
75 if len(resp.Choices) == 0 {
76 return nil, fmt.Errorf("no response from LLM")
77 }
78
79 // Parse the LLM response
80 decision, err := s.parseSubtaskDecision(resp.Choices[0].Message.Content)
81 if err != nil {
82 return nil, fmt.Errorf("failed to parse LLM decision: %w", err)
83 }
84
85 return decision, nil
86}
87
iomodod9ff8da2025-07-28 11:42:22 +040088// AnalyzeTaskForSubtasks uses LLM to analyze a task and propose subtasks
89func (s *SubtaskService) AnalyzeTaskForSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskAnalysis, error) {
90 prompt := s.buildSubtaskAnalysisPrompt(task)
iomodoaf998792025-07-28 19:05:18 +040091
iomodod9ff8da2025-07-28 11:42:22 +040092 req := llm.ChatCompletionRequest{
93 Model: "gpt-4",
94 Messages: []llm.Message{
95 {
96 Role: llm.RoleSystem,
97 Content: s.getSubtaskAnalysisSystemPrompt(),
98 },
99 {
100 Role: llm.RoleUser,
101 Content: prompt,
102 },
103 },
104 MaxTokens: &[]int{4000}[0],
105 Temperature: &[]float64{0.3}[0],
106 }
107
108 resp, err := s.llmProvider.ChatCompletion(ctx, req)
109 if err != nil {
110 return nil, fmt.Errorf("LLM analysis failed: %w", err)
111 }
112
113 if len(resp.Choices) == 0 {
114 return nil, fmt.Errorf("no response from LLM")
115 }
116
117 // Parse the LLM response
118 analysis, err := s.parseSubtaskAnalysis(resp.Choices[0].Message.Content, task.ID)
119 if err != nil {
120 return nil, fmt.Errorf("failed to parse LLM response: %w", err)
121 }
122
123 return analysis, nil
124}
125
iomodo5c99a442025-07-28 14:23:52 +0400126// getSubtaskDecisionSystemPrompt returns the system prompt for subtask decision
127func (s *SubtaskService) getSubtaskDecisionSystemPrompt() string {
128 availableRoles := strings.Join(s.agentRoles, ", ")
iomodoaf998792025-07-28 19:05:18 +0400129
iomodo5c99a442025-07-28 14:23:52 +0400130 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.
131
132Currently available team roles and their capabilities: %s
133
134When evaluating a task, consider:
1351. Task complexity and scope
1362. Whether multiple specialized skills are needed
1373. If the task can be completed by a single agent with current capabilities
1384. Whether new agent roles might be needed for specialized skills
139
140Respond with a JSON object in this exact format:
141{
142 "needs_subtasks": true/false,
143 "reasoning": "Clear explanation of why subtasks are or aren't needed",
144 "complexity_score": 5,
145 "required_skills": ["skill1", "skill2", "skill3"]
146}
147
148Complexity score should be 1-10 where:
149- 1-3: Simple tasks that can be handled by one agent
150- 4-6: Moderate complexity, might benefit from subtasks
151- 7-10: Complex tasks that definitely need breaking down
152
153Required skills should list all technical/domain skills needed to complete the task.`, availableRoles)
154}
155
iomodod9ff8da2025-07-28 11:42:22 +0400156// getSubtaskAnalysisSystemPrompt returns the system prompt for subtask analysis
157func (s *SubtaskService) getSubtaskAnalysisSystemPrompt() string {
158 availableRoles := strings.Join(s.agentRoles, ", ")
iomodoaf998792025-07-28 19:05:18 +0400159
iomodod9ff8da2025-07-28 11:42:22 +0400160 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.
161
iomodo5c99a442025-07-28 14:23:52 +0400162Currently available team roles: %s
iomodod9ff8da2025-07-28 11:42:22 +0400163
164When analyzing a task, you should:
1651. Understand the task requirements and scope
1662. Break it down into logical, manageable subtasks
iomodo5c99a442025-07-28 14:23:52 +04001673. Assign each subtask to the most appropriate team role OR propose creating new agents
iomodod9ff8da2025-07-28 11:42:22 +04001684. Estimate effort and identify dependencies
1695. Provide a clear execution strategy
170
iomodo5c99a442025-07-28 14:23:52 +0400171If you need specialized skills not covered by existing roles, propose new agent creation.
172
iomodod9ff8da2025-07-28 11:42:22 +0400173Respond with a JSON object in this exact format:
174{
175 "analysis_summary": "Brief analysis of the task and approach",
176 "subtasks": [
177 {
178 "title": "Subtask title",
179 "description": "Detailed description of what needs to be done",
180 "priority": "high|medium|low",
181 "assigned_to": "role_name",
182 "estimated_hours": 8,
iomodo5c99a442025-07-28 14:23:52 +0400183 "dependencies": ["subtask_index_1", "subtask_index_2"],
184 "required_skills": ["skill1", "skill2"]
185 }
186 ],
187 "agent_creations": [
188 {
189 "role": "new_role_name",
190 "skills": ["specialized_skill1", "specialized_skill2"],
191 "description": "Description of what this agent does",
192 "justification": "Why this new agent is needed"
iomodod9ff8da2025-07-28 11:42:22 +0400193 }
194 ],
195 "recommended_approach": "High-level strategy for executing these subtasks",
196 "estimated_total_hours": 40,
197 "risk_assessment": "Potential risks and mitigation strategies"
198}
199
iomodo5c99a442025-07-28 14:23:52 +0400200For existing roles, use: %s
201For new agents, propose appropriate role names and skill sets.
202Dependencies should reference subtask indices (e.g., ["0", "1"] means depends on first and second subtasks).`, availableRoles, availableRoles)
203}
204
205// buildSubtaskDecisionPrompt creates the user prompt for subtask decision
206func (s *SubtaskService) buildSubtaskDecisionPrompt(task *tm.Task) string {
207 return fmt.Sprintf(`Please evaluate whether the following task needs to be broken down into subtasks:
208
209**Task Title:** %s
210
211**Description:** %s
212
213**Priority:** %s
214
215**Current Status:** %s
216
217Consider:
218- Can this be completed by a single agent with existing capabilities?
219- Does it require multiple specialized skills?
220- Is the scope too large for one person?
221- Are there logical components that could be parallelized?
222
iomodoaf998792025-07-28 19:05:18 +0400223Provide your decision in the JSON format specified in the system prompt.`,
224 task.Title,
225 task.Description,
226 task.Priority,
iomodo5c99a442025-07-28 14:23:52 +0400227 task.Status)
iomodod9ff8da2025-07-28 11:42:22 +0400228}
229
230// buildSubtaskAnalysisPrompt creates the user prompt for LLM analysis
231func (s *SubtaskService) buildSubtaskAnalysisPrompt(task *tm.Task) string {
232 return fmt.Sprintf(`Please analyze the following task and break it down into subtasks:
233
234**Task Title:** %s
235
236**Description:** %s
237
238**Priority:** %s
239
240**Current Status:** %s
241
242Please analyze this task and provide a detailed breakdown into subtasks. Consider:
243- Technical complexity and requirements
244- Logical task dependencies
245- Appropriate skill sets needed for each subtask
246- Risk factors and potential blockers
247- Estimated effort for each component
248
iomodoaf998792025-07-28 19:05:18 +0400249Provide the analysis in the JSON format specified in the system prompt.`,
250 task.Title,
251 task.Description,
252 task.Priority,
iomodod9ff8da2025-07-28 11:42:22 +0400253 task.Status)
254}
255
iomodo5c99a442025-07-28 14:23:52 +0400256// parseSubtaskDecision parses the LLM response into a SubtaskDecision struct
257func (s *SubtaskService) parseSubtaskDecision(response string) (*tm.SubtaskDecision, error) {
258 // Try to extract JSON from the response
259 jsonStart := strings.Index(response, "{")
260 jsonEnd := strings.LastIndex(response, "}")
iomodoaf998792025-07-28 19:05:18 +0400261
iomodo5c99a442025-07-28 14:23:52 +0400262 if jsonStart == -1 || jsonEnd == -1 {
263 return nil, fmt.Errorf("no JSON found in LLM response")
264 }
iomodoaf998792025-07-28 19:05:18 +0400265
iomodo5c99a442025-07-28 14:23:52 +0400266 jsonStr := response[jsonStart : jsonEnd+1]
iomodoaf998792025-07-28 19:05:18 +0400267
iomodo5c99a442025-07-28 14:23:52 +0400268 var decision tm.SubtaskDecision
269 if err := json.Unmarshal([]byte(jsonStr), &decision); err != nil {
270 return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
271 }
iomodoaf998792025-07-28 19:05:18 +0400272
iomodo5c99a442025-07-28 14:23:52 +0400273 return &decision, nil
274}
275
iomodod9ff8da2025-07-28 11:42:22 +0400276// parseSubtaskAnalysis parses the LLM response into a SubtaskAnalysis struct
277func (s *SubtaskService) parseSubtaskAnalysis(response string, parentTaskID string) (*tm.SubtaskAnalysis, error) {
278 // Try to extract JSON from the response (LLM might wrap it in markdown)
279 jsonStart := strings.Index(response, "{")
280 jsonEnd := strings.LastIndex(response, "}")
iomodoaf998792025-07-28 19:05:18 +0400281
iomodod9ff8da2025-07-28 11:42:22 +0400282 if jsonStart == -1 || jsonEnd == -1 {
283 return nil, fmt.Errorf("no JSON found in LLM response")
284 }
iomodoaf998792025-07-28 19:05:18 +0400285
iomodod9ff8da2025-07-28 11:42:22 +0400286 jsonStr := response[jsonStart : jsonEnd+1]
iomodoaf998792025-07-28 19:05:18 +0400287
iomodod9ff8da2025-07-28 11:42:22 +0400288 var rawAnalysis struct {
iomodoaf998792025-07-28 19:05:18 +0400289 AnalysisSummary string `json:"analysis_summary"`
290 Subtasks []struct {
iomodod9ff8da2025-07-28 11:42:22 +0400291 Title string `json:"title"`
292 Description string `json:"description"`
293 Priority string `json:"priority"`
294 AssignedTo string `json:"assigned_to"`
295 EstimatedHours int `json:"estimated_hours"`
296 Dependencies []string `json:"dependencies"`
iomodo5c99a442025-07-28 14:23:52 +0400297 RequiredSkills []string `json:"required_skills"`
iomodod9ff8da2025-07-28 11:42:22 +0400298 } `json:"subtasks"`
iomodo5c99a442025-07-28 14:23:52 +0400299 AgentCreations []tm.AgentCreationProposal `json:"agent_creations"`
iomodoaf998792025-07-28 19:05:18 +0400300 RecommendedApproach string `json:"recommended_approach"`
301 EstimatedTotalHours int `json:"estimated_total_hours"`
302 RiskAssessment string `json:"risk_assessment"`
iomodod9ff8da2025-07-28 11:42:22 +0400303 }
iomodoaf998792025-07-28 19:05:18 +0400304
iomodod9ff8da2025-07-28 11:42:22 +0400305 if err := json.Unmarshal([]byte(jsonStr), &rawAnalysis); err != nil {
306 return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
307 }
iomodoaf998792025-07-28 19:05:18 +0400308
iomodod9ff8da2025-07-28 11:42:22 +0400309 // Convert to our types
310 analysis := &tm.SubtaskAnalysis{
311 ParentTaskID: parentTaskID,
312 AnalysisSummary: rawAnalysis.AnalysisSummary,
iomodo5c99a442025-07-28 14:23:52 +0400313 AgentCreations: rawAnalysis.AgentCreations,
iomodod9ff8da2025-07-28 11:42:22 +0400314 RecommendedApproach: rawAnalysis.RecommendedApproach,
315 EstimatedTotalHours: rawAnalysis.EstimatedTotalHours,
316 RiskAssessment: rawAnalysis.RiskAssessment,
317 }
iomodoaf998792025-07-28 19:05:18 +0400318
iomodod9ff8da2025-07-28 11:42:22 +0400319 // Convert subtasks
320 for _, st := range rawAnalysis.Subtasks {
321 priority := tm.PriorityMedium // default
322 switch strings.ToLower(st.Priority) {
323 case "high":
324 priority = tm.PriorityHigh
325 case "low":
326 priority = tm.PriorityLow
327 }
iomodoaf998792025-07-28 19:05:18 +0400328
iomodod9ff8da2025-07-28 11:42:22 +0400329 subtask := tm.SubtaskProposal{
330 Title: st.Title,
331 Description: st.Description,
332 Priority: priority,
333 AssignedTo: st.AssignedTo,
334 EstimatedHours: st.EstimatedHours,
335 Dependencies: st.Dependencies,
iomodo5c99a442025-07-28 14:23:52 +0400336 RequiredSkills: st.RequiredSkills,
iomodod9ff8da2025-07-28 11:42:22 +0400337 }
iomodoaf998792025-07-28 19:05:18 +0400338
iomodod9ff8da2025-07-28 11:42:22 +0400339 analysis.Subtasks = append(analysis.Subtasks, subtask)
340 }
iomodoaf998792025-07-28 19:05:18 +0400341
iomodo5c99a442025-07-28 14:23:52 +0400342 // Validate agent assignments and handle new agent creation
343 if err := s.validateAndHandleAgentAssignments(analysis); err != nil {
iomodo62da94a2025-07-28 19:01:55 +0400344 s.logger.Warn("Warning during agent assignment handling", slog.String("error", err.Error()))
iomodod9ff8da2025-07-28 11:42:22 +0400345 }
iomodoaf998792025-07-28 19:05:18 +0400346
iomodod9ff8da2025-07-28 11:42:22 +0400347 return analysis, nil
348}
349
iomodo5c99a442025-07-28 14:23:52 +0400350// validateAndHandleAgentAssignments validates assignments and creates agent creation subtasks if needed
351func (s *SubtaskService) validateAndHandleAgentAssignments(analysis *tm.SubtaskAnalysis) error {
352 // Collect all agent roles that will be available (existing + proposed new ones)
353 availableRoles := make(map[string]bool)
354 for _, role := range s.agentRoles {
355 availableRoles[role] = true
356 }
iomodoaf998792025-07-28 19:05:18 +0400357
iomodo5c99a442025-07-28 14:23:52 +0400358 // Add proposed new agent roles
359 for _, agentCreation := range analysis.AgentCreations {
360 availableRoles[agentCreation.Role] = true
iomodoaf998792025-07-28 19:05:18 +0400361
iomodo5c99a442025-07-28 14:23:52 +0400362 // Create a subtask for agent creation
363 agentCreationSubtask := tm.SubtaskProposal{
iomodoaf998792025-07-28 19:05:18 +0400364 Title: fmt.Sprintf("Create %s Agent", cases.Title(language.English).String(agentCreation.Role)),
iomodo5c99a442025-07-28 14:23:52 +0400365 Description: fmt.Sprintf("Create and configure a new %s agent with skills: %s. %s", agentCreation.Role, strings.Join(agentCreation.Skills, ", "), agentCreation.Justification),
366 Priority: tm.PriorityHigh, // Agent creation is high priority
iomodoaf998792025-07-28 19:05:18 +0400367 AssignedTo: "ceo", // CEO creates new agents
368 EstimatedHours: 4, // Estimated time to set up new agent
369 Dependencies: []string{}, // No dependencies for agent creation
iomodo5c99a442025-07-28 14:23:52 +0400370 RequiredSkills: []string{"agent_configuration", "system_design"},
371 }
iomodoaf998792025-07-28 19:05:18 +0400372
iomodo5c99a442025-07-28 14:23:52 +0400373 // Insert at the beginning so agent creation happens first
374 analysis.Subtasks = append([]tm.SubtaskProposal{agentCreationSubtask}, analysis.Subtasks...)
iomodoaf998792025-07-28 19:05:18 +0400375
iomodo5c99a442025-07-28 14:23:52 +0400376 // Update dependencies to account for the new subtask at index 0
377 for i := 1; i < len(analysis.Subtasks); i++ {
378 for j, dep := range analysis.Subtasks[i].Dependencies {
379 // Convert dependency index and increment by 1
380 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
381 analysis.Subtasks[i].Dependencies[j] = fmt.Sprintf("%d", depIndex+1)
382 }
383 }
iomodod9ff8da2025-07-28 11:42:22 +0400384 }
385 }
iomodoaf998792025-07-28 19:05:18 +0400386
iomodo5c99a442025-07-28 14:23:52 +0400387 // Now validate all assignments against available roles
iomodod9ff8da2025-07-28 11:42:22 +0400388 defaultRole := "ceo" // fallback role
389 if len(s.agentRoles) > 0 {
390 defaultRole = s.agentRoles[0]
391 }
iomodoaf998792025-07-28 19:05:18 +0400392
iomodod9ff8da2025-07-28 11:42:22 +0400393 for i := range analysis.Subtasks {
iomodo5c99a442025-07-28 14:23:52 +0400394 if !availableRoles[analysis.Subtasks[i].AssignedTo] {
iomodoaf998792025-07-28 19:05:18 +0400395 s.logger.Warn("Unknown agent role for subtask, using default",
iomodo62da94a2025-07-28 19:01:55 +0400396 slog.String("unknown_role", analysis.Subtasks[i].AssignedTo),
397 slog.String("subtask_title", analysis.Subtasks[i].Title),
398 slog.String("assigned_role", defaultRole))
iomodod9ff8da2025-07-28 11:42:22 +0400399 analysis.Subtasks[i].AssignedTo = defaultRole
400 }
401 }
iomodoaf998792025-07-28 19:05:18 +0400402
iomodo5c99a442025-07-28 14:23:52 +0400403 return nil
404}
405
406// parseDependencyIndex parses a dependency string to an integer index
407func (s *SubtaskService) parseDependencyIndex(dep string) int {
408 var idx int
409 if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
410 return idx
411 }
412 return -1 // Invalid dependency format
iomodod9ff8da2025-07-28 11:42:22 +0400413}
414
415// isValidAgentRole checks if a role is in the available agent roles
416func (s *SubtaskService) isValidAgentRole(role string) bool {
417 for _, availableRole := range s.agentRoles {
418 if availableRole == role {
419 return true
420 }
421 }
422 return false
423}
424
425// GenerateSubtaskPR creates a PR with the proposed subtasks
426func (s *SubtaskService) GenerateSubtaskPR(ctx context.Context, analysis *tm.SubtaskAnalysis) (string, error) {
iomodo443b20a2025-07-28 15:24:05 +0400427 if s.prProvider == nil {
428 return "", fmt.Errorf("PR provider not configured")
429 }
430
431 // Generate branch name for subtask proposal
432 branchName := fmt.Sprintf("subtasks/%s-proposal", analysis.ParentTaskID)
iomodo62da94a2025-07-28 19:01:55 +0400433 s.logger.Info("Creating subtask PR", slog.String("branch", branchName))
iomodo443b20a2025-07-28 15:24:05 +0400434
435 // Create Git branch and commit subtask proposal
436 if err := s.createSubtaskBranch(ctx, analysis, branchName); err != nil {
437 return "", fmt.Errorf("failed to create subtask branch: %w", err)
438 }
439
440 // Generate PR content
iomodod9ff8da2025-07-28 11:42:22 +0400441 prContent := s.generateSubtaskPRContent(analysis)
iomodo443b20a2025-07-28 15:24:05 +0400442 title := fmt.Sprintf("Subtask Proposal: %s", analysis.ParentTaskID)
iomodoaf998792025-07-28 19:05:18 +0400443
iomodo43ec6ae2025-07-28 17:40:12 +0400444 // Validate PR content
445 if title == "" {
446 return "", fmt.Errorf("PR title cannot be empty")
447 }
448 if prContent == "" {
449 return "", fmt.Errorf("PR description cannot be empty")
450 }
451
452 // Determine base branch (try main first, fallback to master)
453 baseBranch := s.determineBaseBranch(ctx)
iomodo62da94a2025-07-28 19:01:55 +0400454 s.logger.Info("Using base branch", slog.String("base_branch", baseBranch))
iomodo443b20a2025-07-28 15:24:05 +0400455
456 // Create the pull request
457 options := git.PullRequestOptions{
458 Title: title,
459 Description: prContent,
460 HeadBranch: branchName,
iomodo43ec6ae2025-07-28 17:40:12 +0400461 BaseBranch: baseBranch,
iomodo443b20a2025-07-28 15:24:05 +0400462 Labels: []string{"subtasks", "proposal", "ai-generated"},
463 Draft: false,
464 }
465
iomodo62da94a2025-07-28 19:01:55 +0400466 s.logger.Info("Creating PR with options",
467 slog.String("title", options.Title),
468 slog.String("head_branch", options.HeadBranch),
469 slog.String("base_branch", options.BaseBranch))
iomodo43ec6ae2025-07-28 17:40:12 +0400470
iomodo443b20a2025-07-28 15:24:05 +0400471 pr, err := s.prProvider.CreatePullRequest(ctx, options)
472 if err != nil {
473 return "", fmt.Errorf("failed to create PR: %w", err)
474 }
475
476 prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", s.githubOwner, s.githubRepo, pr.Number)
iomodo62da94a2025-07-28 19:01:55 +0400477 s.logger.Info("Generated subtask proposal PR", slog.String("pr_url", prURL))
iomodo443b20a2025-07-28 15:24:05 +0400478
iomodod9ff8da2025-07-28 11:42:22 +0400479 return prURL, nil
480}
481
iomodo43ec6ae2025-07-28 17:40:12 +0400482// determineBaseBranch determines the correct base branch (main or master)
483func (s *SubtaskService) determineBaseBranch(ctx context.Context) string {
484 if s.cloneManager == nil {
485 return "main" // default fallback
486 }
487
488 // Get clone path to check branches
489 clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
490 if err != nil {
iomodo62da94a2025-07-28 19:01:55 +0400491 s.logger.Warn("Failed to get clone path for base branch detection", slog.String("error", err.Error()))
iomodo43ec6ae2025-07-28 17:40:12 +0400492 return "main"
493 }
494
495 // Check if main branch exists
496 gitCmd := func(args ...string) *exec.Cmd {
497 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
498 }
499
500 // Try to checkout main branch
501 cmd := gitCmd("show-ref", "refs/remotes/origin/main")
502 if err := cmd.Run(); err == nil {
503 return "main"
504 }
505
506 // Try to checkout master branch
507 cmd = gitCmd("show-ref", "refs/remotes/origin/master")
508 if err := cmd.Run(); err == nil {
509 return "master"
510 }
511
512 // Default to main if neither can be detected
iomodo62da94a2025-07-28 19:01:55 +0400513 s.logger.Warn("Could not determine base branch, defaulting to 'main'")
iomodo43ec6ae2025-07-28 17:40:12 +0400514 return "main"
515}
516
517// generateSubtaskFile creates the content for an individual subtask file
518func (s *SubtaskService) generateSubtaskFile(subtask tm.SubtaskProposal, taskID, parentTaskID string) string {
519 var content strings.Builder
iomodoaf998792025-07-28 19:05:18 +0400520
iomodo43ec6ae2025-07-28 17:40:12 +0400521 // Generate YAML frontmatter
522 content.WriteString("---\n")
523 content.WriteString(fmt.Sprintf("id: %s\n", taskID))
524 content.WriteString(fmt.Sprintf("title: %s\n", subtask.Title))
525 content.WriteString(fmt.Sprintf("description: %s\n", subtask.Description))
526 content.WriteString(fmt.Sprintf("assignee: %s\n", subtask.AssignedTo))
527 content.WriteString(fmt.Sprintf("owner_id: %s\n", subtask.AssignedTo))
528 content.WriteString(fmt.Sprintf("owner_name: %s\n", subtask.AssignedTo))
iomodoaf998792025-07-28 19:05:18 +0400529 content.WriteString("status: todo\n")
iomodo43ec6ae2025-07-28 17:40:12 +0400530 content.WriteString(fmt.Sprintf("priority: %s\n", strings.ToLower(string(subtask.Priority))))
531 content.WriteString(fmt.Sprintf("parent_task_id: %s\n", parentTaskID))
532 content.WriteString(fmt.Sprintf("estimated_hours: %d\n", subtask.EstimatedHours))
533 content.WriteString(fmt.Sprintf("created_at: %s\n", time.Now().Format(time.RFC3339)))
534 content.WriteString(fmt.Sprintf("updated_at: %s\n", time.Now().Format(time.RFC3339)))
535 content.WriteString("completed_at: null\n")
536 content.WriteString("archived_at: null\n")
iomodoaf998792025-07-28 19:05:18 +0400537
iomodo43ec6ae2025-07-28 17:40:12 +0400538 // Add dependencies if any
539 if len(subtask.Dependencies) > 0 {
540 content.WriteString("dependencies:\n")
541 for _, dep := range subtask.Dependencies {
542 // Convert dependency index to actual subtask ID
543 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
544 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
545 content.WriteString(fmt.Sprintf(" - %s\n", depTaskID))
546 }
547 }
548 }
iomodoaf998792025-07-28 19:05:18 +0400549
iomodo43ec6ae2025-07-28 17:40:12 +0400550 // Add required skills if any
551 if len(subtask.RequiredSkills) > 0 {
552 content.WriteString("required_skills:\n")
553 for _, skill := range subtask.RequiredSkills {
554 content.WriteString(fmt.Sprintf(" - %s\n", skill))
555 }
556 }
iomodoaf998792025-07-28 19:05:18 +0400557
iomodo43ec6ae2025-07-28 17:40:12 +0400558 content.WriteString("---\n\n")
iomodoaf998792025-07-28 19:05:18 +0400559
iomodo43ec6ae2025-07-28 17:40:12 +0400560 // Add markdown content
561 content.WriteString("# Task Description\n\n")
562 content.WriteString(fmt.Sprintf("%s\n\n", subtask.Description))
iomodoaf998792025-07-28 19:05:18 +0400563
iomodo43ec6ae2025-07-28 17:40:12 +0400564 if subtask.EstimatedHours > 0 {
565 content.WriteString("## Estimated Effort\n\n")
566 content.WriteString(fmt.Sprintf("**Estimated Hours:** %d\n\n", subtask.EstimatedHours))
567 }
iomodoaf998792025-07-28 19:05:18 +0400568
iomodo43ec6ae2025-07-28 17:40:12 +0400569 if len(subtask.RequiredSkills) > 0 {
570 content.WriteString("## Required Skills\n\n")
571 for _, skill := range subtask.RequiredSkills {
572 content.WriteString(fmt.Sprintf("- %s\n", skill))
573 }
574 content.WriteString("\n")
575 }
iomodoaf998792025-07-28 19:05:18 +0400576
iomodo43ec6ae2025-07-28 17:40:12 +0400577 if len(subtask.Dependencies) > 0 {
578 content.WriteString("## Dependencies\n\n")
579 content.WriteString("This task depends on the completion of:\n\n")
580 for _, dep := range subtask.Dependencies {
581 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
582 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
583 content.WriteString(fmt.Sprintf("- %s\n", depTaskID))
584 }
585 }
586 content.WriteString("\n")
587 }
iomodoaf998792025-07-28 19:05:18 +0400588
iomodo43ec6ae2025-07-28 17:40:12 +0400589 content.WriteString("## Notes\n\n")
590 content.WriteString(fmt.Sprintf("This subtask was generated from parent task: %s\n", parentTaskID))
591 content.WriteString("Generated by Staff AI Agent System\n\n")
iomodoaf998792025-07-28 19:05:18 +0400592
iomodo43ec6ae2025-07-28 17:40:12 +0400593 return content.String()
594}
595
596// updateParentTaskAsCompleted updates the parent task file to mark it as completed
597func (s *SubtaskService) updateParentTaskAsCompleted(taskFilePath string, analysis *tm.SubtaskAnalysis) error {
598 // Read the existing parent task file
599 content, err := os.ReadFile(taskFilePath)
600 if err != nil {
601 return fmt.Errorf("failed to read parent task file: %w", err)
602 }
603
604 taskContent := string(content)
iomodoaf998792025-07-28 19:05:18 +0400605
iomodo43ec6ae2025-07-28 17:40:12 +0400606 // Find the YAML frontmatter boundaries
607 lines := strings.Split(taskContent, "\n")
608 var frontmatterStart, frontmatterEnd int = -1, -1
iomodoaf998792025-07-28 19:05:18 +0400609
iomodo43ec6ae2025-07-28 17:40:12 +0400610 for i, line := range lines {
611 if line == "---" {
612 if frontmatterStart == -1 {
613 frontmatterStart = i
614 } else {
615 frontmatterEnd = i
616 break
617 }
618 }
619 }
iomodoaf998792025-07-28 19:05:18 +0400620
iomodo43ec6ae2025-07-28 17:40:12 +0400621 if frontmatterStart == -1 || frontmatterEnd == -1 {
622 return fmt.Errorf("invalid task file format: missing YAML frontmatter")
623 }
iomodoaf998792025-07-28 19:05:18 +0400624
iomodo43ec6ae2025-07-28 17:40:12 +0400625 // Update the frontmatter
626 now := time.Now().Format(time.RFC3339)
627 var updatedLines []string
iomodoaf998792025-07-28 19:05:18 +0400628
iomodo43ec6ae2025-07-28 17:40:12 +0400629 // Add lines before frontmatter
630 updatedLines = append(updatedLines, lines[:frontmatterStart+1]...)
iomodoaf998792025-07-28 19:05:18 +0400631
iomodo43ec6ae2025-07-28 17:40:12 +0400632 // Process frontmatter lines
633 for i := frontmatterStart + 1; i < frontmatterEnd; i++ {
634 line := lines[i]
635 if strings.HasPrefix(line, "status:") {
636 updatedLines = append(updatedLines, "status: completed")
637 } else if strings.HasPrefix(line, "updated_at:") {
638 updatedLines = append(updatedLines, fmt.Sprintf("updated_at: %s", now))
639 } else if strings.HasPrefix(line, "completed_at:") {
640 updatedLines = append(updatedLines, fmt.Sprintf("completed_at: %s", now))
641 } else {
642 updatedLines = append(updatedLines, line)
643 }
644 }
iomodoaf998792025-07-28 19:05:18 +0400645
iomodo43ec6ae2025-07-28 17:40:12 +0400646 // Add closing frontmatter and rest of content
647 updatedLines = append(updatedLines, lines[frontmatterEnd:]...)
iomodoaf998792025-07-28 19:05:18 +0400648
iomodo43ec6ae2025-07-28 17:40:12 +0400649 // Add subtask information to the task description
650 if frontmatterEnd+1 < len(lines) {
651 // Add subtask information
652 subtaskInfo := fmt.Sprintf("\n\n## Subtasks Created\n\nThis task has been broken down into %d subtasks:\n\n", len(analysis.Subtasks))
653 for i, subtask := range analysis.Subtasks {
654 subtaskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
655 subtaskInfo += fmt.Sprintf("- **%s**: %s (assigned to %s)\n", subtaskID, subtask.Title, subtask.AssignedTo)
656 }
657 subtaskInfo += fmt.Sprintf("\n**Total Estimated Hours:** %d\n", analysis.EstimatedTotalHours)
658 subtaskInfo += fmt.Sprintf("**Completed:** %s - Task broken down into actionable subtasks\n", now)
iomodoaf998792025-07-28 19:05:18 +0400659
iomodo43ec6ae2025-07-28 17:40:12 +0400660 // Insert subtask info before any existing body content
iomodoaf998792025-07-28 19:05:18 +0400661 updatedContent := strings.Join(updatedLines[:], "\n") + subtaskInfo
662
iomodo43ec6ae2025-07-28 17:40:12 +0400663 // Write the updated content back to the file
664 if err := os.WriteFile(taskFilePath, []byte(updatedContent), 0644); err != nil {
665 return fmt.Errorf("failed to write updated parent task file: %w", err)
666 }
667 }
iomodoaf998792025-07-28 19:05:18 +0400668
iomodo62da94a2025-07-28 19:01:55 +0400669 s.logger.Info("Updated parent task to completed status", slog.String("task_id", analysis.ParentTaskID))
iomodo43ec6ae2025-07-28 17:40:12 +0400670 return nil
671}
672
iomodod9ff8da2025-07-28 11:42:22 +0400673// generateSubtaskPRContent creates markdown content for the subtask proposal PR
674func (s *SubtaskService) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string {
675 var content strings.Builder
iomodoaf998792025-07-28 19:05:18 +0400676
iomodo43ec6ae2025-07-28 17:40:12 +0400677 content.WriteString(fmt.Sprintf("# Subtasks Created for Task %s\n\n", analysis.ParentTaskID))
678 content.WriteString(fmt.Sprintf("This PR creates **%d individual task files** in `/operations/tasks/` ready for agent assignment.\n\n", len(analysis.Subtasks)))
679 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))
iomodod9ff8da2025-07-28 11:42:22 +0400680 content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary))
681 content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach))
682 content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours))
iomodoaf998792025-07-28 19:05:18 +0400683
iomodo43ec6ae2025-07-28 17:40:12 +0400684 // List the created task files
685 content.WriteString("## Created Task Files\n\n")
686 for i, subtask := range analysis.Subtasks {
687 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
688 content.WriteString(fmt.Sprintf("### %d. `%s.md`\n", i+1, taskID))
689 content.WriteString(fmt.Sprintf("- **Title:** %s\n", subtask.Title))
690 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
691 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
692 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
693 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
694 }
iomodoaf998792025-07-28 19:05:18 +0400695
iomodod9ff8da2025-07-28 11:42:22 +0400696 if analysis.RiskAssessment != "" {
697 content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment))
698 }
iomodoaf998792025-07-28 19:05:18 +0400699
iomodod9ff8da2025-07-28 11:42:22 +0400700 content.WriteString("## Proposed Subtasks\n\n")
iomodoaf998792025-07-28 19:05:18 +0400701
iomodod9ff8da2025-07-28 11:42:22 +0400702 for i, subtask := range analysis.Subtasks {
703 content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title))
704 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
705 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
706 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
iomodoaf998792025-07-28 19:05:18 +0400707
iomodod9ff8da2025-07-28 11:42:22 +0400708 if len(subtask.Dependencies) > 0 {
709 deps := strings.Join(subtask.Dependencies, ", ")
710 content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps))
711 }
iomodoaf998792025-07-28 19:05:18 +0400712
iomodod9ff8da2025-07-28 11:42:22 +0400713 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
714 }
iomodoaf998792025-07-28 19:05:18 +0400715
iomodod9ff8da2025-07-28 11:42:22 +0400716 content.WriteString("---\n")
717 content.WriteString("*Generated by Staff AI Agent System*\n\n")
718 content.WriteString("**Instructions:**\n")
719 content.WriteString("- Review the proposed subtasks\n")
720 content.WriteString("- Approve or request changes\n")
721 content.WriteString("- When merged, the subtasks will be automatically created and assigned\n")
iomodoaf998792025-07-28 19:05:18 +0400722
iomodod9ff8da2025-07-28 11:42:22 +0400723 return content.String()
724}
725
iomodo443b20a2025-07-28 15:24:05 +0400726// createSubtaskBranch creates a Git branch with subtask proposal content
727func (s *SubtaskService) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName string) error {
728 if s.cloneManager == nil {
729 return fmt.Errorf("clone manager not configured")
730 }
731
732 // Get a temporary clone for creating the subtask branch
733 clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
734 if err != nil {
735 return fmt.Errorf("failed to get clone path: %w", err)
736 }
737
738 // All Git operations use the clone directory
739 gitCmd := func(args ...string) *exec.Cmd {
740 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
741 }
742
743 // Ensure we're on main branch before creating new branch
744 cmd := gitCmd("checkout", "main")
745 if err := cmd.Run(); err != nil {
746 // Try master branch if main doesn't exist
747 cmd = gitCmd("checkout", "master")
748 if err := cmd.Run(); err != nil {
749 return fmt.Errorf("failed to checkout main/master branch: %w", err)
750 }
751 }
752
753 // Pull latest changes
754 cmd = gitCmd("pull", "origin")
755 if err := cmd.Run(); err != nil {
iomodo62da94a2025-07-28 19:01:55 +0400756 s.logger.Warn("Failed to pull latest changes", slog.String("error", err.Error()))
iomodo443b20a2025-07-28 15:24:05 +0400757 }
758
iomodo43ec6ae2025-07-28 17:40:12 +0400759 // Delete branch if it exists (cleanup from previous attempts)
760 cmd = gitCmd("branch", "-D", branchName)
761 _ = cmd.Run() // Ignore error if branch doesn't exist
762
763 // Also delete remote tracking branch if it exists
764 cmd = gitCmd("push", "origin", "--delete", branchName)
765 _ = cmd.Run() // Ignore error if branch doesn't exist
766
iomodo443b20a2025-07-28 15:24:05 +0400767 // Create and checkout new branch
768 cmd = gitCmd("checkout", "-b", branchName)
769 if err := cmd.Run(); err != nil {
770 return fmt.Errorf("failed to create branch: %w", err)
771 }
772
iomodo43ec6ae2025-07-28 17:40:12 +0400773 // Create individual task files for each subtask
774 tasksDir := filepath.Join(clonePath, "operations", "tasks")
775 if err := os.MkdirAll(tasksDir, 0755); err != nil {
776 return fmt.Errorf("failed to create tasks directory: %w", err)
iomodo443b20a2025-07-28 15:24:05 +0400777 }
778
iomodo43ec6ae2025-07-28 17:40:12 +0400779 var stagedFiles []string
iomodo443b20a2025-07-28 15:24:05 +0400780
iomodo43ec6ae2025-07-28 17:40:12 +0400781 // Update parent task to mark as completed
782 parentTaskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", analysis.ParentTaskID))
783 if err := s.updateParentTaskAsCompleted(parentTaskFile, analysis); err != nil {
784 return fmt.Errorf("failed to update parent task: %w", err)
785 }
iomodoaf998792025-07-28 19:05:18 +0400786
iomodo43ec6ae2025-07-28 17:40:12 +0400787 // Track parent task file for staging
788 parentRelativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", analysis.ParentTaskID))
789 stagedFiles = append(stagedFiles, parentRelativeFile)
iomodo62da94a2025-07-28 19:01:55 +0400790 s.logger.Info("Updated parent task file", slog.String("file", parentRelativeFile))
iomodo43ec6ae2025-07-28 17:40:12 +0400791
792 // Create a file for each subtask
793 for i, subtask := range analysis.Subtasks {
794 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
795 taskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", taskID))
796 taskContent := s.generateSubtaskFile(subtask, taskID, analysis.ParentTaskID)
797
798 if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil {
799 return fmt.Errorf("failed to write subtask file %s: %w", taskID, err)
800 }
801
802 // Track file for staging
803 relativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", taskID))
804 stagedFiles = append(stagedFiles, relativeFile)
iomodo62da94a2025-07-28 19:01:55 +0400805 s.logger.Info("Created subtask file", slog.String("file", relativeFile))
iomodo443b20a2025-07-28 15:24:05 +0400806 }
807
iomodo43ec6ae2025-07-28 17:40:12 +0400808 // Stage all subtask files
809 for _, file := range stagedFiles {
810 cmd = gitCmd("add", file)
811 if err := cmd.Run(); err != nil {
812 return fmt.Errorf("failed to stage file %s: %w", file, err)
813 }
iomodo443b20a2025-07-28 15:24:05 +0400814 }
815
816 // Commit changes
iomodoaf998792025-07-28 19:05:18 +0400817 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",
iomodo43ec6ae2025-07-28 17:40:12 +0400818 len(analysis.Subtasks), analysis.ParentTaskID, analysis.ParentTaskID)
iomodoaf998792025-07-28 19:05:18 +0400819
iomodo43ec6ae2025-07-28 17:40:12 +0400820 // Add list of created files to commit message
821 for i := range analysis.Subtasks {
822 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
823 commitMsg += fmt.Sprintf("- %s.md\n", taskID)
824 }
iomodoaf998792025-07-28 19:05:18 +0400825
iomodo43ec6ae2025-07-28 17:40:12 +0400826 if len(analysis.AgentCreations) > 0 {
827 commitMsg += fmt.Sprintf("\nProposed %d new agents for specialized skills", len(analysis.AgentCreations))
828 }
iomodo443b20a2025-07-28 15:24:05 +0400829 cmd = gitCmd("commit", "-m", commitMsg)
830 if err := cmd.Run(); err != nil {
831 return fmt.Errorf("failed to commit: %w", err)
832 }
833
834 // Push branch
835 cmd = gitCmd("push", "-u", "origin", branchName)
836 if err := cmd.Run(); err != nil {
837 return fmt.Errorf("failed to push branch: %w", err)
838 }
839
iomodo62da94a2025-07-28 19:01:55 +0400840 s.logger.Info("Created subtask proposal branch", slog.String("branch", branchName))
iomodo443b20a2025-07-28 15:24:05 +0400841 return nil
842}
843
iomodod9ff8da2025-07-28 11:42:22 +0400844// Close cleans up the service
845func (s *SubtaskService) Close() error {
846 if s.llmProvider != nil {
847 return s.llmProvider.Close()
848 }
849 return nil
iomodoaf998792025-07-28 19:05:18 +0400850}