# Plan: Add a `todo` Tool

## Goal

Add a lightweight task-tracking tool that lets the agent manage a todo list during a session. The agent adds items as it discovers work, marks them done as it progresses, and can review outstanding items at any point. The list persists to disk so it survives context compaction.

## Why

When the agent works on multi-step tasks, it needs to track what's been done and what's left. Today it either keeps the plan in its head (lost on compaction) or abuses `think` with notes (not structured, can't be checked off). Small models especially struggle to hold a mental todo list across many turns — they forget items or repeat work.

A dedicated `todo` tool gives the agent a structured way to:

- Collect tasks as it discovers them (not just at the start)
- Mark items done incrementally so it always knows what's left
- Review the remaining list before deciding what to do next
- Survive context compaction (persisted to `.swival/todo.md`)

## Design Principles

- **Dead simple.** Small models need to use this reliably. One tool with an `action` field and a few parameters — not a family of tools.
- **Plain text persistence.** The todo list is a markdown checklist in `.swival/todo.md`. Human-readable, model-readable, survives compaction via `read_file`.
- **No IDs.** Items are identified by their text (or a prefix of it). This avoids forcing models to track numeric IDs across turns. Actions that need to target an item use a `task` string that's matched fuzzily against existing items.
- **Always returns the current list.** Every action (add, done, remove, list) returns the full current list so the model doesn't need a separate call to see what changed.

## Tool Schema

```python
{
    "type": "function",
    "function": {
        "name": "todo",
        "description": (
            "Manage a task list. Use this to track work items as you discover them "
            "and mark them done as you complete them. Every action returns the current "
            "list. The list is saved to .swival/todo.md and survives context compaction."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["add", "done", "remove", "clear", "list"],
                    "description": (
                        "add: Add a new task. "
                        "done: Mark a task as completed (no-op if already done). "
                        "remove: Remove a task entirely. "
                        "clear: Remove all tasks and start fresh. "
                        "list: Return the current list (no other params needed)."
                    ),
                },
                "task": {
                    "type": "string",
                    "description": (
                        "For 'add': the task description. "
                        "For 'done'/'remove': text that matches an existing task "
                        "(prefix match, case-insensitive). "
                        "Not needed for 'list'."
                    ),
                },
            },
            "required": ["action"],
        },
    },
}
```

### Why `action` instead of separate tools

Small models handle one tool with an action enum better than choosing between `todo_add`, `todo_done`, `todo_remove`, `todo_list`. Fewer tool names means less confusion in the function-calling decision.

### Matching strategy for `done` and `remove`

When the model calls `done` or `remove` with a `task` string:

1. **Exact match** — case-insensitive full string comparison.
2. **Prefix match** — `task` is a case-insensitive prefix of exactly one item.
3. **Substring match** — `task` appears as a case-insensitive substring in exactly one item.
4. If zero matches: return `"error: no task matching '<task>'"`.
5. If multiple matches: return `"error: '<task>' matches multiple items — be more specific"` and list the ambiguous matches.

This progressive matching means the model can say `done "fix the bug"` even if the full item text is `"fix the bug in the login handler"`. It handles sloppy recall from small models without needing IDs.

## State Management

### `TodoState` class in `todo.py` (new file)

```python
@dataclass
class TodoItem:
    text: str
    done: bool = False

class TodoState:
    def __init__(self, notes_dir: str | None = None, verbose: bool = False):
        self.items: list[TodoItem] = []
        self.notes_dir = notes_dir
        self.verbose = verbose
        # Usage counters
        self.add_count = 0
        self.done_count = 0

    def process(self, args: dict) -> str:
        """Handle a todo action. Returns JSON with the current list or an error string."""
        ...

    def summary_line(self) -> str | None:
        """One-line summary for end-of-loop stats, or None if never called."""
        ...
```

### Persistence

The list is saved to `.swival/todo.md` after every mutation (add, done, remove). Format:

```markdown
- [x] Read the existing test suite
- [ ] Fix the failing test in test_edit.py
- [ ] Update CLAUDE.md with new tool docs
```

Standard markdown checkbox syntax — readable by the model via `read_file`, readable by humans in the terminal.

On init, `TodoState` deletes any existing `.swival/todo.md` (session isolation, same as `ThinkingState` with notes). Cleared on REPL `/clear` too.

### Caps

| Limit | Value | Rationale |
|-------|-------|-----------|
| Max items | 50 | Prevent unbounded list growth |
| Max item text | 500 chars | Error if exceeded (not truncated — model should know to shorten) |
| Max total text returned | ~5000 chars | Keep tool results compact |

## Return Format

`process()` returns a **JSON string** (for the tool result to the model). `_save()` writes **markdown checkboxes** (for disk persistence and `read_file` fallback). Two formats, two purposes.

Every action returns a JSON string with:

```python
{
    "action": "add",            # echoes what was done
    "total": 5,                 # total items
    "remaining": 3,             # items not yet done
    "items": [                  # full current list
        {"task": "Read the existing test suite", "done": true},
        {"task": "Fix the failing test in test_edit.py", "done": false},
        ...
    ]
}
```

Returning the full list every time means the model never needs a separate `list` call after mutating — it always has a fresh view.

## Integration Points

### `todo.py` (new file, ~120 lines)

- `TodoItem` dataclass.
- `TodoState` class: `process()`, `_match_item()`, `_save()`, `summary_line()`.
- Reuse the `_safe_notes_path()` pattern from `thinking.py` — extract a shared `_safe_swival_path(base_dir, filename)` helper into a small utility, or just inline the same 3-line check. No need for a separate `_safe_todo_path()` function that duplicates the logic.

### `tools.py`

- Add the todo schema dict to the `TOOLS` list (always available, like `think`).
- Add a `"todo"` branch in `dispatch()` that calls `todo_state.process(args)`.
- If `todo_state` is missing from kwargs, return `"error: todo tool is not available"`.

### `agent.py`

- Create `TodoState(notes_dir=base_dir, verbose=args.verbose)` alongside `ThinkingState`.
- Pass `todo_state=todo_state` to `dispatch()` via kwargs.
- Add `todo_state.summary_line()` to the end-of-loop stats (same pattern as `thinking_state.summary_line()`). Example output: `"todo: 5 added, 3 done, 2 remaining"`. Returns `None` if todo was never called.
- On REPL `/clear`, reset the todo state.

### `report.py`

- Track todo usage in the report collector alongside thinking stats. At minimum: `todo_added` (total add calls), `todo_completed` (total done calls), `todo_remaining` (items not done at end of run). Added to the final report JSON under a `todo` key.

### `fmt.py`

- Add a `todo_update()` function for stderr logging when `--verbose` is set. Format:

```
[todo +1] Fix the failing test (3 remaining)
[todo ✓] Read the existing test suite (2 remaining)
[todo -1] Removed: stale item (2 remaining)
[todo cleared] 4 items removed
```

Keep it minimal — one line per action.

### `system_prompt.txt`

Add under the **Reasoning** section:

```
- `todo`: Track work items. Use `add` to collect tasks as you discover them, `done` to check them off, `list` to review what's left, and `clear` to wipe the list if you pivot to a different task. Prefer this over mental checklists for multi-step work — it survives context compaction.
```

### `agent.py` — logging

Skip verbose tool result logging for `todo` calls (same treatment as `think`). The `fmt.todo_update()` line is sufficient — no need to dump the full JSON list to stderr on every call.

## What This Does NOT Do

- **No priorities or ordering.** Items are shown in insertion order. Sorting and prioritization adds complexity that small models would misuse.
- **No subtasks or nesting.** A flat list. The model can encode hierarchy in the text if it wants, but the tool doesn't manage it.
- **No deadlines or metadata.** Just text and a done flag.
- **No auto-population.** The model decides what to add. The tool doesn't parse instructions or infer tasks.
- **No cross-session persistence.** The file is deleted on startup (session isolation, matching `ThinkingState`).

## Files to Change

| File | Change |
|------|--------|
| `swival/todo.py` | New. ~120 lines. `TodoItem`, `TodoState`, matching, persistence. |
| `swival/tools.py` | Add `todo` schema to `TOOLS`. Add `"todo"` branch in `dispatch()`. |
| `swival/agent.py` | Create `TodoState`, pass to `dispatch()`. Add summary line. REPL `/clear` reset. |
| `swival/fmt.py` | Add `todo_update()` for stderr logging. |
| `swival/system_prompt.txt` | Document the tool. |
| `tests/test_todo.py` | Full test suite. |

## Testing

### Unit tests (`tests/test_todo.py`)

**Core operations:**
- `add` creates an item, returned in the list with `done: false`.
- `add` allows duplicates (models may re-add after compaction with slightly different wording; a 50-item list doesn't need dedup).
- `done` marks an item, verify `done: true` in the response.
- `done` on an already-done item is a no-op success (returns the current list, no error). After compaction, models often re-complete items — an error would trigger pointless retry loops.
- `remove` deletes an item from the list entirely.
- `clear` removes all items and returns an empty list.
- `clear` on an already-empty list is a no-op success.
- `list` with no items returns `{"action": "list", "total": 0, "remaining": 0, "items": []}`.

**Matching:**
- Exact match (case-insensitive).
- Prefix match works when unambiguous.
- Substring match works when unambiguous.
- Ambiguous match returns error with the conflicting items listed.
- No match returns error.

**Persistence:**
- After add, `.swival/todo.md` exists with correct checkbox format.
- After done, `.swival/todo.md` shows `[x]` for the completed item.
- New `TodoState` instance with the same `notes_dir` loads existing items.

**Caps:**
- 51st item returns error.
- Item text > 500 chars returns an error telling the model to shorten it (not silently truncated — the model should know its description was too long).

**Edge cases:**
- `action` is missing or invalid → error.
- `add` without `task` → error.
- `done` without `task` → error.
- Empty `task` string → error.
- `notes_dir` is None → items work in memory but no persistence (no crash).

**Integration:**
- `dispatch("todo", {"action": "add", "task": "test"}, base_dir, todo_state=state)` works.
- `dispatch("todo", ..., base_dir)` without `todo_state` returns error string.

### Manual test

Run a multi-step task and verify:
- Agent adds items as it discovers work.
- Agent checks items off as it goes.
- After context compaction, agent can still `list` or `read_file .swival/todo.md` to recover the list.
- `--verbose` shows one-line `[todo ...]` updates on stderr.
