---
phase: 02-action-registry
plan: 04
type: execute
wave: 3
depends_on:
  - "02-01"
  - "02-02"
  - "02-03"
files_modified:
  - src/ztlctl/actions/_register_core.py
  - src/ztlctl/actions/__init__.py
  - tests/actions/test_core_registrations.py
autonomous: true
requirements:
  - ACTN-01
  - ACTN-02

must_haves:
  truths:
    - "All ~50 public controller methods are registered as ActionDefinitions in the singleton registry"
    - "Each registered ActionDefinition has params matching its handler's signature (parity test)"
    - "Registry lookup by name returns correct ActionDefinition for every registered action"
    - "Filtering by category, side_effect, and custom_presentation returns correct subsets"
    - "custom_presentation=True is set on batch, init_vault, init_workflow, update_workflow, export_assets"
  artifacts:
    - path: "src/ztlctl/actions/_register_core.py"
      provides: "Registration of all ~50 built-in ActionDefinitions"
      contains: "_register_core_actions"
      min_lines: 200
    - path: "tests/actions/test_core_registrations.py"
      provides: "Integration tests for core registrations and handler parity"
      min_lines: 80
  key_links:
    - from: "src/ztlctl/actions/_register_core.py"
      to: "src/ztlctl/actions/registry.py"
      via: "get_action_registry().register()"
      pattern: "get_action_registry\\(\\)\\.register"
    - from: "src/ztlctl/actions/_register_core.py"
      to: "src/ztlctl/controllers/"
      via: "lambda factories referencing controller methods"
      pattern: "Controller\\(vault\\)\\."
    - from: "src/ztlctl/actions/__init__.py"
      to: "src/ztlctl/actions/_register_core.py"
      via: "module-load-time call to _register_core_actions()"
      pattern: "_register_core_actions\\(\\)"
---

<objective>
Register all ~50 public controller methods as ActionDefinitions in the singleton registry, completing the define-once action model. This is the capstone plan that ties together the actions infrastructure (Plan 01) with the controller layer (Plans 02-03).

Purpose: After this plan, every core operation is described as a declarative ActionDefinition in a central registry. Phases 3-4 will use this registry to auto-generate MCP and CLI surfaces.

Output: `_register_core.py` with all registrations, handler signature parity tests, and integration verification.
</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
@.planning/phases/02-action-registry/02-01-SUMMARY.md
@.planning/phases/02-action-registry/02-02-SUMMARY.md
@.planning/phases/02-action-registry/02-03-SUMMARY.md

<interfaces>
<!-- From Plan 01 -->
From src/ztlctl/actions/definitions.py:
```python
@dataclass(frozen=True)
class ActionParam:
    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 = ""

@dataclass(frozen=True)
class ActionDefinition:
    name: str
    description: str
    category: str
    params: tuple[ActionParam, ...]
    handler: Callable[..., Any]
    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
```

From src/ztlctl/actions/registry.py:
```python
class ActionRegistry:
    def register(self, action: ActionDefinition) -> None: ...
    def get(self, name: str) -> ActionDefinition: ...
    def list_actions(self, *, category=None, side_effect=None, custom_presentation=None) -> list[ActionDefinition]: ...

def get_action_registry() -> ActionRegistry: ...
```

<!-- From Plans 02-03: Controller inventory -->
CheckController: check, fix, rebuild, rollback
UpgradeController: check_pending, apply, stamp_current
ExportController: export_markdown, export_indexes, export_graph, export_dashboard
GraphController: related, themes, rank, path, gaps, bridges, unlink, materialize_metrics
VectorController: reindex_all
ReweaveController: reweave, prune, undo
CreateController: create_note, create_reference, create_task, create_batch
UpdateController: update, archive, supersede
QueryController: count_items, search, get, list_items, work_queue, list_tags, decision_support, topic_packet, draft_from_topic, vault_review
SessionController: start, close, reopen, status, log_entry, cost, context, brief, extract_decision
IngestController: list_providers, ingest_text, ingest_file, ingest_url
WorkflowController: init_workflow, update_workflow, export_assets
InitController: init_vault, regenerate_self, check_staleness
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create _register_core.py with all ~50 ActionDefinition registrations</name>
  <files>
    src/ztlctl/actions/_register_core.py,
    src/ztlctl/actions/__init__.py
  </files>
  <read_first>
    src/ztlctl/actions/definitions.py,
    src/ztlctl/actions/registry.py,
    src/ztlctl/actions/__init__.py,
    src/ztlctl/controllers/create.py,
    src/ztlctl/controllers/query.py,
    src/ztlctl/controllers/graph.py,
    src/ztlctl/controllers/update.py,
    src/ztlctl/controllers/reweave.py,
    src/ztlctl/controllers/session.py,
    src/ztlctl/controllers/check.py,
    src/ztlctl/controllers/ingest.py,
    src/ztlctl/controllers/export.py,
    src/ztlctl/controllers/vector.py,
    src/ztlctl/controllers/upgrade.py,
    src/ztlctl/controllers/workflow.py,
    src/ztlctl/controllers/init_ctrl.py,
    src/ztlctl/mcp/tools.py
  </read_first>
  <action>
Create `src/ztlctl/actions/_register_core.py` containing a `_register_core_actions()` function that registers all ~50 ActionDefinitions into the singleton registry.

**Handler binding convention (factory lambda per research recommendation):**
Each handler is a lambda factory that constructs the controller with a vault at dispatch time:
```python
handler=lambda vault, **kw: CreateController(vault).create_note(**kw)
```

This avoids constructing dummy controllers at registration time. Controller imports inside `_register_core_actions()` are lazy local imports at function scope (not module level) — matching the existing lazy import convention:

```python
def _register_core_actions() -> None:
    """Register all built-in ActionDefinitions into the singleton registry."""
    from ztlctl.actions.registry import get_action_registry
    from ztlctl.actions.definitions import ActionDefinition, ActionParam

    # Lazy controller imports — avoid module-level cross-layer imports
    from ztlctl.controllers.create import CreateController
    from ztlctl.controllers.query import QueryController
    # ... etc

    registry = get_action_registry()

    # --- creation category ---
    registry.register(ActionDefinition(
        name="create_note",
        description="Create a new note in the vault",
        category="creation",
        params=(
            ActionParam("title", str, required=True, description="Note title", cli_is_argument=True),
            ActionParam("subtype", str, required=False, default=None, description="Note subtype (knowledge, decision)", choices=("knowledge", "decision")),
            ActionParam("tags", list, required=False, default=None, cli_multiple=True, description="Tags to apply"),
            ActionParam("topic", str, required=False, default=None, description="Topic/space for the note"),
            ActionParam("body", str, required=False, default=None, description="Note body content"),
            ActionParam("key_points", list, required=False, default=None, cli_multiple=True, description="Key points"),
            ActionParam("links", dict, required=False, default=None, description="Links to other notes"),
            ActionParam("aliases", list, required=False, default=None, cli_multiple=True, description="Alias titles"),
        ),
        handler=lambda vault, **kw: CreateController(vault).create_note(**kw),
        side_effect="write",
        mcp_when_to_use="When capturing a new idea, insight, or concept",
        cli_group="create",
        cli_interactive_params=("title",),
    ))
    # ... continue for all remaining actions
```

**Category assignments:**
- `"creation"`: create_note, create_reference, create_task, create_batch (custom_presentation=True)
- `"query"`: count_items, search, get, list_items, work_queue, list_tags, decision_support, topic_packet, draft_from_topic, vault_review
- `"graph"`: related, themes, rank, path, gaps, bridges, unlink, materialize_metrics
- `"lifecycle"`: update, archive, supersede
- `"reweave"`: reweave, prune, undo
- `"session"`: start, close, reopen, status, log_entry, cost, context, brief, extract_decision
- `"check"`: check, fix, rebuild, rollback
- `"ingest"`: list_providers, ingest_text, ingest_file, ingest_url
- `"export"`: export_markdown, export_indexes, export_graph, export_dashboard
- `"vector"`: reindex_all
- `"upgrade"`: check_pending, apply, stamp_current
- `"workflow"`: init_workflow (custom_presentation=True), update_workflow (custom_presentation=True), export_assets (custom_presentation=True)
- `"init"`: init_vault (custom_presentation=True), regenerate_self, check_staleness

**side_effect assignments:**
- `"read"`: all query/*, graph/* (except unlink, materialize_metrics), session status/cost/context/brief, check (no fix), list_providers, export_*, check_pending, check_staleness, count_items, list_tags, reindex_all
- `"write"`: all creation/*, lifecycle/*, reweave/*, session start/close/reopen/log_entry/extract_decision, fix/rebuild/rollback, ingest_text/ingest_file/ingest_url, apply/stamp_current, init_vault, regenerate_self, unlink, materialize_metrics, init_workflow, update_workflow, export_assets

**custom_presentation=True actions:** create_batch, init_vault, init_workflow, update_workflow, export_assets

**MCP metadata:** Read `src/ztlctl/mcp/tools.py` for existing `ToolCatalogEntry` values (when_to_use, avoid_when, common_errors). Copy these values into corresponding ActionDefinitions' `mcp_when_to_use`, `mcp_avoid_when`, `mcp_common_errors` fields.

**CLI metadata:** Set `cli_group` based on existing Click command group structure (check `src/ztlctl/commands/` directory — groups are: create, graph, session, check, export, workflow, vector, ingest). For top-level commands (get, list, search, update, etc.), set `cli_group=None`.

**Complexity budget:** Each ActionDefinition registration should be under 20 lines. Use short but descriptive strings for description, mcp_when_to_use, etc.

**Update `src/ztlctl/actions/__init__.py`:** Add `_register_core_actions()` call at module load time, after the registry import:
```python
from ztlctl.actions.definitions import ActionDefinition, ActionParam
from ztlctl.actions.registry import ActionRegistry, get_action_registry
from ztlctl.actions._register_core import _register_core_actions

_register_core_actions()

__all__ = ["ActionDefinition", "ActionParam", "ActionRegistry", "get_action_registry"]
```

This mirrors the pattern in `domain/registry.py` where `_register_builtins()` is called at module level after the registry is created.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run python -c "from ztlctl.actions import get_action_registry; r = get_action_registry(); print(f'Registered: {len(r.list_actions())} actions'); assert len(r.list_actions()) >= 45, f'Expected >=45 actions, got {len(r.list_actions())}'"</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/actions/_register_core.py contains "def _register_core_actions()"
    - src/ztlctl/actions/_register_core.py contains "registry.register(ActionDefinition(" (multiple times)
    - src/ztlctl/actions/_register_core.py contains 'name="create_note"'
    - src/ztlctl/actions/_register_core.py contains 'name="search"'
    - src/ztlctl/actions/_register_core.py contains 'name="start"' (session start)
    - src/ztlctl/actions/_register_core.py contains 'name="check"'
    - src/ztlctl/actions/_register_core.py contains 'name="rebuild"'
    - src/ztlctl/actions/_register_core.py contains 'name="related"' (graph related)
    - src/ztlctl/actions/_register_core.py contains 'custom_presentation=True' (at least 5 times)
    - src/ztlctl/actions/__init__.py contains "_register_core_actions()"
    - `python -c "from ztlctl.actions import get_action_registry; print(len(get_action_registry().list_actions()))"` outputs >= 45
    - `uv run mypy src/ztlctl/actions/` exits 0
  </acceptance_criteria>
  <done>_register_core.py registers all ~50 ActionDefinitions with correct categories, side_effects, params, and handler lambdas. Registry loads at module import time. At least 45 actions registered.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Integration tests — core registrations count, handler signature parity, filtering</name>
  <files>
    tests/actions/test_core_registrations.py
  </files>
  <read_first>
    src/ztlctl/actions/_register_core.py,
    src/ztlctl/actions/registry.py,
    src/ztlctl/actions/definitions.py,
    tests/actions/test_registry.py
  </read_first>
  <behavior>
    - get_action_registry().list_actions() returns >= 45 actions
    - get_action_registry().get("create_note") returns ActionDefinition with name="create_note"
    - get_action_registry().get("create_note").category == "creation"
    - get_action_registry().get("create_note").side_effect == "write"
    - get_action_registry().get("search").side_effect == "read"
    - get_action_registry().list_actions(category="creation") returns >= 3 actions
    - get_action_registry().list_actions(custom_presentation=True) returns >= 5 actions
    - For each registered action: the set of required ActionParam names matches the handler's required keyword arguments (signature parity)
    - All registered action names are unique (no duplicates)
    - Every category has at least one registered action
  </behavior>
  <action>
Create `tests/actions/test_core_registrations.py` with these test classes:

**TestCoreRegistrationCount:**
- `test_minimum_action_count`: Assert `len(get_action_registry().list_actions()) >= 45`
- `test_all_names_unique`: Collect all action names, assert len(names) == len(set(names))
- `test_categories_covered`: Assert these categories all have at least 1 action: creation, query, graph, lifecycle, reweave, session, check, ingest, export, vector, upgrade, workflow, init

**TestActionLookup:**
- `test_get_create_note`: Verify name, category="creation", side_effect="write", has params with "title" required
- `test_get_search`: Verify name, category="query", side_effect="read"
- `test_get_check`: Verify name, category="check"
- `test_custom_presentation_actions`: Get all with custom_presentation=True, assert names include "create_batch", "init_vault", "init_workflow"

**TestFilteringConsistency:**
- `test_read_actions_have_no_write`: All actions with side_effect="read" do not mutate data (spot-check a few)
- `test_custom_presentation_subset`: All custom_presentation=True actions are a subset of total actions

**TestHandlerSignatureParity:**
This is the critical parity test from VALIDATION.md (ACTN-01+02 combined):
```python
import inspect

def test_handler_signature_parity():
    """Each registered action's handler params match its ActionDefinition.params."""
    registry = get_action_registry()
    for action in registry.list_actions():
        sig = inspect.signature(action.handler)
        # handler is lambda vault, **kw — skip "vault" param, check kw against action.params
        handler_params = set(sig.parameters.keys()) - {"vault", "kw", "kwargs"}
        action_param_names = {p.name for p in action.params}
        # handler uses **kw so we can't inspect individual params — instead verify
        # that the handler is callable (basic sanity)
        assert callable(action.handler), f"Action {action.name} handler is not callable"
```

Note: Because handlers are lambda factories (`lambda vault, **kw: ...`), we cannot inspect individual parameter names from the lambda signature. Instead, create a separate test that verifies the ActionParam names match the actual controller method signatures:

```python
def test_action_params_match_controller_methods():
    """Verify that ActionParam names match corresponding controller method signatures."""
    registry = get_action_registry()
    for action in registry.list_actions():
        if action.custom_presentation:
            continue  # skip complex actions with non-standard signatures
        # The handler is a lambda — extract the controller method it wraps
        # by checking the lambda's closure or testing with a mock vault
        # Alternative: maintain a separate mapping and verify programmatically
        # For now: verify all required params have descriptions (completeness check)
        for param in action.params:
            assert param.description, f"Action {action.name} param {param.name} has no description"
```

The strongest parity verification: for each non-custom_presentation action, construct the controller with a mock vault and call with the declared params to verify no TypeError. This requires a vault fixture, so scope it to a representative sample (create_note, search, check, related).
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/actions/test_core_registrations.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - tests/actions/test_core_registrations.py contains "class TestCoreRegistrationCount"
    - tests/actions/test_core_registrations.py contains "test_minimum_action_count"
    - tests/actions/test_core_registrations.py contains "test_all_names_unique"
    - tests/actions/test_core_registrations.py contains "test_categories_covered"
    - tests/actions/test_core_registrations.py contains "class TestActionLookup"
    - tests/actions/test_core_registrations.py contains "test_get_create_note"
    - tests/actions/test_core_registrations.py contains "test_custom_presentation_actions"
    - tests/actions/test_core_registrations.py contains "class TestHandlerSignatureParity" or "test_handler"
    - `uv run pytest tests/actions/test_core_registrations.py -x -q` exits 0
  </acceptance_criteria>
  <done>Integration tests verify: >=45 actions registered, all names unique, all categories covered, lookups return correct ActionDefinitions, custom_presentation flags are correct, and handler parity is validated. All tests pass.</done>
</task>

<task type="auto">
  <name>Task 3: Full suite validation and cleanup</name>
  <files>
    src/ztlctl/actions/__init__.py
  </files>
  <read_first>
    src/ztlctl/actions/__init__.py,
    src/ztlctl/actions/_register_core.py,
    src/ztlctl/controllers/__init__.py
  </read_first>
  <action>
Run the full validation suite to confirm no regressions:

1. `uv run pytest` — full test suite must pass (existing 1553+ tests + new tests)
2. `uv run mypy src/` — strict type checking on entire source tree
3. `uv run ruff check .` — lint clean
4. `uv run ruff format --check .` — format clean

If any failures:
- Fix type errors in actions/ or controllers/ packages
- Ensure no circular imports (test with `python -c "import ztlctl.actions"`)
- Ensure `_register_core_actions()` does not trigger heavy imports at module load (verify with `python -X importtime -c "import ztlctl.actions" 2>&1 | head -30`)

Verify the final action count:
```bash
uv run python -c "
from ztlctl.actions import get_action_registry
r = get_action_registry()
actions = r.list_actions()
print(f'Total actions: {len(actions)}')
categories = sorted(set(a.category for a in actions))
for cat in categories:
    count = len(r.list_actions(category=cat))
    print(f'  {cat}: {count}')
reads = len(r.list_actions(side_effect=\"read\"))
writes = len(r.list_actions(side_effect=\"write\"))
print(f'Read: {reads}, Write: {writes}')
custom = len(r.list_actions(custom_presentation=True))
print(f'Custom presentation: {custom}')
"
```
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest -x -q && uv run mypy src/ && uv run ruff check . && uv run ruff format --check .</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest -x -q` exits 0 with all tests passing
    - `uv run mypy src/` exits 0 with no errors
    - `uv run ruff check .` exits 0 with no errors
    - `uv run ruff format --check .` exits 0 with no errors
    - `python -c "import ztlctl.actions"` does not raise ImportError
    - `python -c "from ztlctl.actions import get_action_registry; print(len(get_action_registry().list_actions()))"` outputs >= 45
  </acceptance_criteria>
  <done>Full validation suite passes. No regressions in existing 1553+ tests. New actions/ and controllers/ packages are fully type-safe, lint-clean, and format-clean. Registry loads successfully at import time with >=45 registered actions.</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/actions/ -x -q` — all action tests green
- `uv run pytest tests/controllers/ -x -q` — all controller tests green
- `uv run pytest -x -q` — full suite green (no regressions)
- `uv run mypy src/` — strict type checking passes on entire source tree
- `uv run ruff check . && uv run ruff format --check .` — clean
- Registry contains >=45 registered ActionDefinitions
- All 13 categories have at least one action
- Handler signature parity verified
</verification>

<success_criteria>
- _register_core.py registers all ~50 ActionDefinitions with correct metadata
- Integration tests verify count, uniqueness, categories, lookups, custom_presentation flags, and handler parity
- Full validation suite passes with no regressions
- Phase 2 goal achieved: every core operation is described as a declarative ActionDefinition in a central registry
</success_criteria>

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