todo_write — the full-state write
todo_write is the only operation: it replaces the entire TODO list with the
submitted one. The submitted list IS the new state — to change any task’s
status, rewrite the whole list with the updated status (the dominant trained
pattern, e.g. Claude Code’s TodoWrite).
pub fn todo_write(todos: &[TodoItemInput])
-> Result<TodoWriteOutput, PlanError>
An Err means the write was rejected and nothing changed. A success
means the list was replaced — but read the nags, because some problems are
tolerated rather than rejected (see nags are advice, errors are
rejections).
The Plan wrapper method
Plan::todo_write(&mut self, todos) applies the returned list to its
internal state automatically.
The submitted shape
let input: TodoWriteInput = serde_json::from_value(serde_json::json!({
"todos": [
{ "description": "Design schema", "key": "schema" },
{ "description": "Write migrations", "depends_on": ["schema"] },
{ "description": "Health check", "status": "pending" }
]
}))?;
Each entry carries:
| Field | Meaning |
|---|---|
description |
Required, never empty. |
status |
pending (default when omitted), in_progress, completed, or cancelled. |
key |
Optional alias other tasks in the same call reference in depends_on; resolved to the assigned task-N id. Must not look like a task id (task-<number>) — that would shadow the ids assigned by the same call. |
depends_on |
Optional list of sibling keys or existing task-N ids. |
What the write does
- The previous list is discarded; the replacement gets
task-1..Nids in listed order. - An empty list clears the plan (the protected context block disappears).
- The model sends ALL tasks on every call, including unchanged ones. There is no separate start/complete/cancel/edit/remove operation: those are all rewrites of the list.
Errors:
- An empty or whitespace-only
descriptionis rejected ("Description must be non-empty text (item N)."); - Duplicate
keys are rejected ("Duplicate key 'x'; keys must be unique."); - Keys shaped like
task-<number>are rejected (they would silently shadow the ids assigned by the same call); - More than one
in_progresstask is rejected ("Only one task at a time can be in_progress; got 'task-1', 'task-2'.").
Nags (the list is still written):
- Unknown dependency references (neither a sibling key nor an existing id);
- Self-references and circular dependency chains;
- An
in_progresstask whose dependencies are notcompleted.
Example
use cosh_tools::plan::{TodoItemInput, TodoStatus, todo_write};
// Create the plan.
let out = todo_write(&[
TodoItemInput { key: Some("schema".into()), description: "Design schema".into(),
status: TodoStatus::Pending, depends_on: None },
TodoItemInput { key: None, description: "Write migrations".into(),
status: TodoStatus::Pending, depends_on: Some(vec!["schema".into()]) },
])?;
// Start the first task: rewrite the full list with the updated status.
let out = todo_write(&[
TodoItemInput { key: Some("schema".into()), description: "Design schema".into(),
status: TodoStatus::InProgress, depends_on: None },
TodoItemInput { key: None, description: "Write migrations".into(),
status: TodoStatus::Pending, depends_on: Some(vec!["schema".into()]) },
])?;
// Two in_progress tasks are rejected.
assert!(todo_write(&[
TodoItemInput { key: None, description: "A".into(),
status: TodoStatus::InProgress, depends_on: None },
TodoItemInput { key: None, description: "B".into(),
status: TodoStatus::InProgress, depends_on: None },
]).is_err());
// Clear the plan.
let out = todo_write(&[])?;
assert!(out.list.items.is_empty());
Note the free-function pattern: each call produces a new list and you keep
the returned list. The Plan wrapper does this
bookkeeping for you.