---
phase: 17-registry-decomposition-and-plugin-runtime
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/plugins/manager.py
  - src/ztlctl/plugins/runtime.py
  - src/ztlctl/commands/__init__.py
  - src/ztlctl/services/init.py
  - src/ztlctl/services/workflow.py
  - src/ztlctl/workspace_profiles.py
  - src/ztlctl/infrastructure/vault.py
  - tests/plugins/test_plugin_runtime.py
autonomous: true
requirements: [ARCH-08, DEBT-07]

must_haves:
  truths:
    - "PluginManager is not independently constructed in 5 separate call sites — a centralized factory provides configured instances"
    - "load_plugin_commands uses the same discovery path and config injection as the vault runtime plugin manager"
    - "Plugin discovery happens once per process scope — repeated calls return the same PM instance for the same scope"
  artifacts:
    - path: "src/ztlctl/plugins/runtime.py"
      provides: "Centralized PluginManager factory with scope-aware caching"
      contains: "def get_plugin_manager"
    - path: "src/ztlctl/commands/__init__.py"
      provides: "load_plugin_commands using centralized factory with inject_configs"
      contains: "inject_configs"
    - path: "tests/plugins/test_plugin_runtime.py"
      provides: "Tests for centralized plugin runtime factory"
      contains: "def test_get_plugin_manager"
  key_links:
    - from: "src/ztlctl/commands/__init__.py"
      to: "src/ztlctl/plugins/runtime.py"
      via: "get_plugin_manager call"
      pattern: "get_plugin_manager"
    - from: "src/ztlctl/infrastructure/vault.py"
      to: "src/ztlctl/plugins/runtime.py"
      via: "get_plugin_manager call"
      pattern: "get_plugin_manager"
    - from: "src/ztlctl/services/init.py"
      to: "src/ztlctl/plugins/runtime.py"
      via: "get_plugin_manager call"
      pattern: "get_plugin_manager"
---

<objective>
Create a centralized PluginManager factory that replaces 5 independent PluginManager() constructions, and fix load_plugin_commands to call inject_configs.

Purpose: ARCH-08 — single coherent runtime owner for plugin discovery. DEBT-07 — load_plugin_commands participates in config injection.
Output: New `runtime.py` factory module, 5 call sites updated, load_plugin_commands fixed.
</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
@src/ztlctl/plugins/manager.py
@src/ztlctl/commands/__init__.py
@src/ztlctl/services/init.py
@src/ztlctl/services/workflow.py
@src/ztlctl/workspace_profiles.py
@src/ztlctl/infrastructure/vault.py

<interfaces>
<!-- Key types the executor needs -->

From src/ztlctl/plugins/manager.py:
```python
class PluginManager:
    def __init__(self) -> None: ...
    def discover_and_load(self, *, local_dir: Path | None = None, include_entrypoints: bool = True) -> list[str]: ...
    def inject_configs(self, settings: Any) -> None: ...
    def cli_command_contributions(self, *, reserved_names: set[str] | None = None) -> list[CliCommandContribution]: ...
    def vault_init_step_contributions(self, *, reserved_names: set[str] | None = None) -> list[VaultInitStepContribution]: ...
    def workspace_profile_contributions(self, *, reserved_names: set[str] | None = None) -> list[WorkspaceProfileContribution]: ...
    def workflow_module_contributions(self, *, reserved_names: set[str] | None = None) -> list[WorkflowModuleContribution]: ...
    @property
    def is_loaded(self) -> bool: ...
    @property
    def hook(self) -> pluggy.HookRelay: ...
```

Current PluginManager() construction sites:
- commands/__init__.py:35 — `pm = PluginManager(); pm.discover_and_load(local_dir=...)` — NO inject_configs (DEBT-07)
- services/init.py:158 — `plugin_manager = PluginManager(); plugin_manager.discover_and_load(local_dir=None, include_entrypoints=True)`
- services/workflow.py:200 — `pm = PluginManager(); pm.discover_and_load(local_dir=vault_root / ".ztlctl" / "plugins")`
- workspace_profiles.py:162 — `plugin_manager = PluginManager(); plugin_manager.discover_and_load(local_dir=..., include_entrypoints=...)`
- infrastructure/vault.py:380 — `pm = PluginManager(); pm.discover_and_load(local_dir=...); pm.inject_configs(self._settings)`
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create centralized PluginManager factory in runtime.py</name>
  <files>
    src/ztlctl/plugins/runtime.py,
    tests/plugins/test_plugin_runtime.py
  </files>
  <read_first>
    src/ztlctl/plugins/manager.py,
    src/ztlctl/infrastructure/vault.py,
    src/ztlctl/commands/__init__.py,
    src/ztlctl/services/init.py,
    src/ztlctl/services/workflow.py,
    src/ztlctl/workspace_profiles.py
  </read_first>
  <action>
    Create `src/ztlctl/plugins/runtime.py` with a `get_plugin_manager()` factory function. The factory:

    1. Accepts parameters that cover all 5 current construction patterns:
       ```python
       def get_plugin_manager(
           *,
           local_dir: Path | None = None,
           include_entrypoints: bool = True,
           settings: Any = None,
       ) -> PluginManager:
       ```

    2. Creates a PluginManager, calls `discover_and_load(local_dir=local_dir, include_entrypoints=include_entrypoints)`, and if `settings` is not None calls `inject_configs(settings)`.

    3. Uses module-level caching keyed on `(local_dir, include_entrypoints)` so that repeated calls with the same scope return the same PM instance. Use a `dict[tuple[Path | None, bool], PluginManager]` cache. Thread safety is not required (ztlctl is single-threaded).

    4. Provide a `reset_plugin_manager_cache()` function for test teardown.

    Full implementation:
    ```python
    """Centralized PluginManager factory — single coherent runtime owner (ARCH-08)."""
    from __future__ import annotations

    from pathlib import Path
    from typing import Any

    from ztlctl.plugins.manager import PluginManager

    _cache: dict[tuple[Path | None, bool], PluginManager] = {}


    def get_plugin_manager(
        *,
        local_dir: Path | None = None,
        include_entrypoints: bool = True,
        settings: Any = None,
    ) -> PluginManager:
        """Return a discovered PluginManager, creating one if needed for this scope.

        Args:
            local_dir: Path to local plugins directory (e.g., vault_root / ".ztlctl" / "plugins").
            include_entrypoints: Whether to load setuptools entry-point plugins.
            settings: If provided, calls inject_configs(settings) on the PM.
                Config injection runs on every call even for cached instances,
                since settings may differ between invocations.

        Returns:
            A fully discovered PluginManager instance.
        """
        key = (local_dir, include_entrypoints)
        pm = _cache.get(key)
        if pm is None:
            pm = PluginManager()
            pm.discover_and_load(local_dir=local_dir, include_entrypoints=include_entrypoints)
            _cache[key] = pm
        if settings is not None:
            pm.inject_configs(settings)
        return pm


    def reset_plugin_manager_cache() -> None:
        """Clear the PM cache. Used in test teardown."""
        _cache.clear()
    ```

    Also create `tests/plugins/test_plugin_runtime.py` with tests:
    - `test_get_plugin_manager_returns_loaded_instance` — call get_plugin_manager(), assert pm.is_loaded is True
    - `test_get_plugin_manager_caches_by_scope` — call twice with same args, assert identity (is)
    - `test_get_plugin_manager_different_scope_different_instance` — call with local_dir=None vs local_dir=Path("/tmp"), assert not same instance
    - `test_reset_clears_cache` — get pm, reset, get again, assert different instance
    - `test_inject_configs_called_when_settings_provided` — mock inject_configs, pass settings, verify called

    Use `reset_plugin_manager_cache()` in a fixture's teardown for all tests.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/plugins/test_plugin_runtime.py -x -v && uv run ruff check src/ztlctl/plugins/runtime.py && uv run mypy src/ztlctl/plugins/runtime.py</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/plugins/runtime.py contains `def get_plugin_manager`
    - src/ztlctl/plugins/runtime.py contains `def reset_plugin_manager_cache`
    - src/ztlctl/plugins/runtime.py contains `_cache: dict`
    - src/ztlctl/plugins/runtime.py contains `pm.inject_configs(settings)`
    - tests/plugins/test_plugin_runtime.py contains `test_get_plugin_manager_caches_by_scope`
    - tests/plugins/test_plugin_runtime.py contains `test_inject_configs_called_when_settings_provided`
    - `uv run pytest tests/plugins/test_plugin_runtime.py` exits 0
  </acceptance_criteria>
  <done>runtime.py factory exists with scope-aware caching and config injection, all tests pass</done>
</task>

<task type="auto">
  <name>Task 2: Replace all 5 PluginManager() constructions with get_plugin_manager()</name>
  <files>
    src/ztlctl/commands/__init__.py,
    src/ztlctl/services/init.py,
    src/ztlctl/services/workflow.py,
    src/ztlctl/workspace_profiles.py,
    src/ztlctl/infrastructure/vault.py
  </files>
  <read_first>
    src/ztlctl/plugins/runtime.py,
    src/ztlctl/commands/__init__.py,
    src/ztlctl/services/init.py,
    src/ztlctl/services/workflow.py,
    src/ztlctl/workspace_profiles.py,
    src/ztlctl/infrastructure/vault.py
  </read_first>
  <action>
    Replace each independent `PluginManager()` construction with `get_plugin_manager()` from `ztlctl.plugins.runtime`. Each site uses a lazy/local import of the factory.

    **Site 1: `src/ztlctl/commands/__init__.py` (load_plugin_commands) — DEBT-07 fix**
    Current (line 35):
    ```python
    pm = PluginManager()
    pm.discover_and_load(local_dir=settings.vault_root / ".ztlctl" / "plugins")
    ```
    Replace with:
    ```python
    from ztlctl.plugins.runtime import get_plugin_manager
    pm = get_plugin_manager(
        local_dir=settings.vault_root / ".ztlctl" / "plugins",
        settings=settings,  # DEBT-07: inject_configs support
    )
    ```
    Remove the `from ztlctl.plugins.manager import PluginManager` import (no longer needed).
    This is the DEBT-07 fix — `settings` parameter triggers `inject_configs(settings)`.

    **Site 2: `src/ztlctl/services/init.py` (line 158)**
    Current:
    ```python
    plugin_manager = PluginManager()
    plugin_manager.discover_and_load(local_dir=None, include_entrypoints=True)
    ```
    Replace with:
    ```python
    from ztlctl.plugins.runtime import get_plugin_manager
    plugin_manager = get_plugin_manager(local_dir=None, include_entrypoints=True)
    ```
    Remove module-level `from ztlctl.plugins.manager import PluginManager` if it was only used here (check if PluginManager is used elsewhere in the file first; if not, remove).

    **Site 3: `src/ztlctl/services/workflow.py` (_load_plugin_manager, line 200)**
    Current:
    ```python
    pm = PluginManager()
    pm.discover_and_load(local_dir=vault_root / ".ztlctl" / "plugins")
    ```
    Replace with:
    ```python
    from ztlctl.plugins.runtime import get_plugin_manager
    pm = get_plugin_manager(local_dir=vault_root / ".ztlctl" / "plugins")
    ```
    Remove the local `from ztlctl.plugins.manager import PluginManager` import.

    **Site 4: `src/ztlctl/workspace_profiles.py` (line 162)**
    Current:
    ```python
    plugin_manager = PluginManager()
    plugin_manager.discover_and_load(local_dir=local_dir, include_entrypoints=include_entrypoints)
    ```
    Replace with:
    ```python
    from ztlctl.plugins.runtime import get_plugin_manager
    plugin_manager = get_plugin_manager(local_dir=local_dir, include_entrypoints=include_entrypoints)
    ```
    Remove module-level `from ztlctl.plugins.manager import PluginManager`.

    **Site 5: `src/ztlctl/infrastructure/vault.py` (init_event_bus, line 380)**
    Current:
    ```python
    pm = PluginManager()
    local_plugins = self.root / ".ztlctl" / "plugins"
    pm.discover_and_load(local_dir=local_plugins)
    pm.inject_configs(self._settings)
    ```
    Replace with:
    ```python
    from ztlctl.plugins.runtime import get_plugin_manager
    local_plugins = self.root / ".ztlctl" / "plugins"
    pm = get_plugin_manager(local_dir=local_plugins, settings=self._settings)
    ```
    Remove `from ztlctl.plugins.manager import PluginManager` from the local imports in init_event_bus.

    After all replacements, verify no remaining `PluginManager()` construction calls exist outside of `manager.py` and `runtime.py` themselves:
    ```bash
    grep -rn "PluginManager()" src/ztlctl/ --include="*.py" | grep -v manager.py | grep -v runtime.py | grep -v __pycache__
    ```
    This should return no results.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest -x && uv run ruff check . && uv run mypy src/</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/commands/__init__.py contains `get_plugin_manager` and `settings=settings`
    - src/ztlctl/commands/__init__.py does NOT contain `PluginManager()`
    - src/ztlctl/services/init.py contains `get_plugin_manager`
    - src/ztlctl/services/init.py does NOT contain `PluginManager()`
    - src/ztlctl/services/workflow.py contains `get_plugin_manager`
    - src/ztlctl/services/workflow.py does NOT contain `PluginManager()`
    - src/ztlctl/workspace_profiles.py contains `get_plugin_manager`
    - src/ztlctl/workspace_profiles.py does NOT contain `PluginManager()`
    - src/ztlctl/infrastructure/vault.py contains `get_plugin_manager`
    - src/ztlctl/infrastructure/vault.py does NOT contain `PluginManager()`
    - `grep -rn "PluginManager()" src/ztlctl/ --include="*.py" | grep -v manager.py | grep -v runtime.py | grep -v __pycache__` returns empty
    - `uv run pytest` exits 0 (full suite)
    - `uv run ruff check .` exits 0
    - `uv run mypy src/` exits 0
  </acceptance_criteria>
  <done>All 5 PluginManager() construction sites replaced with get_plugin_manager(), load_plugin_commands now calls inject_configs via settings parameter, full test suite passes, zero independent PM constructions remain</done>
</task>

</tasks>

<verification>
1. `grep -rn "PluginManager()" src/ztlctl/ --include="*.py" | grep -v manager.py | grep -v runtime.py | grep -v __pycache__` — returns empty (no independent constructions)
2. `uv run pytest -x` — full test suite passes
3. `uv run ruff check . && uv run ruff format --check . && uv run mypy src/` — lint, format, types clean
4. `grep "inject_configs\|settings=" src/ztlctl/commands/__init__.py` — shows DEBT-07 fix is present
5. `grep "get_plugin_manager" src/ztlctl/plugins/runtime.py src/ztlctl/commands/__init__.py src/ztlctl/services/init.py src/ztlctl/services/workflow.py src/ztlctl/workspace_profiles.py src/ztlctl/infrastructure/vault.py` — all 5 sites use the factory
</verification>

<success_criteria>
- Centralized get_plugin_manager() factory exists in runtime.py with scope-aware caching
- All 5 independent PluginManager() constructions replaced
- load_plugin_commands passes settings to get_plugin_manager (DEBT-07 fixed)
- Config injection happens for all PM instances that receive settings
- Full test suite, ruff, mypy pass
</success_criteria>

<output>
After completion, create `.planning/phases/17-registry-decomposition-and-plugin-runtime/17-02-SUMMARY.md`
</output>
