# Plan: Batch todo operations

Allow the LLM to add, done, or remove multiple items in a single `todo` tool call.

## Current state

The `todo` tool accepts a single `task` string parameter. To add 5 items, the LLM must make 5 separate tool calls, which wastes tokens on repeated tool-call overhead and round-trips.

## Design

Replace the `task` parameter with `tasks`, which accepts either a single string or an array of strings. The old `task` name is silently accepted as an alias (no mutual-exclusivity logic, no schema exposure of both).

### Schema change (`tools.py`)

Rename `task` to `tasks` in the tool schema. Use `oneOf` to accept both a string and an array of strings:

```python
"tasks": {
    "oneOf": [
        {"type": "string"},
        {"type": "array", "items": {"type": "string"}},
    ],
    "description": (
        "For 'add': one or more task descriptions (each max 500 chars). "
        "For 'done'/'remove': text matching existing tasks "
        "(prefix match, case-insensitive). "
        "Not needed for 'list' or 'clear'. "
        "Accepts a single string or a list of strings."
    ),
},
```

### Input normalization (`todo.py`)

At the top of `process()`, normalize the input:

1. If `tasks` is present, use it. Otherwise fall back to legacy `task`.
2. If both `tasks` and `task` are present: normalize each independently (string → `[string]`), strip items. If they produce the same item list, proceed. Otherwise return `"error: provide either 'tasks' or legacy alias 'task', not conflicting values"`.
3. If the chosen value is a string, wrap it as a one-element list. If it's a list, use as-is.
4. Strip each item. Whitespace-only or empty entries are **per-item failures** reported in `"errors"` (not silently dropped).
5. Validate each item's length against `MAX_ITEM_TEXT` individually — per-item failure, not whole-batch rejection.
6. If no valid items remain after steps 4–5 and the action requires items, return top-level `"error: ..."`.

### Batch processing (`todo.py`)

For `add`, `done`, and `remove`:

1. Loop over the normalized list, calling the internal `_add`/`_done`/`_remove` method for each item.
2. These methods no longer call `_save()` — the caller does that once after the loop.
3. Track results per item: success, skipped (duplicate add), or failed (no match, length exceeded, list full).
4. Duplicate adds are **skips, not errors** — same as today's behavior. They are reported in a `"skipped"` key in the response, not in `"errors"`.

### Error signaling

The agent loop treats any result starting with `"error:"` as a failure (`agent.py:1149`). Batch responses must respect this contract:

- **All items succeeded or were skipped**: return the normal JSON response. Skipped items noted in a `"skipped"` list.
- **Mixed results (some succeeded, some failed)**: return the normal JSON response with an additional `"errors"` list (e.g. `[{"task": "foo", "reason": "no matching task"}]`). This is **not** prefixed with `"error:"` — the call counts as a success because work was done.
- **All items failed**: return `"error: all N items failed — <summary>"`. This is a plain error string, consistent with single-item behavior.
- **Pre-validation failures** (empty tasks list, invalid action): return `"error: ..."` as today.

### Overflow semantics

Best-effort, not all-or-nothing. When a batch `add` would exceed `MAX_ITEMS`:

- Add items until the list is full.
- Remaining items go into the `"errors"` list with reason `"todo list full"`.
- If the list was already full before the call (zero items added), return `"error: todo list full (50 items max)"` — the all-failed rule applies.

### Display change (`fmt.py`)

- For batch operations, call `fmt.todo_list` once after the batch completes.
- Use a note like "added 3 items" / "marked 2 done" / "removed 4 items".
- If there were partial failures, append to the note: "added 3 items (2 failed)".

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

| Test | What it verifies |
|------|-----------------|
| Batch add multiple items | 3 items added, response shows all 3, `add_count` incremented by 3 |
| Batch add with duplicates | Duplicates skipped (in `"skipped"` key), non-duplicates added |
| Batch add hitting MAX_ITEMS | Items added until full, rest in `"errors"`, not a top-level error |
| Batch add when already full | All fail, returns `"error: ..."` string |
| Batch done multiple items | Marks multiple items done in one call |
| Batch remove multiple items | Removes multiple items in one call |
| Batch with partial match failures | Some done/remove items match, some don't — successes applied, failures in `"errors"` |
| Batch all-miss done/remove | Returns `"error: ..."` string |
| Per-item strip and empty rejection | `"  "` and `""` entries are dropped, valid items processed |
| Per-item length validation | Items exceeding 500 chars go to `"errors"`, others are added |
| `task` accepted as alias for `tasks` | Old key name still works |
| Single string accepted for `tasks` | `"tasks": "foo"` works like `"tasks": ["foo"]` |
| `list` and `clear` ignore `tasks` | No change from current behavior |
| Conflicting `task` and `tasks` | Returns `"error: ..."` when both present with different values |
| Matching `task` and `tasks` | Proceeds normally when `tasks: ["x"]` and `task: "x"` agree |
| Alias match after normalization | `"tasks": " x ", "task": "x"` accepted (strip before compare) |
| Persistence on partial failure | `_save()` called exactly once even when some items fail |
| Verbose with errors and skips | `fmt.todo_list` called once; note includes both counts (e.g. "added 2 items (1 skipped, 1 failed)") |
| Persistence: single `_save()` call | Batch add writes to `todo.md` once (mock/spy on `_save`) |
| Verbose display for batch | `fmt.todo_list` called once, not per-item |

## Files to change

| File | Change |
|------|--------|
| `swival/tools.py` | Replace `task` with `tasks` in schema (oneOf string/array) |
| `swival/todo.py` | Input normalization, batch loop, single `_save()`, error collection |
| `swival/fmt.py` | Batch-aware display note |
| `tests/test_todo.py` | New tests above; update existing tests to use `tasks` key |

## Non-goals

- No new actions. The existing actions work on one-or-many.
- No change to the todo.md file format or compaction behavior.
- No breaking change for callers sending a single string via the old `task` key.
