diff --git a/loopx/cli_commands/action_help.py b/loopx/cli_commands/action_help.py index c714eda32d..f998b54efa 100644 --- a/loopx/cli_commands/action_help.py +++ b/loopx/cli_commands/action_help.py @@ -23,7 +23,12 @@ def __call__(self, parser, namespace, values, option_string=None): fields = set(allowed) | set(self.common_fields) | {self.dest} actions = [action for action in parser._actions if action.dest in fields] formatter = parser.formatter_class(prog=f"{parser.prog} {command}") - formatter.add_usage(None, actions, []) + # Option declarations below retain the complete action grammar. + # A second long list in usage only repeats it (and can itself be + # truncated before the descriptions). Keep parser-required inputs. + required = [action for action in actions if action.required] + optional = argparse.Action([], dest="options", metavar="options", nargs="?") + formatter.add_usage(None, [*required, optional], []) formatter.add_text("Options for this action. Conditional requirements and " "state/authority checks still apply. Global --registry " "and --runtime-root go before the command.") diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 969994b3b7..b72756a4f1 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -139,8 +139,8 @@ ) _TODO_OPTION_FLAGS = {field: flag for flag, field in TODO_OPTION_FIELDS} -# The same strict subset drives validation and action help. Complex lifecycle -# commands retain full help until their conditional grammar is centralized. +# Read/claim subsets share validation and help. Update help reuses the mutable +# field owner; other lifecycle commands retain full help until centralized. TODO_ACTION_FIELDS = { "list": frozenset({"role", "todo_id", "status", "agent_id", "todo_limit", "todo_thin", "state_file", "project", "dry_run"}), "receipt": frozenset({"operation_id"}), @@ -149,6 +149,10 @@ "project-markdown": frozenset({"state_file", "execute", "provider_revision", "project"}), "claim": frozenset({"role", "todo_id", "claimed_by", "agent_id", "state_file", "claim_operation_id", "task_lease_idempotency_key", "task_lease_expected_version", "project", "dry_run"}), + "update": frozenset(_TODO_UPDATE_MUTABLE_FIELDS) | frozenset({ + "role", "todo_id", "agent_id", "state_file", "project", "dry_run", "authority_reason", + "claim_operation_id", "update_operation_id", "update_expected_provider_revision", + }), } diff --git a/tests/control_plane/test_cli_action_help.py b/tests/control_plane/test_cli_action_help.py index 5982ef727c..e39e2c23a6 100644 --- a/tests/control_plane/test_cli_action_help.py +++ b/tests/control_plane/test_cli_action_help.py @@ -19,6 +19,8 @@ (["todo", "list"], ["--todo-id", "--thin", "--limit"], ["--claimed-by", "--next-agent-todo"]), (["todo", "claim"], ["--claimed-by", "--claim-operation-id"], ["--note", "--next-agent-todo", "--task-lease-expected-version", "--task-lease-idempotency-key"]), + (["todo", "update"], ["--note", "--update-operation-id", "--validation-command", "--resume-when"], + ["--next-agent-todo", "--decision-outcome", "--result-file", "--task-lease-expected-version"]), (["todo", "receipt"], ["--operation-id"], ["--todo-id", "--claimed-by"]), (["todo", "result-read"], ["--todo-id"], ["--text", "--claimed-by"]), (["todo", "plan"], ["--agent-id", "--text"], ["--claimed-by", "--todo-id"]), @@ -38,6 +40,20 @@ def test_action_help_from_real_cli_with_unavailable_registry(tmp_path, command, assert not registry.exists() +def test_action_help_keeps_required_usage_and_lists_optional_grammar_once(capsys): + parser = build_parser() + command = ["todo", "update", "--goal-id", "fixture", "--todo-id", "todo_a", "--note", "progress"] + before = vars(parser.parse_args(command)) + with pytest.raises(SystemExit) as exited: + parser.parse_args(["todo", "update", "--help"]) + assert exited.value.code == 0 + output = capsys.readouterr().out + usage = output.split("\n\n", 1)[0] + assert "--goal-id" in usage and "[options]" in usage + assert "--note" not in usage and "--note NOTE" in output + assert vars(parser.parse_args(command)) == before + + def test_help_does_not_mutate_reused_parser_or_change_claim_input(capsys): parser = build_parser() command = ["todo", "claim", "--goal-id", "fixture", "--todo-id", "todo_a",