---
phase: 16-plugin-bridge-and-action-executor
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/plugins/event_bus.py
  - tests/plugins/test_event_bus_post_action_bridge.py
  - src/ztlctl/controllers/base.py
  - tests/controllers/test_base.py
autonomous: true
requirements: [ARCH-05, ARCH-06]

must_haves:
  truths:
    - "Per-event hook dispatch (post_create, post_update, etc.) does NOT fire a second post_action -- services own all post_action emission"
    - "Legacy per-event hooks still fire for backward compatibility (just without the bridge to post_action)"
    - "BaseController._run_action encapsulates the pre_action dispatch + rejection-to-ServiceResult pattern in one method"
  artifacts:
    - path: "src/ztlctl/plugins/event_bus.py"
      provides: "Bridge code removed, _HOOK_TO_ACTION dict removed"
      contains: "class EventBus"
    - path: "src/ztlctl/controllers/base.py"
      provides: "_run_action utility method on BaseController"
      contains: "_run_action"
  key_links:
    - from: "src/ztlctl/plugins/event_bus.py"
      to: "services/base.py _dispatch_post_action_event"
      via: "Services own all post_action dispatch; EventBus no longer bridges"
      pattern: "No _HOOK_TO_ACTION in event_bus.py"
    - from: "src/ztlctl/controllers/base.py"
      to: "controllers/*.py"
      via: "_run_action available for all controller methods"
      pattern: "def _run_action"
---

<objective>
Remove the legacy-to-stable bridge in EventBus and add a generic action executor to BaseController.

Purpose: After Phase 15, services emit post_action directly. The bridge in EventBus._execute_hook now causes duplicate post_action delivery. Removing it (ARCH-05) eliminates the duplication. Adding _run_action to BaseController (ARCH-06) provides a single utility that all controller methods will migrate to in Plan 03.

Output: Bridge code removed from event_bus.py, _run_action method added to BaseController, all tests updated.
</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

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From src/ztlctl/controllers/base.py:
```python
class BaseController:
    def __init__(self, vault: Vault) -> None:
        self._vault = vault

    def _dispatch_pre_action(
        self,
        action_name: str,
        kwargs: dict[str, Any],
    ) -> tuple[dict[str, Any], ActionRejection | None]:
        ...
```

From src/ztlctl/services/result.py (used by rejection mapping):
```python
class ServiceError(BaseModel):
    code: str
    message: str
    detail: str | None = None
    recovery: str | None = None

class ServiceResult(BaseModel):
    ok: bool
    op: str
    data: dict[str, Any] | None = None
    error: ServiceError | None = None
    warnings: list[str] = []
    meta: dict[str, Any] = {}
```

From src/ztlctl/plugins/contracts.py:
```python
class ActionRejection(NamedTuple):
    reason: str
    detail: str | None = None
```

From src/ztlctl/plugins/event_bus.py (bridge code to remove, lines 280-294):
```python
# Bridge: also fire post_action so migrated plugins receive lifecycle events.
action_name = _HOOK_TO_ACTION.get(hook_name)
if action_name is not None:
    if hook_name == "post_create":
        content_type = payload.get("content_type", "note")
        action_name = f"create_{content_type}"
    try:
        post_action_fn = getattr(self._pm.hook, "post_action", None)
        if post_action_fn is not None:
            post_action_fn(action_name=action_name, kwargs=payload, result=None)
    except Exception:
        logger.debug("post_action bridge failed for %s", hook_name, exc_info=True)
```

_HOOK_TO_ACTION dict (lines 33-42 of event_bus.py):
```python
_HOOK_TO_ACTION: dict[str, str] = {
    "post_create": "create",
    "post_update": "update",
    "post_close": "close",
    "post_reweave": "reweave",
    "post_session_start": "session_start",
    "post_session_close": "session_close",
    "post_check": "check",
    "post_init": "init",
}
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Remove post_action bridge from EventBus._execute_hook (ARCH-05)</name>
  <files>src/ztlctl/plugins/event_bus.py, tests/plugins/test_event_bus_post_action_bridge.py</files>
  <read_first>src/ztlctl/plugins/event_bus.py, tests/plugins/test_event_bus_post_action_bridge.py, src/ztlctl/services/base.py</read_first>
  <action>
In `src/ztlctl/plugins/event_bus.py`:
1. Remove the `_HOOK_TO_ACTION` dict (lines 30-42). This mapping is dead code after Phase 15 since services emit post_action directly via _dispatch_post_action_event.
2. Remove the bridge block in `_execute_hook` (lines 280-294) that fires post_action when a per-event hook runs. After this removal, per-event hooks (post_create, post_update, etc.) dispatch ONLY the per-event hook -- no second post_action call.
3. Remove any import that was only used by the bridge code (check for unused imports after removal).

In `tests/plugins/test_event_bus_post_action_bridge.py`:
1. REWRITE the test file. The old tests assert the bridge fires post_action for per-event hooks. After ARCH-05, the opposite is true: per-event hooks must NOT fire post_action.
2. Rename the test class to `TestEventBusBridgeRemoved` (or similar).
3. Keep the `_RecordingPlugin` and `_make_plugin_manager` helpers -- they are useful.
4. Key test cases to write:
   - `test_post_create_does_not_trigger_post_action`: dispatch "post_create" with a payload, assert `plugin.post_action_calls` is empty (0 calls, not 1).
   - `test_post_update_does_not_trigger_post_action`: same for post_update.
   - `test_non_lifecycle_hook_does_not_trigger_post_action`: keep this test (unchanged behavior).
   - `test_per_event_hook_still_fires_normally`: dispatch "post_create" with a plugin that implements post_create, assert the per-event hook itself fires (the hook call without the bridge).
5. Do NOT delete the test file entirely -- it remains the correctness anchor for the bridge removal.

Anti-patterns to avoid:
- Do NOT leave any reference to `_HOOK_TO_ACTION` in event_bus.py.
- Do NOT add any new bridge code. Services already own post_action dispatch.
  </action>
  <verify>
    <automated>uv run pytest tests/plugins/test_event_bus_post_action_bridge.py tests/plugins/test_event_bus.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - grep -c "_HOOK_TO_ACTION" src/ztlctl/plugins/event_bus.py returns 0
    - grep -c "post_action bridge" src/ztlctl/plugins/event_bus.py returns 0
    - grep -c "does_not_trigger_post_action" tests/plugins/test_event_bus_post_action_bridge.py returns at least 2
    - uv run pytest tests/plugins/ -x -q passes
  </acceptance_criteria>
  <done>Bridge code and _HOOK_TO_ACTION dict removed from event_bus.py. Tests assert per-event hooks do NOT trigger post_action. All plugin tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Add _run_action generic executor to BaseController (ARCH-06)</name>
  <files>src/ztlctl/controllers/base.py, tests/controllers/test_base.py</files>
  <read_first>src/ztlctl/controllers/base.py, tests/controllers/test_base.py, src/ztlctl/controllers/create.py, src/ztlctl/services/result.py, src/ztlctl/plugins/contracts.py</read_first>
  <action>
In `src/ztlctl/controllers/base.py`:
1. Add a `_run_action` method to `BaseController` that encapsulates the entire pre_action-dispatch + rejection-to-ServiceResult + invoke pipeline:

```python
def _run_action(
    self,
    action_name: str,
    kwargs: dict[str, Any],
    invoke: Callable[[dict[str, Any]], ServiceResult],
) -> ServiceResult:
    """Execute an action through the pre-action hook pipeline.

    1. Dispatches pre_action hook (plugins may modify kwargs or reject).
    2. If rejected, returns a ServiceResult with ACTION_REJECTED error.
    3. Otherwise, calls *invoke* with the (possibly modified) kwargs.

    All controller methods should delegate to this instead of calling
    _dispatch_pre_action directly.
    """
    from ztlctl.services.result import ServiceError, ServiceResult as SR

    kwargs, rejection = self._dispatch_pre_action(action_name, kwargs)
    if rejection is not None:
        return SR(
            ok=False,
            op=action_name,
            error=ServiceError(
                code="ACTION_REJECTED",
                message=rejection.reason,
                detail=rejection.detail,
                recovery=f"Action rejected by plugin: {rejection.reason}",
            ),
        )
    return invoke(kwargs)
```

2. Add `Callable` to the TYPE_CHECKING imports (or to the runtime typing import). The `invoke` parameter type hint needs `Callable` from `collections.abc` (use `from collections.abc import Callable` inside TYPE_CHECKING or at module level with `from __future__ import annotations`).

In `tests/controllers/test_base.py`:
1. Read the existing file first. Add tests for `_run_action`:
   - `test_run_action_calls_invoke_on_no_rejection`: create a BaseController with mock vault (plugin_manager=None), call `_run_action("test_op", {"key": "val"}, invoke=mock_fn)`, assert mock_fn called with kwargs, assert result matches mock_fn return.
   - `test_run_action_returns_rejection_on_pre_action_reject`: mock `_dispatch_pre_action` to return `(kwargs, ActionRejection("denied", "detail"))`, assert result.ok is False, result.error.code == "ACTION_REJECTED", result.error.message == "denied".
   - `test_run_action_passes_modified_kwargs_from_plugin`: mock `_dispatch_pre_action` to return modified kwargs `({"key": "modified"}, None)`, assert invoke called with `{"key": "modified"}`.

Anti-patterns to avoid:
- Do NOT put _run_action in a separate module. Keep it on BaseController.
- Do NOT remove _dispatch_pre_action yet. It is still called by _run_action internally and may be useful for edge cases.
  </action>
  <verify>
    <automated>uv run pytest tests/controllers/test_base.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - grep -c "_run_action" src/ztlctl/controllers/base.py returns at least 1
    - grep -c "ACTION_REJECTED" src/ztlctl/controllers/base.py returns at least 1
    - grep -c "test_run_action" tests/controllers/test_base.py returns at least 2
    - uv run pytest tests/controllers/test_base.py -x -q passes
  </acceptance_criteria>
  <done>BaseController has _run_action method that encapsulates pre_action dispatch + rejection mapping. Tests verify happy path, rejection path, and kwargs modification by plugins.</done>
</task>

</tasks>

<verification>
After both tasks:
1. `uv run pytest tests/plugins/ tests/controllers/ -x -q` -- all pass
2. `uv run ruff check src/ztlctl/plugins/event_bus.py src/ztlctl/controllers/base.py` -- clean
3. `uv run mypy src/ztlctl/plugins/event_bus.py src/ztlctl/controllers/base.py` -- clean
4. `grep -r "_HOOK_TO_ACTION" src/` returns no matches
</verification>

<success_criteria>
- Bridge code completely removed from EventBus (ARCH-05 complete)
- _run_action method exists on BaseController ready for controller migration (ARCH-06 foundation)
- All existing tests pass (no regressions)
- New tests cover bridge removal and executor behavior
</success_criteria>

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