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