KAOS/code/internal/supervisor/task.go
djuka 79bcd52076 T02: Task loader — parsiranje markdown taskova
- Task struct sa svim poljima (ID, Title, Status, Agent, Model, DependsOn, Description)
- LoadTask, ScanTasks, FindTask, NextTask, MoveTask
- 17 testova — svi prolaze

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:41:55 +00:00

231 lines
5.4 KiB
Go

// Package supervisor implements the KAOS supervisor process
// that orchestrates agent execution and task management.
package supervisor
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Known task folder names and their order.
var taskFolders = []string{"backlog", "ready", "active", "review", "done"}
// Task represents a parsed KAOS task from a markdown file.
type Task struct {
ID string // T01, T02...
Title string // title from # heading
Status string // backlog, ready, active, review, done (from folder name)
Agent string // coder, checker, frontend...
Model string // Haiku, Sonnet, Opus
DependsOn []string // list of task IDs
Description string // full description text
FilePath string // path to the file
}
// headerRegex matches "# T01: Some Title" format.
var headerRegex = regexp.MustCompile(`^#\s+(T\d+):\s+(.+)$`)
// metaRegex matches "**Key:** Value" format.
var metaRegex = regexp.MustCompile(`^\*\*(.+?):\*\*\s+(.+)$`)
// LoadTask parses a single task markdown file and returns a Task.
// The Status field is derived from the parent folder name.
func LoadTask(filePath string) (Task, error) {
f, err := os.Open(filePath)
if err != nil {
return Task{}, fmt.Errorf("open task file: %w", err)
}
defer f.Close()
task := Task{
FilePath: filePath,
Status: statusFromPath(filePath),
}
scanner := bufio.NewScanner(f)
inDescription := false
var descLines []string
for scanner.Scan() {
line := scanner.Text()
// Check for header line
if matches := headerRegex.FindStringSubmatch(line); matches != nil {
task.ID = matches[1]
task.Title = matches[2]
continue
}
// If we're collecting description, check for end marker
if inDescription {
if strings.HasPrefix(line, "## ") || strings.HasPrefix(line, "---") {
inDescription = false
continue
}
descLines = append(descLines, line)
continue
}
// Check for description section start
if strings.HasPrefix(line, "## Opis") {
inDescription = true
continue
}
// Check for metadata fields
if matches := metaRegex.FindStringSubmatch(line); matches != nil {
key := matches[1]
value := strings.TrimSpace(matches[2])
switch key {
case "Agent":
task.Agent = value
case "Model":
task.Model = value
case "Zavisi od":
task.DependsOn = parseDependencies(value)
}
}
}
if err := scanner.Err(); err != nil {
return Task{}, fmt.Errorf("read task file: %w", err)
}
if task.ID == "" {
return Task{}, fmt.Errorf("no task ID found in %s", filePath)
}
task.Description = strings.TrimSpace(strings.Join(descLines, "\n"))
return task, nil
}
// ScanTasks reads all task files from all status folders under tasksDir.
func ScanTasks(tasksDir string) ([]Task, error) {
var tasks []Task
for _, folder := range taskFolders {
dir := filepath.Join(tasksDir, folder)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf("read folder %s: %w", folder, err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
path := filepath.Join(dir, entry.Name())
task, err := LoadTask(path)
if err != nil {
return nil, fmt.Errorf("load task %s: %w", path, err)
}
tasks = append(tasks, task)
}
}
return tasks, nil
}
// FindTask returns a pointer to the task with the given ID, or nil if not found.
func FindTask(tasks []Task, id string) *Task {
for i := range tasks {
if tasks[i].ID == id {
return &tasks[i]
}
}
return nil
}
// NextTask returns the first task in ready/ whose dependencies are all in done/.
// Returns nil if no task is ready.
func NextTask(tasks []Task) *Task {
// Build a set of done task IDs
doneSet := make(map[string]bool)
for _, t := range tasks {
if t.Status == "done" {
doneSet[t.ID] = true
}
}
for i := range tasks {
if tasks[i].Status != "ready" {
continue
}
allDone := true
for _, dep := range tasks[i].DependsOn {
if !doneSet[dep] {
allDone = false
break
}
}
if allDone {
return &tasks[i]
}
}
return nil
}
// MoveTask moves a task file from one status folder to another.
func MoveTask(tasksDir, taskID, fromFolder, toFolder string) error {
filename := taskID + ".md"
src := filepath.Join(tasksDir, fromFolder, filename)
dst := filepath.Join(tasksDir, toFolder, filename)
// Verify source exists
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("source task not found: %w", err)
}
// Ensure destination folder exists
if err := os.MkdirAll(filepath.Join(tasksDir, toFolder), 0755); err != nil {
return fmt.Errorf("create destination folder: %w", err)
}
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("move task: %w", err)
}
return nil
}
// statusFromPath extracts the status folder name from a file path.
// E.g., "/path/to/TASKS/ready/T01.md" → "ready"
func statusFromPath(path string) string {
dir := filepath.Base(filepath.Dir(path))
for _, f := range taskFolders {
if dir == f {
return f
}
}
return dir
}
// parseDependencies parses "T01, T03" or "T01 ✅, T03" into ["T01", "T03"].
// Returns nil for "—" or empty.
func parseDependencies(value string) []string {
if value == "—" || value == "-" || value == "" {
return nil
}
depRegex := regexp.MustCompile(`T\d+`)
matches := depRegex.FindAllString(value, -1)
if len(matches) == 0 {
return nil
}
return matches
}