From 79bcd52076b78672764b5f4870d4e890b22c1526 Mon Sep 17 00:00:00 2001 From: djuka Date: Fri, 20 Feb 2026 11:41:55 +0000 Subject: [PATCH] =?UTF-8?q?T02:=20Task=20loader=20=E2=80=94=20parsiranje?= =?UTF-8?q?=20markdown=20taskova?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- TASKS/reports/T02-report.md | 54 ++++ TASKS/review/T02.md | 95 +++++++ code/internal/supervisor/task.go | 230 ++++++++++++++++ code/internal/supervisor/task_test.go | 381 ++++++++++++++++++++++++++ 4 files changed, 760 insertions(+) create mode 100644 TASKS/reports/T02-report.md create mode 100644 TASKS/review/T02.md create mode 100644 code/internal/supervisor/task.go create mode 100644 code/internal/supervisor/task_test.go diff --git a/TASKS/reports/T02-report.md b/TASKS/reports/T02-report.md new file mode 100644 index 0000000..fa81a63 --- /dev/null +++ b/TASKS/reports/T02-report.md @@ -0,0 +1,54 @@ +# T02 Izveštaj: Task loader — parsiranje markdown taskova + +**Agent:** coder +**Model:** Opus +**Datum:** 2026-02-20 + +--- + +## Šta je urađeno + +Implementiran task loader u `code/internal/supervisor/`: + +### Kreirani fajlovi + +| Fajl | Opis | +|------|------| +| `task.go` | Task struct, LoadTask, ScanTasks, FindTask, NextTask, MoveTask | +| `task_test.go` | 17 testova za sve funkcije i edge cases | + +### Funkcije + +- **LoadTask** — parsira markdown task fajl (ID, Title, Agent, Model, DependsOn, Description, Status iz foldera) +- **ScanTasks** — skenira sve status foldere, vraća listu svih taskova +- **FindTask** — traži task po ID-u +- **NextTask** — prvi ready/ task čije su zavisnosti sve u done/ +- **MoveTask** — premešta task fajl između foldera + +### Testovi — 17/17 PASS + +``` +TestLoadTask_ParsesAllFields PASS +TestLoadTask_WithDependencies PASS +TestLoadTask_WithCheckmarkDependencies PASS +TestLoadTask_NoID PASS +TestLoadTask_NonexistentFile PASS +TestScanTasks_FindsAllFolders PASS +TestScanTasks_EmptyFolder PASS +TestScanTasks_MissingFolders PASS +TestFindTask PASS +TestNextTask_ReadyWithDoneDeps PASS +TestNextTask_BlockedByNonDone PASS +TestNextTask_NoDependencies PASS +TestNextTask_NoReadyTasks PASS +TestMoveTask PASS +TestMoveTask_NonexistentSource PASS +TestMoveTask_CreatesDestFolder PASS +TestParseDependencies PASS +``` + +### Provere + +- `go vet ./...` — čist +- `go build ./...` — prolazi +- `make all` — prolazi diff --git a/TASKS/review/T02.md b/TASKS/review/T02.md new file mode 100644 index 0000000..b61ff01 --- /dev/null +++ b/TASKS/review/T02.md @@ -0,0 +1,95 @@ +# T02: Task loader — parsiranje markdown taskova + +**Kreirao:** planer +**Datum:** 2026-02-20 +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T01 ✅ + +--- + +## Opis + +Supervisor mora da čita taskove iz markdown fajlova u TASKS/ folderima. +Parsira pojedinačne task fajlove, izvlači ID, status (folder), zavisnosti. + +## Fajlovi za kreiranje + +``` +code/internal/supervisor/ +├── task.go ← Task struct, LoadTask(), ScanTasks(), NextTask() +└── task_test.go ← testovi sa primer markdown-om +``` + +## Task struct + +```go +type Task struct { + ID string // T01, T02... + Title string // naslov iz # zaglavlja + Status string // backlog, ready, active, review, done (iz foldera) + Agent string // coder, checker, frontend... + Model string // Haiku, Sonnet, Opus + DependsOn []string // lista task ID-eva + Description string // ceo opis + FilePath string // putanja do fajla +} +``` + +## Funkcije + +- `LoadTask(filePath string) (Task, error)` — parsira jedan task fajl +- `ScanTasks(tasksDir string) ([]Task, error)` — skenira sve foldere (backlog/, ready/, active/, review/, done/), učita sve taskove, status iz imena foldera +- `FindTask(tasks []Task, id string) *Task` — nađi po ID-u +- `NextTask(tasks []Task) *Task` — prvi task u ready/ čije su zavisnosti u done/ +- `MoveTask(tasksDir, taskID, fromFolder, toFolder string) error` — premesti fajl između foldera + +## Parsiranje + +Task fajl format (primer T01.md): +```markdown +# T01: Naslov + +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** — + +--- + +## Opis +Tekst opisa... +``` + +Parser izvlači: +- ID i Title iz `# T{XX}: Naslov` +- Agent iz `**Agent:** vrednost` +- Model iz `**Model:** vrednost` +- DependsOn iz `**Zavisi od:** T01, T03` (ili `—` za bez zavisnosti) +- Description iz `## Opis` sekcije +- Status iz imena foldera u kojem se fajl nalazi + +## Testovi + +- LoadTask sa primer fajlom → parsira tačno sva polja +- ScanTasks sa temp folderima → nalazi taskove u svim folderima +- NextTask: task u ready/ sa zavisnostima u done/ → vraća ga +- NextTask: task u ready/ sa zavisnošću u backlog/ → ne vraća +- MoveTask: premesti fajl, proveri da je u novom folderu +- Edge case: prazan folder, fajl bez zavisnosti, nepostojeći ID + +## Očekivani izlaz + +`go test ./internal/supervisor/ -v` — svi testovi zeleni. +`go vet ./...` čist. `go build ./...` prolazi. + +--- + +## Pitanja + +*(agent piše pitanja ovde, planer odgovara)* + +--- + +## Odgovori + +*(planer piše odgovore ovde)* diff --git a/code/internal/supervisor/task.go b/code/internal/supervisor/task.go new file mode 100644 index 0000000..f49a2f4 --- /dev/null +++ b/code/internal/supervisor/task.go @@ -0,0 +1,230 @@ +// 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 +} diff --git a/code/internal/supervisor/task_test.go b/code/internal/supervisor/task_test.go new file mode 100644 index 0000000..278dbf6 --- /dev/null +++ b/code/internal/supervisor/task_test.go @@ -0,0 +1,381 @@ +package supervisor + +import ( + "os" + "path/filepath" + "testing" +) + +const sampleTask = `# T01: Inicijalizacija Go projekta + +**Kreirao:** planer +**Datum:** 2026-02-20 +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** — + +--- + +## Opis + +Kreirati Go projekat u code/ folderu sa osnovnom strukturom +za KAOS supervisor. + +--- + +## Pitanja +` + +const sampleTaskWithDeps = `# T02: Task loader + +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T01, T03 + +--- + +## Opis + +Implementirati task loader koji parsira markdown. + +--- +` + +const sampleTaskWithDoneDeps = `# T05: Deploy + +**Agent:** deployer +**Model:** Haiku +**Zavisi od:** T01 ✅, T02 ✅ + +--- + +## Opis + +Deploy aplikacije. + +--- +` + +func writeTempTask(t *testing.T, dir, folder, filename, content string) string { + t.Helper() + folderPath := filepath.Join(dir, folder) + if err := os.MkdirAll(folderPath, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + path := filepath.Join(folderPath, filename) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write: %v", err) + } + return path +} + +func TestLoadTask_ParsesAllFields(t *testing.T) { + dir := t.TempDir() + path := writeTempTask(t, dir, "ready", "T01.md", sampleTask) + + task, err := LoadTask(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if task.ID != "T01" { + t.Errorf("expected ID T01, got %s", task.ID) + } + if task.Title != "Inicijalizacija Go projekta" { + t.Errorf("expected title 'Inicijalizacija Go projekta', got %q", task.Title) + } + if task.Agent != "coder" { + t.Errorf("expected agent coder, got %s", task.Agent) + } + if task.Model != "Sonnet" { + t.Errorf("expected model Sonnet, got %s", task.Model) + } + if len(task.DependsOn) != 0 { + t.Errorf("expected no dependencies, got %v", task.DependsOn) + } + if task.Status != "ready" { + t.Errorf("expected status ready, got %s", task.Status) + } + if task.Description == "" { + t.Error("expected non-empty description") + } +} + +func TestLoadTask_WithDependencies(t *testing.T) { + dir := t.TempDir() + path := writeTempTask(t, dir, "backlog", "T02.md", sampleTaskWithDeps) + + task, err := LoadTask(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(task.DependsOn) != 2 { + t.Fatalf("expected 2 dependencies, got %d: %v", len(task.DependsOn), task.DependsOn) + } + if task.DependsOn[0] != "T01" || task.DependsOn[1] != "T03" { + t.Errorf("expected [T01, T03], got %v", task.DependsOn) + } +} + +func TestLoadTask_WithCheckmarkDependencies(t *testing.T) { + dir := t.TempDir() + path := writeTempTask(t, dir, "ready", "T05.md", sampleTaskWithDoneDeps) + + task, err := LoadTask(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(task.DependsOn) != 2 { + t.Fatalf("expected 2 dependencies, got %d: %v", len(task.DependsOn), task.DependsOn) + } + if task.DependsOn[0] != "T01" || task.DependsOn[1] != "T02" { + t.Errorf("expected [T01, T02], got %v", task.DependsOn) + } +} + +func TestLoadTask_NoID(t *testing.T) { + dir := t.TempDir() + content := "No header here\nJust some text\n" + path := writeTempTask(t, dir, "backlog", "bad.md", content) + + _, err := LoadTask(path) + if err == nil { + t.Fatal("expected error for file without task ID") + } +} + +func TestLoadTask_NonexistentFile(t *testing.T) { + _, err := LoadTask("/nonexistent/path/T99.md") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestScanTasks_FindsAllFolders(t *testing.T) { + dir := t.TempDir() + + writeTempTask(t, dir, "backlog", "T01.md", sampleTask) + writeTempTask(t, dir, "ready", "T02.md", sampleTaskWithDeps) + writeTempTask(t, dir, "done", "T03.md", `# T03: Done task +**Agent:** coder +**Model:** Haiku +**Zavisi od:** — + +--- + +## Opis + +Already done. + +--- +`) + + // Create empty folders that should be handled gracefully + os.MkdirAll(filepath.Join(dir, "active"), 0755) + os.MkdirAll(filepath.Join(dir, "review"), 0755) + + tasks, err := ScanTasks(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tasks) != 3 { + t.Fatalf("expected 3 tasks, got %d", len(tasks)) + } + + // Check statuses + statuses := map[string]string{} + for _, task := range tasks { + statuses[task.ID] = task.Status + } + + if statuses["T01"] != "backlog" { + t.Errorf("T01 expected backlog, got %s", statuses["T01"]) + } + if statuses["T02"] != "ready" { + t.Errorf("T02 expected ready, got %s", statuses["T02"]) + } + if statuses["T03"] != "done" { + t.Errorf("T03 expected done, got %s", statuses["T03"]) + } +} + +func TestScanTasks_EmptyFolder(t *testing.T) { + dir := t.TempDir() + + // Only create empty folders + for _, f := range taskFolders { + os.MkdirAll(filepath.Join(dir, f), 0755) + } + + tasks, err := ScanTasks(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tasks) != 0 { + t.Errorf("expected 0 tasks, got %d", len(tasks)) + } +} + +func TestScanTasks_MissingFolders(t *testing.T) { + dir := t.TempDir() + // Don't create any subfolders + + tasks, err := ScanTasks(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tasks) != 0 { + t.Errorf("expected 0 tasks, got %d", len(tasks)) + } +} + +func TestFindTask(t *testing.T) { + tasks := []Task{ + {ID: "T01", Title: "First"}, + {ID: "T02", Title: "Second"}, + {ID: "T03", Title: "Third"}, + } + + found := FindTask(tasks, "T02") + if found == nil { + t.Fatal("expected to find T02") + } + if found.Title != "Second" { + t.Errorf("expected title Second, got %s", found.Title) + } + + notFound := FindTask(tasks, "T99") + if notFound != nil { + t.Error("expected nil for nonexistent task") + } +} + +func TestNextTask_ReadyWithDoneDeps(t *testing.T) { + tasks := []Task{ + {ID: "T01", Status: "done"}, + {ID: "T02", Status: "ready", DependsOn: []string{"T01"}}, + {ID: "T03", Status: "ready", DependsOn: []string{"T01", "T02"}}, + } + + next := NextTask(tasks) + if next == nil { + t.Fatal("expected a next task") + } + if next.ID != "T02" { + t.Errorf("expected T02, got %s", next.ID) + } +} + +func TestNextTask_BlockedByNonDone(t *testing.T) { + tasks := []Task{ + {ID: "T01", Status: "active"}, + {ID: "T02", Status: "ready", DependsOn: []string{"T01"}}, + } + + next := NextTask(tasks) + if next != nil { + t.Errorf("expected nil (T02 blocked), got %s", next.ID) + } +} + +func TestNextTask_NoDependencies(t *testing.T) { + tasks := []Task{ + {ID: "T01", Status: "ready", DependsOn: nil}, + } + + next := NextTask(tasks) + if next == nil { + t.Fatal("expected T01 (no deps)") + } + if next.ID != "T01" { + t.Errorf("expected T01, got %s", next.ID) + } +} + +func TestNextTask_NoReadyTasks(t *testing.T) { + tasks := []Task{ + {ID: "T01", Status: "done"}, + {ID: "T02", Status: "active"}, + } + + next := NextTask(tasks) + if next != nil { + t.Errorf("expected nil, got %s", next.ID) + } +} + +func TestMoveTask(t *testing.T) { + dir := t.TempDir() + writeTempTask(t, dir, "ready", "T01.md", sampleTask) + + err := MoveTask(dir, "T01", "ready", "active") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify file moved + if _, err := os.Stat(filepath.Join(dir, "ready", "T01.md")); !os.IsNotExist(err) { + t.Error("expected file removed from ready/") + } + if _, err := os.Stat(filepath.Join(dir, "active", "T01.md")); err != nil { + t.Error("expected file in active/") + } +} + +func TestMoveTask_NonexistentSource(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "ready"), 0755) + + err := MoveTask(dir, "T99", "ready", "active") + if err == nil { + t.Fatal("expected error for nonexistent source") + } +} + +func TestMoveTask_CreatesDestFolder(t *testing.T) { + dir := t.TempDir() + writeTempTask(t, dir, "ready", "T01.md", sampleTask) + + // Don't create active/ — MoveTask should create it + err := MoveTask(dir, "T01", "ready", "active") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "active", "T01.md")); err != nil { + t.Error("expected file in active/") + } +} + +func TestParseDependencies(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"—", nil}, + {"-", nil}, + {"", nil}, + {"T01", []string{"T01"}}, + {"T01, T03", []string{"T01", "T03"}}, + {"T01 ✅", []string{"T01"}}, + {"T01 ✅, T02 ✅", []string{"T01", "T02"}}, + } + + for _, tt := range tests { + result := parseDependencies(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("parseDependencies(%q): expected %v, got %v", tt.input, tt.expected, result) + continue + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("parseDependencies(%q)[%d]: expected %s, got %s", tt.input, i, tt.expected[i], result[i]) + } + } + } +}