blob: d704a33f0b327ca042a83d7a4a1c08954050f701 [file] [log] [blame]
iomodod9ff8da2025-07-28 11:42:22 +04001package subtasks
2
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"
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
iomodo62da94a2025-07-28 19:01:55 +040028 logger *slog.Logger
iomodod9ff8da2025-07-28 11:42:22 +040029}
30
31// NewSubtaskService creates a new subtask service
iomodo62da94a2025-07-28 19:01:55 +040032func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string, prProvider git.PullRequestProvider, githubOwner, githubRepo string, cloneManager *git.CloneManager, logger *slog.Logger) *SubtaskService {
33 if logger == nil {
34 logger = slog.Default()
35 }
iomodod9ff8da2025-07-28 11:42:22 +040036 return &SubtaskService{
iomodo443b20a2025-07-28 15:24:05 +040037 llmProvider: provider,
38 taskManager: taskManager,
39 agentRoles: agentRoles,
40 prProvider: prProvider,
41 githubOwner: githubOwner,
42 githubRepo: githubRepo,
43 cloneManager: cloneManager,
iomodo62da94a2025-07-28 19:01:55 +040044 logger: logger,
iomodod9ff8da2025-07-28 11:42:22 +040045 }
46}
47
iomodo5c99a442025-07-28 14:23:52 +040048// ShouldGenerateSubtasks asks LLM whether a task needs subtasks based on existing agents
49func (s *SubtaskService) ShouldGenerateSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskDecision, error) {
50 prompt := s.buildSubtaskDecisionPrompt(task)
51
52 req := llm.ChatCompletionRequest{
53 Model: "gpt-4",
54 Messages: []llm.Message{
55 {
56 Role: llm.RoleSystem,
57 Content: s.getSubtaskDecisionSystemPrompt(),
58 },
59 {
60 Role: llm.RoleUser,
61 Content: prompt,
62 },
63 },
64 MaxTokens: &[]int{1000}[0],
65 Temperature: &[]float64{0.3}[0],
66 }
67
68 resp, err := s.llmProvider.ChatCompletion(ctx, req)
69 if err != nil {
70 return nil, fmt.Errorf("LLM decision failed: %w", err)
71 }
72
73 if len(resp.Choices) == 0 {
74 return nil, fmt.Errorf("no response from LLM")
75 }
76
77 // Parse the LLM response
78 decision, err := s.parseSubtaskDecision(resp.Choices[0].Message.Content)
79 if err != nil {
80 return nil, fmt.Errorf("failed to parse LLM decision: %w", err)
81 }
82
83 return decision, nil
84}
85
iomodod9ff8da2025-07-28 11:42:22 +040086// AnalyzeTaskForSubtasks uses LLM to analyze a task and propose subtasks
87func (s *SubtaskService) AnalyzeTaskForSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskAnalysis, error) {
88 prompt := s.buildSubtaskAnalysisPrompt(task)
89
90 req := llm.ChatCompletionRequest{
91 Model: "gpt-4",
92 Messages: []llm.Message{
93 {
94 Role: llm.RoleSystem,
95 Content: s.getSubtaskAnalysisSystemPrompt(),
96 },
97 {
98 Role: llm.RoleUser,
99 Content: prompt,
100 },
101 },
102 MaxTokens: &[]int{4000}[0],
103 Temperature: &[]float64{0.3}[0],
104 }
105
106 resp, err := s.llmProvider.ChatCompletion(ctx, req)
107 if err != nil {
108 return nil, fmt.Errorf("LLM analysis failed: %w", err)
109 }
110
111 if len(resp.Choices) == 0 {
112 return nil, fmt.Errorf("no response from LLM")
113 }
114
115 // Parse the LLM response
116 analysis, err := s.parseSubtaskAnalysis(resp.Choices[0].Message.Content, task.ID)
117 if err != nil {
118 return nil, fmt.Errorf("failed to parse LLM response: %w", err)
119 }
120
121 return analysis, nil
122}
123
iomodo5c99a442025-07-28 14:23:52 +0400124// getSubtaskDecisionSystemPrompt returns the system prompt for subtask decision
125func (s *SubtaskService) getSubtaskDecisionSystemPrompt() string {
126 availableRoles := strings.Join(s.agentRoles, ", ")
127
128 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.
129
130Currently available team roles and their capabilities: %s
131
132When evaluating a task, consider:
1331. Task complexity and scope
1342. Whether multiple specialized skills are needed
1353. If the task can be completed by a single agent with current capabilities
1364. Whether new agent roles might be needed for specialized skills
137
138Respond with a JSON object in this exact format:
139{
140 "needs_subtasks": true/false,
141 "reasoning": "Clear explanation of why subtasks are or aren't needed",
142 "complexity_score": 5,
143 "required_skills": ["skill1", "skill2", "skill3"]
144}
145
146Complexity score should be 1-10 where:
147- 1-3: Simple tasks that can be handled by one agent
148- 4-6: Moderate complexity, might benefit from subtasks
149- 7-10: Complex tasks that definitely need breaking down
150
151Required skills should list all technical/domain skills needed to complete the task.`, availableRoles)
152}
153
iomodod9ff8da2025-07-28 11:42:22 +0400154// getSubtaskAnalysisSystemPrompt returns the system prompt for subtask analysis
155func (s *SubtaskService) getSubtaskAnalysisSystemPrompt() string {
156 availableRoles := strings.Join(s.agentRoles, ", ")
157
158 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.
159
iomodo5c99a442025-07-28 14:23:52 +0400160Currently available team roles: %s
iomodod9ff8da2025-07-28 11:42:22 +0400161
162When analyzing a task, you should:
1631. Understand the task requirements and scope
1642. Break it down into logical, manageable subtasks
iomodo5c99a442025-07-28 14:23:52 +04001653. Assign each subtask to the most appropriate team role OR propose creating new agents
iomodod9ff8da2025-07-28 11:42:22 +04001664. Estimate effort and identify dependencies
1675. Provide a clear execution strategy
168
iomodo5c99a442025-07-28 14:23:52 +0400169If you need specialized skills not covered by existing roles, propose new agent creation.
170
iomodod9ff8da2025-07-28 11:42:22 +0400171Respond with a JSON object in this exact format:
172{
173 "analysis_summary": "Brief analysis of the task and approach",
174 "subtasks": [
175 {
176 "title": "Subtask title",
177 "description": "Detailed description of what needs to be done",
178 "priority": "high|medium|low",
179 "assigned_to": "role_name",
180 "estimated_hours": 8,
iomodo5c99a442025-07-28 14:23:52 +0400181 "dependencies": ["subtask_index_1", "subtask_index_2"],
182 "required_skills": ["skill1", "skill2"]
183 }
184 ],
185 "agent_creations": [
186 {
187 "role": "new_role_name",
188 "skills": ["specialized_skill1", "specialized_skill2"],
189 "description": "Description of what this agent does",
190 "justification": "Why this new agent is needed"
iomodod9ff8da2025-07-28 11:42:22 +0400191 }
192 ],
193 "recommended_approach": "High-level strategy for executing these subtasks",
194 "estimated_total_hours": 40,
195 "risk_assessment": "Potential risks and mitigation strategies"
196}
197
iomodo5c99a442025-07-28 14:23:52 +0400198For existing roles, use: %s
199For new agents, propose appropriate role names and skill sets.
200Dependencies should reference subtask indices (e.g., ["0", "1"] means depends on first and second subtasks).`, availableRoles, availableRoles)
201}
202
203// buildSubtaskDecisionPrompt creates the user prompt for subtask decision
204func (s *SubtaskService) buildSubtaskDecisionPrompt(task *tm.Task) string {
205 return fmt.Sprintf(`Please evaluate whether the following task needs to be broken down into subtasks:
206
207**Task Title:** %s
208
209**Description:** %s
210
211**Priority:** %s
212
213**Current Status:** %s
214
215Consider:
216- Can this be completed by a single agent with existing capabilities?
217- Does it require multiple specialized skills?
218- Is the scope too large for one person?
219- Are there logical components that could be parallelized?
220
221Provide your decision in the JSON format specified in the system prompt.`,
222 task.Title,
223 task.Description,
224 task.Priority,
225 task.Status)
iomodod9ff8da2025-07-28 11:42:22 +0400226}
227
228// buildSubtaskAnalysisPrompt creates the user prompt for LLM analysis
229func (s *SubtaskService) buildSubtaskAnalysisPrompt(task *tm.Task) string {
230 return fmt.Sprintf(`Please analyze the following task and break it down into subtasks:
231
232**Task Title:** %s
233
234**Description:** %s
235
236**Priority:** %s
237
238**Current Status:** %s
239
240Please analyze this task and provide a detailed breakdown into subtasks. Consider:
241- Technical complexity and requirements
242- Logical task dependencies
243- Appropriate skill sets needed for each subtask
244- Risk factors and potential blockers
245- Estimated effort for each component
246
247Provide the analysis in the JSON format specified in the system prompt.`,
248 task.Title,
249 task.Description,
250 task.Priority,
251 task.Status)
252}
253
iomodo5c99a442025-07-28 14:23:52 +0400254// parseSubtaskDecision parses the LLM response into a SubtaskDecision struct
255func (s *SubtaskService) parseSubtaskDecision(response string) (*tm.SubtaskDecision, error) {
256 // Try to extract JSON from the response
257 jsonStart := strings.Index(response, "{")
258 jsonEnd := strings.LastIndex(response, "}")
259
260 if jsonStart == -1 || jsonEnd == -1 {
261 return nil, fmt.Errorf("no JSON found in LLM response")
262 }
263
264 jsonStr := response[jsonStart : jsonEnd+1]
265
266 var decision tm.SubtaskDecision
267 if err := json.Unmarshal([]byte(jsonStr), &decision); err != nil {
268 return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
269 }
270
271 return &decision, nil
272}
273
iomodod9ff8da2025-07-28 11:42:22 +0400274// parseSubtaskAnalysis parses the LLM response into a SubtaskAnalysis struct
275func (s *SubtaskService) parseSubtaskAnalysis(response string, parentTaskID string) (*tm.SubtaskAnalysis, error) {
276 // Try to extract JSON from the response (LLM might wrap it in markdown)
277 jsonStart := strings.Index(response, "{")
278 jsonEnd := strings.LastIndex(response, "}")
279
280 if jsonStart == -1 || jsonEnd == -1 {
281 return nil, fmt.Errorf("no JSON found in LLM response")
282 }
283
284 jsonStr := response[jsonStart : jsonEnd+1]
285
286 var rawAnalysis struct {
287 AnalysisSummary string `json:"analysis_summary"`
288 Subtasks []struct {
289 Title string `json:"title"`
290 Description string `json:"description"`
291 Priority string `json:"priority"`
292 AssignedTo string `json:"assigned_to"`
293 EstimatedHours int `json:"estimated_hours"`
294 Dependencies []string `json:"dependencies"`
iomodo5c99a442025-07-28 14:23:52 +0400295 RequiredSkills []string `json:"required_skills"`
iomodod9ff8da2025-07-28 11:42:22 +0400296 } `json:"subtasks"`
iomodo5c99a442025-07-28 14:23:52 +0400297 AgentCreations []tm.AgentCreationProposal `json:"agent_creations"`
iomodod9ff8da2025-07-28 11:42:22 +0400298 RecommendedApproach string `json:"recommended_approach"`
299 EstimatedTotalHours int `json:"estimated_total_hours"`
300 RiskAssessment string `json:"risk_assessment"`
301 }
302
303 if err := json.Unmarshal([]byte(jsonStr), &rawAnalysis); err != nil {
304 return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
305 }
306
307 // Convert to our types
308 analysis := &tm.SubtaskAnalysis{
309 ParentTaskID: parentTaskID,
310 AnalysisSummary: rawAnalysis.AnalysisSummary,
iomodo5c99a442025-07-28 14:23:52 +0400311 AgentCreations: rawAnalysis.AgentCreations,
iomodod9ff8da2025-07-28 11:42:22 +0400312 RecommendedApproach: rawAnalysis.RecommendedApproach,
313 EstimatedTotalHours: rawAnalysis.EstimatedTotalHours,
314 RiskAssessment: rawAnalysis.RiskAssessment,
315 }
316
317 // Convert subtasks
318 for _, st := range rawAnalysis.Subtasks {
319 priority := tm.PriorityMedium // default
320 switch strings.ToLower(st.Priority) {
321 case "high":
322 priority = tm.PriorityHigh
323 case "low":
324 priority = tm.PriorityLow
325 }
326
327 subtask := tm.SubtaskProposal{
328 Title: st.Title,
329 Description: st.Description,
330 Priority: priority,
331 AssignedTo: st.AssignedTo,
332 EstimatedHours: st.EstimatedHours,
333 Dependencies: st.Dependencies,
iomodo5c99a442025-07-28 14:23:52 +0400334 RequiredSkills: st.RequiredSkills,
iomodod9ff8da2025-07-28 11:42:22 +0400335 }
336
337 analysis.Subtasks = append(analysis.Subtasks, subtask)
338 }
339
iomodo5c99a442025-07-28 14:23:52 +0400340 // Validate agent assignments and handle new agent creation
341 if err := s.validateAndHandleAgentAssignments(analysis); err != nil {
iomodo62da94a2025-07-28 19:01:55 +0400342 s.logger.Warn("Warning during agent assignment handling", slog.String("error", err.Error()))
iomodod9ff8da2025-07-28 11:42:22 +0400343 }
344
345 return analysis, nil
346}
347
iomodo5c99a442025-07-28 14:23:52 +0400348// validateAndHandleAgentAssignments validates assignments and creates agent creation subtasks if needed
349func (s *SubtaskService) validateAndHandleAgentAssignments(analysis *tm.SubtaskAnalysis) error {
350 // Collect all agent roles that will be available (existing + proposed new ones)
351 availableRoles := make(map[string]bool)
352 for _, role := range s.agentRoles {
353 availableRoles[role] = true
354 }
355
356 // Add proposed new agent roles
357 for _, agentCreation := range analysis.AgentCreations {
358 availableRoles[agentCreation.Role] = true
359
360 // Create a subtask for agent creation
361 agentCreationSubtask := tm.SubtaskProposal{
362 Title: fmt.Sprintf("Create %s Agent", strings.Title(agentCreation.Role)),
363 Description: fmt.Sprintf("Create and configure a new %s agent with skills: %s. %s", agentCreation.Role, strings.Join(agentCreation.Skills, ", "), agentCreation.Justification),
364 Priority: tm.PriorityHigh, // Agent creation is high priority
365 AssignedTo: "ceo", // CEO creates new agents
366 EstimatedHours: 4, // Estimated time to set up new agent
367 Dependencies: []string{}, // No dependencies for agent creation
368 RequiredSkills: []string{"agent_configuration", "system_design"},
369 }
370
371 // Insert at the beginning so agent creation happens first
372 analysis.Subtasks = append([]tm.SubtaskProposal{agentCreationSubtask}, analysis.Subtasks...)
373
374 // Update dependencies to account for the new subtask at index 0
375 for i := 1; i < len(analysis.Subtasks); i++ {
376 for j, dep := range analysis.Subtasks[i].Dependencies {
377 // Convert dependency index and increment by 1
378 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
379 analysis.Subtasks[i].Dependencies[j] = fmt.Sprintf("%d", depIndex+1)
380 }
381 }
iomodod9ff8da2025-07-28 11:42:22 +0400382 }
383 }
iomodo5c99a442025-07-28 14:23:52 +0400384
385 // Now validate all assignments against available roles
iomodod9ff8da2025-07-28 11:42:22 +0400386 defaultRole := "ceo" // fallback role
387 if len(s.agentRoles) > 0 {
388 defaultRole = s.agentRoles[0]
389 }
390
391 for i := range analysis.Subtasks {
iomodo5c99a442025-07-28 14:23:52 +0400392 if !availableRoles[analysis.Subtasks[i].AssignedTo] {
iomodo62da94a2025-07-28 19:01:55 +0400393 s.logger.Warn("Unknown agent role for subtask, using default",
394 slog.String("unknown_role", analysis.Subtasks[i].AssignedTo),
395 slog.String("subtask_title", analysis.Subtasks[i].Title),
396 slog.String("assigned_role", defaultRole))
iomodod9ff8da2025-07-28 11:42:22 +0400397 analysis.Subtasks[i].AssignedTo = defaultRole
398 }
399 }
iomodo5c99a442025-07-28 14:23:52 +0400400
401 return nil
402}
403
404// parseDependencyIndex parses a dependency string to an integer index
405func (s *SubtaskService) parseDependencyIndex(dep string) int {
406 var idx int
407 if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
408 return idx
409 }
410 return -1 // Invalid dependency format
iomodod9ff8da2025-07-28 11:42:22 +0400411}
412
413// isValidAgentRole checks if a role is in the available agent roles
414func (s *SubtaskService) isValidAgentRole(role string) bool {
415 for _, availableRole := range s.agentRoles {
416 if availableRole == role {
417 return true
418 }
419 }
420 return false
421}
422
423// GenerateSubtaskPR creates a PR with the proposed subtasks
424func (s *SubtaskService) GenerateSubtaskPR(ctx context.Context, analysis *tm.SubtaskAnalysis) (string, error) {
iomodo443b20a2025-07-28 15:24:05 +0400425 if s.prProvider == nil {
426 return "", fmt.Errorf("PR provider not configured")
427 }
428
429 // Generate branch name for subtask proposal
430 branchName := fmt.Sprintf("subtasks/%s-proposal", analysis.ParentTaskID)
iomodo62da94a2025-07-28 19:01:55 +0400431 s.logger.Info("Creating subtask PR", slog.String("branch", branchName))
iomodo443b20a2025-07-28 15:24:05 +0400432
433 // Create Git branch and commit subtask proposal
434 if err := s.createSubtaskBranch(ctx, analysis, branchName); err != nil {
435 return "", fmt.Errorf("failed to create subtask branch: %w", err)
436 }
437
438 // Generate PR content
iomodod9ff8da2025-07-28 11:42:22 +0400439 prContent := s.generateSubtaskPRContent(analysis)
iomodo443b20a2025-07-28 15:24:05 +0400440 title := fmt.Sprintf("Subtask Proposal: %s", analysis.ParentTaskID)
iomodo43ec6ae2025-07-28 17:40:12 +0400441
442 // Validate PR content
443 if title == "" {
444 return "", fmt.Errorf("PR title cannot be empty")
445 }
446 if prContent == "" {
447 return "", fmt.Errorf("PR description cannot be empty")
448 }
449
450 // Determine base branch (try main first, fallback to master)
451 baseBranch := s.determineBaseBranch(ctx)
iomodo62da94a2025-07-28 19:01:55 +0400452 s.logger.Info("Using base branch", slog.String("base_branch", baseBranch))
iomodo443b20a2025-07-28 15:24:05 +0400453
454 // Create the pull request
455 options := git.PullRequestOptions{
456 Title: title,
457 Description: prContent,
458 HeadBranch: branchName,
iomodo43ec6ae2025-07-28 17:40:12 +0400459 BaseBranch: baseBranch,
iomodo443b20a2025-07-28 15:24:05 +0400460 Labels: []string{"subtasks", "proposal", "ai-generated"},
461 Draft: false,
462 }
463
iomodo62da94a2025-07-28 19:01:55 +0400464 s.logger.Info("Creating PR with options",
465 slog.String("title", options.Title),
466 slog.String("head_branch", options.HeadBranch),
467 slog.String("base_branch", options.BaseBranch))
iomodo43ec6ae2025-07-28 17:40:12 +0400468
iomodo443b20a2025-07-28 15:24:05 +0400469 pr, err := s.prProvider.CreatePullRequest(ctx, options)
470 if err != nil {
471 return "", fmt.Errorf("failed to create PR: %w", err)
472 }
473
474 prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", s.githubOwner, s.githubRepo, pr.Number)
iomodo62da94a2025-07-28 19:01:55 +0400475 s.logger.Info("Generated subtask proposal PR", slog.String("pr_url", prURL))
iomodo443b20a2025-07-28 15:24:05 +0400476
iomodod9ff8da2025-07-28 11:42:22 +0400477 return prURL, nil
478}
479
iomodo43ec6ae2025-07-28 17:40:12 +0400480// determineBaseBranch determines the correct base branch (main or master)
481func (s *SubtaskService) determineBaseBranch(ctx context.Context) string {
482 if s.cloneManager == nil {
483 return "main" // default fallback
484 }
485
486 // Get clone path to check branches
487 clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
488 if err != nil {
iomodo62da94a2025-07-28 19:01:55 +0400489 s.logger.Warn("Failed to get clone path for base branch detection", slog.String("error", err.Error()))
iomodo43ec6ae2025-07-28 17:40:12 +0400490 return "main"
491 }
492
493 // Check if main branch exists
494 gitCmd := func(args ...string) *exec.Cmd {
495 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
496 }
497
498 // Try to checkout main branch
499 cmd := gitCmd("show-ref", "refs/remotes/origin/main")
500 if err := cmd.Run(); err == nil {
501 return "main"
502 }
503
504 // Try to checkout master branch
505 cmd = gitCmd("show-ref", "refs/remotes/origin/master")
506 if err := cmd.Run(); err == nil {
507 return "master"
508 }
509
510 // Default to main if neither can be detected
iomodo62da94a2025-07-28 19:01:55 +0400511 s.logger.Warn("Could not determine base branch, defaulting to 'main'")
iomodo43ec6ae2025-07-28 17:40:12 +0400512 return "main"
513}
514
515// generateSubtaskFile creates the content for an individual subtask file
516func (s *SubtaskService) generateSubtaskFile(subtask tm.SubtaskProposal, taskID, parentTaskID string) string {
517 var content strings.Builder
518
519 // Generate YAML frontmatter
520 content.WriteString("---\n")
521 content.WriteString(fmt.Sprintf("id: %s\n", taskID))
522 content.WriteString(fmt.Sprintf("title: %s\n", subtask.Title))
523 content.WriteString(fmt.Sprintf("description: %s\n", subtask.Description))
524 content.WriteString(fmt.Sprintf("assignee: %s\n", subtask.AssignedTo))
525 content.WriteString(fmt.Sprintf("owner_id: %s\n", subtask.AssignedTo))
526 content.WriteString(fmt.Sprintf("owner_name: %s\n", subtask.AssignedTo))
527 content.WriteString(fmt.Sprintf("status: todo\n"))
528 content.WriteString(fmt.Sprintf("priority: %s\n", strings.ToLower(string(subtask.Priority))))
529 content.WriteString(fmt.Sprintf("parent_task_id: %s\n", parentTaskID))
530 content.WriteString(fmt.Sprintf("estimated_hours: %d\n", subtask.EstimatedHours))
531 content.WriteString(fmt.Sprintf("created_at: %s\n", time.Now().Format(time.RFC3339)))
532 content.WriteString(fmt.Sprintf("updated_at: %s\n", time.Now().Format(time.RFC3339)))
533 content.WriteString("completed_at: null\n")
534 content.WriteString("archived_at: null\n")
535
536 // Add dependencies if any
537 if len(subtask.Dependencies) > 0 {
538 content.WriteString("dependencies:\n")
539 for _, dep := range subtask.Dependencies {
540 // Convert dependency index to actual subtask ID
541 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
542 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
543 content.WriteString(fmt.Sprintf(" - %s\n", depTaskID))
544 }
545 }
546 }
547
548 // Add required skills if any
549 if len(subtask.RequiredSkills) > 0 {
550 content.WriteString("required_skills:\n")
551 for _, skill := range subtask.RequiredSkills {
552 content.WriteString(fmt.Sprintf(" - %s\n", skill))
553 }
554 }
555
556 content.WriteString("---\n\n")
557
558 // Add markdown content
559 content.WriteString("# Task Description\n\n")
560 content.WriteString(fmt.Sprintf("%s\n\n", subtask.Description))
561
562 if subtask.EstimatedHours > 0 {
563 content.WriteString("## Estimated Effort\n\n")
564 content.WriteString(fmt.Sprintf("**Estimated Hours:** %d\n\n", subtask.EstimatedHours))
565 }
566
567 if len(subtask.RequiredSkills) > 0 {
568 content.WriteString("## Required Skills\n\n")
569 for _, skill := range subtask.RequiredSkills {
570 content.WriteString(fmt.Sprintf("- %s\n", skill))
571 }
572 content.WriteString("\n")
573 }
574
575 if len(subtask.Dependencies) > 0 {
576 content.WriteString("## Dependencies\n\n")
577 content.WriteString("This task depends on the completion of:\n\n")
578 for _, dep := range subtask.Dependencies {
579 if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
580 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
581 content.WriteString(fmt.Sprintf("- %s\n", depTaskID))
582 }
583 }
584 content.WriteString("\n")
585 }
586
587 content.WriteString("## Notes\n\n")
588 content.WriteString(fmt.Sprintf("This subtask was generated from parent task: %s\n", parentTaskID))
589 content.WriteString("Generated by Staff AI Agent System\n\n")
590
591 return content.String()
592}
593
594// updateParentTaskAsCompleted updates the parent task file to mark it as completed
595func (s *SubtaskService) updateParentTaskAsCompleted(taskFilePath string, analysis *tm.SubtaskAnalysis) error {
596 // Read the existing parent task file
597 content, err := os.ReadFile(taskFilePath)
598 if err != nil {
599 return fmt.Errorf("failed to read parent task file: %w", err)
600 }
601
602 taskContent := string(content)
603
604 // Find the YAML frontmatter boundaries
605 lines := strings.Split(taskContent, "\n")
606 var frontmatterStart, frontmatterEnd int = -1, -1
607
608 for i, line := range lines {
609 if line == "---" {
610 if frontmatterStart == -1 {
611 frontmatterStart = i
612 } else {
613 frontmatterEnd = i
614 break
615 }
616 }
617 }
618
619 if frontmatterStart == -1 || frontmatterEnd == -1 {
620 return fmt.Errorf("invalid task file format: missing YAML frontmatter")
621 }
622
623 // Update the frontmatter
624 now := time.Now().Format(time.RFC3339)
625 var updatedLines []string
626
627 // Add lines before frontmatter
628 updatedLines = append(updatedLines, lines[:frontmatterStart+1]...)
629
630 // Process frontmatter lines
631 for i := frontmatterStart + 1; i < frontmatterEnd; i++ {
632 line := lines[i]
633 if strings.HasPrefix(line, "status:") {
634 updatedLines = append(updatedLines, "status: completed")
635 } else if strings.HasPrefix(line, "updated_at:") {
636 updatedLines = append(updatedLines, fmt.Sprintf("updated_at: %s", now))
637 } else if strings.HasPrefix(line, "completed_at:") {
638 updatedLines = append(updatedLines, fmt.Sprintf("completed_at: %s", now))
639 } else {
640 updatedLines = append(updatedLines, line)
641 }
642 }
643
644 // Add closing frontmatter and rest of content
645 updatedLines = append(updatedLines, lines[frontmatterEnd:]...)
646
647 // Add subtask information to the task description
648 if frontmatterEnd+1 < len(lines) {
649 // Add subtask information
650 subtaskInfo := fmt.Sprintf("\n\n## Subtasks Created\n\nThis task has been broken down into %d subtasks:\n\n", len(analysis.Subtasks))
651 for i, subtask := range analysis.Subtasks {
652 subtaskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
653 subtaskInfo += fmt.Sprintf("- **%s**: %s (assigned to %s)\n", subtaskID, subtask.Title, subtask.AssignedTo)
654 }
655 subtaskInfo += fmt.Sprintf("\n**Total Estimated Hours:** %d\n", analysis.EstimatedTotalHours)
656 subtaskInfo += fmt.Sprintf("**Completed:** %s - Task broken down into actionable subtasks\n", now)
657
658 // Insert subtask info before any existing body content
659 updatedContent := strings.Join(updatedLines[:len(updatedLines)], "\n") + subtaskInfo
660
661 // Write the updated content back to the file
662 if err := os.WriteFile(taskFilePath, []byte(updatedContent), 0644); err != nil {
663 return fmt.Errorf("failed to write updated parent task file: %w", err)
664 }
665 }
666
iomodo62da94a2025-07-28 19:01:55 +0400667 s.logger.Info("Updated parent task to completed status", slog.String("task_id", analysis.ParentTaskID))
iomodo43ec6ae2025-07-28 17:40:12 +0400668 return nil
669}
670
iomodod9ff8da2025-07-28 11:42:22 +0400671// generateSubtaskPRContent creates markdown content for the subtask proposal PR
672func (s *SubtaskService) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string {
673 var content strings.Builder
674
iomodo43ec6ae2025-07-28 17:40:12 +0400675 content.WriteString(fmt.Sprintf("# Subtasks Created for Task %s\n\n", analysis.ParentTaskID))
676 content.WriteString(fmt.Sprintf("This PR creates **%d individual task files** in `/operations/tasks/` ready for agent assignment.\n\n", len(analysis.Subtasks)))
677 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 +0400678 content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary))
679 content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach))
680 content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours))
681
iomodo43ec6ae2025-07-28 17:40:12 +0400682 // List the created task files
683 content.WriteString("## Created Task Files\n\n")
684 for i, subtask := range analysis.Subtasks {
685 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
686 content.WriteString(fmt.Sprintf("### %d. `%s.md`\n", i+1, taskID))
687 content.WriteString(fmt.Sprintf("- **Title:** %s\n", subtask.Title))
688 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
689 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
690 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
691 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
692 }
693
iomodod9ff8da2025-07-28 11:42:22 +0400694 if analysis.RiskAssessment != "" {
695 content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment))
696 }
697
698 content.WriteString("## Proposed Subtasks\n\n")
699
700 for i, subtask := range analysis.Subtasks {
701 content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title))
702 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
703 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
704 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
705
706 if len(subtask.Dependencies) > 0 {
707 deps := strings.Join(subtask.Dependencies, ", ")
708 content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps))
709 }
710
711 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
712 }
713
714 content.WriteString("---\n")
715 content.WriteString("*Generated by Staff AI Agent System*\n\n")
716 content.WriteString("**Instructions:**\n")
717 content.WriteString("- Review the proposed subtasks\n")
718 content.WriteString("- Approve or request changes\n")
719 content.WriteString("- When merged, the subtasks will be automatically created and assigned\n")
720
721 return content.String()
722}
723
iomodo443b20a2025-07-28 15:24:05 +0400724// createSubtaskBranch creates a Git branch with subtask proposal content
725func (s *SubtaskService) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName string) error {
726 if s.cloneManager == nil {
727 return fmt.Errorf("clone manager not configured")
728 }
729
730 // Get a temporary clone for creating the subtask branch
731 clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
732 if err != nil {
733 return fmt.Errorf("failed to get clone path: %w", err)
734 }
735
736 // All Git operations use the clone directory
737 gitCmd := func(args ...string) *exec.Cmd {
738 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
739 }
740
741 // Ensure we're on main branch before creating new branch
742 cmd := gitCmd("checkout", "main")
743 if err := cmd.Run(); err != nil {
744 // Try master branch if main doesn't exist
745 cmd = gitCmd("checkout", "master")
746 if err := cmd.Run(); err != nil {
747 return fmt.Errorf("failed to checkout main/master branch: %w", err)
748 }
749 }
750
751 // Pull latest changes
752 cmd = gitCmd("pull", "origin")
753 if err := cmd.Run(); err != nil {
iomodo62da94a2025-07-28 19:01:55 +0400754 s.logger.Warn("Failed to pull latest changes", slog.String("error", err.Error()))
iomodo443b20a2025-07-28 15:24:05 +0400755 }
756
iomodo43ec6ae2025-07-28 17:40:12 +0400757 // Delete branch if it exists (cleanup from previous attempts)
758 cmd = gitCmd("branch", "-D", branchName)
759 _ = cmd.Run() // Ignore error if branch doesn't exist
760
761 // Also delete remote tracking branch if it exists
762 cmd = gitCmd("push", "origin", "--delete", branchName)
763 _ = cmd.Run() // Ignore error if branch doesn't exist
764
iomodo443b20a2025-07-28 15:24:05 +0400765 // Create and checkout new branch
766 cmd = gitCmd("checkout", "-b", branchName)
767 if err := cmd.Run(); err != nil {
768 return fmt.Errorf("failed to create branch: %w", err)
769 }
770
iomodo43ec6ae2025-07-28 17:40:12 +0400771 // Create individual task files for each subtask
772 tasksDir := filepath.Join(clonePath, "operations", "tasks")
773 if err := os.MkdirAll(tasksDir, 0755); err != nil {
774 return fmt.Errorf("failed to create tasks directory: %w", err)
iomodo443b20a2025-07-28 15:24:05 +0400775 }
776
iomodo43ec6ae2025-07-28 17:40:12 +0400777 var stagedFiles []string
iomodo443b20a2025-07-28 15:24:05 +0400778
iomodo43ec6ae2025-07-28 17:40:12 +0400779 // Update parent task to mark as completed
780 parentTaskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", analysis.ParentTaskID))
781 if err := s.updateParentTaskAsCompleted(parentTaskFile, analysis); err != nil {
782 return fmt.Errorf("failed to update parent task: %w", err)
783 }
784
785 // Track parent task file for staging
786 parentRelativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", analysis.ParentTaskID))
787 stagedFiles = append(stagedFiles, parentRelativeFile)
iomodo62da94a2025-07-28 19:01:55 +0400788 s.logger.Info("Updated parent task file", slog.String("file", parentRelativeFile))
iomodo43ec6ae2025-07-28 17:40:12 +0400789
790 // Create a file for each subtask
791 for i, subtask := range analysis.Subtasks {
792 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
793 taskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", taskID))
794 taskContent := s.generateSubtaskFile(subtask, taskID, analysis.ParentTaskID)
795
796 if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil {
797 return fmt.Errorf("failed to write subtask file %s: %w", taskID, err)
798 }
799
800 // Track file for staging
801 relativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", taskID))
802 stagedFiles = append(stagedFiles, relativeFile)
iomodo62da94a2025-07-28 19:01:55 +0400803 s.logger.Info("Created subtask file", slog.String("file", relativeFile))
iomodo443b20a2025-07-28 15:24:05 +0400804 }
805
iomodo43ec6ae2025-07-28 17:40:12 +0400806 // Stage all subtask files
807 for _, file := range stagedFiles {
808 cmd = gitCmd("add", file)
809 if err := cmd.Run(); err != nil {
810 return fmt.Errorf("failed to stage file %s: %w", file, err)
811 }
iomodo443b20a2025-07-28 15:24:05 +0400812 }
813
814 // Commit changes
iomodo43ec6ae2025-07-28 17:40:12 +0400815 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",
816 len(analysis.Subtasks), analysis.ParentTaskID, analysis.ParentTaskID)
817
818 // Add list of created files to commit message
819 for i := range analysis.Subtasks {
820 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
821 commitMsg += fmt.Sprintf("- %s.md\n", taskID)
822 }
823
824 if len(analysis.AgentCreations) > 0 {
825 commitMsg += fmt.Sprintf("\nProposed %d new agents for specialized skills", len(analysis.AgentCreations))
826 }
iomodo443b20a2025-07-28 15:24:05 +0400827 cmd = gitCmd("commit", "-m", commitMsg)
828 if err := cmd.Run(); err != nil {
829 return fmt.Errorf("failed to commit: %w", err)
830 }
831
832 // Push branch
833 cmd = gitCmd("push", "-u", "origin", branchName)
834 if err := cmd.Run(); err != nil {
835 return fmt.Errorf("failed to push branch: %w", err)
836 }
837
iomodo62da94a2025-07-28 19:01:55 +0400838 s.logger.Info("Created subtask proposal branch", slog.String("branch", branchName))
iomodo443b20a2025-07-28 15:24:05 +0400839 return nil
840}
841
iomodo443b20a2025-07-28 15:24:05 +0400842
iomodod9ff8da2025-07-28 11:42:22 +0400843// Close cleans up the service
844func (s *SubtaskService) Close() error {
845 if s.llmProvider != nil {
846 return s.llmProvider.Close()
847 }
848 return nil
849}