---
phase: 05-plugin-formalization
plan: 03
type: execute
wave: 3
depends_on: ["05-01", "05-02"]
files_modified:
  - src/ztlctl/plugins/builtins/git.py
  - src/ztlctl/plugins/builtins/reweave_plugin.py
  - src/ztlctl/plugins/event_bus.py
  - tests/plugins/test_git_plugin.py
  - tests/plugins/test_reweave_plugin.py
  - tests/plugins/test_event_bus_post_action_bridge.py
autonomous: true
requirements:
  - PLUG-01
  - PLUG-02

must_haves:
  truths:
    - "GitPlugin uses a single post_action method with action-name filtering instead of 8 per-event hookimpl methods"
    - "GitPlugin declares PLUGIN_API_VERSION = 1"
    - "ReweavePlugin uses post_action instead of post_create"
    - "ReweavePlugin declares PLUGIN_API_VERSION = 1"
    - "Both plugins produce identical behavior after migration (git commits same messages, reweave triggers on same conditions)"
    - "Old per-event hookimpl decorators are removed from both plugins"
    - "Service-layer _dispatch_event calls (via EventBus) still reach migrated plugins through the post_action bridge"
  artifacts:
    - path: "src/ztlctl/plugins/builtins/git.py"
      provides: "GitPlugin with post_action method and PLUGIN_API_VERSION = 1"
      contains: "post_action"
    - path: "src/ztlctl/plugins/builtins/reweave_plugin.py"
      provides: "ReweavePlugin with post_action method and PLUGIN_API_VERSION = 1"
      contains: "post_action"
    - path: "src/ztlctl/plugins/event_bus.py"
      provides: "EventBus._execute_hook bridges per-event dispatch to post_action"
      contains: "post_action"
  key_links:
    - from: "src/ztlctl/plugins/builtins/git.py"
      to: "src/ztlctl/plugins/hookspecs.py"
      via: "@hookimpl post_action method replaces 8 per-event methods"
      pattern: "post_action"
    - from: "src/ztlctl/plugins/builtins/reweave_plugin.py"
      to: "src/ztlctl/plugins/hookspecs.py"
      via: "@hookimpl post_action method replaces post_create"
      pattern: "post_action"
    - from: "src/ztlctl/plugins/event_bus.py"
      to: "src/ztlctl/plugins/hookspecs.py"
      via: "_execute_hook also fires pm.hook.post_action for lifecycle events"
      pattern: "post_action"
    - from: "src/ztlctl/services/create.py"
      to: "src/ztlctl/plugins/builtins/git.py"
      via: "CreateService._dispatch_event('post_create') -> EventBus -> post_action bridge -> GitPlugin.post_action"
      pattern: "post_action"
    - from: "src/ztlctl/services/create.py"
      to: "src/ztlctl/plugins/builtins/reweave_plugin.py"
      via: "CreateService._dispatch_event('post_create') -> EventBus -> post_action bridge -> ReweavePlugin.post_action"
      pattern: "post_action"
---

<objective>
Port GitPlugin and ReweavePlugin to the new post_action hookspec, and bridge the service-layer EventBus dispatch so migrated plugins continue to receive lifecycle events.

Purpose: The built-in plugins serve as the acid test for the new API. GitPlugin's 8 per-event hookimpl methods collapse to 1 post_action method with action-name filtering. ReweavePlugin's post_create maps to post_action filtered by create actions. Both declare PLUGIN_API_VERSION = 1. Critically, the EventBus must also fire post_action when dispatching per-event hooks, because the service layer (CreateService, UpdateService, SessionService, etc.) dispatches lifecycle events through EventBus using per-event hook names. Without this bridge, migrated plugins would silently stop receiving events.

Output: Migrated GitPlugin and ReweavePlugin with post_action, PLUGIN_API_VERSION declarations, EventBus post_action bridge, and updated tests proving identical behavior through both dispatch paths.
</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/05-plugin-formalization/05-RESEARCH.md
@.planning/phases/05-plugin-formalization/05-01-SUMMARY.md
@.planning/phases/05-plugin-formalization/05-02-SUMMARY.md

<interfaces>
<!-- Current GitPlugin and ReweavePlugin signatures to migrate -->

From src/ztlctl/plugins/builtins/git.py (BEFORE migration):
```python
class GitPlugin:
    def __init__(self, config: GitConfig | None = None, vault_root: Path | None = None): ...
    @hookimpl def post_create(self, content_type, content_id, title, path, tags) -> None: ...
    @hookimpl def post_update(self, content_type, content_id, fields_changed, path) -> None: ...
    @hookimpl def post_close(self, content_type, content_id, path, summary) -> None: ...
    @hookimpl def post_reweave(self, source_id, affected_ids, links_added) -> None: ...
    @hookimpl def post_session_start(self, session_id) -> None: ...
    @hookimpl def post_session_close(self, session_id, stats) -> None: ...
    @hookimpl def post_check(self, issues_found, issues_fixed) -> None: ...
    @hookimpl def post_init(self, vault_name, client, tone) -> None: ...
```

From src/ztlctl/plugins/builtins/reweave_plugin.py (BEFORE migration):
```python
class ReweavePlugin:
    def __init__(self, vault: Vault) -> None: ...
    @hookimpl def post_create(self, content_type, content_id, title, path, tags) -> None: ...
```

From src/ztlctl/plugins/hookspecs.py (AFTER Plan 01):
```python
class ZtlctlHookSpec:
    @hookspec
    def post_action(self, action_name: str, kwargs: dict[str, Any], result: Any) -> None: ...
```

From src/ztlctl/plugins/event_bus.py (CURRENT — dispatch path to update):
```python
class EventBus:
    def _execute_hook(self, event_id, hook_name, payload) -> None:
        hook_fn = getattr(self._pm.hook, hook_name, None)
        if hook_fn is None:
            self._mark_completed(event_id)
            return
        try:
            hook_fn(**payload)
        except Exception as exc:
            ...
```

From src/ztlctl/services/base.py (callers — NOT modified, but context for bridge):
```python
class BaseService:
    def _dispatch_event(self, hook_name, payload, warnings, *, session_id=None) -> int | None:
        bus = self._vault.event_bus
        return bus.dispatch(hook_name, payload, session_id=session_id)
```

Service-layer dispatch calls (the source of lifecycle events):
- CreateService: _dispatch_event("post_create", {content_type, content_id, title, path, tags}, ...)
- UpdateService: _dispatch_event("post_update", {content_type, content_id, fields_changed, path}, ...)
- UpdateService: _dispatch_event("post_close", {content_type, content_id, path, summary}, ...)
- ReweaveService: _dispatch_event("post_reweave", {source_id, affected_ids, links_added}, ...)
- SessionService: _dispatch_event("post_session_start", {session_id}, ...)
- SessionService: _dispatch_event("post_session_close", {session_id, stats}, ...)
- CheckService: _dispatch_event("post_check", {issues_found, issues_fixed}, ...)

Action name mapping (from ActionRegistry _register_core.py):
- post_create -> action_name in {"create_note", "create_reference", "create_task"}
- post_update -> action_name == "update"
- post_close -> action_name in {"close", "archive"}
- post_reweave -> action_name == "reweave"
- post_session_start -> action_name == "session_start"
- post_session_close -> action_name == "session_close"
- post_check -> action_name in {"check", "check_rebuild"}
- post_init -> action_name == "init"
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Migrate GitPlugin to post_action with PLUGIN_API_VERSION</name>
  <files>
    src/ztlctl/plugins/builtins/git.py,
    tests/plugins/test_git_plugin.py
  </files>
  <read_first>
    src/ztlctl/plugins/builtins/git.py,
    tests/plugins/test_git_plugin.py,
    src/ztlctl/plugins/hookspecs.py,
    src/ztlctl/plugins/__init__.py,
    src/ztlctl/actions/_register_core.py,
    .planning/phases/05-plugin-formalization/05-01-SUMMARY.md
  </read_first>
  <behavior>
    - GitPlugin.PLUGIN_API_VERSION = 1 (class attribute)
    - Single @hookimpl post_action(self, action_name, kwargs, result) method replaces all 8 per-event methods
    - post_action checks result.ok before any side effects (pitfall 4 from research)
    - post_action checks self._enabled before any side effects
    - Action name routing:
      - action_name in {"create_note", "create_reference", "create_task"}: _git_add(path from result), commit if not batch
      - action_name == "update": _git_add(path from result), commit if not batch
      - action_name in {"close", "archive"}: _git_add(path from result), commit if not batch
      - action_name in {"reweave"}: no-op (frontmatter committed at session close)
      - action_name == "session_start": no-op
      - action_name == "session_close": batch commit + optional push
      - action_name in {"check", "check_rebuild"}: no-op
      - action_name == "init": git init, add all, commit
    - Commit messages match existing patterns exactly:
      - create: "feat: create {content_type} {content_id} — {title}"
      - update: "docs: update {content_id} ({fields})"
      - close: "docs: close {content_id} — {summary}"
      - session_close: "docs: session {session_id} — N created, N updated"
      - init: "feat: initialize vault '{vault_name}'"
    - All existing test behaviors preserved (add new tests for post_action, update existing)
    - No more @hookimpl on post_create, post_update, etc. — all removed
    - _sanitize_for_commit, _GITIGNORE_CONTENT, and all git subprocess helpers unchanged
  </behavior>
  <action>
    1. First read existing tests in tests/plugins/test_git_plugin.py to understand current test patterns and what needs updating.

    2. Read src/ztlctl/actions/_register_core.py to confirm exact action names used in the registry (the action_name values that post_action will filter on).

    3. **src/ztlctl/plugins/builtins/git.py** — Rewrite lifecycle section:
       - Add `PLUGIN_API_VERSION = 1` as class attribute on GitPlugin
       - Remove ALL 8 @hookimpl per-event methods (post_create, post_update, post_close, post_reweave, post_session_start, post_session_close, post_check, post_init)
       - Add single `@hookimpl def post_action(self, action_name: str, kwargs: dict[str, Any], result: Any) -> None:` method
       - In post_action:
         - Guard: `if not self._enabled: return`
         - Guard: `if not hasattr(result, 'ok') or not result.ok: return` (pitfall 4 — don't act on failed results). Exception: "init" may not use ServiceResult, handle specially.
         - Route by action_name using if/elif chain
         - Extract path, content_id, title, etc. from kwargs dict and result.data dict as appropriate
         - For creates: `self._git_add(kwargs.get("path", result.data.get("path", ".")))`. Actually the path comes from result.data["path"] typically. Check existing code patterns.
         - For session_close: check batch_commits, get summary, commit, push
         - For init: git_init, add ".", commit. Note: init may need special handling since the hook receives kwargs not result.data for vault_name.
       - Keep all private helper methods (_run_git, _git_add, _git_commit, _git_staged_summary, _git_push, _git_init, _write_gitignore) exactly as-is
       - Keep _sanitize_for_commit and _GITIGNORE_CONTENT exactly as-is

    4. **tests/plugins/test_git_plugin.py** — Update tests:
       - Update all tests to call plugin.post_action(action_name=..., kwargs={...}, result=mock_result) instead of plugin.post_create(...), plugin.post_update(...), etc.
       - Add test_plugin_api_version: assert GitPlugin.PLUGIN_API_VERSION == 1
       - Add test_post_action_create_note: call post_action(action_name="create_note", kwargs={...}, result=mock_ok_result), verify _git_add called with path
       - Add test_post_action_skips_failed_result: call post_action with result.ok=False, verify no git operations
       - Add test_post_action_session_close_batch: verify batch commit on session_close
       - Add test_post_action_init: verify git init, add, commit
       - Ensure all existing behavioral tests are preserved (just adapted to post_action calling convention)
       - Verify no remaining references to old per-event method calls in tests
  </action>
  <verify>
    <automated>uv run pytest tests/plugins/test_git_plugin.py -x -q && uv run ruff check src/ztlctl/plugins/builtins/git.py && uv run mypy src/ztlctl/plugins/builtins/git.py</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "PLUGIN_API_VERSION" src/ztlctl/plugins/builtins/git.py` returns >= 1
    - `grep -c "def post_action" src/ztlctl/plugins/builtins/git.py` returns >= 1
    - `grep -c "def post_create" src/ztlctl/plugins/builtins/git.py` returns 0 (removed)
    - `grep -c "def post_update" src/ztlctl/plugins/builtins/git.py` returns 0 (removed)
    - `grep -c "def post_close" src/ztlctl/plugins/builtins/git.py` returns 0 (removed)
    - `grep -c "def post_session_close" src/ztlctl/plugins/builtins/git.py` returns 0 (removed)
    - `grep -c "def post_init" src/ztlctl/plugins/builtins/git.py` returns 0 (removed)
    - All tests pass, ruff clean, mypy clean
  </acceptance_criteria>
  <done>
    GitPlugin migrated: PLUGIN_API_VERSION=1, single post_action method replaces 8 per-event hookimpls, result.ok check prevents side effects on failures, commit messages preserved. All tests pass.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Migrate ReweavePlugin to post_action + EventBus post_action bridge</name>
  <files>
    src/ztlctl/plugins/builtins/reweave_plugin.py,
    src/ztlctl/plugins/event_bus.py,
    tests/plugins/test_reweave_plugin.py,
    tests/plugins/test_event_bus_post_action_bridge.py
  </files>
  <read_first>
    src/ztlctl/plugins/builtins/reweave_plugin.py,
    tests/plugins/test_reweave_plugin.py,
    src/ztlctl/plugins/event_bus.py,
    src/ztlctl/plugins/hookspecs.py,
    src/ztlctl/plugins/__init__.py,
    src/ztlctl/actions/_register_core.py,
    src/ztlctl/services/base.py,
    src/ztlctl/plugins/builtins/git.py,
    .planning/phases/05-plugin-formalization/05-01-SUMMARY.md
  </read_first>
  <behavior>
    ReweavePlugin migration:
    - ReweavePlugin.PLUGIN_API_VERSION = 1 (class attribute)
    - Single @hookimpl post_action(self, action_name, kwargs, result) method replaces post_create
    - post_action only acts when action_name in {"create_note", "create_reference"}
    - Checks result.ok before proceeding
    - Existing decision-note exclusion preserved (subtype check)
    - Existing no_reweave / reweave.enabled settings checks preserved
    - Content_id extracted from result.data.get("id") or kwargs.get("content_id")
    - All existing test behaviors preserved
    - No more @hookimpl on post_create

    EventBus post_action bridge (CRITICAL for service-layer dispatch):
    - EventBus._execute_hook must also fire pm.hook.post_action() when dispatching lifecycle events
    - A module-level HOOK_TO_ACTION_NAME dict maps per-event hook names to action names:
      {"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"}
    - Note: post_create maps to "create" (generic) because the EventBus payload has content_type
      but not the specific action name like "create_note". The bridge constructs action_name from
      f"create_{payload.get('content_type', 'note')}" for post_create events specifically, falling
      back to the generic mapping for other events.
    - After dispatching the per-event hook, _execute_hook ALSO calls pm.hook.post_action(
      action_name=mapped_name, kwargs=payload, result=None) — result is None because the
      service-layer dispatch occurs after the service method completes but the EventBus
      doesn't have the ServiceResult. Plugins receiving post_action via this bridge path
      must handle result=None gracefully.
    - IMPORTANT: Both GitPlugin and ReweavePlugin post_action methods must handle result=None
      (from EventBus bridge) in addition to result with .ok attribute (from controller dispatch).
      The guard should be: `if result is not None and (not hasattr(result, 'ok') or not result.ok): return`
      i.e., None result = pass through (trust EventBus only dispatches on success), explicit
      result.ok=False = skip.
    - Test: EventBus dispatches "post_create" with content_type="note" payload -> post_action
      fires with action_name="create_note", kwargs=payload, result=None
    - Test: EventBus dispatches "post_update" payload -> post_action fires with
      action_name="update", kwargs=payload, result=None
    - Test: EventBus dispatches "post_session_close" -> post_action fires with
      action_name="session_close"
    - Test: Non-lifecycle hooks (e.g., "register_content_models") do NOT trigger post_action
    - Test: post_action bridge errors are caught and logged, don't break per-event dispatch

    Full integration validation:
    - Run full test suite to confirm no regressions
    - The per-event hookspecs still exist (deprecated with warn_on_impl) for third-party plugins
    - The post_action bridge ensures migrated built-in plugins receive events from both paths
  </behavior>
  <action>
    1. First read existing tests in tests/plugins/test_reweave_plugin.py to understand current patterns.

    2. **src/ztlctl/plugins/event_bus.py** — Add post_action bridge in _execute_hook:
       - Add module-level constant:
         ```python
         _HOOK_TO_ACTION: dict[str, str] = {
             "post_create": "create",  # Will be refined to create_{content_type}
             "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",
         }
         ```
       - In `_execute_hook`, AFTER the existing per-event hook dispatch (after the try/except block
         that calls `hook_fn(**payload)`), add a bridge block:
         ```python
         # Bridge: also fire post_action for migrated plugins
         action_name = _HOOK_TO_ACTION.get(hook_name)
         if action_name is not None:
             # Refine create action name using content_type from payload
             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)
         ```
       - The bridge fires REGARDLESS of whether the per-event hook succeeded or had subscribers.
         This ensures migrated plugins always receive the event.
       - The bridge catches its own exceptions independently — a failure in post_action dispatch
         does not affect the per-event dispatch status in the WAL.

    3. **src/ztlctl/plugins/builtins/reweave_plugin.py** — Rewrite:
       - Add `PLUGIN_API_VERSION = 1` as class attribute
       - Remove @hookimpl post_create method
       - Add `@hookimpl def post_action(self, action_name: str, kwargs: dict[str, Any], result: Any) -> None:` method
       - In post_action:
         - Guard: `if action_name not in {"create_note", "create_reference"}: return`
         - Guard: `if result is not None and (not hasattr(result, 'ok') or not result.ok): return`
           (None result from EventBus bridge = proceed; explicit failed result = skip)
         - Extract content_id from kwargs.get("content_id") (works for both EventBus bridge
           payload and controller-dispatched kwargs)
         - Decision note exclusion: check subtype from kwargs or do the DB lookup as before
         - Settings checks: self._vault.settings.no_reweave, self._vault.settings.reweave.enabled
         - Call ReweaveService(self._vault).reweave(content_id=content_id) same as before

    4. **Update GitPlugin post_action guard** (from Task 1) to also handle result=None:
       - Change the result.ok guard to: `if result is not None and (not hasattr(result, 'ok') or not result.ok): return`
       - This ensures GitPlugin works via both controller dispatch (result=ServiceResult) and
         EventBus bridge (result=None).

    5. **tests/plugins/test_reweave_plugin.py** — Update tests:
       - Update all tests to call plugin.post_action(action_name="create_note", kwargs={...}, result=mock_ok_result) instead of plugin.post_create(...)
       - Add test_plugin_api_version: assert ReweavePlugin.PLUGIN_API_VERSION == 1
       - Add test_post_action_create_note: call post_action with action_name="create_note", verify reweave triggered
       - Add test_post_action_create_task_skipped: action_name="create_task" skips reweave
       - Add test_post_action_skips_failed_result: result.ok=False, no reweave
       - Add test_post_action_none_result_proceeds: result=None (EventBus bridge path), reweave triggered
       - Preserve all existing behavioral tests (decision exclusion, no_reweave flag, settings check)
       - Verify no remaining references to old post_create calls

    6. **tests/plugins/test_event_bus_post_action_bridge.py** — New test file:
       - Use a fresh pluggy PluginManager + in-memory SQLite EventBus (sync=True)
       - Register a mock plugin that implements post_action and records calls
       - test_post_create_bridges_to_post_action: EventBus.dispatch("post_create", {"content_type": "note", "content_id": "N-001", "title": "Test", "path": "/tmp/test.md", "tags": []}), verify mock.post_action called with action_name="create_note", kwargs=payload, result=None
       - test_post_update_bridges: dispatch("post_update", {...}), verify post_action called with action_name="update"
       - test_post_session_close_bridges: dispatch("post_session_close", {...}), verify post_action called with action_name="session_close"
       - test_non_lifecycle_hook_no_bridge: dispatch a non-lifecycle hook name, verify post_action NOT called
       - test_bridge_error_does_not_break_per_event: mock post_action raises, verify per-event hook still completed and WAL marked completed
       - test_post_create_content_type_mapping: dispatch("post_create", {"content_type": "reference", ...}), verify action_name="create_reference"
       - test_bridge_with_no_post_action_hookspec: PluginManager without post_action hookspec registered, verify no error (graceful degradation)

    7. **Run full suite**: `uv run pytest -x -q` to confirm no regressions. Verify:
       - Existing service-layer _dispatch_event("post_create") calls now reach GitPlugin and ReweavePlugin via the EventBus bridge
       - Controller-layer _dispatch_post_action calls also reach them (direct path from Plan 01)
       - Both dispatch paths work correctly
  </action>
  <verify>
    <automated>uv run pytest tests/plugins/test_reweave_plugin.py tests/plugins/test_git_plugin.py tests/plugins/test_event_bus_post_action_bridge.py -x -q && uv run pytest -x -q && uv run ruff check . && uv run mypy src/</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "PLUGIN_API_VERSION" src/ztlctl/plugins/builtins/reweave_plugin.py` returns >= 1
    - `grep -c "def post_action" src/ztlctl/plugins/builtins/reweave_plugin.py` returns >= 1
    - `grep -c "def post_create" src/ztlctl/plugins/builtins/reweave_plugin.py` returns 0 (removed)
    - `grep -c "_HOOK_TO_ACTION" src/ztlctl/plugins/event_bus.py` returns >= 1
    - `grep -c "post_action" src/ztlctl/plugins/event_bus.py` returns >= 2
    - All tests pass in test_reweave_plugin.py, test_git_plugin.py, and test_event_bus_post_action_bridge.py
    - Full test suite passes (no regressions)
    - ruff clean, mypy clean on entire codebase
  </acceptance_criteria>
  <done>
    ReweavePlugin migrated: PLUGIN_API_VERSION=1, single post_action method replaces post_create, action-name filtering for create_note/create_reference, result.ok guard (with None passthrough for EventBus bridge), decision-note exclusion preserved. EventBus._execute_hook bridges per-event lifecycle dispatches to post_action with hook-to-action-name mapping, ensuring service-layer events reach migrated plugins. Both GitPlugin and ReweavePlugin receive events from both dispatch paths (controller direct + EventBus bridge). Full test suite green, ruff clean, mypy clean. Both built-in plugins serve as reference implementations for the stable plugin API.
  </done>
</task>

</tasks>

<verification>
After both tasks complete:
1. `uv run pytest tests/plugins/ -x -q` — all plugin tests pass
2. `uv run pytest -x -q` — full test suite passes (no regressions from migration)
3. `uv run ruff check .` — no lint issues
4. `uv run mypy src/` — no type errors
5. `grep "PLUGIN_API_VERSION" src/ztlctl/plugins/builtins/git.py` — version declared
6. `grep "PLUGIN_API_VERSION" src/ztlctl/plugins/builtins/reweave_plugin.py` — version declared
7. `grep "post_action" src/ztlctl/plugins/builtins/git.py` — new hookimpl exists
8. `grep -c "def post_create" src/ztlctl/plugins/builtins/git.py` — returns 0 (removed)
9. `grep -c "def post_create" src/ztlctl/plugins/builtins/reweave_plugin.py` — returns 0 (removed)
10. `grep "_HOOK_TO_ACTION" src/ztlctl/plugins/event_bus.py` — bridge mapping exists
11. `grep "post_action" src/ztlctl/plugins/event_bus.py` — bridge dispatch exists
</verification>

<success_criteria>
- GitPlugin uses single post_action with action-name filtering, PLUGIN_API_VERSION=1, all 8 old hookimpls removed
- ReweavePlugin uses post_action for create_note/create_reference, PLUGIN_API_VERSION=1, old post_create removed
- Both plugins check result.ok before side effects (with None passthrough for EventBus bridge path)
- EventBus._execute_hook bridges per-event lifecycle hooks to post_action with correct action-name mapping
- Service-layer _dispatch_event("post_create", ...) calls reach migrated plugins via EventBus bridge
- Controller-layer _dispatch_post_action calls reach migrated plugins via direct path
- Commit messages and reweave behavior identical to pre-migration through both dispatch paths
- Full test suite green with no regressions
- Both built-in plugins validated as reference implementations of the stable plugin API
</success_criteria>

<output>
After completion, create `.planning/phases/05-plugin-formalization/05-03-SUMMARY.md`
</output>
</content>
</invoke>
