---
phase: 05-plugin-formalization
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/plugins/__init__.py
  - src/ztlctl/plugins/hookspecs.py
  - src/ztlctl/plugins/contracts.py
  - src/ztlctl/plugins/manager.py
  - src/ztlctl/controllers/base.py
  - src/ztlctl/config/models.py
  - tests/plugins/test_api_version.py
  - tests/plugins/test_pre_action_hooks.py
  - tests/plugins/test_plugin_config.py
autonomous: true
requirements:
  - PLUG-01
  - PLUG-02
  - PLUG-03

must_haves:
  truths:
    - "Plugins that declare a PLUGIN_API_VERSION too new or too old are rejected at load time with clear error messages"
    - "Plugins that declare a deprecated API version emit a warning but still load"
    - "A plugin pre_action hook can return modified kwargs that replace the originals before handler execution"
    - "A plugin pre_action hook can return an ActionRejection to abort action execution"
    - "Plugin configuration from [plugins.<name>] TOML sections is validated against plugin-declared Pydantic schemas at load time"
    - "Deprecated per-event hookspecs (post_create, etc.) emit DeprecationWarning at plugin registration time via pluggy warn_on_impl"
  artifacts:
    - path: "src/ztlctl/plugins/__init__.py"
      provides: "PLUGIN_API_VERSION constant, check_plugin_api_version() function, PluginLoadError"
      contains: "PLUGIN_API_VERSION"
    - path: "src/ztlctl/plugins/hookspecs.py"
      provides: "pre_action and post_action hookspecs, deprecated per-event hookspecs with warn_on_impl"
      contains: "pre_action"
    - path: "src/ztlctl/plugins/contracts.py"
      provides: "ActionRejection frozen dataclass"
      contains: "ActionRejection"
    - path: "src/ztlctl/controllers/base.py"
      provides: "_dispatch_pre_action and _dispatch_post_action methods"
      contains: "_dispatch_pre_action"
    - path: "src/ztlctl/config/models.py"
      provides: "PluginsConfig with extra=allow for arbitrary plugin sections"
      contains: "extra"
  key_links:
    - from: "src/ztlctl/plugins/manager.py"
      to: "src/ztlctl/plugins/__init__.py"
      via: "check_plugin_api_version() called during discover_and_load()"
      pattern: "check_plugin_api_version"
    - from: "src/ztlctl/controllers/base.py"
      to: "src/ztlctl/plugins/hookspecs.py"
      via: "pm.hook.pre_action() called before handler"
      pattern: "pre_action"
    - from: "src/ztlctl/plugins/manager.py"
      to: "src/ztlctl/config/models.py"
      via: "plugin config injection from PluginsConfig extra fields"
      pattern: "get_config_schema"
---

<objective>
Establish the plugin API versioning, pre/post-action hooks, and plugin configuration infrastructure.

Purpose: These three capabilities (PLUG-01, PLUG-02, PLUG-03) form the foundational contracts that all other plugin features depend on. API versioning enables stable third-party plugins, pre/post-action hooks replace the 8 per-event hookspecs with a generic action-based pattern, and config validation ensures plugins get properly typed configuration at load time.

Output: PLUGIN_API_VERSION constant, pre_action/post_action hookspecs, ActionRejection dataclass, BaseController dispatch methods, PluginsConfig with extra=allow, and comprehensive tests for all three subsystems.
</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

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

From src/ztlctl/plugins/__init__.py:
```python
from ztlctl.plugins.event_bus import EventBus
from ztlctl.plugins.manager import PluginManager
__all__ = ["EventBus", "PluginManager"]
```

From src/ztlctl/plugins/hookspecs.py:
```python
hookspec = pluggy.HookspecMarker("ztlctl")
class ZtlctlHookSpec:
    # 8 lifecycle hookspecs: post_create, post_update, post_close, post_reweave,
    # post_session_start, post_session_close, post_check, post_init, post_init_profile
    # 8 registration hookspecs: register_content_models, register_cli_commands, etc.
```

From src/ztlctl/plugins/contracts.py:
```python
# All contribution types are @dataclass(frozen=True)
# Pattern: name/key field + handler/callable field + metadata
```

From src/ztlctl/plugins/manager.py:
```python
class PluginManager:
    def __init__(self) -> None:
        self._pm = pluggy.PluginManager(PROJECT_NAME)
        self._pm.add_hookspecs(ZtlctlHookSpec)
    def discover_and_load(self, *, local_dir, include_entrypoints) -> list[str]: ...
    def register_plugin(self, plugin, name=None) -> None: ...
    @property
    def hook(self) -> pluggy.HookRelay: ...
```

From src/ztlctl/controllers/base.py:
```python
class BaseController:
    def __init__(self, vault: Vault) -> None:
        self._vault = vault
    def _dispatch_event(self, hook_name, payload, warnings, *, session_id=None) -> int | None: ...
```

From src/ztlctl/config/models.py:
```python
class PluginsConfig(BaseModel):
    model_config = {"frozen": True}
    git: dict[str, Any] = Field(default_factory=lambda: {"enabled": True})
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Plugin API versioning + ActionRejection + pre/post-action hookspecs + deprecated hookspec markers</name>
  <files>
    src/ztlctl/plugins/__init__.py,
    src/ztlctl/plugins/hookspecs.py,
    src/ztlctl/plugins/contracts.py,
    tests/plugins/test_api_version.py,
    tests/plugins/test_pre_action_hooks.py
  </files>
  <read_first>
    src/ztlctl/plugins/__init__.py,
    src/ztlctl/plugins/hookspecs.py,
    src/ztlctl/plugins/contracts.py,
    src/ztlctl/plugins/manager.py,
    src/ztlctl/plugins/event_bus.py
  </read_first>
  <behavior>
    API Versioning (PLUG-01):
    - PLUGIN_API_VERSION = 1 is an integer constant in plugins/__init__.py
    - PluginLoadError is a new exception class in plugins/__init__.py
    - check_plugin_api_version(plugin, plugin_name) returns list[str] warnings
    - Plugin with PLUGIN_API_VERSION == 1 (current) returns empty warnings list
    - Plugin with PLUGIN_API_VERSION == 2 (too new) raises PluginLoadError with "requires API version 2"
    - Plugin with PLUGIN_API_VERSION == -2 (too old, below current - _COMPATIBILITY_WINDOW=2) raises PluginLoadError with "no longer supported"
    - Plugin with no PLUGIN_API_VERSION attr returns empty warnings (legacy plugin)
    - Plugin with PLUGIN_API_VERSION == 0 (deprecated but in window) returns warning containing "consider updating"

    ActionRejection + Hookspecs (PLUG-02):
    - ActionRejection is a frozen dataclass in contracts.py with fields: reason (str), code (str, default="plugin_rejected"), detail (dict, default_factory=dict)
    - pre_action hookspec is firstresult=True, accepts action_name (str) and kwargs (dict[str, Any]), returns ActionRejection | dict[str, Any] | None
    - post_action hookspec accepts action_name (str), kwargs (dict[str, Any]), result (Any), returns None
    - Test: pluggy manager with pre_action firstresult returns ActionRejection on first non-None result
    - Test: pluggy manager with pre_action returning modified dict passes through that dict
    - Test: pluggy manager with pre_action returning None passes through (firstresult continues)
    - Test: post_action fires with the result object after handler

    Deprecated Hookspecs (PLUG-01):
    - All 9 per-event hookspecs (post_create, post_update, post_close, post_reweave, post_session_start, post_session_close, post_check, post_init, post_init_profile) get warn_on_impl=DeprecationWarning(...) markers
    - Registering a plugin that implements deprecated post_create emits DeprecationWarning
    - Deprecation message includes "implement post_action instead"
  </behavior>
  <action>
    1. **src/ztlctl/plugins/__init__.py** — Add exports:
       - `PLUGIN_API_VERSION: int = 1`
       - `_COMPATIBILITY_WINDOW: int = 2`
       - `class PluginLoadError(Exception): pass`
       - `def check_plugin_api_version(plugin: object, plugin_name: str) -> list[str]` — returns warnings list, raises PluginLoadError if incompatible. Logic: no attr = empty list (legacy). declared > PLUGIN_API_VERSION = raise "requires API version {declared} but host provides {PLUGIN_API_VERSION}". declared < PLUGIN_API_VERSION - _COMPATIBILITY_WINDOW = raise "no longer supported (min: {min})". declared < PLUGIN_API_VERSION = return warning "consider updating to {PLUGIN_API_VERSION}". declared == PLUGIN_API_VERSION = empty list.
       - Update `__all__` to include new exports.

    2. **src/ztlctl/plugins/contracts.py** — Add at end:
       ```python
       @dataclass(frozen=True)
       class ActionRejection:
           """Returned from pre_action to abort action execution."""
           reason: str
           code: str = "plugin_rejected"
           detail: dict[str, Any] = field(default_factory=dict)
       ```

    3. **src/ztlctl/plugins/hookspecs.py** — Modify ZtlctlHookSpec:
       - Add `pre_action` hookspec with `@hookspec(firstresult=True)`:
         ```python
         @hookspec(firstresult=True)
         def pre_action(self, action_name: str, kwargs: dict[str, Any]) -> ActionRejection | dict[str, Any] | None:
             """Called before action execution. Return modified kwargs, ActionRejection to abort, or None to pass through."""
         ```
       - Add `post_action` hookspec (no firstresult):
         ```python
         @hookspec
         def post_action(self, action_name: str, kwargs: dict[str, Any], result: Any) -> None:
             """Called after action execution with the ServiceResult."""
         ```
       - Add `warn_on_impl` to ALL 9 per-event hookspecs. For each, add `warn_on_impl=DeprecationWarning("hookname is deprecated since plugin API v2; implement post_action and filter by action_name instead")`. Example for post_create:
         ```python
         @hookspec(
             warn_on_impl=DeprecationWarning(
                 "post_create is deprecated since plugin API v2; "
                 "implement post_action and filter by action_name instead"
             )
         )
         def post_create(self, content_type: str, content_id: str, title: str, path: str, tags: list[str]) -> None:
         ```
       - Add TYPE_CHECKING import for ActionRejection to avoid circular imports.
       - Add `get_config_schema` hookspec (firstresult=True, returns type[BaseModel] | None)
       - Add `initialize` hookspec (accepts config: BaseModel | None, returns None)

    4. **tests/plugins/test_api_version.py** — Write tests:
       - test_plugin_api_version_constant: assert PLUGIN_API_VERSION == 1 and isinstance(PLUGIN_API_VERSION, int)
       - test_compatible_plugin_loads: mock plugin with PLUGIN_API_VERSION=1, check_plugin_api_version returns []
       - test_plugin_too_new_rejected: mock plugin with PLUGIN_API_VERSION=99, raises PluginLoadError
       - test_plugin_too_old_rejected: mock plugin with PLUGIN_API_VERSION=-99, raises PluginLoadError
       - test_deprecated_api_version_warns: mock plugin with PLUGIN_API_VERSION=0, returns list with "consider updating"
       - test_legacy_plugin_no_version: plugin with no attr returns []
       - test_deprecated_hookspec_warns_on_registration: register a plugin with post_create hookimpl, assert DeprecationWarning emitted via pytest.warns(DeprecationWarning)

    5. **tests/plugins/test_pre_action_hooks.py** — Write tests:
       - test_rejection_aborts_action: plugin pre_action returns ActionRejection("blocked"), firstresult yields ActionRejection
       - test_modified_kwargs_used: plugin pre_action returns {"query": "modified"}, firstresult yields dict
       - test_none_return_passthrough: plugin pre_action returns None, firstresult yields None (no result)
       - test_post_action_fires: plugin post_action receives action_name, kwargs, result correctly
       - test_action_rejection_frozen: ActionRejection fields immutable
       - All tests use fresh pluggy.PluginManager("ztlctl") instances (not the singleton)
  </action>
  <verify>
    <automated>uv run pytest tests/plugins/test_api_version.py tests/plugins/test_pre_action_hooks.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "PLUGIN_API_VERSION" src/ztlctl/plugins/__init__.py` returns >= 1
    - `grep -c "PluginLoadError" src/ztlctl/plugins/__init__.py` returns >= 1
    - `grep -c "check_plugin_api_version" src/ztlctl/plugins/__init__.py` returns >= 1
    - `grep -c "pre_action" src/ztlctl/plugins/hookspecs.py` returns >= 1
    - `grep -c "post_action" src/ztlctl/plugins/hookspecs.py` returns >= 2
    - `grep -c "warn_on_impl" src/ztlctl/plugins/hookspecs.py` returns >= 9
    - `grep -c "ActionRejection" src/ztlctl/plugins/contracts.py` returns >= 1
    - `grep -c "get_config_schema" src/ztlctl/plugins/hookspecs.py` returns >= 1
    - `grep -c "initialize" src/ztlctl/plugins/hookspecs.py` returns >= 1
    - All tests in test_api_version.py and test_pre_action_hooks.py pass
  </acceptance_criteria>
  <done>
    PLUGIN_API_VERSION=1 constant exported from plugins package. check_plugin_api_version() validates compatibility with 2-version window. PluginLoadError raised for incompatible plugins. ActionRejection frozen dataclass in contracts.py. pre_action (firstresult=True) and post_action hookspecs added. All 9 per-event hookspecs have warn_on_impl DeprecationWarning. get_config_schema and initialize hookspecs added. All tests pass.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: PluginManager version checking + config injection + BaseController pre/post-action dispatch</name>
  <files>
    src/ztlctl/plugins/manager.py,
    src/ztlctl/controllers/base.py,
    src/ztlctl/config/models.py,
    tests/plugins/test_plugin_config.py
  </files>
  <read_first>
    src/ztlctl/plugins/manager.py,
    src/ztlctl/controllers/base.py,
    src/ztlctl/config/models.py,
    src/ztlctl/config/settings.py,
    src/ztlctl/infrastructure/vault.py,
    src/ztlctl/plugins/__init__.py,
    src/ztlctl/plugins/hookspecs.py,
    src/ztlctl/plugins/contracts.py,
    tests/plugins/test_api_version.py,
    tests/plugins/test_pre_action_hooks.py
  </read_first>
  <behavior>
    PluginManager version check:
    - discover_and_load() calls check_plugin_api_version() for each plugin after instantiation
    - Incompatible plugins are unregistered and logged as warnings
    - Compatible plugins with deprecated versions produce logged warnings

    Plugin config injection (PLUG-03):
    - PluginsConfig model_config changes to {"frozen": True, "extra": "allow"}
    - PluginManager._inject_plugin_configs(settings) iterates plugins, calls get_config_schema hookspec
    - If schema returned and [plugins.<name>] section exists in settings.plugins model_extra, validate with schema
    - Validated config passed to plugin via initialize hookspec
    - Invalid config raises at load time with descriptive error (logged as warning, plugin skipped)
    - PluginsConfig.get_plugin_config(name) helper returns dict[str, Any] or empty dict

    BaseController dispatch:
    - _dispatch_pre_action(action_name, kwargs) calls pm.hook.pre_action(), returns (kwargs, rejection)
    - _dispatch_post_action(action_name, kwargs, result) calls pm.hook.post_action()
    - Both access vault.plugin_manager (need to verify/add this property on Vault)
    - Exception in pre_action dispatch is warning, returns original kwargs with None rejection
    - Exception in post_action dispatch is warning, logged and ignored
  </behavior>
  <action>
    1. **src/ztlctl/config/models.py** — Modify PluginsConfig:
       - Change model_config to `{"frozen": True, "extra": "allow"}` to accept arbitrary `[plugins.<name>]` sections
       - Add `get_plugin_config(self, name: str) -> dict[str, Any]` method that returns `self.model_extra.get(name, {})` if model_extra is not None, else `{}`. Note: since frozen=True, this is a read-only accessor.

    2. **src/ztlctl/plugins/manager.py** — Add version checking and config injection:
       - Import `check_plugin_api_version`, `PluginLoadError` from `ztlctl.plugins`
       - In `_normalize_plugin_instances()`, after instantiation, call `check_plugin_api_version(instance, plugin_name)`. If PluginLoadError raised, log warning and skip (don't register). If warnings returned, log each as warning.
       - Same version check in `_discover_local()` after instantiation.
       - Same version check in `register_plugin()` after registration — unregister if incompatible.
       - Add `_inject_plugin_configs(self, settings: ZtlSettings) -> None` method:
         - For each plugin, call `pm.hook.get_config_schema.call_extra([plugin], {})` or iterate plugins manually
         - Actually: since get_config_schema is firstresult, call it per-plugin. Use `self._pm.subset_hook_caller("get_config_schema", [plugin])` pattern OR iterate plugins and call method directly if it exists.
         - Simpler approach: iterate `self._pm.get_plugins()`, check if plugin has `get_config_schema` method, call it, validate config, then call `initialize(config=validated)`.
         - Get plugin name, look up `settings.plugins.get_plugin_config(plugin_name)` for raw config dict.
         - If schema class returned and raw config exists, validate: `schema_cls(**raw_config)`. On ValidationError, log warning, call initialize(config=None).
         - If no schema but raw config exists, call initialize(config=None) with warning about unvalidated config.
         - If no get_config_schema or no initialize, skip.
       - Add `inject_configs(self, settings: ZtlSettings) -> None` public method that calls `_inject_plugin_configs`. Call this from discover_and_load() after version checks, passing settings if available. Actually: discover_and_load() doesn't have settings — add optional `settings` parameter: `def discover_and_load(self, *, local_dir=None, include_entrypoints=True, settings=None) -> list[str]`. If settings provided, call _inject_plugin_configs after loading.

    3. **src/ztlctl/controllers/base.py** — Add pre/post-action dispatch:
       - Import `ActionRejection` from `ztlctl.plugins.contracts` (TYPE_CHECKING only)
       - Add `_dispatch_pre_action(self, action_name: str, kwargs: dict[str, Any]) -> tuple[dict[str, Any], ActionRejection | None]`:
         - Get plugin_manager from vault: `pm = self._vault.plugin_manager` (need to verify Vault exposes this)
         - If pm is None, return (kwargs, None)
         - Try: `result = pm.hook.pre_action(action_name=action_name, kwargs=kwargs)`
         - If isinstance(result, ActionRejection): return (kwargs, result)
         - If isinstance(result, dict): return (result, None)
         - Return (kwargs, None) for None/other
         - Except: log debug, return (kwargs, None)
       - Add `_dispatch_post_action(self, action_name: str, kwargs: dict[str, Any], result: Any) -> None`:
         - Get pm, if None return
         - Try: `pm.hook.post_action(action_name=action_name, kwargs=kwargs, result=result)`
         - Except: log debug

    4. **Check/add Vault.plugin_manager property** — Read src/ztlctl/infrastructure/vault.py. If Vault doesn't expose plugin_manager directly, add a property that returns the PluginManager (the EventBus._pm wraps it, but pre-action needs direct access). If EventBus already provides access, use that. Otherwise add `@property def plugin_manager(self) -> PluginManager | None` on Vault.

    5. **tests/plugins/test_plugin_config.py** — Write tests:
       - test_plugins_config_extra_allow: create PluginsConfig(git={"enabled": True}, myplugin={"key": "val"}), assert model_extra contains "myplugin"
       - test_get_plugin_config_exists: PluginsConfig with extra, get_plugin_config("myplugin") returns {"key": "val"}
       - test_get_plugin_config_missing: get_plugin_config("nonexistent") returns {}
       - test_config_injected: Create a mock plugin with get_config_schema returning a Pydantic model, register it, call _inject_plugin_configs, assert initialize called with validated model
       - test_invalid_config_rejected: Plugin with schema, provide invalid config, assert warning logged and initialize called with None
       - test_version_check_in_discover: Create a plugin with PLUGIN_API_VERSION=99, register it, assert it gets unregistered/skipped with warning
  </action>
  <verify>
    <automated>uv run pytest tests/plugins/test_plugin_config.py tests/plugins/test_api_version.py tests/plugins/test_pre_action_hooks.py -x -q && uv run ruff check src/ztlctl/plugins/ src/ztlctl/controllers/base.py src/ztlctl/config/models.py && uv run mypy src/ztlctl/plugins/ src/ztlctl/controllers/base.py src/ztlctl/config/models.py</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "extra.*allow" src/ztlctl/config/models.py` returns >= 1
    - `grep -c "get_plugin_config" src/ztlctl/config/models.py` returns >= 1
    - `grep -c "check_plugin_api_version" src/ztlctl/plugins/manager.py` returns >= 1
    - `grep -c "_inject_plugin_configs" src/ztlctl/plugins/manager.py` returns >= 1
    - `grep -c "_dispatch_pre_action" src/ztlctl/controllers/base.py` returns >= 1
    - `grep -c "_dispatch_post_action" src/ztlctl/controllers/base.py` returns >= 1
    - `grep -c "plugin_manager" src/ztlctl/infrastructure/vault.py` returns >= 1 (property exists)
    - All tests pass, ruff clean, mypy clean
  </acceptance_criteria>
  <done>
    PluginManager validates API version at load time (incompatible plugins rejected). Plugin config from [plugins.<name>] TOML sections validated via Pydantic schemas and injected via initialize hookspec. BaseController has _dispatch_pre_action and _dispatch_post_action methods. Vault exposes plugin_manager property. All tests pass with ruff and mypy clean.
  </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)
3. `uv run ruff check .` — no lint issues
4. `uv run mypy src/` — no type errors
5. `grep "PLUGIN_API_VERSION" src/ztlctl/plugins/__init__.py` — constant exists
6. `grep "pre_action" src/ztlctl/plugins/hookspecs.py` — hookspec exists
7. `grep "ActionRejection" src/ztlctl/plugins/contracts.py` — dataclass exists
</verification>

<success_criteria>
- PLUGIN_API_VERSION=1 exported from plugins package with compatibility checks
- pre_action (firstresult) and post_action hookspecs defined
- All 9 per-event hookspecs marked deprecated with warn_on_impl
- ActionRejection frozen dataclass for pre-action abort
- BaseController._dispatch_pre_action() and _dispatch_post_action() wired to vault.plugin_manager
- PluginsConfig accepts arbitrary [plugins.<name>] sections via extra="allow"
- Plugin config validated against declared Pydantic schema at load time
- All new tests pass, full test suite green, ruff clean, mypy clean
</success_criteria>

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