loop: add todo checklist

This should improve Sketch's executive function and user communication.
diff --git a/claudetool/todo.go b/claudetool/todo.go
new file mode 100644
index 0000000..64c7550
--- /dev/null
+++ b/claudetool/todo.go
@@ -0,0 +1,176 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"sketch.dev/llm"
+)
+
+var TodoRead = &llm.Tool{
+	Name:        "todo_read",
+	Description: `Reads the current todo list. Use frequently to track progress and understand what's pending.`,
+	InputSchema: llm.EmptySchema(),
+	Run:         todoReadRun,
+}
+
+var TodoWrite = &llm.Tool{
+	Name:        "todo_write",
+	Description: todoWriteDescription,
+	InputSchema: llm.MustSchema(todoWriteInputSchema),
+	Run:         todoWriteRun,
+}
+
+const (
+	todoWriteDescription = `todo_write: Creates and manages a structured task list for tracking work and communicating progress to users. Use early and often.
+
+Use for:
+- multi-step tasks
+- complex work
+- when users provide multiple requests
+- conversations that start trivial but grow in scope
+- when users request additional work (directly or via feedback)
+
+Skip for:
+- trivial single-step tasks
+- purely conversational exchanges
+
+Update dynamically as work evolves - conversations can spawn tasks, simple tasks can become complex, and new discoveries may require additional work.
+
+Rules:
+- Update immediately when task states or task list changes
+- Only one task "in-progress" at any time
+- Each update completely replaces the task list - include all tasks (past and present)
+- Never modify or delete completed tasks
+- Queued and in-progress tasks may be restructured as understanding evolves
+- Tasks should be atomic, clear, precise, and actionable
+- If the user adds new tasks: append, don't replace
+`
+
+	todoWriteInputSchema = `
+{
+  "type": "object",
+  "required": ["tasks"],
+  "properties": {
+    "tasks": {
+      "type": "array",
+      "description": "Array of tasks to write",
+      "items": {
+        "type": "object",
+        "required": ["id", "task", "status"],
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "stable, unique hyphenated slug"
+          },
+          "task": {
+            "type": "string",
+            "description": "actionable step in active tense, sentence case, plain text only, displayed to user"
+          },
+          "status": {
+            "type": "string",
+            "enum": ["queued", "in-progress", "completed"],
+            "description": "current task status"
+          }
+        }
+      }
+    }
+  }
+}
+`
+)
+
+type TodoItem struct {
+	ID     string `json:"id"`
+	Task   string `json:"task"`
+	Status string `json:"status"`
+}
+
+type TodoList struct {
+	Items []TodoItem `json:"items"`
+}
+
+type TodoWriteInput struct {
+	Tasks []TodoItem `json:"tasks"`
+}
+
+// TodoFilePath returns the path to the todo file for the given session ID.
+func TodoFilePath(sessionID string) string {
+	if sessionID == "" {
+		return "/tmp/sketch_todos.json"
+	}
+	return filepath.Join("/tmp", sessionID, "todos.json")
+}
+
+func todoFilePathForContext(ctx context.Context) string {
+	return TodoFilePath(SessionID(ctx))
+}
+
+func todoReadRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+	todoPath := todoFilePathForContext(ctx)
+	content, err := os.ReadFile(todoPath)
+	if os.IsNotExist(err) {
+		return llm.TextContent("No todo list found. Use todo_write to create one."), nil
+	}
+	if err != nil {
+		return nil, fmt.Errorf("failed to read todo file: %w", err)
+	}
+
+	var todoList TodoList
+	if err := json.Unmarshal(content, &todoList); err != nil {
+		return nil, fmt.Errorf("failed to parse todo file: %w", err)
+	}
+
+	result := fmt.Sprintf(`<todo_list count="%d">%s`, len(todoList.Items), "\n")
+	for _, item := range todoList.Items {
+		result += fmt.Sprintf(`  <task id="%s" status="%s">%s</task>%s`, item.ID, item.Status, item.Task, "\n")
+	}
+	result += "</todo_list>"
+
+	return llm.TextContent(result), nil
+}
+
+func todoWriteRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+	var input TodoWriteInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return nil, fmt.Errorf("invalid input: %w", err)
+	}
+
+	// Validate that only one task is in-progress
+	inProgressCount := 0
+	for _, task := range input.Tasks {
+		if task.Status == "in-progress" {
+			inProgressCount++
+		}
+	}
+	switch {
+	case inProgressCount > 1:
+		return nil, fmt.Errorf("only one task can be 'in-progress' at a time, found %d", inProgressCount)
+	}
+
+	todoList := TodoList{
+		Items: input.Tasks,
+	}
+
+	todoPath := todoFilePathForContext(ctx)
+	// Ensure directory exists
+	if err := os.MkdirAll(filepath.Dir(todoPath), 0o700); err != nil {
+		return nil, fmt.Errorf("failed to create todo directory: %w", err)
+	}
+
+	content, err := json.Marshal(todoList)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal todo list: %w", err)
+	}
+
+	if err := os.WriteFile(todoPath, content, 0o600); err != nil {
+		return nil, fmt.Errorf("failed to write todo file: %w", err)
+	}
+
+	result := fmt.Sprintf("Updated todo list with %d items.", len(input.Tasks))
+
+	return llm.TextContent(result), nil
+}