---
phase: 16-plugin-bridge-and-action-executor
plan: 03
type: execute
wave: 2
depends_on: [16-01]
files_modified:
  - src/ztlctl/controllers/check.py
  - src/ztlctl/controllers/create.py
  - src/ztlctl/controllers/discovery.py
  - src/ztlctl/controllers/export.py
  - src/ztlctl/controllers/graph.py
  - src/ztlctl/controllers/ingest.py
  - src/ztlctl/controllers/init_ctrl.py
  - src/ztlctl/controllers/query.py
  - src/ztlctl/controllers/reweave.py
  - src/ztlctl/controllers/session.py
  - src/ztlctl/controllers/update.py
  - src/ztlctl/controllers/upgrade.py
  - src/ztlctl/controllers/vector.py
  - src/ztlctl/controllers/workflow.py
  - src/ztlctl/actions/_register_core.py
  - src/ztlctl/commands/__init__.py
  - src/ztlctl/commands/garden.py
  - src/ztlctl/commands/generator.py
  - tests/controllers/test_hook_wiring.py
  - tests/controllers/test_hook_wiring_batch2.py
  - tests/actions/test_garden_seed_registration.py
autonomous: true
requirements: [ARCH-06, ARCH-09]

must_haves:
  truths:
    - "Every controller method that had pre_action boilerplate now delegates to _run_action"
    - "garden seed is a registered ActionDefinition that routes through CreateController, not directly to CreateService"
    - "ztlctl garden seed exercises pre_action hooks identically to ztlctl create note"
    - "The hand-written commands/garden.py is deleted; the garden group is generated"
  artifacts:
    - path: "src/ztlctl/controllers/create.py"
      provides: "Controller methods using _run_action"
      contains: "_run_action"
    - path: "src/ztlctl/controllers/query.py"
      provides: "Controller methods using _run_action"
      contains: "_run_action"
    - path: "src/ztlctl/actions/_register_core.py"
      provides: "garden_seed ActionDefinition"
      contains: "garden_seed"
    - path: "src/ztlctl/commands/__init__.py"
      provides: "No manual garden import"
    - path: "src/ztlctl/commands/generator.py"
      provides: "garden group help text in _GROUP_HELP"
      contains: "garden"
  key_links:
    - from: "src/ztlctl/controllers/create.py"
      to: "src/ztlctl/controllers/base.py"
      via: "self._run_action() call"
      pattern: "self\\._run_action"
    - from: "src/ztlctl/actions/_register_core.py"
      to: "src/ztlctl/controllers/create.py"
      via: "garden_seed handler calls CreateController.create_note"
      pattern: "CreateController.*create_note"
    - from: "src/ztlctl/commands/__init__.py"
      to: "src/ztlctl/commands/generator.py"
      via: "generate_commands handles garden group automatically"
      pattern: "No.*garden.*import in __init__.py"
---

<objective>
Migrate all controllers to use _run_action and make garden seed a first-class ActionRegistry action.

Purpose: Completes ARCH-06 by replacing the repeated pre_action dispatch + rejection boilerplate across all 14 controllers with the single _run_action call from Plan 01. Completes ARCH-09 by adding garden_seed as an ActionDefinition so it exercises the same pre-action/post-commit machinery as other create flows.

Output: All controllers migrated, garden_seed registered in ActionRegistry, hand-written garden.py deleted.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-plugin-bridge-and-action-executor/16-RESEARCH.md
@.planning/phases/16-plugin-bridge-and-action-executor/16-01-SUMMARY.md

<interfaces>
<!-- Key contracts from Plan 01 output. -->

From src/ztlctl/controllers/base.py (added in Plan 01):
```python
class BaseController:
    def _run_action(
        self,
        action_name: str,
        kwargs: dict[str, Any],
        invoke: Callable[[dict[str, Any]], ServiceResult],
    ) -> ServiceResult:
        """Execute action through pre-action hook pipeline.
        Dispatches pre_action, maps rejection to ServiceResult, or calls invoke.
        """
```

From src/ztlctl/controllers/create.py (BEFORE migration -- the pattern to replace):
```python
def create_note(self, title: str, ...) -> ServiceResult:
    kwargs: dict[str, Any] = {"title": title, "subtype": subtype, ...}

    kwargs, rejection = self._dispatch_pre_action("create_note", kwargs)
    if rejection is not None:
        return ServiceResult(
            ok=False, op="create_note",
            error=ServiceError(code="ACTION_REJECTED", message=rejection.reason, ...),
        )

    result = CreateService(self._vault).create_note(kwargs["title"], ...)
    return result
```

AFTER migration pattern:
```python
def create_note(self, title: str, ...) -> ServiceResult:
    kwargs: dict[str, Any] = {"title": title, "subtype": subtype, ...}

    def _invoke(kw: dict[str, Any]) -> ServiceResult:
        return CreateService(self._vault).create_note(
            kw["title"], subtype=kw["subtype"], ...
        )

    return self._run_action("create_note", kwargs, _invoke)
```

From src/ztlctl/actions/definitions.py:
```python
@dataclass(frozen=True)
class ActionDefinition:
    name: str
    description: str
    category: str
    params: tuple[ActionParam, ...]
    handler: Callable[[Any], Callable[..., Any]]  # vault -> action_fn
    side_effect: str = "none"
    cli_group: str | None = None
    cli_name: str | None = None
    custom_presentation: bool = False
    ...
```

From src/ztlctl/commands/generator.py:
```python
_GROUP_HELP: dict[str, str] = {
    "query": "Search, list, and query vault content.",
    "graph": "Traverse and analyze the knowledge graph.",
    # ... (no "garden" entry yet)
}
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Migrate all 14 controllers to use _run_action (ARCH-06)</name>
  <files>src/ztlctl/controllers/check.py, src/ztlctl/controllers/create.py, src/ztlctl/controllers/discovery.py, src/ztlctl/controllers/export.py, src/ztlctl/controllers/graph.py, src/ztlctl/controllers/ingest.py, src/ztlctl/controllers/init_ctrl.py, src/ztlctl/controllers/query.py, src/ztlctl/controllers/reweave.py, src/ztlctl/controllers/session.py, src/ztlctl/controllers/update.py, src/ztlctl/controllers/upgrade.py, src/ztlctl/controllers/vector.py, src/ztlctl/controllers/workflow.py, tests/controllers/test_hook_wiring.py, tests/controllers/test_hook_wiring_batch2.py</files>
  <read_first>src/ztlctl/controllers/base.py, src/ztlctl/controllers/create.py, src/ztlctl/controllers/check.py, src/ztlctl/controllers/query.py, tests/controllers/test_hook_wiring.py, tests/controllers/test_hook_wiring_batch2.py</read_first>
  <action>
**Mechanical migration across all 14 controllers** (docs.py excluded -- it has no pre_action dispatch).

For EVERY method that currently calls `self._dispatch_pre_action(action_name, kwargs)` followed by the rejection check:

BEFORE pattern (repeated ~60 times):
```python
kwargs, rejection = self._dispatch_pre_action("action_name", kwargs)
if rejection is not None:
    return ServiceResult(
        ok=False,
        op="action_name",
        error=ServiceError(
            code="ACTION_REJECTED",
            message=rejection.reason,
            detail=rejection.detail,
            recovery=f"Action rejected by plugin: {rejection.reason}",
        ),
    )
result = SomeService(self._vault).method(kwargs["param1"], ...)
return result
```

AFTER pattern:
```python
def _invoke(kw: dict[str, Any]) -> ServiceResult:
    return SomeService(self._vault).method(kw["param1"], ...)

return self._run_action("action_name", kwargs, _invoke)
```

Key rules:
1. The `_invoke` inner function MUST use `kw` parameter (the possibly-modified kwargs from pre_action hook), NOT the outer `kwargs` variable. This preserves plugin modification semantics.
2. Remove the `ServiceError` and `ServiceResult` imports from methods that ONLY used them for the rejection pattern. Keep them if used elsewhere in the method.
3. The `kwargs` dict construction at the top of each method stays unchanged.
4. Some methods have logic AFTER the service call (e.g., CreateController methods sometimes do post-processing). Keep that logic; just wrap the service call inside `_invoke` and handle post-processing on the returned result.
5. For methods that have a `dispatch_post_create` parameter or other special args not in the kwargs dict, handle those in the closure (captured from the outer scope).

Controllers to migrate (with approximate method count):
- check.py (4 methods)
- create.py (4 methods -- watch for dispatch_post_create param)
- discovery.py (3 methods)
- export.py (4 methods)
- graph.py (8 methods)
- ingest.py (4 methods)
- init_ctrl.py (3 methods)
- query.py (10 methods)
- reweave.py (3 methods)
- session.py (9 methods)
- update.py (3 methods)
- upgrade.py (3 methods)
- vector.py (2 methods)
- workflow.py (4 methods)

**Test updates:**
In `tests/controllers/test_hook_wiring.py` and `test_hook_wiring_batch2.py`:
- Tests mock `_dispatch_pre_action`. These tests will still work because `_run_action` calls `_dispatch_pre_action` internally. However, verify they still pass.
- If any test directly asserts the rejection ServiceResult shape, it should still pass since _run_action produces the same shape.
- Run the tests after migration to confirm no regressions.

After migration, verify with: `grep -r "_dispatch_pre_action" src/ztlctl/controllers/ | grep -v "base.py" | grep -v "def _dispatch"` should return 0 lines (only base.py should still reference _dispatch_pre_action).
  </action>
  <verify>
    <automated>uv run pytest tests/controllers/ -x -q</automated>
  </verify>
  <acceptance_criteria>
    - grep -rn "self._dispatch_pre_action" src/ztlctl/controllers/ outputs ONLY lines from base.py (the definition and the call inside _run_action)
    - grep -c "_run_action" src/ztlctl/controllers/create.py returns at least 3
    - grep -c "_run_action" src/ztlctl/controllers/query.py returns at least 5
    - uv run pytest tests/controllers/ -x -q passes
    - uv run ruff check src/ztlctl/controllers/ passes
  </acceptance_criteria>
  <done>All 14 controllers migrated. No controller method (outside base.py) calls _dispatch_pre_action directly. All existing hook wiring tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Register garden_seed as first-class ActionDefinition (ARCH-09)</name>
  <files>src/ztlctl/actions/_register_core.py, src/ztlctl/commands/__init__.py, src/ztlctl/commands/garden.py, src/ztlctl/commands/generator.py, tests/actions/test_garden_seed_registration.py</files>
  <read_first>src/ztlctl/actions/_register_core.py, src/ztlctl/commands/__init__.py, src/ztlctl/commands/garden.py, src/ztlctl/commands/generator.py, src/ztlctl/actions/definitions.py</read_first>
  <action>
**1. Add garden_seed ActionDefinition to _register_core.py:**

At the end of the "creation" category section (after create_batch or create_task), add:

```python
registry.register(
    ActionDefinition(
        name="garden_seed",
        description="Plant a seed note -- quick capture with minimal metadata.",
        category="creation",
        params=(
            ActionParam(
                "title",
                str,
                required=True,
                description="Seed note title -- quick thought or half-formed idea.",
                cli_is_argument=True,
                mcp_example="Half-formed idea about caching",
            ),
            ActionParam(
                "tags",
                list,
                required=False,
                default=None,
                description="Optional tags. Comma-separated in CLI.",
                cli_multiple=True,
            ),
            ActionParam(
                "topic",
                str,
                required=False,
                default=None,
                description="Optional topic scope.",
            ),
        ),
        handler=lambda vault: (
            lambda title, tags=None, topic=None:
                CreateController(vault).create_note(
                    title, tags=tags, topic=topic, maturity="seed"
                )
        ),
        side_effect="write",
        cli_group="garden",
        cli_name="seed",
    )
)
```

The handler calls `CreateController(vault).create_note(...)` with `maturity="seed"` baked in. This ensures pre_action hooks run through the controller (via _run_action from Plan 01 Task 1).

**2. Add "garden" to _GROUP_HELP in generator.py:**

Add to the `_GROUP_HELP` dict:
```python
"garden": "Cultivate knowledge with the garden persona.",
```

**3. Remove manual garden registration from commands/__init__.py:**

Remove these lines (around lines 73-76):
```python
# garden: not in ActionRegistry (calls CreateService with maturity=seed directly)
from ztlctl.commands.garden import garden

cli.add_command(garden)
```

The generator will now auto-create the `garden` group with the `seed` subcommand.

**4. Delete commands/garden.py:**

The file is dead code. Before deleting, grep for imports:
- `grep -r "from ztlctl.commands.garden" tests/` -- if any test imports from garden.py, update it.
- `grep -r "commands.garden" tests/` -- same check.

Delete `src/ztlctl/commands/garden.py`.

**5. Create test for garden_seed registration:**

Create `tests/actions/test_garden_seed_registration.py`:
```python
def test_garden_seed_registered():
    """garden_seed ActionDefinition exists in the registry."""
    import ztlctl.actions  # triggers registration
    from ztlctl.actions.registry import get_action_registry

    registry = get_action_registry()
    action = registry.get_action("garden_seed")
    assert action is not None
    assert action.name == "garden_seed"
    assert action.cli_group == "garden"
    assert action.cli_name == "seed"
    assert action.side_effect == "write"
    assert action.category == "creation"

def test_garden_seed_handler_routes_through_controller():
    """garden_seed handler calls CreateController, not CreateService directly."""
    from unittest.mock import MagicMock, patch
    import ztlctl.actions
    from ztlctl.actions.registry import get_action_registry

    registry = get_action_registry()
    action = registry.get_action("garden_seed")
    mock_vault = MagicMock()
    mock_vault.plugin_manager = None

    # Get the action function
    action_fn = action.handler(mock_vault)

    with patch("ztlctl.controllers.create.CreateController.create_note") as mock_create:
        mock_create.return_value = MagicMock(ok=True)
        action_fn("Test seed title", tags=["tag1"], topic="test")

    mock_create.assert_called_once()
    call_kwargs = mock_create.call_args
    assert call_kwargs.kwargs.get("maturity") == "seed" or "seed" in str(call_kwargs)
```

Anti-patterns to avoid:
- Do NOT make the handler call CreateService directly. It MUST go through CreateController so pre_action hooks fire.
- Do NOT keep commands/garden.py as a dead file. Delete it.
- Do NOT forget to check if any test imports from commands.garden before deleting.
  </action>
  <verify>
    <automated>uv run pytest tests/actions/test_garden_seed_registration.py tests/controllers/ -x -q</automated>
  </verify>
  <acceptance_criteria>
    - grep -c "garden_seed" src/ztlctl/actions/_register_core.py returns at least 1
    - grep -c "garden" src/ztlctl/commands/generator.py returns at least 1 (in _GROUP_HELP)
    - test -f src/ztlctl/commands/garden.py returns non-zero (file deleted)
    - grep -c "commands.garden" src/ztlctl/commands/__init__.py returns 0
    - grep -c "test_garden_seed" tests/actions/test_garden_seed_registration.py returns at least 2
    - uv run pytest tests/actions/test_garden_seed_registration.py -x -q passes
    - uv run pytest -x -q passes (full suite -- no regressions from garden.py deletion)
  </acceptance_criteria>
  <done>garden_seed registered in ActionRegistry with cli_group="garden", cli_name="seed". Handler routes through CreateController.create_note with maturity="seed". Hand-written garden.py deleted. Full test suite passes.</done>
</task>

</tasks>

<verification>
After both tasks:
1. `uv run pytest -x -q` -- full suite green (critical: garden.py deletion may break imports)
2. `uv run ruff check src/ztlctl/controllers/ src/ztlctl/actions/ src/ztlctl/commands/` -- clean
3. `uv run mypy src/ztlctl/controllers/ src/ztlctl/actions/ src/ztlctl/commands/` -- clean
4. Verify no controller (except base.py) references _dispatch_pre_action
5. Verify garden_seed is in the registry: `uv run python -c "import ztlctl.actions; from ztlctl.actions.registry import get_action_registry; print(get_action_registry().get_action('garden_seed'))"`
</verification>

<success_criteria>
- All 14 controllers use _run_action exclusively (ARCH-06 complete)
- garden_seed is a first-class ActionDefinition routing through CreateController (ARCH-09 complete)
- commands/garden.py deleted, generator handles garden group
- Full test suite passes with no regressions
</success_criteria>

<output>
After completion, create `.planning/phases/16-plugin-bridge-and-action-executor/16-03-SUMMARY.md`
</output>
