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>
This commit is contained in:
djuka 2026-02-20 11:41:55 +00:00
parent ceb7f9ecfa
commit 79bcd52076
4 changed files with 760 additions and 0 deletions

View File

@ -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

95
TASKS/review/T02.md Normal file
View File

@ -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)*

View File

@ -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
}

View File

@ -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])
}
}
}
}