---
phase: 05-plugin-formalization
plan: 02
type: execute
wave: 2
depends_on: ["05-01"]
files_modified:
  - src/ztlctl/plugins/hookspecs.py
  - src/ztlctl/plugins/contracts.py
  - src/ztlctl/plugins/manager.py
  - tests/plugins/test_custom_note_types.py
  - tests/plugins/test_render_contributions.py
  - tests/plugins/test_marketplace_metadata.py
autonomous: true
requirements:
  - PLUG-05
  - PLUG-06
  - PLUG-07

must_haves:
  truths:
    - "A plugin that registers a NoteTypeDefinition automatically gets create/update/close CLI commands"
    - "A plugin that registers a NoteTypeDefinition automatically gets create/update/close MCP tools"
    - "A plugin can provide custom Rich output and MCP response formatting for its note type via RenderContribution"
    - "Plugin marketplace metadata convention is documented with a helper function to read [tool.ztlctl-plugin] from pyproject.toml"
  artifacts:
    - path: "src/ztlctl/plugins/hookspecs.py"
      provides: "register_note_types and register_render_contributions hookspecs"
      contains: "register_note_types"
    - path: "src/ztlctl/plugins/contracts.py"
      provides: "RenderContribution frozen dataclass, PluginMetadata frozen dataclass"
      contains: "RenderContribution"
    - path: "src/ztlctl/plugins/manager.py"
      provides: "_register_note_types and _register_note_type_actions methods"
      contains: "_register_note_types"
  key_links:
    - from: "src/ztlctl/plugins/manager.py"
      to: "src/ztlctl/domain/registry.py"
      via: "get_note_type_registry().register() for plugin NoteTypeDefinitions"
      pattern: "get_note_type_registry"
    - from: "src/ztlctl/plugins/manager.py"
      to: "src/ztlctl/actions/registry.py"
      via: "get_action_registry().register() for auto-generated ActionDefinitions"
      pattern: "get_action_registry"
    - from: "src/ztlctl/plugins/contracts.py"
      to: "src/ztlctl/plugins/hookspecs.py"
      via: "RenderContribution used by register_render_contributions hookspec"
      pattern: "RenderContribution"
---

<objective>
Enable plugins to register custom note types (with automatic CLI/MCP surfaces), provide custom rendering, and declare marketplace metadata.

Purpose: This is the "define-once, use-everywhere" payoff of Phases 1-4. A plugin registers a NoteTypeDefinition and ActionDefinitions are auto-created, which the CLI generator (Phase 4) and MCP generator (Phase 3) pick up automatically. RenderContribution allows custom output formatting. Marketplace metadata establishes the convention for future plugin discovery.

Output: register_note_types and register_render_contributions hookspecs, RenderContribution dataclass, PluginMetadata dataclass, auto-registration pipeline in PluginManager, marketplace metadata helper, and tests.
</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

<interfaces>
<!-- Interfaces from Plan 01 that this plan depends on -->

From src/ztlctl/plugins/hookspecs.py (after Plan 01):
```python
class ZtlctlHookSpec:
    @hookspec(firstresult=True)
    def pre_action(self, action_name: str, kwargs: dict[str, Any]) -> ActionRejection | dict[str, Any] | None: ...
    @hookspec
    def post_action(self, action_name: str, kwargs: dict[str, Any], result: Any) -> None: ...
    @hookspec(firstresult=True)
    def get_config_schema(self) -> type[BaseModel] | None: ...
    @hookspec
    def initialize(self, config: BaseModel | None) -> None: ...
```

From src/ztlctl/domain/registry.py:
```python
@dataclass(frozen=True)
class NoteTypeDefinition:
    name: str
    content_type: str
    model_cls: type[ContentModel]
    transitions: dict[str, list[str]]
    template_name: str
    required_sections: list[str] = field(default_factory=list)
    initial_status: str = ""
    is_subtype: bool = False
    parent_type: str | None = None

class NoteTypeRegistry:
    def register(self, note_type: NoteTypeDefinition) -> None: ...
    def get(self, name: str) -> NoteTypeDefinition: ...
    def list_types(self, content_type: str | None = None) -> list[NoteTypeDefinition]: ...

def get_note_type_registry() -> NoteTypeRegistry: ...
```

From src/ztlctl/actions/definitions.py:
```python
@dataclass(frozen=True)
class ActionParam:
    name: str; type: type; required: bool = True; default: Any = None
    description: str = ""; choices: tuple[str, ...] | None = None
    cli_multiple: bool = False; cli_is_argument: bool = False; cli_flag: bool = False
    cli_name: str | None = None; mcp_example: str | None = None

@dataclass(frozen=True)
class ActionDefinition:
    name: str; description: str; category: str
    params: tuple[ActionParam, ...]; handler: Callable[..., Any]
    side_effect: Literal["read", "write"]; help_text: str = ""
    cli_group: str | None = None; cli_name: str | None = None
    custom_presentation: bool = False
    mcp_when_to_use: str = ""; mcp_avoid_when: str = ""
    mcp_catalog_description: str = ""
```

From src/ztlctl/actions/registry.py:
```python
class ActionRegistry:
    def register(self, action: ActionDefinition) -> None: ...
def get_action_registry() -> ActionRegistry: ...
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Custom note type registration pipeline + RenderContribution + hookspecs</name>
  <files>
    src/ztlctl/plugins/hookspecs.py,
    src/ztlctl/plugins/contracts.py,
    src/ztlctl/plugins/manager.py,
    tests/plugins/test_custom_note_types.py,
    tests/plugins/test_render_contributions.py
  </files>
  <read_first>
    src/ztlctl/plugins/hookspecs.py,
    src/ztlctl/plugins/contracts.py,
    src/ztlctl/plugins/manager.py,
    src/ztlctl/domain/registry.py,
    src/ztlctl/actions/registry.py,
    src/ztlctl/actions/definitions.py,
    src/ztlctl/controllers/base.py,
    .planning/phases/05-plugin-formalization/05-01-SUMMARY.md
  </read_first>
  <behavior>
    Custom note types (PLUG-05):
    - register_note_types hookspec returns list[NoteTypeDefinition] | None
    - PluginManager._register_note_types() iterates hook results, registers into NoteTypeRegistry
    - For each registered NoteTypeDefinition, auto-create 3 ActionDefinitions (create_{name}, update_{name}, close_{name}) and register into ActionRegistry
    - Auto-created ActionDefinitions use correct handler lambdas pointing to CreateController/UpdateController patterns
    - Test: register a fake "sprint" NoteTypeDefinition via plugin, verify NoteTypeRegistry.get("sprint") works
    - Test: verify ActionRegistry has "create_sprint", "update_sprint", "close_sprint" after plugin load
    - Test: verify CLI generator would pick up the new actions (action.cli_group, action.category correct)
    - Test: verify MCP generator would pick up the new actions (action.mcp_catalog_description set)

    Render contributions (PLUG-06):
    - RenderContribution is frozen dataclass: note_type (str), rich_formatter (Callable[[dict], str]), mcp_formatter (Callable[[dict], dict])
    - register_render_contributions hookspec returns list[RenderContribution] | None
    - PluginManager.render_contributions() collects and returns all RenderContribution instances
    - Test: plugin registers RenderContribution for "sprint" type, verify collection returns it
    - Test: rich_formatter and mcp_formatter are callable with sample data
  </behavior>
  <action>
    1. **src/ztlctl/plugins/hookspecs.py** — Add to ZtlctlHookSpec:
       ```python
       @hookspec
       def register_note_types(self) -> list[NoteTypeDefinition] | None:
           """Return NoteTypeDefinitions to register into NoteTypeRegistry + ActionRegistry."""

       @hookspec
       def register_render_contributions(self) -> list[RenderContribution] | None:
           """Return render contributions for custom note types."""
       ```
       Add TYPE_CHECKING imports for NoteTypeDefinition and RenderContribution.

    2. **src/ztlctl/plugins/contracts.py** — Add:
       ```python
       @dataclass(frozen=True)
       class RenderContribution:
           """Plugin-provided rendering for a custom note type."""
           note_type: str
           rich_formatter: Callable[[dict[str, Any]], str]
           mcp_formatter: Callable[[dict[str, Any]], dict[str, Any]]
       ```

    3. **src/ztlctl/plugins/manager.py** — Add note type registration pipeline:
       - Add `_register_note_types(self) -> None` method:
         - Call `self._pm.hook.register_note_types()` to get list of lists
         - For each NoteTypeDefinition in results, call `get_note_type_registry().register(ntd)` (catch ValueError for duplicates, log warning)
         - For each registered NoteTypeDefinition, call `_register_note_type_actions(ntd)`
       - Add `_register_note_type_actions(self, ntd: NoteTypeDefinition) -> None` method:
         - Use lazy local import: `from ztlctl.actions.registry import get_action_registry` and `from ztlctl.actions.definitions import ActionDefinition, ActionParam`
         - Create 3 ActionDefinitions for the note type:
           - `create_{ntd.name}`: category="creation", side_effect="write", params=(title, tags, links, body), handler=lambda that constructs CreateController and calls create_note with content_type=ntd.content_type, subtype=ntd.name
           - `update_{ntd.name}`: category="mutation", side_effect="write", params=(content_id, title, tags, body, status), handler=lambda for UpdateController.update()
           - `close_{ntd.name}`: category="mutation", side_effect="write", params=(content_id, summary), handler=lambda for UpdateController.close() or archive
         - Register all 3 into ActionRegistry (catch ValueError for duplicates)
         - Set cli_group to ntd.content_type, cli_name to ntd.name for create action
         - Set mcp_catalog_description from ntd.name
       - Add `render_contributions(self) -> list[RenderContribution]` public method using _collect_contributions pattern
       - Call `_register_note_types()` in `discover_and_load()` after `_register_content_models()` and version checks

    4. **tests/plugins/test_custom_note_types.py** — Write tests:
       - Use fresh PluginManager + NoteTypeRegistry + ActionRegistry fixtures (NOT singletons — create isolated instances or use monkeypatch to reset singletons)
       - test_plugin_registers_note_type: plugin returns [NoteTypeDefinition(name="sprint", content_type="task", model_cls=ContentModel, transitions={"open": ["closed"], "closed": []}, template_name="sprint.md.j2")], verify get_note_type_registry().get("sprint") works
       - test_auto_action_definitions_created: after plugin load, ActionRegistry has create_sprint, update_sprint, close_sprint
       - test_auto_actions_have_correct_metadata: create_sprint has category="creation", side_effect="write", cli_group set
       - test_duplicate_note_type_logged_not_raised: register same type twice, second is warning not crash
       - test_custom_type_cli_commands: verify create_sprint ActionDefinition has cli_group and params that CLI generator would accept
       - test_custom_type_mcp_tools: verify create_sprint ActionDefinition has mcp_catalog_description set

    5. **tests/plugins/test_render_contributions.py** — Write tests:
       - test_render_contribution_frozen: RenderContribution is immutable
       - test_rich_formatter_called: create RenderContribution with mock formatter, call it, assert called
       - test_mcp_formatter_called: same for mcp_formatter
       - test_collection_from_plugin: register plugin with register_render_contributions, verify PluginManager.render_contributions() returns it
       - test_duplicate_render_contribution_skipped: two plugins return same note_type, second skipped with warning
  </action>
  <verify>
    <automated>uv run pytest tests/plugins/test_custom_note_types.py tests/plugins/test_render_contributions.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "register_note_types" src/ztlctl/plugins/hookspecs.py` returns >= 1
    - `grep -c "register_render_contributions" src/ztlctl/plugins/hookspecs.py` returns >= 1
    - `grep -c "RenderContribution" src/ztlctl/plugins/contracts.py` returns >= 1
    - `grep -c "_register_note_types" src/ztlctl/plugins/manager.py` returns >= 1
    - `grep -c "_register_note_type_actions" src/ztlctl/plugins/manager.py` returns >= 1
    - `grep -c "render_contributions" src/ztlctl/plugins/manager.py` returns >= 1
    - All tests in test_custom_note_types.py and test_render_contributions.py pass
  </acceptance_criteria>
  <done>
    Plugins can register custom NoteTypeDefinitions via register_note_types hookspec. PluginManager auto-creates create/update/close ActionDefinitions for each custom type, which CLI and MCP generators pick up automatically. RenderContribution dataclass and register_render_contributions hookspec enable custom output formatting. All tests pass.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Marketplace metadata convention + helper</name>
  <files>
    src/ztlctl/plugins/contracts.py,
    src/ztlctl/plugins/metadata.py,
    tests/plugins/test_marketplace_metadata.py
  </files>
  <read_first>
    src/ztlctl/plugins/contracts.py,
    .planning/phases/05-plugin-formalization/05-RESEARCH.md
  </read_first>
  <behavior>
    Marketplace metadata (PLUG-07):
    - PluginMetadata frozen dataclass: name (str), version (str), author (str), capabilities (tuple[str, ...]), ztlctl_api_version (int), description (str, default="")
    - read_plugin_metadata(pyproject_path: Path) -> PluginMetadata | None — reads [tool.ztlctl-plugin] section from a pyproject.toml file, returns PluginMetadata or None if section missing
    - Test: valid pyproject.toml with [tool.ztlctl-plugin] section returns PluginMetadata
    - Test: pyproject.toml without [tool.ztlctl-plugin] returns None
    - Test: pyproject.toml with missing required fields returns None with warning
  </behavior>
  <action>
    1. **src/ztlctl/plugins/contracts.py** — Add at end:
       ```python
       @dataclass(frozen=True)
       class PluginMetadata:
           """Structured metadata from [tool.ztlctl-plugin] in a plugin's pyproject.toml."""
           name: str
           version: str
           author: str
           capabilities: tuple[str, ...]
           ztlctl_api_version: int
           description: str = ""
       ```

    2. **src/ztlctl/plugins/metadata.py** — New file:
       ```python
       """Plugin marketplace metadata helpers.

       Reads [tool.ztlctl-plugin] sections from pyproject.toml files
       for future plugin discoverability.
       """
       from __future__ import annotations

       import logging
       from pathlib import Path
       from typing import Any

       from ztlctl.plugins.contracts import PluginMetadata

       logger = logging.getLogger(__name__)

       def read_plugin_metadata(pyproject_path: Path) -> PluginMetadata | None:
           """Read [tool.ztlctl-plugin] from a pyproject.toml file.

           Returns PluginMetadata or None if section missing or invalid.
           """
           try:
               import tomllib
           except ImportError:
               import tomli as tomllib  # type: ignore[no-redef]

           try:
               with open(pyproject_path, "rb") as f:
                   data = tomllib.load(f)
           except (OSError, tomllib.TOMLDecodeError):
               logger.warning("Failed to read %s", pyproject_path, exc_info=True)
               return None

           section: dict[str, Any] | None = data.get("tool", {}).get("ztlctl-plugin")
           if section is None:
               return None

           try:
               return PluginMetadata(
                   name=section["name"],
                   version=section["version"],
                   author=section["author"],
                   capabilities=tuple(section.get("capabilities", ())),
                   ztlctl_api_version=section["ztlctl_api_version"],
                   description=section.get("description", ""),
               )
           except (KeyError, TypeError) as exc:
               logger.warning(
                   "Invalid [tool.ztlctl-plugin] in %s: %s",
                   pyproject_path, exc,
               )
               return None
       ```

    3. **tests/plugins/test_marketplace_metadata.py** — Write tests:
       - test_metadata_read: write a tmp pyproject.toml with valid [tool.ztlctl-plugin], call read_plugin_metadata, assert all fields match
       - test_metadata_missing_section: pyproject.toml without [tool.ztlctl-plugin], returns None
       - test_metadata_missing_required_field: section missing "name" field, returns None
       - test_metadata_frozen: PluginMetadata is immutable
       - test_metadata_default_description: section without description field gets default ""
       - Use tmp_path fixture for temporary pyproject.toml files
  </action>
  <verify>
    <automated>uv run pytest tests/plugins/test_marketplace_metadata.py -x -q && uv run ruff check src/ztlctl/plugins/metadata.py && uv run mypy src/ztlctl/plugins/metadata.py</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "PluginMetadata" src/ztlctl/plugins/contracts.py` returns >= 1
    - `grep -c "read_plugin_metadata" src/ztlctl/plugins/metadata.py` returns >= 1
    - `grep -c "ztlctl-plugin" src/ztlctl/plugins/metadata.py` returns >= 1
    - All tests in test_marketplace_metadata.py pass
    - ruff and mypy clean on new file
  </acceptance_criteria>
  <done>
    PluginMetadata frozen dataclass captures name, version, author, capabilities, ztlctl_api_version, description. read_plugin_metadata() reads [tool.ztlctl-plugin] from pyproject.toml. Convention-based, no runtime enforcement. All tests pass.
  </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
3. `uv run ruff check .` — no lint issues
4. `uv run mypy src/` — no type errors
5. `grep "register_note_types" src/ztlctl/plugins/hookspecs.py` — hookspec exists
6. `grep "RenderContribution" src/ztlctl/plugins/contracts.py` — dataclass exists
7. `grep "PluginMetadata" src/ztlctl/plugins/contracts.py` — dataclass exists
</verification>

<success_criteria>
- Plugins can register NoteTypeDefinitions that auto-generate create/update/close ActionDefinitions
- ActionDefinitions registered by plugins are picked up by CLI and MCP generators (verified by checking ActionRegistry contents in tests)
- RenderContribution frozen dataclass enables custom Rich and MCP output formatting
- register_render_contributions hookspec collects RenderContributions from plugins
- PluginMetadata frozen dataclass captures marketplace metadata
- read_plugin_metadata() reads [tool.ztlctl-plugin] from pyproject.toml
- All new tests pass, full suite green, ruff clean, mypy clean
</success_criteria>

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