diff --git a/TASKS/review/T05.md b/TASKS/done/T05.md similarity index 100% rename from TASKS/review/T05.md rename to TASKS/done/T05.md diff --git a/TASKS/reports/T06-report.md b/TASKS/reports/T06-report.md new file mode 100644 index 0000000..4eaf9ff --- /dev/null +++ b/TASKS/reports/T06-report.md @@ -0,0 +1,47 @@ +# T06 Izveštaj: CLI — komandni interfejs + +**Agent:** coder +**Model:** Opus +**Datum:** 2026-02-20 + +--- + +## Šta je urađeno + +Implementiran CLI u `code/cmd/kaos-supervisor/main.go`: + +### Izmenjeni/kreirani fajlovi + +| Fajl | Opis | +|------|------| +| `cmd/kaos-supervisor/main.go` | 5 komandi: run, status, next, verify, history | +| `cmd/kaos-supervisor/main_test.go` | 8 testova za CLI komande | +| `internal/config/config.go` | Dodat KAOS_TASKS_DIR | +| `internal/config/config_test.go` | Dodat test za KAOS_TASKS_DIR | +| `.env.example` | Dodat KAOS_TASKS_DIR | + +### Komande + +- **run [TASK_ID]** — pokreni task (ili sledeći), verify, report, premesti u review/ +- **status** — tabelarni prikaz svih taskova sa emoji statusima +- **next** — sledeći task za rad +- **verify [PATH]** — verifikacija Go projekta +- **history** — lista izveštaja + +### Testovi — 8/8 PASS (CLI) + +``` +TestCmdStatus_ShowsTasks PASS +TestCmdNext_FindsReadyTask PASS +TestCmdVerify_OnTestProject PASS (0.46s) +TestCmdHistory_ShowsReports PASS +TestUnknownCommand PASS +TestCmdRun_NoReadyTasks PASS +TestStatusIcon PASS +TestNextTask_Integration PASS +``` + +### Ukupno projekat: 59 testova, svi prolaze + +- `go vet ./...` — čist +- `go build ./...` — prolazi diff --git a/TASKS/review/T06.md b/TASKS/review/T06.md new file mode 100644 index 0000000..a777384 --- /dev/null +++ b/TASKS/review/T06.md @@ -0,0 +1,90 @@ +# T06: CLI — komandni interfejs + +**Kreirao:** planer +**Datum:** 2026-02-20 +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T02 ✅, T03 ✅, T04 ✅, T05 + +--- + +## Opis + +Entry point za supervisor. Parsira komande, poziva supervisor funkcije. +Koristi sve module iz T02-T05. + +## Fajlovi za izmenu + +``` +code/cmd/kaos-supervisor/main.go ← komande +``` + +## Komande + +```bash +kaos-supervisor run [TASK_ID] # pokreni task (ili sledeći iz ready/) +kaos-supervisor status # prikaži status svih taskova +kaos-supervisor next # prikaži šta je sledeće za rad +kaos-supervisor verify [PATH] # pokreni verifikaciju na projektu +kaos-supervisor history # prikaži izvršene taskove iz reports/ +``` + +## Detalji komandi + +### run [TASK_ID] +1. ScanTasks() — učitaj sve taskove +2. Ako je TASK_ID dat → FindTask, proveri da je u ready/ +3. Ako nije dat → NextTask (prvi iz ready/ sa ispunjenim zavisnostima) +4. MoveTask → active/ +5. RunTask (iz runner.go) +6. Verify (iz checker.go) +7. WriteReport (iz reporter.go) +8. MoveTask → review/ +9. Prikaži rezime + +### status +- ScanTasks → tabelarni prikaz svih taskova +- Format: ID | Naslov | Folder | Zavisi od + +### next +- NextTask → prikaži koji task je sledeći za rad + +### verify [PATH] +- Verify(path) → prikaži rezultat +- Default path: code/ + +### history +- Čitaj fajlove iz TASKS/reports/ → prikaži listu + +## Pravila + +- Bez eksternih CLI biblioteka (flag package ili os.Args) +- Formatiran terminal output sa emoji za status +- Poruke na srpskom +- Config čita iz .env (putanja do TASKS/, timeout) +- Exit code: 0 uspeh, 1 greška + +## Testovi + +- status sa primer taskovima → ispravan output +- next → vraća pravi task +- verify na test projektu → prikaže rezultat +- Nepoznata komanda → help poruka +- run bez taskova u ready/ → poruka "nema taskova" + +## Očekivani izlaz + +`make build` → `bin/kaos-supervisor` radi sve komande. +`go test ./... -v` — svi testovi zeleni. + +--- + +## Pitanja + +*(agent piše pitanja ovde, planer odgovara)* + +--- + +## Odgovori + +*(planer piše odgovore ovde)* diff --git a/code/.env.example b/code/.env.example index efac364..4bb7bae 100644 --- a/code/.env.example +++ b/code/.env.example @@ -1,2 +1,3 @@ KAOS_TIMEOUT=30m KAOS_PROJECT_PATH=. +KAOS_TASKS_DIR=../TASKS diff --git a/code/cmd/kaos-supervisor/main.go b/code/cmd/kaos-supervisor/main.go index 8d1a37b..2990a1d 100644 --- a/code/cmd/kaos-supervisor/main.go +++ b/code/cmd/kaos-supervisor/main.go @@ -1,22 +1,242 @@ // Package main is the entry point for the KAOS supervisor process. -// It loads configuration and starts the supervisor. +// It parses CLI commands and delegates to supervisor functions. package main import ( "fmt" - "log" "os" + "path/filepath" + "strings" + "text/tabwriter" "github.com/dal/kaos/internal/config" - _ "github.com/dal/kaos/internal/supervisor" + "github.com/dal/kaos/internal/supervisor" ) func main() { cfg, err := config.Load() if err != nil { - log.Fatalf("Failed to load config: %v", err) + fmt.Fprintf(os.Stderr, "Greška pri učitavanju konfiguracije: %v\n", err) + os.Exit(1) } - fmt.Fprintf(os.Stdout, "KAOS Supervisor started (timeout=%s, project_path=%s)\n", - cfg.Timeout, cfg.ProjectPath) + if len(os.Args) < 2 { + printHelp() + os.Exit(1) + } + + command := os.Args[1] + + switch command { + case "run": + os.Exit(cmdRun(cfg)) + case "status": + os.Exit(cmdStatus(cfg)) + case "next": + os.Exit(cmdNext(cfg)) + case "verify": + os.Exit(cmdVerify(cfg)) + case "history": + os.Exit(cmdHistory(cfg)) + case "help", "--help", "-h": + printHelp() + default: + fmt.Fprintf(os.Stderr, "Nepoznata komanda: %s\n\n", command) + printHelp() + os.Exit(1) + } +} + +func printHelp() { + fmt.Println("KAOS Supervisor") + fmt.Println() + fmt.Println("Komande:") + fmt.Println(" run [TASK_ID] Pokreni task (ili sledeći iz ready/)") + fmt.Println(" status Prikaži status svih taskova") + fmt.Println(" next Prikaži sledeći task za rad") + fmt.Println(" verify [PATH] Pokreni verifikaciju na projektu") + fmt.Println(" history Prikaži izvršene taskove iz reports/") + fmt.Println(" help Prikaži ovu pomoć") +} + +func cmdRun(cfg *config.Config) int { + tasks, err := supervisor.ScanTasks(cfg.TasksDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Greška pri skeniranju taskova: %v\n", err) + return 1 + } + + var task *supervisor.Task + + if len(os.Args) > 2 { + taskID := os.Args[2] + task = supervisor.FindTask(tasks, taskID) + if task == nil { + fmt.Fprintf(os.Stderr, "Task %s nije pronađen\n", taskID) + return 1 + } + if task.Status != "ready" { + fmt.Fprintf(os.Stderr, "Task %s nije u ready/ (trenutno: %s)\n", taskID, task.Status) + return 1 + } + } else { + task = supervisor.NextTask(tasks) + if task == nil { + fmt.Println("Nema taskova spremnih za rad") + return 0 + } + } + + fmt.Printf("▶ Pokrećem: %s — %s\n", task.ID, task.Title) + + // Move to active + if err := supervisor.MoveTask(cfg.TasksDir, task.ID, "ready", "active"); err != nil { + fmt.Fprintf(os.Stderr, "Greška pri premještanju u active/: %v\n", err) + return 1 + } + + // Run the task + result := supervisor.RunTask(*task, cfg.ProjectPath, cfg.Timeout, nil) + + fmt.Printf("⏱ Trajanje: %s\n", result.Duration.Round(1000000000)) // round to seconds + fmt.Printf("📤 Exit code: %d\n", result.ExitCode) + + // Verify + fmt.Println("\n🔍 Verifikacija...") + verify := supervisor.Verify(cfg.ProjectPath) + fmt.Print(supervisor.FormatResult(verify)) + + // Write report + reportsDir := filepath.Join(cfg.TasksDir, "reports") + reportPath, err := supervisor.WriteReport(*task, result, verify, reportsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Greška pri pisanju izveštaja: %v\n", err) + } else { + fmt.Printf("\n📝 Izveštaj: %s\n", reportPath) + } + + // Move to review + if err := supervisor.MoveTask(cfg.TasksDir, task.ID, "active", "review"); err != nil { + fmt.Fprintf(os.Stderr, "Greška pri premještanju u review/: %v\n", err) + return 1 + } + + fmt.Printf("\n✅ %s premešten u review/\n", task.ID) + + if !verify.AllPassed { + return 1 + } + return 0 +} + +func cmdStatus(cfg *config.Config) int { + tasks, err := supervisor.ScanTasks(cfg.TasksDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Greška pri skeniranju taskova: %v\n", err) + return 1 + } + + if len(tasks) == 0 { + fmt.Println("Nema taskova") + return 0 + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNaslov\tFolder\tZavisi od") + fmt.Fprintln(w, "──\t──────\t──────\t─────────") + + for _, t := range tasks { + deps := "—" + if len(t.DependsOn) > 0 { + deps = strings.Join(t.DependsOn, ", ") + } + + icon := statusIcon(t.Status) + fmt.Fprintf(w, "%s\t%s\t%s %s\t%s\n", t.ID, t.Title, icon, t.Status, deps) + } + w.Flush() + return 0 +} + +func cmdNext(cfg *config.Config) int { + tasks, err := supervisor.ScanTasks(cfg.TasksDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Greška pri skeniranju taskova: %v\n", err) + return 1 + } + + task := supervisor.NextTask(tasks) + if task == nil { + fmt.Println("Nema taskova spremnih za rad") + return 0 + } + + fmt.Printf("Sledeći: %s — %s\n", task.ID, task.Title) + fmt.Printf("Agent: %s | Model: %s\n", task.Agent, task.Model) + if len(task.DependsOn) > 0 { + fmt.Printf("Zavisi od: %s\n", strings.Join(task.DependsOn, ", ")) + } + return 0 +} + +func cmdVerify(cfg *config.Config) int { + path := cfg.ProjectPath + if len(os.Args) > 2 { + path = os.Args[2] + } + + fmt.Printf("🔍 Verifikacija: %s\n\n", path) + result := supervisor.Verify(path) + fmt.Print(supervisor.FormatResult(result)) + + if !result.AllPassed { + return 1 + } + return 0 +} + +func cmdHistory(cfg *config.Config) int { + reportsDir := filepath.Join(cfg.TasksDir, "reports") + + entries, err := os.ReadDir(reportsDir) + if err != nil { + if os.IsNotExist(err) { + fmt.Println("Nema izveštaja") + return 0 + } + fmt.Fprintf(os.Stderr, "Greška pri čitanju reports/: %v\n", err) + return 1 + } + + if len(entries) == 0 { + fmt.Println("Nema izveštaja") + return 0 + } + + fmt.Println("Izveštaji:") + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), "-report.md") { + continue + } + name := strings.TrimSuffix(entry.Name(), "-report.md") + fmt.Printf(" 📝 %s (%s)\n", name, filepath.Join(reportsDir, entry.Name())) + } + return 0 +} + +func statusIcon(status string) string { + switch status { + case "done": + return "✅" + case "active": + return "🔄" + case "ready": + return "📋" + case "review": + return "👀" + case "backlog": + return "📦" + default: + return "❓" + } } diff --git a/code/cmd/kaos-supervisor/main_test.go b/code/cmd/kaos-supervisor/main_test.go new file mode 100644 index 0000000..21bb07c --- /dev/null +++ b/code/cmd/kaos-supervisor/main_test.go @@ -0,0 +1,268 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dal/kaos/internal/config" + "github.com/dal/kaos/internal/supervisor" +) + +func setupTestTasks(t *testing.T) (string, *config.Config) { + t.Helper() + dir := t.TempDir() + + tasksDir := filepath.Join(dir, "TASKS") + for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} { + os.MkdirAll(filepath.Join(tasksDir, folder), 0755) + } + + // Create test tasks + t1 := `# T01: Prvi task + +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** — + +--- + +## Opis + +Opis prvog taska. + +--- +` + t2 := `# T02: Drugi task + +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T01 + +--- + +## Opis + +Opis drugog taska. + +--- +` + os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(t1), 0644) + os.WriteFile(filepath.Join(tasksDir, "ready", "T02.md"), []byte(t2), 0644) + + cfg := &config.Config{ + TasksDir: tasksDir, + ProjectPath: dir, + } + + return dir, cfg +} + +func TestCmdStatus_ShowsTasks(t *testing.T) { + _, cfg := setupTestTasks(t) + + // Capture output by redirecting stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + exitCode := cmdStatus(cfg) + + w.Close() + os.Stdout = old + + var buf [4096]byte + n, _ := r.Read(buf[:]) + output := string(buf[:n]) + + if exitCode != 0 { + t.Errorf("expected exit code 0, got %d", exitCode) + } + if !strings.Contains(output, "T01") { + t.Errorf("expected T01 in output, got:\n%s", output) + } + if !strings.Contains(output, "T02") { + t.Errorf("expected T02 in output, got:\n%s", output) + } + if !strings.Contains(output, "done") { + t.Errorf("expected 'done' status in output, got:\n%s", output) + } + if !strings.Contains(output, "ready") { + t.Errorf("expected 'ready' status in output, got:\n%s", output) + } +} + +func TestCmdNext_FindsReadyTask(t *testing.T) { + _, cfg := setupTestTasks(t) + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + exitCode := cmdNext(cfg) + + w.Close() + os.Stdout = old + + var buf [4096]byte + n, _ := r.Read(buf[:]) + output := string(buf[:n]) + + if exitCode != 0 { + t.Errorf("expected exit code 0, got %d", exitCode) + } + if !strings.Contains(output, "T02") { + t.Errorf("expected T02 as next task, got:\n%s", output) + } +} + +func TestCmdVerify_OnTestProject(t *testing.T) { + dir := t.TempDir() + + // Create a minimal passing Go project + os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module testproj\n\ngo 1.22\n"), 0644) + os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0644) + + cfg := &config.Config{ProjectPath: dir} + + // Set os.Args so cmdVerify doesn't pick up test flags + oldArgs := os.Args + os.Args = []string{"kaos-supervisor", "verify"} + defer func() { os.Args = oldArgs }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + exitCode := cmdVerify(cfg) + + w.Close() + os.Stdout = old + + var buf [4096]byte + n, _ := r.Read(buf[:]) + output := string(buf[:n]) + + if exitCode != 0 { + t.Errorf("expected exit code 0, got %d\noutput: %s", exitCode, output) + } + if !strings.Contains(output, "All checks passed") { + t.Errorf("expected 'All checks passed', got:\n%s", output) + } +} + +func TestCmdHistory_ShowsReports(t *testing.T) { + _, cfg := setupTestTasks(t) + + // Create a fake report + reportsDir := filepath.Join(cfg.TasksDir, "reports") + os.WriteFile(filepath.Join(reportsDir, "T01-report.md"), []byte("# T01 report\n"), 0644) + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + exitCode := cmdHistory(cfg) + + w.Close() + os.Stdout = old + + var buf [4096]byte + n, _ := r.Read(buf[:]) + output := string(buf[:n]) + + if exitCode != 0 { + t.Errorf("expected exit code 0, got %d", exitCode) + } + if !strings.Contains(output, "T01") { + t.Errorf("expected T01 in history, got:\n%s", output) + } +} + +func TestUnknownCommand(t *testing.T) { + // Test printHelp doesn't panic + old := os.Stdout + _, w, _ := os.Pipe() + os.Stdout = w + printHelp() + w.Close() + os.Stdout = old +} + +func TestCmdRun_NoReadyTasks(t *testing.T) { + dir := t.TempDir() + tasksDir := filepath.Join(dir, "TASKS") + for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} { + os.MkdirAll(filepath.Join(tasksDir, folder), 0755) + } + + cfg := &config.Config{ + TasksDir: tasksDir, + ProjectPath: dir, + } + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Save and restore os.Args + oldArgs := os.Args + os.Args = []string{"kaos-supervisor", "run"} + defer func() { os.Args = oldArgs }() + + exitCode := cmdRun(cfg) + + w.Close() + os.Stdout = old + + var buf [4096]byte + n, _ := r.Read(buf[:]) + output := string(buf[:n]) + + if exitCode != 0 { + t.Errorf("expected exit code 0, got %d", exitCode) + } + if !strings.Contains(output, "Nema taskova") { + t.Errorf("expected 'Nema taskova', got:\n%s", output) + } +} + +func TestStatusIcon(t *testing.T) { + tests := []struct { + status string + want string + }{ + {"done", "✅"}, + {"active", "🔄"}, + {"ready", "📋"}, + {"review", "👀"}, + {"backlog", "📦"}, + {"unknown", "❓"}, + } + + for _, tt := range tests { + got := statusIcon(tt.status) + if got != tt.want { + t.Errorf("statusIcon(%q) = %q, want %q", tt.status, got, tt.want) + } + } +} + +// Verify that ScanTasks and NextTask integration works +func TestNextTask_Integration(t *testing.T) { + _, cfg := setupTestTasks(t) + + tasks, err := supervisor.ScanTasks(cfg.TasksDir) + if err != nil { + t.Fatalf("ScanTasks error: %v", err) + } + + next := supervisor.NextTask(tasks) + if next == nil { + t.Fatal("expected a next task") + } + if next.ID != "T02" { + t.Errorf("expected T02, got %s", next.ID) + } +} diff --git a/code/internal/config/config.go b/code/internal/config/config.go index a98bef2..c17bfda 100644 --- a/code/internal/config/config.go +++ b/code/internal/config/config.go @@ -16,6 +16,8 @@ type Config struct { Timeout time.Duration // ProjectPath is the root path of the project being supervised. ProjectPath string + // TasksDir is the path to the TASKS directory. + TasksDir string } // Load reads configuration from environment variables. @@ -39,9 +41,15 @@ func Load() (*Config, error) { return nil, fmt.Errorf("KAOS_PROJECT_PATH environment variable is required") } + tasksDir := os.Getenv("KAOS_TASKS_DIR") + if tasksDir == "" { + return nil, fmt.Errorf("KAOS_TASKS_DIR environment variable is required") + } + return &Config{ Timeout: timeout, ProjectPath: projectPath, + TasksDir: tasksDir, }, nil } diff --git a/code/internal/config/config_test.go b/code/internal/config/config_test.go index d750e39..5191c78 100644 --- a/code/internal/config/config_test.go +++ b/code/internal/config/config_test.go @@ -12,6 +12,7 @@ import ( func TestLoad_WithValidEnv(t *testing.T) { t.Setenv("KAOS_TIMEOUT", "30m") t.Setenv("KAOS_PROJECT_PATH", "/tmp/test") + t.Setenv("KAOS_TASKS_DIR", "/tmp/tasks") cfg, err := Load() if err != nil { @@ -25,11 +26,16 @@ func TestLoad_WithValidEnv(t *testing.T) { if cfg.ProjectPath != "/tmp/test" { t.Errorf("expected project path /tmp/test, got %s", cfg.ProjectPath) } + + if cfg.TasksDir != "/tmp/tasks" { + t.Errorf("expected tasks dir /tmp/tasks, got %s", cfg.TasksDir) + } } func TestLoad_MissingTimeout(t *testing.T) { t.Setenv("KAOS_TIMEOUT", "") t.Setenv("KAOS_PROJECT_PATH", "/tmp/test") + t.Setenv("KAOS_TASKS_DIR", "/tmp/tasks") _, err := Load() if err == nil { @@ -40,6 +46,7 @@ func TestLoad_MissingTimeout(t *testing.T) { func TestLoad_InvalidTimeout(t *testing.T) { t.Setenv("KAOS_TIMEOUT", "not-a-duration") t.Setenv("KAOS_PROJECT_PATH", "/tmp/test") + t.Setenv("KAOS_TASKS_DIR", "/tmp/tasks") _, err := Load() if err == nil { @@ -50,6 +57,7 @@ func TestLoad_InvalidTimeout(t *testing.T) { func TestLoad_MissingProjectPath(t *testing.T) { t.Setenv("KAOS_TIMEOUT", "30m") t.Setenv("KAOS_PROJECT_PATH", "") + t.Setenv("KAOS_TASKS_DIR", "/tmp/tasks") _, err := Load() if err == nil { @@ -57,6 +65,17 @@ func TestLoad_MissingProjectPath(t *testing.T) { } } +func TestLoad_MissingTasksDir(t *testing.T) { + t.Setenv("KAOS_TIMEOUT", "30m") + t.Setenv("KAOS_PROJECT_PATH", "/tmp/test") + t.Setenv("KAOS_TASKS_DIR", "") + + _, err := Load() + if err == nil { + t.Fatal("expected error for missing KAOS_TASKS_DIR") + } +} + func TestLoadEnvFile(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, ".env")