diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index d78487d7c..45868c6be 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -580,6 +580,14 @@ be bypassed. An evidence-backed peer transition. This closeout records `self_merged=false` and does not create a successor review todo for observation-only work. +User-role todos may still write `done` through `todo update --status done`; +when the todo declares `validation_command` (or the `validation_command_argv` +declared via `--validation-command-json`), +that update runs the same completion validation gate as `todo complete` and +fails closed with a typed `validation_blocked_completion` receipt instead of +committing `done`. Todos without a declared command keep the unchanged fast +path. + Use `--resume-when` when deferring a successor that should wake up after a machine-readable condition instead of living only in prose: diff --git a/loopx/todos.py b/loopx/todos.py index c7c0beb47..a4f55aa36 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1262,11 +1262,10 @@ def update_goal_todo( # validation command). Returns a typed failure payload (ok=False) when # validation blocks; otherwise None. if status: - try: - update_completes_todo = normalize_todo_status(status) == TODO_STATUS_DONE - except ValueError: - update_completes_todo = False # invalid status surfaces in-lock, unchanged - if update_completes_todo: + # normalize_todo_status returns None (never raises) for an invalid + # status, which simply skips this gate; the in-lock write path then + # surfaces the same invalid-status error as before. + if normalize_todo_status(status) == TODO_STATUS_DONE: update_block_match = find_todo_block( resolved_state_file.read_text(encoding="utf-8").splitlines(), todo_id=todo_id,