loop: add todo checklist
This should improve Sketch's executive function and user communication.
diff --git a/claudetool/todo_test.go b/claudetool/todo_test.go
new file mode 100644
index 0000000..ac36cc2
--- /dev/null
+++ b/claudetool/todo_test.go
@@ -0,0 +1,155 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestTodoReadEmpty(t *testing.T) {
+ ctx := WithSessionID(context.Background(), "test-session-1")
+
+ // Ensure todo file doesn't exist
+ todoPath := todoFilePathForContext(ctx)
+ os.Remove(todoPath)
+
+ result, err := todoReadRun(ctx, []byte("{}"))
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if len(result) != 1 {
+ t.Fatalf("expected 1 content item, got %d", len(result))
+ }
+
+ expected := "No todo list found. Use todo_write to create one."
+ if result[0].Text != expected {
+ t.Errorf("expected %q, got %q", expected, result[0].Text)
+ }
+}
+
+func TestTodoWriteAndRead(t *testing.T) {
+ ctx := WithSessionID(context.Background(), "test-session-2")
+
+ // Clean up
+ todoPath := todoFilePathForContext(ctx)
+ defer os.Remove(todoPath)
+ os.Remove(todoPath)
+
+ // Write some todos
+ todos := []TodoItem{
+ {ID: "1", Task: "Implement todo tools", Status: "completed"},
+ {ID: "2", Task: "Update system prompt", Status: "in-progress"},
+ {ID: "3", Task: "Write tests", Status: "queued"},
+ }
+
+ writeInput := TodoWriteInput{Tasks: todos}
+ writeInputJSON, _ := json.Marshal(writeInput)
+
+ result, err := todoWriteRun(ctx, writeInputJSON)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if len(result) != 1 {
+ t.Fatalf("expected 1 content item, got %d", len(result))
+ }
+
+ expected := "Updated todo list with 3 items."
+ if result[0].Text != expected {
+ t.Errorf("expected %q, got %q", expected, result[0].Text)
+ }
+
+ // Read the todos back
+ result, err = todoReadRun(ctx, []byte("{}"))
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if len(result) != 1 {
+ t.Fatalf("expected 1 content item, got %d", len(result))
+ }
+
+ resultText := result[0].Text
+ if !strings.Contains(resultText, "<todo_list count=\"3\">") {
+ t.Errorf("expected result to contain XML todo list header, got %q", resultText)
+ }
+
+ // Check that all todos are present with proper XML structure
+ if !strings.Contains(resultText, `<task id="1" status="completed">Implement todo tools</task>`) {
+ t.Errorf("expected result to contain first todo in XML format, got %q", resultText)
+ }
+ if !strings.Contains(resultText, `<task id="2" status="in-progress">Update system prompt</task>`) {
+ t.Errorf("expected result to contain second todo in XML format, got %q", resultText)
+ }
+ if !strings.Contains(resultText, `<task id="3" status="queued">Write tests</task>`) {
+ t.Errorf("expected result to contain third todo in XML format, got %q", resultText)
+ }
+
+ // Check XML structure
+ if !strings.Contains(resultText, "</todo_list>") {
+ t.Errorf("expected result to contain closing XML tag, got %q", resultText)
+ }
+}
+
+func TestTodoWriteMultipleInProgress(t *testing.T) {
+ ctx := WithSessionID(context.Background(), "test-session-3")
+
+ // Try to write todos with multiple in-progress items
+ todos := []TodoItem{
+ {ID: "1", Task: "Task 1", Status: "in-progress"},
+ {ID: "2", Task: "Task 2", Status: "in-progress"},
+ }
+
+ writeInput := TodoWriteInput{Tasks: todos}
+ writeInputJSON, _ := json.Marshal(writeInput)
+
+ _, err := todoWriteRun(ctx, writeInputJSON)
+ if err == nil {
+ t.Fatal("expected error for multiple in_progress tasks, got none")
+ }
+
+ expected := "only one task can be 'in-progress' at a time, found 2"
+ if err.Error() != expected {
+ t.Errorf("expected error %q, got %q", expected, err.Error())
+ }
+}
+
+func TestTodoSessionIsolation(t *testing.T) {
+ // Test that different sessions have different todo files
+ ctx1 := WithSessionID(context.Background(), "session-1")
+ ctx2 := WithSessionID(context.Background(), "session-2")
+
+ path1 := todoFilePathForContext(ctx1)
+ path2 := todoFilePathForContext(ctx2)
+
+ if path1 == path2 {
+ t.Errorf("expected different paths for different sessions, both got %q", path1)
+ }
+
+ expected1 := filepath.Join("/tmp", "session-1", "todos.json")
+ expected2 := filepath.Join("/tmp", "session-2", "todos.json")
+
+ if path1 != expected1 {
+ t.Errorf("expected path1 %q, got %q", expected1, path1)
+ }
+
+ if path2 != expected2 {
+ t.Errorf("expected path2 %q, got %q", expected2, path2)
+ }
+}
+
+func TestTodoFallbackPath(t *testing.T) {
+ // Test fallback when no session ID in context
+ ctx := context.Background() // No session ID
+
+ path := todoFilePathForContext(ctx)
+ expected := "/tmp/sketch_todos.json"
+
+ if path != expected {
+ t.Errorf("expected fallback path %q, got %q", expected, path)
+ }
+}