---
phase: 02-action-registry
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/actions/__init__.py
  - src/ztlctl/actions/definitions.py
  - src/ztlctl/actions/registry.py
  - tests/actions/__init__.py
  - tests/actions/test_definitions.py
  - tests/actions/test_registry.py
autonomous: true
requirements:
  - ACTN-01
  - ACTN-02

must_haves:
  truths:
    - "ActionParam frozen dataclass captures name, type, required, default, description, choices, and CLI/MCP-specific flags"
    - "ActionDefinition frozen dataclass captures name, description, category, params, handler, side_effect, plus optional CLI and MCP metadata"
    - "ActionRegistry supports register/get/list_actions with name-uniqueness enforcement"
    - "get_action_registry() returns a module-level singleton"
  artifacts:
    - path: "src/ztlctl/actions/definitions.py"
      provides: "ActionParam and ActionDefinition frozen dataclasses"
      exports: ["ActionParam", "ActionDefinition"]
    - path: "src/ztlctl/actions/registry.py"
      provides: "ActionRegistry class and singleton accessor"
      exports: ["ActionRegistry", "get_action_registry"]
    - path: "tests/actions/test_definitions.py"
      provides: "Unit tests for ActionParam and ActionDefinition"
      min_lines: 80
    - path: "tests/actions/test_registry.py"
      provides: "Unit tests for ActionRegistry"
      min_lines: 60
  key_links:
    - from: "src/ztlctl/actions/__init__.py"
      to: "src/ztlctl/actions/definitions.py"
      via: "re-exports ActionParam, ActionDefinition"
      pattern: "from ztlctl\\.actions\\.definitions import"
    - from: "src/ztlctl/actions/__init__.py"
      to: "src/ztlctl/actions/registry.py"
      via: "re-exports ActionRegistry, get_action_registry"
      pattern: "from ztlctl\\.actions\\.registry import"
---

<objective>
Create the ActionDefinition/ActionRegistry infrastructure as frozen dataclasses in a new `src/ztlctl/actions/` package, following the exact structural pattern of `domain/registry.py` (NoteTypeDefinition/NoteTypeRegistry).

Purpose: This is the foundation for the define-once action model. All controller methods will be registered as ActionDefinitions, enabling auto-generation of CLI and MCP surfaces in Phases 3-4.

Output: `src/ztlctl/actions/` package with ActionParam, ActionDefinition, ActionRegistry, plus comprehensive unit tests.
</objective>

<execution_context>
@/Users/shparki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/shparki/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-action-registry/02-CONTEXT.md
@.planning/phases/02-action-registry/02-RESEARCH.md

<interfaces>
<!-- Template pattern from existing codebase — ActionDefinition mirrors this structure -->

From src/ztlctl/domain/registry.py:
```python
@dataclass(frozen=True)
class NoteTypeDefinition:
    name: str
    content_type: str
    model_cls: type[ContentModel]
    transitions: dict[str, list[str]]
    template_name: str
    required_sections: list[str] = field(default_factory=list)
    initial_status: str = ""
    is_subtype: bool = False
    parent_type: str | None = None

class NoteTypeRegistry:
    def __init__(self) -> None:
        self._types: dict[str, NoteTypeDefinition] = {}
    def register(self, note_type: NoteTypeDefinition) -> None: ...
    def get(self, name: str) -> NoteTypeDefinition: ...
    def list_types(self, content_type: str | None = None) -> list[NoteTypeDefinition]: ...

_REGISTRY = NoteTypeRegistry()
def get_note_type_registry() -> NoteTypeRegistry:
    return _REGISTRY
```

From src/ztlctl/services/result.py:
```python
class ServiceResult(BaseModel):
    model_config = {"frozen": True}
    ok: bool
    op: str
    data: dict[str, Any] = Field(default_factory=dict)
    warnings: list[str] = Field(default_factory=list)
    error: ServiceError | None = None
    meta: dict[str, Any] | None = None
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create ActionParam and ActionDefinition frozen dataclasses</name>
  <files>
    src/ztlctl/actions/__init__.py,
    src/ztlctl/actions/definitions.py,
    tests/actions/__init__.py,
    tests/actions/test_definitions.py
  </files>
  <read_first>
    src/ztlctl/domain/registry.py,
    src/ztlctl/services/result.py,
    tests/domain/test_registry.py
  </read_first>
  <behavior>
    - ActionParam: frozen dataclass with fields name(str), type(type), required(bool=True), default(Any=None), description(str=""), choices(tuple[str,...] | None=None), cli_multiple(bool=False), cli_is_argument(bool=False), cli_flag(bool=False), mcp_example(str="")
    - ActionParam: immutable — assigning to any field raises TypeError/AttributeError
    - ActionDefinition: frozen dataclass with fields name(str), description(str), category(str), params(tuple[ActionParam,...]), handler(Callable[..., ServiceResult]), side_effect(Literal["read","write"]), mcp_when_to_use(str=""), mcp_avoid_when(str=""), mcp_common_errors(tuple[str,...]=()),  cli_group(str|None=None), cli_examples(str=""), cli_interactive_params(tuple[str,...]=()),  custom_presentation(bool=False)
    - ActionDefinition: immutable — assigning to any field raises TypeError/AttributeError
    - ActionDefinition: hashable (can be added to a set)
    - ActionDefinition with custom_presentation=True: custom_presentation attribute is True
    - ActionParam with choices=("a","b"): choices attribute is ("a","b")
    - ActionParam with cli_multiple=True: cli_multiple attribute is True
  </behavior>
  <action>
Create `src/ztlctl/actions/` package directory with `__init__.py`.

In `src/ztlctl/actions/definitions.py`, create two frozen dataclasses following the exact pattern of `NoteTypeDefinition` in `domain/registry.py`:

**ActionParam:**
```python
from __future__ import annotations
from dataclasses import dataclass
from typing import Any

@dataclass(frozen=True)
class ActionParam:
    """One parameter descriptor — single source of truth for CLI and MCP."""
    name: str
    type: type                          # Python built-in: str, int, bool, list[str]
    required: bool = True
    default: Any = None
    description: str = ""
    choices: tuple[str, ...] | None = None
    cli_multiple: bool = False
    cli_is_argument: bool = False
    cli_flag: bool = False
    mcp_example: str = ""
```

**ActionDefinition:**
```python
from typing import Callable, Literal, TYPE_CHECKING
if TYPE_CHECKING:
    from ztlctl.services.result import ServiceResult

@dataclass(frozen=True)
class ActionDefinition:
    """One operation in the system. Frozen for thread safety and hashability."""
    # Core identity
    name: str
    description: str
    category: str
    params: tuple[ActionParam, ...]
    handler: Callable[..., Any]  # Callable[..., ServiceResult] but Any avoids circular import
    side_effect: Literal["read", "write"]

    # MCP-specific metadata
    mcp_when_to_use: str = ""
    mcp_avoid_when: str = ""
    mcp_common_errors: tuple[str, ...] = ()

    # CLI-specific metadata
    cli_group: str | None = None
    cli_examples: str = ""
    cli_interactive_params: tuple[str, ...] = ()

    # Presentation escape hatch
    custom_presentation: bool = False
```

In `src/ztlctl/actions/__init__.py`, re-export both classes:
```python
from ztlctl.actions.definitions import ActionDefinition, ActionParam
__all__ = ["ActionDefinition", "ActionParam"]
```

Create `tests/actions/__init__.py` (empty).

Create `tests/actions/test_definitions.py` mirroring the structure of `tests/domain/test_registry.py`:
- `TestActionParam`: test_required_fields, test_default_fields, test_frozen, test_choices_tuple, test_cli_multiple_flag
- `TestActionDefinition`: test_required_fields, test_default_fields, test_frozen, test_hashable, test_custom_presentation, test_mcp_metadata, test_cli_metadata

Use a dummy handler `lambda **kw: None` for handler field in tests (no real service dependency needed).
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/actions/test_definitions.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/actions/__init__.py contains "from ztlctl.actions.definitions import ActionDefinition, ActionParam"
    - src/ztlctl/actions/definitions.py contains "@dataclass(frozen=True)" (twice — once for ActionParam, once for ActionDefinition)
    - src/ztlctl/actions/definitions.py contains "class ActionParam:"
    - src/ztlctl/actions/definitions.py contains "class ActionDefinition:"
    - src/ztlctl/actions/definitions.py contains 'side_effect: Literal["read", "write"]'
    - src/ztlctl/actions/definitions.py contains "custom_presentation: bool = False"
    - src/ztlctl/actions/definitions.py contains "mcp_when_to_use: str"
    - src/ztlctl/actions/definitions.py contains "cli_group: str | None"
    - tests/actions/test_definitions.py contains "class TestActionParam"
    - tests/actions/test_definitions.py contains "class TestActionDefinition"
    - tests/actions/test_definitions.py contains "test_frozen"
    - tests/actions/test_definitions.py contains "test_hashable"
    - `uv run pytest tests/actions/test_definitions.py -x -q` exits 0
  </acceptance_criteria>
  <done>ActionParam and ActionDefinition frozen dataclasses exist with all specified fields. Unit tests verify creation, defaults, immutability, and hashability. All tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create ActionRegistry with singleton accessor</name>
  <files>
    src/ztlctl/actions/registry.py,
    src/ztlctl/actions/__init__.py,
    tests/actions/test_registry.py
  </files>
  <read_first>
    src/ztlctl/domain/registry.py,
    src/ztlctl/actions/definitions.py,
    src/ztlctl/actions/__init__.py,
    tests/domain/test_registry.py
  </read_first>
  <behavior>
    - ActionRegistry.register(action) stores ActionDefinition retrievable by name
    - ActionRegistry.register(action) raises ValueError("already registered") on duplicate name
    - ActionRegistry.get("name") returns the registered ActionDefinition
    - ActionRegistry.get("nonexistent") raises KeyError("No action registered")
    - ActionRegistry.list_actions() returns all registered actions
    - ActionRegistry.list_actions(category="query") returns only actions with category="query"
    - ActionRegistry.list_actions(side_effect="read") returns only read actions
    - ActionRegistry.list_actions(custom_presentation=True) returns only custom_presentation actions
    - get_action_registry() returns the same singleton instance on repeated calls
  </behavior>
  <action>
Create `src/ztlctl/actions/registry.py` following the exact structural pattern of `NoteTypeRegistry` in `domain/registry.py`:

```python
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from ztlctl.actions.definitions import ActionDefinition

class ActionRegistry:
    """Registry of all ActionDefinitions (built-in + plugin-contributed).
    Thread safety: registrations happen at module-load time only.
    """
    def __init__(self) -> None:
        self._actions: dict[str, ActionDefinition] = {}

    def register(self, action: ActionDefinition) -> None:
        """Register an ActionDefinition. Raises ValueError on name collision."""
        if action.name in self._actions:
            msg = f"Action {action.name!r} is already registered"
            raise ValueError(msg)
        self._actions[action.name] = action

    def get(self, name: str) -> ActionDefinition:
        """Return ActionDefinition by name. Raises KeyError if not found."""
        try:
            return self._actions[name]
        except KeyError:
            msg = f"No action registered for name={name!r}"
            raise KeyError(msg) from None

    def list_actions(
        self,
        *,
        category: str | None = None,
        side_effect: Literal["read", "write"] | None = None,
        custom_presentation: bool | None = None,
    ) -> list[ActionDefinition]:
        """Return filtered list of registered actions."""
        actions = list(self._actions.values())
        if category is not None:
            actions = [a for a in actions if a.category == category]
        if side_effect is not None:
            actions = [a for a in actions if a.side_effect == side_effect]
        if custom_presentation is not None:
            actions = [a for a in actions if a.custom_presentation == custom_presentation]
        return actions

_REGISTRY = ActionRegistry()

def get_action_registry() -> ActionRegistry:
    """Return the module-level ActionRegistry singleton."""
    return _REGISTRY
```

Update `src/ztlctl/actions/__init__.py` to also re-export ActionRegistry and get_action_registry:
```python
from ztlctl.actions.definitions import ActionDefinition, ActionParam
from ztlctl.actions.registry import ActionRegistry, get_action_registry
__all__ = ["ActionDefinition", "ActionParam", "ActionRegistry", "get_action_registry"]
```

Create `tests/actions/test_registry.py` mirroring `tests/domain/test_registry.py` structure:
- `TestActionRegistry`: test_register_and_get, test_duplicate_raises, test_get_missing_raises, test_list_actions_all, test_list_actions_by_category, test_list_actions_by_side_effect, test_list_actions_by_custom_presentation
- `TestGetActionRegistry`: test_returns_singleton (call twice, assert `is` identity)

Use a minimal ActionDefinition fixture with dummy handler for all tests. Always use fresh `ActionRegistry()` instances (not the singleton) for isolation — same pattern as NoteTypeRegistry tests.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/actions/ -x -q</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/actions/registry.py contains "class ActionRegistry:"
    - src/ztlctl/actions/registry.py contains "def register(self, action: ActionDefinition)"
    - src/ztlctl/actions/registry.py contains "def get(self, name: str) -> ActionDefinition:"
    - src/ztlctl/actions/registry.py contains "def list_actions("
    - src/ztlctl/actions/registry.py contains "_REGISTRY = ActionRegistry()"
    - src/ztlctl/actions/registry.py contains "def get_action_registry() -> ActionRegistry:"
    - src/ztlctl/actions/__init__.py contains "ActionRegistry"
    - src/ztlctl/actions/__init__.py contains "get_action_registry"
    - tests/actions/test_registry.py contains "class TestActionRegistry"
    - tests/actions/test_registry.py contains "test_register_and_get"
    - tests/actions/test_registry.py contains "test_duplicate_raises"
    - tests/actions/test_registry.py contains "test_list_actions_by_category"
    - tests/actions/test_registry.py contains "test_returns_singleton"
    - `uv run pytest tests/actions/ -x -q` exits 0
    - `uv run mypy src/ztlctl/actions/` exits 0
  </acceptance_criteria>
  <done>ActionRegistry class exists with register/get/list_actions methods. Module-level singleton with get_action_registry() accessor. All tests pass including singleton identity check. mypy strict passes on actions package.</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/actions/ -x -q` — all unit tests green
- `uv run mypy src/ztlctl/actions/` — strict type checking passes
- `uv run ruff check src/ztlctl/actions/` — no lint errors
- `uv run pytest` — full suite still green (no regressions)
</verification>

<success_criteria>
- ActionParam frozen dataclass with 10 fields (name, type, required, default, description, choices, cli_multiple, cli_is_argument, cli_flag, mcp_example)
- ActionDefinition frozen dataclass with 13 fields (name, description, category, params, handler, side_effect, mcp_when_to_use, mcp_avoid_when, mcp_common_errors, cli_group, cli_examples, cli_interactive_params, custom_presentation)
- ActionRegistry with register/get/list_actions and singleton accessor
- All tests pass, mypy strict clean, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/02-action-registry/02-01-SUMMARY.md`
</output>
