// Package agentic provides AI collaboration features for task management. package agentic import ( "bytes" "os" "os/exec" "path/filepath" "regexp" "strings" errors "github.com/host-uk/core/pkg/framework/core" "github.com/host-uk/core/pkg/io" ) // FileContent represents the content of a file for AI context. type FileContent struct { // Path is the relative path to the file. Path string `json:"path"` // Content is the file content. Content string `json:"content"` // Language is the detected programming language. Language string `json:"language"` } // TaskContext contains gathered context for AI collaboration. type TaskContext struct { // Task is the task being worked on. Task *Task `json:"task"` // Files is a list of relevant file contents. Files []FileContent `json:"files"` // GitStatus is the current git status output. GitStatus string `json:"git_status"` // RecentCommits is the recent commit log. RecentCommits string `json:"recent_commits"` // RelatedCode contains code snippets related to the task. RelatedCode []FileContent `json:"related_code"` } // BuildTaskContext gathers context for AI collaboration on a task. func BuildTaskContext(task *Task, dir string) (*TaskContext, error) { const op = "agentic.BuildTaskContext" if task == nil { return nil, errors.E(op, "task is required", nil) } if dir == "" { cwd, err := os.Getwd() if err != nil { return nil, errors.E(op, "failed to get working directory", err) } dir = cwd } ctx := &TaskContext{ Task: task, } // Gather files mentioned in the task files, err := GatherRelatedFiles(task, dir) if err != nil { // Non-fatal: continue without files files = nil } ctx.Files = files // Get git status gitStatus, _ := runGitCommand(dir, "status", "--porcelain") ctx.GitStatus = gitStatus // Get recent commits recentCommits, _ := runGitCommand(dir, "log", "--oneline", "-10") ctx.RecentCommits = recentCommits // Find related code by searching for keywords relatedCode, err := findRelatedCode(task, dir) if err != nil { relatedCode = nil } ctx.RelatedCode = relatedCode return ctx, nil } // GatherRelatedFiles reads files mentioned in the task. func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) { const op = "agentic.GatherRelatedFiles" if task == nil { return nil, errors.E(op, "task is required", nil) } var files []FileContent // Read files explicitly mentioned in the task for _, relPath := range task.Files { fullPath := filepath.Join(dir, relPath) content, err := io.Local.Read(fullPath) if err != nil { // Skip files that don't exist continue } files = append(files, FileContent{ Path: relPath, Content: content, Language: detectLanguage(relPath), }) } return files, nil } // findRelatedCode searches for code related to the task by keywords. func findRelatedCode(task *Task, dir string) ([]FileContent, error) { const op = "agentic.findRelatedCode" if task == nil { return nil, errors.E(op, "task is required", nil) } // Extract keywords from title and description keywords := extractKeywords(task.Title + " " + task.Description) if len(keywords) == 0 { return nil, nil } var files []FileContent seen := make(map[string]bool) // Search for each keyword using git grep for _, keyword := range keywords { if len(keyword) < 3 { continue } output, err := runGitCommand(dir, "grep", "-l", "-i", keyword, "--", "*.go", "*.ts", "*.js", "*.py") if err != nil { continue } // Parse matched files for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if line == "" || seen[line] { continue } seen[line] = true // Limit to 10 related files if len(files) >= 10 { break } fullPath := filepath.Join(dir, line) content, err := io.Local.Read(fullPath) if err != nil { continue } // Truncate large files if len(content) > 5000 { content = content[:5000] + "\n... (truncated)" } files = append(files, FileContent{ Path: line, Content: content, Language: detectLanguage(line), }) } if len(files) >= 10 { break } } return files, nil } // extractKeywords extracts meaningful words from text for searching. func extractKeywords(text string) []string { // Remove common words and extract identifiers text = strings.ToLower(text) // Split by non-alphanumeric characters re := regexp.MustCompile(`[^a-zA-Z0-9]+`) words := re.Split(text, -1) // Filter stop words and short words stopWords := map[string]bool{ "the": true, "a": true, "an": true, "and": true, "or": true, "but": true, "in": true, "on": true, "at": true, "to": true, "for": true, "of": true, "with": true, "by": true, "from": true, "is": true, "are": true, "was": true, "be": true, "been": true, "being": true, "have": true, "has": true, "had": true, "do": true, "does": true, "did": true, "will": true, "would": true, "could": true, "should": true, "may": true, "might": true, "must": true, "shall": true, "this": true, "that": true, "these": true, "those": true, "it": true, "add": true, "create": true, "update": true, "fix": true, "remove": true, "implement": true, "new": true, "file": true, "code": true, } var keywords []string for _, word := range words { word = strings.TrimSpace(word) if len(word) >= 3 && !stopWords[word] { keywords = append(keywords, word) } } // Limit to first 5 keywords if len(keywords) > 5 { keywords = keywords[:5] } return keywords } // detectLanguage detects the programming language from a file extension. func detectLanguage(path string) string { ext := strings.ToLower(filepath.Ext(path)) languages := map[string]string{ ".go": "go", ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript", ".py": "python", ".rs": "rust", ".java": "java", ".kt": "kotlin", ".swift": "swift", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp", ".rb": "ruby", ".php": "php", ".cs": "csharp", ".fs": "fsharp", ".scala": "scala", ".sh": "bash", ".bash": "bash", ".zsh": "zsh", ".yaml": "yaml", ".yml": "yaml", ".json": "json", ".xml": "xml", ".html": "html", ".css": "css", ".scss": "scss", ".sql": "sql", ".md": "markdown", } if lang, ok := languages[ext]; ok { return lang } return "text" } // runGitCommand runs a git command and returns the output. func runGitCommand(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = dir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", err } return stdout.String(), nil } // FormatContext formats the TaskContext for AI consumption. func (tc *TaskContext) FormatContext() string { var sb strings.Builder sb.WriteString("# Task Context\n\n") // Task info sb.WriteString("## Task\n") sb.WriteString("ID: " + tc.Task.ID + "\n") sb.WriteString("Title: " + tc.Task.Title + "\n") sb.WriteString("Priority: " + string(tc.Task.Priority) + "\n") sb.WriteString("Status: " + string(tc.Task.Status) + "\n") sb.WriteString("\n### Description\n") sb.WriteString(tc.Task.Description + "\n\n") // Files if len(tc.Files) > 0 { sb.WriteString("## Task Files\n") for _, f := range tc.Files { sb.WriteString("### " + f.Path + " (" + f.Language + ")\n") sb.WriteString("```" + f.Language + "\n") sb.WriteString(f.Content) sb.WriteString("\n```\n\n") } } // Git status if tc.GitStatus != "" { sb.WriteString("## Git Status\n") sb.WriteString("```\n") sb.WriteString(tc.GitStatus) sb.WriteString("\n```\n\n") } // Recent commits if tc.RecentCommits != "" { sb.WriteString("## Recent Commits\n") sb.WriteString("```\n") sb.WriteString(tc.RecentCommits) sb.WriteString("\n```\n\n") } // Related code if len(tc.RelatedCode) > 0 { sb.WriteString("## Related Code\n") for _, f := range tc.RelatedCode { sb.WriteString("### " + f.Path + " (" + f.Language + ")\n") sb.WriteString("```" + f.Language + "\n") sb.WriteString(f.Content) sb.WriteString("\n```\n\n") } } return sb.String() }