blob: c1a1bc99abce00b1ec6c7fa627aed048edb89961 [file] [log] [blame]
iomodod9ff8da2025-07-28 11:42:22 +04001package subtasks
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log"
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"
17)
18
19// SubtaskService handles subtask generation and management
20type SubtaskService struct {
21 llmProvider llm.LLMProvider
22 taskManager tm.TaskManager
23 agentRoles []string // Available agent roles for assignment
iomodo443b20a2025-07-28 15:24:05 +040024 prProvider git.PullRequestProvider // GitHub PR provider
25 githubOwner string
26 githubRepo string
27 cloneManager *git.CloneManager
iomodod9ff8da2025-07-28 11:42:22 +040028}
29
30// NewSubtaskService creates a new subtask service
iomodo443b20a2025-07-28 15:24:05 +040031func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string, prProvider git.PullRequestProvider, githubOwner, githubRepo string, cloneManager *git.CloneManager) *SubtaskService {
iomodod9ff8da2025-07-28 11:42:22 +040032 return &SubtaskService{
iomodo443b20a2025-07-28 15:24:05 +040033 llmProvider: provider,
34 taskManager: taskManager,
35 agentRoles: agentRoles,
36 prProvider: prProvider,
37 githubOwner: githubOwner,
38 githubRepo: githubRepo,
39 cloneManager: cloneManager,
iomodod9ff8da2025-07-28 11:42:22 +040040 }
41}
42
iomodo5c99a442025-07-28 14:23:52 +040043// ShouldGenerateSubtasks asks LLM whether a task needs subtasks based on existing agents
44func (s *SubtaskService) ShouldGenerateSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskDecision, error) {
45 prompt := s.buildSubtaskDecisionPrompt(task)
46
47 req := llm.ChatCompletionRequest{
48 Model: "gpt-4",
49 Messages: []llm.Message{
50 {
51 Role: llm.RoleSystem,
52 Content: s.getSubtaskDecisionSystemPrompt(),
53 },
54 {
55 Role: llm.RoleUser,
56 Content: prompt,
57 },
58 },
59 MaxTokens: &[]int{1000}[0],
60 Temperature: &[]float64{0.3}[0],
61 }
62
63 resp, err := s.llmProvider.ChatCompletion(ctx, req)
64 if err != nil {
65 return nil, fmt.Errorf("LLM decision failed: %w", err)
66 }
67
68 if len(resp.Choices) == 0 {
69 return nil, fmt.Errorf("no response from LLM")
70 }
71
72 // Parse the LLM response
73 decision, err := s.parseSubtaskDecision(resp.Choices[0].Message.Content)
74 if err != nil {
75 return nil, fmt.Errorf("failed to parse LLM decision: %w", err)
76 }
77
78 return decision, nil
79}
80
iomodod9ff8da2025-07-28 11:42:22 +040081// AnalyzeTaskForSubtasks uses LLM to analyze a task and propose subtasks
82func (s *SubtaskService) AnalyzeTaskForSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskAnalysis, error) {
83 prompt := s.buildSubtaskAnalysisPrompt(task)
84
85 req := llm.ChatCompletionRequest{
86 Model: "gpt-4",
87 Messages: []llm.Message{
88 {
89 Role: llm.RoleSystem,
90 Content: s.getSubtaskAnalysisSystemPrompt(),
91 },
92 {
93 Role: llm.RoleUser,
94 Content: prompt,
95 },
96 },
97 MaxTokens: &[]int{4000}[0],
98 Temperature: &[]float64{0.3}[0],
99 }
100
101 resp, err := s.llmProvider.ChatCompletion(ctx, req)
102 if err != nil {
103 return nil, fmt.Errorf("LLM analysis failed: %w", err)
104 }
105
106 if len(resp.Choices) == 0 {
107 return nil, fmt.Errorf("no response from LLM")
108 }
109
110 // Parse the LLM response
111 analysis, err := s.parseSubtaskAnalysis(resp.Choices[0].Message.Content, task.ID)
112 if err != nil {
113 return nil, fmt.Errorf("failed to parse LLM response: %w", err)
114 }
115
116 return analysis, nil
117}
118
iomodo5c99a442025-07-28 14:23:52 +0400119// getSubtaskDecisionSystemPrompt returns the system prompt for subtask decision
120func (s *SubtaskService) getSubtaskDecisionSystemPrompt() string {
121 availableRoles := strings.Join(s.agentRoles, ", ")
122
123 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.
124
125Currently available team roles and their capabilities: %s
126
127When evaluating a task, consider:
1281. Task complexity and scope
1292. Whether multiple specialized skills are needed
1303. If the task can be completed by a single agent with current capabilities
1314. Whether new agent roles might be needed for specialized skills
132
133Respond with a JSON object in this exact format:
134{
135 "needs_subtasks": true/false,
136 "reasoning": "Clear explanation of why subtasks are or aren't needed",
137 "complexity_score": 5,
138 "required_skills": ["skill1", "skill2", "skill3"]
139}
140
141Complexity score should be 1-10 where:
142- 1-3: Simple tasks that can be handled by one agent
143- 4-6: Moderate complexity, might benefit from subtasks
144- 7-10: Complex tasks that definitely need breaking down
145
146Required skills should list all technical/domain skills needed to complete the task.`, availableRoles)
147}
148
iomodod9ff8da2025-07-28 11:42:22 +0400149// getSubtaskAnalysisSystemPrompt returns the system prompt for subtask analysis
150func (s *SubtaskService) getSubtaskAnalysisSystemPrompt() string {
151 availableRoles := strings.Join(s.agentRoles, ", ")
152
153 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.
154
iomodo5c99a442025-07-28 14:23:52 +0400155Currently available team roles: %s
iomodod9ff8da2025-07-28 11:42:22 +0400156
157When analyzing a task, you should:
1581. Understand the task requirements and scope
1592. Break it down into logical, manageable subtasks
iomodo5c99a442025-07-28 14:23:52 +04001603. Assign each subtask to the most appropriate team role OR propose creating new agents
iomodod9ff8da2025-07-28 11:42:22 +04001614. Estimate effort and identify dependencies
1625. Provide a clear execution strategy
163
iomodo5c99a442025-07-28 14:23:52 +0400164If you need specialized skills not covered by existing roles, propose new agent creation.
165
iomodod9ff8da2025-07-28 11:42:22 +0400166Respond with a JSON object in this exact format:
167{
168 "analysis_summary": "Brief analysis of the task and approach",
169 "subtasks": [
170 {
171 "title": "Subtask title",
172 "description": "Detailed description of what needs to be done",
173 "priority": "high|medium|low",
174 "assigned_to": "role_name",
175 "estimated_hours": 8,
iomodo5c99a442025-07-28 14:23:52 +0400176 "dependencies": ["subtask_index_1", "subtask_index_2"],
177 "required_skills": ["skill1", "skill2"]
178 }
179 ],
180 "agent_creations": [
181 {
182 "role": "new_role_name",
183 "skills": ["specialized_skill1", "specialized_skill2"],
184 "description": "Description of what this agent does",
185 "justification": "Why this new agent is needed"
iomodod9ff8da2025-07-28 11:42:22 +0400186 }
187 ],
188 "recommended_approach": "High-level strategy for executing these subtasks",
189 "estimated_total_hours": 40,
190 "risk_assessment": "Potential risks and mitigation strategies"
191}
192
iomodo5c99a442025-07-28 14:23:52 +0400193For existing roles, use: %s
194For new agents, propose appropriate role names and skill sets.
195Dependencies should reference subtask indices (e.g., ["0", "1"] means depends on first and second subtasks).`, availableRoles, availableRoles)
196}
197
198// buildSubtaskDecisionPrompt creates the user prompt for subtask decision
199func (s *SubtaskService) buildSubtaskDecisionPrompt(task *tm.Task) string {
200 return fmt.Sprintf(`Please evaluate whether the following task needs to be broken down into subtasks:
201
202**Task Title:** %s
203
204**Description:** %s
205
206**Priority:** %s
207
208**Current Status:** %s
209
210Consider:
211- Can this be completed by a single agent with existing capabilities?
212- Does it require multiple specialized skills?
213- Is the scope too large for one person?
214- Are there logical components that could be parallelized?
215
216Provide your decision in the JSON format specified in the system prompt.`,
217 task.Title,
218 task.Description,
219 task.Priority,
220 task.Status)
iomodod9ff8da2025-07-28 11:42:22 +0400221}
222
223// buildSubtaskAnalysisPrompt creates the user prompt for LLM analysis
224func (s *SubtaskService) buildSubtaskAnalysisPrompt(task *tm.Task) string {
225 return fmt.Sprintf(`Please analyze the following task and break it down into subtasks:
226
227**Task Title:** %s
228
229**Description:** %s
230
231**Priority:** %s
232
233**Current Status:** %s
234
235Please analyze this task and provide a detailed breakdown into subtasks. Consider:
236- Technical complexity and requirements
237- Logical task dependencies
238- Appropriate skill sets needed for each subtask
239- Risk factors and potential blockers
240- Estimated effort for each component
241
242Provide the analysis in the JSON format specified in the system prompt.`,
243 task.Title,
244 task.Description,
245 task.Priority,
246 task.Status)
247}
248
iomodo5c99a442025-07-28 14:23:52 +0400249// parseSubtaskDecision parses the LLM response into a SubtaskDecision struct
250func (s *SubtaskService) parseSubtaskDecision(response string) (*tm.SubtaskDecision, error) {
251 // Try to extract JSON from the response
252 jsonStart := strings.Index(response, "{")
253 jsonEnd := strings.LastIndex(response, "}")
254
255 if jsonStart == -1 || jsonEnd == -1 {
256 return nil, fmt.Errorf("no JSON found in LLM response")
257 }
258
259 jsonStr := response[jsonStart : jsonEnd+1]
260
261 var decision tm.SubtaskDecision
262 if err := json.Unmarshal([]byte(jsonStr), &decision); err != nil {
263 return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
264 }
265
266 return &decision, nil
267}
268
iomodod9ff8da2025-07-28 11:42:22 +0400269// parseSubtaskAnalysis parses the LLM response into a SubtaskAnalysis struct
270func (s *SubtaskService) parseSubtaskAnalysis(response string, parentTaskID string) (*tm.SubtaskAnalysis, error) {
271 // Try to extract JSON from the response (LLM might wrap it in markdown)
272 jsonStart := strings.Index(response, "{")
273 jsonEnd := strings.LastIndex(response, "}")
274
275 if jsonStart == -1 || jsonEnd == -1 {
276 return nil, fmt.Errorf("no JSON found in LLM response")
277 }
278
279 jsonStr := response[jsonStart : jsonEnd+1]
280
281 var rawAnalysis struct {
282 AnalysisSummary string `json:"analysis_summary"`
283 Subtasks []struct {
284 Title string `json:"title"`
285 Description string `json:"description"`
286 Priority string `json:"priority"`
287 AssignedTo string `json:"assigned_to"`
288 EstimatedHours int `json:"estimated_hours"`
289 Dependencies []string `json:"dependencies"`
iomodo5c99a442025-07-28 14:23:52 +0400290 RequiredSkills []string `json:"required_skills"`
iomodod9ff8da2025-07-28 11:42:22 +0400291 } `json:"subtasks"`
iomodo5c99a442025-07-28 14:23:52 +0400292 AgentCreations []tm.AgentCreationProposal `json:"agent_creations"`
iomodod9ff8da2025-07-28 11:42:22 +0400293 RecommendedApproach string `json:"recommended_approach"`
294 EstimatedTotalHours int `json:"estimated_total_hours"`
295 RiskAssessment string `json:"risk_assessment"`
296 }
297
298 if err := json.Unmarshal([]byte(jsonStr), &rawAnalysis); err != nil {
299 return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
300 }
301
302 // Convert to our types
303 analysis := &tm.SubtaskAnalysis{
304 ParentTaskID: parentTaskID,
305 AnalysisSummary: rawAnalysis.AnalysisSummary,
iomodo5c99a442025-07-28 14:23:52 +0400306 AgentCreations: rawAnalysis.AgentCreations,
iomodod9ff8da2025-07-28 11:42:22 +0400307 RecommendedApproach: rawAnalysis.RecommendedApproach,
308 EstimatedTotalHours: rawAnalysis.EstimatedTotalHours,
309 RiskAssessment: rawAnalysis.RiskAssessment,
310 }
311
312 // Convert subtasks
313 for _, st := range rawAnalysis.Subtasks {
314 priority := tm.PriorityMedium // default
315 switch strings.ToLower(st.Priority) {
316 case "high":
317 priority = tm.PriorityHigh
318 case "low":
319 priority = tm.PriorityLow
320 }
321
322 subtask := tm.SubtaskProposal{
323 Title: st.Title,
324 Description: st.Description,
325 Priority: priority,
326 AssignedTo: st.AssignedTo,
327 EstimatedHours: st.EstimatedHours,
328 Dependencies: st.Dependencies,
iomodo5c99a442025-07-28 14:23:52 +0400329 RequiredSkills: st.RequiredSkills,
iomodod9ff8da2025-07-28 11:42:22 +0400330 }
331
332 analysis.Subtasks = append(analysis.Subtasks, subtask)
333 }
334
iomodo5c99a442025-07-28 14:23:52 +0400335 // Validate agent assignments and handle new agent creation
336 if err := s.validateAndHandleAgentAssignments(analysis); err != nil {
337 log.Printf("Warning during agent assignment handling: %v", err)
iomodod9ff8da2025-07-28 11:42:22 +0400338 }
339
340 return analysis, nil
341}
342
iomodo5c99a442025-07-28 14:23:52 +0400343// validateAndHandleAgentAssignments validates assignments and creates agent creation subtasks if needed
344func (s *SubtaskService) validateAndHandleAgentAssignments(analysis *tm.SubtaskAnalysis) error {
345 // Collect all agent roles that will be available (existing + proposed new ones)
346 availableRoles := make(map[string]bool)
347 for _, role := range s.agentRoles {
348 availableRoles[role] = true
349 }
350
351 // Add proposed new agent roles
352 for _, agentCreation := range analysis.AgentCreations {
353 availableRoles[agentCreation.Role] = true
354
355 // Create a subtask for agent creation
356 agentCreationSubtask := tm.SubtaskProposal{
357 Title: fmt.Sprintf("Create %s Agent", strings.Title(agentCreation.Role)),
358 Description: fmt.Sprintf("Create and configure a new %s agent with skills: %s. %s", agentCreation.Role, strings.Join(agentCreation.Skills, ", "), agentCreation.Justification),
359 Priority: tm.PriorityHigh, // Agent creation is high priority
360 AssignedTo: "ceo", // CEO creates new agents
361 EstimatedHours: 4, // Estimated time to set up new agent
362 Dependencies: []string{}, // No dependencies for agent creation
363 RequiredSkills: []string{"agent_configuration", "system_design"},
364 }
365
366 // Insert at the beginning so agent creation happens first
367 analysis.Subtasks = append([]tm.SubtaskProposal{agentCreationSubtask}, analysis.Subtasks...)
368
369 // Update dependencies to account for the new subtask at index 0
370 for i := 1; i < len(analysis.Subtasks); i++ {
371 for j, dep := range analysis.Subtasks[i].Dependencies {
372 // Convert dependency index and increment by 1
373 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
374 analysis.Subtasks[i].Dependencies[j] = fmt.Sprintf("%d", depIndex+1)
375 }
376 }
iomodod9ff8da2025-07-28 11:42:22 +0400377 }
378 }
iomodo5c99a442025-07-28 14:23:52 +0400379
380 // Now validate all assignments against available roles
iomodod9ff8da2025-07-28 11:42:22 +0400381 defaultRole := "ceo" // fallback role
382 if len(s.agentRoles) > 0 {
383 defaultRole = s.agentRoles[0]
384 }
385
386 for i := range analysis.Subtasks {
iomodo5c99a442025-07-28 14:23:52 +0400387 if !availableRoles[analysis.Subtasks[i].AssignedTo] {
388 log.Printf("Warning: Unknown agent role '%s' for subtask '%s', assigning to %s",
389 analysis.Subtasks[i].AssignedTo, analysis.Subtasks[i].Title, defaultRole)
iomodod9ff8da2025-07-28 11:42:22 +0400390 analysis.Subtasks[i].AssignedTo = defaultRole
391 }
392 }
iomodo5c99a442025-07-28 14:23:52 +0400393
394 return nil
395}
396
397// parseDependencyIndex parses a dependency string to an integer index
398func (s *SubtaskService) parseDependencyIndex(dep string) int {
399 var idx int
400 if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
401 return idx
402 }
403 return -1 // Invalid dependency format
iomodod9ff8da2025-07-28 11:42:22 +0400404}
405
406// isValidAgentRole checks if a role is in the available agent roles
407func (s *SubtaskService) isValidAgentRole(role string) bool {
408 for _, availableRole := range s.agentRoles {
409 if availableRole == role {
410 return true
411 }
412 }
413 return false
414}
415
416// GenerateSubtaskPR creates a PR with the proposed subtasks
417func (s *SubtaskService) GenerateSubtaskPR(ctx context.Context, analysis *tm.SubtaskAnalysis) (string, error) {
iomodo443b20a2025-07-28 15:24:05 +0400418 if s.prProvider == nil {
419 return "", fmt.Errorf("PR provider not configured")
420 }
421
422 // Generate branch name for subtask proposal
423 branchName := fmt.Sprintf("subtasks/%s-proposal", analysis.ParentTaskID)
iomodo43ec6ae2025-07-28 17:40:12 +0400424 log.Printf("Creating subtask PR with branch: %s", branchName)
iomodo443b20a2025-07-28 15:24:05 +0400425
426 // Create Git branch and commit subtask proposal
427 if err := s.createSubtaskBranch(ctx, analysis, branchName); err != nil {
428 return "", fmt.Errorf("failed to create subtask branch: %w", err)
429 }
430
431 // Generate PR content
iomodod9ff8da2025-07-28 11:42:22 +0400432 prContent := s.generateSubtaskPRContent(analysis)
iomodo443b20a2025-07-28 15:24:05 +0400433 title := fmt.Sprintf("Subtask Proposal: %s", analysis.ParentTaskID)
iomodo43ec6ae2025-07-28 17:40:12 +0400434
435 // Validate PR content
436 if title == "" {
437 return "", fmt.Errorf("PR title cannot be empty")
438 }
439 if prContent == "" {
440 return "", fmt.Errorf("PR description cannot be empty")
441 }
442
443 // Determine base branch (try main first, fallback to master)
444 baseBranch := s.determineBaseBranch(ctx)
445 log.Printf("Using base branch: %s", baseBranch)
iomodo443b20a2025-07-28 15:24:05 +0400446
447 // Create the pull request
448 options := git.PullRequestOptions{
449 Title: title,
450 Description: prContent,
451 HeadBranch: branchName,
iomodo43ec6ae2025-07-28 17:40:12 +0400452 BaseBranch: baseBranch,
iomodo443b20a2025-07-28 15:24:05 +0400453 Labels: []string{"subtasks", "proposal", "ai-generated"},
454 Draft: false,
455 }
456
iomodo43ec6ae2025-07-28 17:40:12 +0400457 log.Printf("Creating PR with options: title=%s, head=%s, base=%s", options.Title, options.HeadBranch, options.BaseBranch)
458
iomodo443b20a2025-07-28 15:24:05 +0400459 pr, err := s.prProvider.CreatePullRequest(ctx, options)
460 if err != nil {
461 return "", fmt.Errorf("failed to create PR: %w", err)
462 }
463
464 prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", s.githubOwner, s.githubRepo, pr.Number)
iomodod9ff8da2025-07-28 11:42:22 +0400465 log.Printf("Generated subtask proposal PR: %s", prURL)
iomodo443b20a2025-07-28 15:24:05 +0400466
iomodod9ff8da2025-07-28 11:42:22 +0400467 return prURL, nil
468}
469
iomodo43ec6ae2025-07-28 17:40:12 +0400470// determineBaseBranch determines the correct base branch (main or master)
471func (s *SubtaskService) determineBaseBranch(ctx context.Context) string {
472 if s.cloneManager == nil {
473 return "main" // default fallback
474 }
475
476 // Get clone path to check branches
477 clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
478 if err != nil {
479 log.Printf("Warning: failed to get clone path for base branch detection: %v", err)
480 return "main"
481 }
482
483 // Check if main branch exists
484 gitCmd := func(args ...string) *exec.Cmd {
485 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
486 }
487
488 // Try to checkout main branch
489 cmd := gitCmd("show-ref", "refs/remotes/origin/main")
490 if err := cmd.Run(); err == nil {
491 return "main"
492 }
493
494 // Try to checkout master branch
495 cmd = gitCmd("show-ref", "refs/remotes/origin/master")
496 if err := cmd.Run(); err == nil {
497 return "master"
498 }
499
500 // Default to main if neither can be detected
501 log.Printf("Warning: Could not determine base branch, defaulting to 'main'")
502 return "main"
503}
504
505// generateSubtaskFile creates the content for an individual subtask file
506func (s *SubtaskService) generateSubtaskFile(subtask tm.SubtaskProposal, taskID, parentTaskID string) string {
507 var content strings.Builder
508
509 // Generate YAML frontmatter
510 content.WriteString("---\n")
511 content.WriteString(fmt.Sprintf("id: %s\n", taskID))
512 content.WriteString(fmt.Sprintf("title: %s\n", subtask.Title))
513 content.WriteString(fmt.Sprintf("description: %s\n", subtask.Description))
514 content.WriteString(fmt.Sprintf("assignee: %s\n", subtask.AssignedTo))
515 content.WriteString(fmt.Sprintf("owner_id: %s\n", subtask.AssignedTo))
516 content.WriteString(fmt.Sprintf("owner_name: %s\n", subtask.AssignedTo))
517 content.WriteString(fmt.Sprintf("status: todo\n"))
518 content.WriteString(fmt.Sprintf("priority: %s\n", strings.ToLower(string(subtask.Priority))))
519 content.WriteString(fmt.Sprintf("parent_task_id: %s\n", parentTaskID))
520 content.WriteString(fmt.Sprintf("estimated_hours: %d\n", subtask.EstimatedHours))
521 content.WriteString(fmt.Sprintf("created_at: %s\n", time.Now().Format(time.RFC3339)))
522 content.WriteString(fmt.Sprintf("updated_at: %s\n", time.Now().Format(time.RFC3339)))
523 content.WriteString("completed_at: null\n")
524 content.WriteString("archived_at: null\n")
525
526 // Add dependencies if any
527 if len(subtask.Dependencies) > 0 {
528 content.WriteString("dependencies:\n")
529 for _, dep := range subtask.Dependencies {
530 // Convert dependency index to actual subtask ID
531 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
532 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
533 content.WriteString(fmt.Sprintf(" - %s\n", depTaskID))
534 }
535 }
536 }
537
538 // Add required skills if any
539 if len(subtask.RequiredSkills) > 0 {
540 content.WriteString("required_skills:\n")
541 for _, skill := range subtask.RequiredSkills {
542 content.WriteString(fmt.Sprintf(" - %s\n", skill))
543 }
544 }
545
546 content.WriteString("---\n\n")
547
548 // Add markdown content
549 content.WriteString("# Task Description\n\n")
550 content.WriteString(fmt.Sprintf("%s\n\n", subtask.Description))
551
552 if subtask.EstimatedHours > 0 {
553 content.WriteString("## Estimated Effort\n\n")
554 content.WriteString(fmt.Sprintf("**Estimated Hours:** %d\n\n", subtask.EstimatedHours))
555 }
556
557 if len(subtask.RequiredSkills) > 0 {
558 content.WriteString("## Required Skills\n\n")
559 for _, skill := range subtask.RequiredSkills {
560 content.WriteString(fmt.Sprintf("- %s\n", skill))
561 }
562 content.WriteString("\n")
563 }
564
565 if len(subtask.Dependencies) > 0 {
566 content.WriteString("## Dependencies\n\n")
567 content.WriteString("This task depends on the completion of:\n\n")
568 for _, dep := range subtask.Dependencies {
569 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
570 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
571 content.WriteString(fmt.Sprintf("- %s\n", depTaskID))
572 }
573 }
574 content.WriteString("\n")
575 }
576
577 content.WriteString("## Notes\n\n")
578 content.WriteString(fmt.Sprintf("This subtask was generated from parent task: %s\n", parentTaskID))
579 content.WriteString("Generated by Staff AI Agent System\n\n")
580
581 return content.String()
582}
583
584// updateParentTaskAsCompleted updates the parent task file to mark it as completed
585func (s *SubtaskService) updateParentTaskAsCompleted(taskFilePath string, analysis *tm.SubtaskAnalysis) error {
586 // Read the existing parent task file
587 content, err := os.ReadFile(taskFilePath)
588 if err != nil {
589 return fmt.Errorf("failed to read parent task file: %w", err)
590 }
591
592 taskContent := string(content)
593
594 // Find the YAML frontmatter boundaries
595 lines := strings.Split(taskContent, "\n")
596 var frontmatterStart, frontmatterEnd int = -1, -1
597
598 for i, line := range lines {
599 if line == "---" {
600 if frontmatterStart == -1 {
601 frontmatterStart = i
602 } else {
603 frontmatterEnd = i
604 break
605 }
606 }
607 }
608
609 if frontmatterStart == -1 || frontmatterEnd == -1 {
610 return fmt.Errorf("invalid task file format: missing YAML frontmatter")
611 }
612
613 // Update the frontmatter
614 now := time.Now().Format(time.RFC3339)
615 var updatedLines []string
616
617 // Add lines before frontmatter
618 updatedLines = append(updatedLines, lines[:frontmatterStart+1]...)
619
620 // Process frontmatter lines
621 for i := frontmatterStart + 1; i < frontmatterEnd; i++ {
622 line := lines[i]
623 if strings.HasPrefix(line, "status:") {
624 updatedLines = append(updatedLines, "status: completed")
625 } else if strings.HasPrefix(line, "updated_at:") {
626 updatedLines = append(updatedLines, fmt.Sprintf("updated_at: %s", now))
627 } else if strings.HasPrefix(line, "completed_at:") {
628 updatedLines = append(updatedLines, fmt.Sprintf("completed_at: %s", now))
629 } else {
630 updatedLines = append(updatedLines, line)
631 }
632 }
633
634 // Add closing frontmatter and rest of content
635 updatedLines = append(updatedLines, lines[frontmatterEnd:]...)
636
637 // Add subtask information to the task description
638 if frontmatterEnd+1 < len(lines) {
639 // Add subtask information
640 subtaskInfo := fmt.Sprintf("\n\n## Subtasks Created\n\nThis task has been broken down into %d subtasks:\n\n", len(analysis.Subtasks))
641 for i, subtask := range analysis.Subtasks {
642 subtaskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
643 subtaskInfo += fmt.Sprintf("- **%s**: %s (assigned to %s)\n", subtaskID, subtask.Title, subtask.AssignedTo)
644 }
645 subtaskInfo += fmt.Sprintf("\n**Total Estimated Hours:** %d\n", analysis.EstimatedTotalHours)
646 subtaskInfo += fmt.Sprintf("**Completed:** %s - Task broken down into actionable subtasks\n", now)
647
648 // Insert subtask info before any existing body content
649 updatedContent := strings.Join(updatedLines[:len(updatedLines)], "\n") + subtaskInfo
650
651 // Write the updated content back to the file
652 if err := os.WriteFile(taskFilePath, []byte(updatedContent), 0644); err != nil {
653 return fmt.Errorf("failed to write updated parent task file: %w", err)
654 }
655 }
656
657 log.Printf("Updated parent task %s to completed status", analysis.ParentTaskID)
658 return nil
659}
660
iomodod9ff8da2025-07-28 11:42:22 +0400661// generateSubtaskPRContent creates markdown content for the subtask proposal PR
662func (s *SubtaskService) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string {
663 var content strings.Builder
664
iomodo43ec6ae2025-07-28 17:40:12 +0400665 content.WriteString(fmt.Sprintf("# Subtasks Created for Task %s\n\n", analysis.ParentTaskID))
666 content.WriteString(fmt.Sprintf("This PR creates **%d individual task files** in `/operations/tasks/` ready for agent assignment.\n\n", len(analysis.Subtasks)))
667 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 +0400668 content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary))
669 content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach))
670 content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours))
671
iomodo43ec6ae2025-07-28 17:40:12 +0400672 // List the created task files
673 content.WriteString("## Created Task Files\n\n")
674 for i, subtask := range analysis.Subtasks {
675 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
676 content.WriteString(fmt.Sprintf("### %d. `%s.md`\n", i+1, taskID))
677 content.WriteString(fmt.Sprintf("- **Title:** %s\n", subtask.Title))
678 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
679 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
680 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
681 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
682 }
683
iomodod9ff8da2025-07-28 11:42:22 +0400684 if analysis.RiskAssessment != "" {
685 content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment))
686 }
687
688 content.WriteString("## Proposed Subtasks\n\n")
689
690 for i, subtask := range analysis.Subtasks {
691 content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title))
692 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
693 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
694 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
695
696 if len(subtask.Dependencies) > 0 {
697 deps := strings.Join(subtask.Dependencies, ", ")
698 content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps))
699 }
700
701 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
702 }
703
704 content.WriteString("---\n")
705 content.WriteString("*Generated by Staff AI Agent System*\n\n")
706 content.WriteString("**Instructions:**\n")
707 content.WriteString("- Review the proposed subtasks\n")
708 content.WriteString("- Approve or request changes\n")
709 content.WriteString("- When merged, the subtasks will be automatically created and assigned\n")
710
711 return content.String()
712}
713
iomodo443b20a2025-07-28 15:24:05 +0400714// createSubtaskBranch creates a Git branch with subtask proposal content
715func (s *SubtaskService) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName string) error {
716 if s.cloneManager == nil {
717 return fmt.Errorf("clone manager not configured")
718 }
719
720 // Get a temporary clone for creating the subtask branch
721 clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
722 if err != nil {
723 return fmt.Errorf("failed to get clone path: %w", err)
724 }
725
726 // All Git operations use the clone directory
727 gitCmd := func(args ...string) *exec.Cmd {
728 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
729 }
730
731 // Ensure we're on main branch before creating new branch
732 cmd := gitCmd("checkout", "main")
733 if err := cmd.Run(); err != nil {
734 // Try master branch if main doesn't exist
735 cmd = gitCmd("checkout", "master")
736 if err := cmd.Run(); err != nil {
737 return fmt.Errorf("failed to checkout main/master branch: %w", err)
738 }
739 }
740
741 // Pull latest changes
742 cmd = gitCmd("pull", "origin")
743 if err := cmd.Run(); err != nil {
744 log.Printf("Warning: failed to pull latest changes: %v", err)
745 }
746
iomodo43ec6ae2025-07-28 17:40:12 +0400747 // Delete branch if it exists (cleanup from previous attempts)
748 cmd = gitCmd("branch", "-D", branchName)
749 _ = cmd.Run() // Ignore error if branch doesn't exist
750
751 // Also delete remote tracking branch if it exists
752 cmd = gitCmd("push", "origin", "--delete", branchName)
753 _ = cmd.Run() // Ignore error if branch doesn't exist
754
iomodo443b20a2025-07-28 15:24:05 +0400755 // Create and checkout new branch
756 cmd = gitCmd("checkout", "-b", branchName)
757 if err := cmd.Run(); err != nil {
758 return fmt.Errorf("failed to create branch: %w", err)
759 }
760
iomodo43ec6ae2025-07-28 17:40:12 +0400761 // Create individual task files for each subtask
762 tasksDir := filepath.Join(clonePath, "operations", "tasks")
763 if err := os.MkdirAll(tasksDir, 0755); err != nil {
764 return fmt.Errorf("failed to create tasks directory: %w", err)
iomodo443b20a2025-07-28 15:24:05 +0400765 }
766
iomodo43ec6ae2025-07-28 17:40:12 +0400767 var stagedFiles []string
iomodo443b20a2025-07-28 15:24:05 +0400768
iomodo43ec6ae2025-07-28 17:40:12 +0400769 // Update parent task to mark as completed
770 parentTaskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", analysis.ParentTaskID))
771 if err := s.updateParentTaskAsCompleted(parentTaskFile, analysis); err != nil {
772 return fmt.Errorf("failed to update parent task: %w", err)
773 }
774
775 // Track parent task file for staging
776 parentRelativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", analysis.ParentTaskID))
777 stagedFiles = append(stagedFiles, parentRelativeFile)
778 log.Printf("Updated parent task file: %s", parentRelativeFile)
779
780 // Create a file for each subtask
781 for i, subtask := range analysis.Subtasks {
782 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
783 taskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", taskID))
784 taskContent := s.generateSubtaskFile(subtask, taskID, analysis.ParentTaskID)
785
786 if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil {
787 return fmt.Errorf("failed to write subtask file %s: %w", taskID, err)
788 }
789
790 // Track file for staging
791 relativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", taskID))
792 stagedFiles = append(stagedFiles, relativeFile)
793 log.Printf("Created subtask file: %s", relativeFile)
iomodo443b20a2025-07-28 15:24:05 +0400794 }
795
iomodo43ec6ae2025-07-28 17:40:12 +0400796 // Stage all subtask files
797 for _, file := range stagedFiles {
798 cmd = gitCmd("add", file)
799 if err := cmd.Run(); err != nil {
800 return fmt.Errorf("failed to stage file %s: %w", file, err)
801 }
iomodo443b20a2025-07-28 15:24:05 +0400802 }
803
804 // Commit changes
iomodo43ec6ae2025-07-28 17:40:12 +0400805 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",
806 len(analysis.Subtasks), analysis.ParentTaskID, analysis.ParentTaskID)
807
808 // Add list of created files to commit message
809 for i := range analysis.Subtasks {
810 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
811 commitMsg += fmt.Sprintf("- %s.md\n", taskID)
812 }
813
814 if len(analysis.AgentCreations) > 0 {
815 commitMsg += fmt.Sprintf("\nProposed %d new agents for specialized skills", len(analysis.AgentCreations))
816 }
iomodo443b20a2025-07-28 15:24:05 +0400817 cmd = gitCmd("commit", "-m", commitMsg)
818 if err := cmd.Run(); err != nil {
819 return fmt.Errorf("failed to commit: %w", err)
820 }
821
822 // Push branch
823 cmd = gitCmd("push", "-u", "origin", branchName)
824 if err := cmd.Run(); err != nil {
825 return fmt.Errorf("failed to push branch: %w", err)
826 }
827
828 log.Printf("Created subtask proposal branch: %s", branchName)
829 return nil
830}
831
iomodo443b20a2025-07-28 15:24:05 +0400832
iomodod9ff8da2025-07-28 11:42:22 +0400833// Close cleans up the service
834func (s *SubtaskService) Close() error {
835 if s.llmProvider != nil {
836 return s.llmProvider.Close()
837 }
838 return nil
839}