---
phase: 07-plugin-agentic-wiring-fixes
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/controllers/check.py
  - src/ztlctl/controllers/create.py
  - src/ztlctl/controllers/discovery.py
  - src/ztlctl/controllers/export.py
  - src/ztlctl/controllers/graph.py
  - src/ztlctl/controllers/ingest.py
  - src/ztlctl/controllers/init_ctrl.py
  - tests/controllers/test_hook_wiring.py
autonomous: true
requirements: [PLUG-02]

must_haves:
  truths:
    - "Every controller method calls _dispatch_pre_action before service delegation"
    - "A plugin returning ActionRejection prevents service execution and returns a ServiceResult error"
    - "_dispatch_post_action fires after every successful service call regardless of result.ok"
    - "Internal flags like dispatch_post_create are excluded from the kwargs dict passed to plugins"
  artifacts:
    - path: "src/ztlctl/controllers/check.py"
      provides: "Hook-wired check, fix, rebuild, rollback methods"
      contains: "_dispatch_pre_action"
    - path: "src/ztlctl/controllers/create.py"
      provides: "Hook-wired create_note, create_reference, create_task, create_batch methods"
      contains: "_dispatch_pre_action"
    - path: "src/ztlctl/controllers/discovery.py"
      provides: "Hook-wired discover_categories, activate_category, deactivate_category"
      contains: "_dispatch_pre_action"
    - path: "src/ztlctl/controllers/export.py"
      provides: "Hook-wired export_markdown, export_indexes, export_graph, export_dashboard"
      contains: "_dispatch_pre_action"
    - path: "src/ztlctl/controllers/graph.py"
      provides: "Hook-wired related, themes, rank, path, gaps, bridges, unlink, materialize_metrics"
      contains: "_dispatch_pre_action"
    - path: "src/ztlctl/controllers/ingest.py"
      provides: "Hook-wired list_providers, ingest_text, ingest_file, ingest_url"
      contains: "_dispatch_pre_action"
    - path: "src/ztlctl/controllers/init_ctrl.py"
      provides: "Hook-wired init_vault, regenerate_self, check_staleness"
      contains: "_dispatch_pre_action"
    - path: "tests/controllers/test_hook_wiring.py"
      provides: "Spot-check tests for hook wiring per controller"
  key_links:
    - from: "src/ztlctl/controllers/*.py"
      to: "src/ztlctl/controllers/base.py"
      via: "_dispatch_pre_action and _dispatch_post_action inherited methods"
      pattern: "_dispatch_pre_action"
---

<objective>
Wire pre/post-action hooks into the first 7 controllers (check, create, discovery, export, graph, ingest, init_ctrl) — 30 methods total. Each method gets the standard 4-step pattern: build kwargs dict, dispatch pre_action, call service (or return rejection), dispatch post_action.

Purpose: Enables plugins to observe, modify, or reject any action dispatched through these controllers (PLUG-02).
Output: 7 updated controller files + hook wiring spot-check 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/07-plugin-agentic-wiring-fixes/07-CONTEXT.md
@.planning/phases/07-plugin-agentic-wiring-fixes/07-RESEARCH.md

<interfaces>
<!-- BaseController hook dispatch methods (inherited by all controllers) -->

From src/ztlctl/controllers/base.py:
```python
class BaseController:
    def _dispatch_pre_action(
        self, action_name: str, kwargs: dict[str, Any],
    ) -> tuple[dict[str, Any], ActionRejection | None]: ...

    def _dispatch_post_action(
        self, action_name: str, kwargs: dict[str, Any], result: Any,
    ) -> None: ...
```

From src/ztlctl/plugins/contracts.py:
```python
@dataclass(frozen=True)
class ActionRejection:
    reason: str
    code: str = ""
    detail: dict[str, Any] = field(default_factory=dict)
```

From src/ztlctl/services/result.py:
```python
class ServiceError(BaseModel):
    code: str
    message: str
    detail: dict[str, Any] = Field(default_factory=dict)
    recovery: str | None = None

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

<!-- Action names from _register_core.py (authoritative) -->
CheckController: "check", "fix", "rebuild", "rollback"
CreateController: "create_note", "create_reference", "create_task", "create_batch"
DiscoveryController: "discover_categories", "activate_category", "deactivate_category"
ExportController: "export_markdown", "export_indexes", "export_graph", "export_dashboard"
GraphController: "related", "themes", "rank", "path", "gaps", "bridges", "unlink", "materialize_metrics"
IngestController: "list_providers", "ingest_text", "ingest_file", "ingest_url"
InitController: "init_vault", "regenerate_self", "check_staleness"
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Wire hooks into 7 controllers (check, create, discovery, export, graph, ingest, init_ctrl)</name>
  <files>src/ztlctl/controllers/check.py, src/ztlctl/controllers/create.py, src/ztlctl/controllers/discovery.py, src/ztlctl/controllers/export.py, src/ztlctl/controllers/graph.py, src/ztlctl/controllers/ingest.py, src/ztlctl/controllers/init_ctrl.py</files>
  <read_first>
    - src/ztlctl/controllers/base.py (full — _dispatch_pre_action and _dispatch_post_action signatures)
    - src/ztlctl/controllers/check.py (full — current method bodies)
    - src/ztlctl/controllers/create.py (full — current method bodies, note dispatch_post_create)
    - src/ztlctl/controllers/discovery.py (full — current method bodies, inline ServiceResult)
    - src/ztlctl/controllers/export.py (full — current method bodies, export_graph special logic)
    - src/ztlctl/controllers/graph.py (full — current method bodies)
    - src/ztlctl/controllers/ingest.py (full — current method bodies)
    - src/ztlctl/controllers/init_ctrl.py (full — current method bodies)
    - src/ztlctl/plugins/contracts.py (ActionRejection dataclass)
    - src/ztlctl/actions/_register_core.py (lines 42-300 for creation/query action names, lines 1680-2170 for remaining)
  </read_first>
  <action>
Apply the following 4-step pattern to EVERY public method in these 7 controllers. The pattern is identical across all methods — only the action name, kwargs dict keys, and service call differ.

**Standard pattern (example: CheckController.check):**
```python
def check(self, *, min_severity: str = "warning") -> ServiceResult:
    """Report integrity issues without modifying anything."""
    from ztlctl.services.check import CheckService
    from ztlctl.services.result import ServiceError, ServiceResult

    kwargs: dict[str, Any] = {"min_severity": min_severity}

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

    result = CheckService(self._vault).check(min_severity=kwargs["min_severity"])

    self._dispatch_post_action("check", kwargs, result)
    return result
```

**Important rules:**
1. Add `from typing import Any` to the imports of each file (if not already present). For TYPE_CHECKING-only files, add `Any` to the runtime imports block, not inside TYPE_CHECKING.
2. Add `from ztlctl.services.result import ServiceError, ServiceResult` as a lazy import inside each method (alongside the existing service import). If `ServiceResult` is already imported via TYPE_CHECKING, it still needs a runtime import inside the method body for the rejection path.
3. The kwargs dict MUST only contain ActionDefinition parameters. Exclude: `dispatch_post_create` (CreateController), `partial` (create_batch — include it, it IS an ActionDefinition param).
4. Service calls MUST use `kwargs["param_name"]` for ALL parameters (not the original local variables) so that plugin-modified kwargs take effect.
5. For positional args (like `title` in create_note), pass as `kwargs["title"]` positionally.
6. For the rejection ServiceResult, use `op=` matching the action_name string.

**Controller-specific notes:**

**CheckController (4 methods):**
- `check`: kwargs = `{"min_severity": min_severity}`, action = `"check"`
- `fix`: kwargs = `{"level": level}`, action = `"fix"`
- `rebuild`: kwargs = `{}`, action = `"rebuild"`
- `rollback`: kwargs = `{}`, action = `"rollback"`

**CreateController (4 methods):**
- `create_note`: kwargs includes all params EXCEPT `dispatch_post_create`. Pass `dispatch_post_create=dispatch_post_create` directly to the service call (NOT from kwargs). Action = `"create_note"`.
- `create_reference`: Same pattern — exclude `dispatch_post_create` from kwargs. Action = `"create_reference"`.
- `create_task`: kwargs = `{"title": title, "priority": priority, "impact": impact, "effort": effort, "tags": tags, "session": session}`. Action = `"create_task"`.
- `create_batch`: kwargs = `{"items": items, "partial": partial}`. Action = `"create_batch"`.

**DiscoveryController (3 methods):**
These construct ServiceResult inline (no service delegation). Apply the pattern with the hook dispatch wrapping the inline logic. The `**_kwargs` catch-all in the signature should NOT be forwarded to plugins.
- `discover_categories`: kwargs = `{}`, action = `"discover_categories"`. Wrap the existing body (registry lookup + category list construction) after the rejection check.
- `activate_category`: kwargs = `{"category": category}`, action = `"activate_category"`. Wrap existing body after rejection check.
- `deactivate_category`: kwargs = `{"category": category}`, action = `"deactivate_category"`. Wrap existing body after rejection check.
For post_action, call with the `result` variable (which is the ServiceResult constructed inline).

**ExportController (4 methods):**
- `export_markdown`: kwargs = `{"output_dir": output_dir, "filters": filters}`, action = `"export_markdown"`. Service call: `ExportService(self._vault).export_markdown(Path(kwargs["output_dir"]), filters=kwargs["filters"])`.
- `export_indexes`: kwargs = `{"output_dir": output_dir, "filters": filters}`, action = `"export_indexes"`.
- `export_graph`: kwargs = `{"fmt": fmt, "output_file": output_file, "filters": filters}`, action = `"export_graph"`. The post-processing logic (writing output_file) stays AFTER the service call but BEFORE post_action — the result variable holds the final ServiceResult.
- `export_dashboard`: kwargs = `{"output_dir": output_dir, "viewer": viewer}`, action = `"export_dashboard"`.

**GraphController (8 methods):**
- `related`: kwargs = `{"content_id": content_id, "depth": depth, "top": top}`, action = `"related"`
- `themes`: kwargs = `{}`, action = `"themes"`
- `rank`: kwargs = `{"top": top}`, action = `"rank"`
- `path`: kwargs = `{"source_id": source_id, "target_id": target_id}`, action = `"path"`
- `gaps`: kwargs = `{"top": top}`, action = `"gaps"`
- `bridges`: kwargs = `{"top": top}`, action = `"bridges"`
- `unlink`: kwargs = `{"source_id": source_id, "target_id": target_id, "both": both}`, action = `"unlink"`
- `materialize_metrics`: kwargs = `{}`, action = `"materialize_metrics"`

**IngestController (4 methods):**
- `list_providers`: kwargs = `{}`, action = `"list_providers"`
- `ingest_text`: kwargs includes ALL params (title, body_text, target_type, topic, tags, session, subtype, summary, source_kind, modalities, capture_agent, capture_method, citations, excerpts, artifacts, source_bundle, dry_run, no_reweave). Action = `"ingest_text"`.
- `ingest_file`: kwargs includes ALL params (path, title, target_type, topic, tags, session, subtype, summary, source_kind, modalities, capture_agent, capture_method, citations, excerpts, artifacts, source_bundle, dry_run, no_reweave). Note: pass `Path(kwargs["path"])` to service. Action = `"ingest_file"`.
- `ingest_url`: kwargs includes ALL params (url, provider, title, target_type, topic, tags, session, subtype, summary, source_kind, modalities, capture_agent, capture_method, citations, excerpts, artifacts, source_bundle, dry_run, no_reweave). Action = `"ingest_url"`.

**InitController (3 methods):**
- `init_vault`: kwargs = `{"path": path, "name": name, "profile": profile, "client": client, "tone": tone, "topics": topics, "no_workflow": no_workflow}`. Service is static: `InitService.init_vault(kwargs["path"], name=kwargs["name"], ...)`. Action = `"init_vault"`.
- `regenerate_self`: kwargs = `{}`. Service: `InitService.regenerate_self(self._vault)`. Action = `"regenerate_self"`.
- `check_staleness`: kwargs = `{}`. Service: `InitService.check_staleness(self._vault)`. Action = `"check_staleness"`.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && for f in check create discovery export graph ingest init_ctrl; do echo "=== $f ===" && grep -c "_dispatch_pre_action" src/ztlctl/controllers/${f}.py; done && uv run ruff check src/ztlctl/controllers/check.py src/ztlctl/controllers/create.py src/ztlctl/controllers/discovery.py src/ztlctl/controllers/export.py src/ztlctl/controllers/graph.py src/ztlctl/controllers/ingest.py src/ztlctl/controllers/init_ctrl.py && uv run mypy src/ztlctl/controllers/check.py src/ztlctl/controllers/create.py src/ztlctl/controllers/discovery.py src/ztlctl/controllers/export.py src/ztlctl/controllers/graph.py src/ztlctl/controllers/ingest.py src/ztlctl/controllers/init_ctrl.py</automated>
  </verify>
  <acceptance_criteria>
    - check.py: `grep -c "_dispatch_pre_action" src/ztlctl/controllers/check.py` returns 4
    - create.py: `grep -c "_dispatch_pre_action" src/ztlctl/controllers/create.py` returns 4
    - discovery.py: `grep -c "_dispatch_pre_action" src/ztlctl/controllers/discovery.py` returns 3
    - export.py: `grep -c "_dispatch_pre_action" src/ztlctl/controllers/export.py` returns 4
    - graph.py: `grep -c "_dispatch_pre_action" src/ztlctl/controllers/graph.py` returns 8
    - ingest.py: `grep -c "_dispatch_pre_action" src/ztlctl/controllers/ingest.py` returns 4
    - init_ctrl.py: `grep -c "_dispatch_pre_action" src/ztlctl/controllers/init_ctrl.py` returns 3
    - create.py does NOT contain `"dispatch_post_create"` in any kwargs dict (grep for `"dispatch_post_create": dispatch_post_create` inside a dict literal returns 0)
    - `uv run ruff check` on all 7 files exits 0
    - `uv run mypy` on all 7 files exits 0
  </acceptance_criteria>
  <done>30 methods across 7 controllers are wired with pre/post-action hooks, all using correct action names from _register_core.py, ruff and mypy clean</done>
</task>

<task type="auto">
  <name>Task 2: Spot-check hook wiring tests for batch 1 controllers</name>
  <files>tests/controllers/test_hook_wiring.py</files>
  <read_first>
    - tests/controllers/test_base.py (existing test patterns for controller dispatch)
    - tests/plugins/test_pre_action_hooks.py (existing hook dispatch test patterns)
    - src/ztlctl/controllers/check.py (after Task 1 wiring)
    - src/ztlctl/controllers/create.py (after Task 1 wiring)
    - src/ztlctl/controllers/graph.py (after Task 1 wiring)
    - src/ztlctl/plugins/contracts.py (ActionRejection dataclass)
  </read_first>
  <action>
Create a new test file `tests/controllers/test_hook_wiring.py` with spot-check tests for hook wiring. The tests verify that `_dispatch_pre_action` and `_dispatch_post_action` are called with correct action names and kwargs. Test one representative method per controller in this batch.

**Test structure:**
Use `unittest.mock.patch.object` on the controller instance to mock `_dispatch_pre_action` and `_dispatch_post_action`. Also mock the service class to avoid needing a real vault.

```python
"""Spot-check tests for pre/post-action hook wiring in controllers.

Verifies that controller methods call _dispatch_pre_action with the correct
action name and kwargs dict, and _dispatch_post_action after service calls.
"""
from __future__ import annotations

from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from ztlctl.plugins.contracts import ActionRejection
from ztlctl.services.result import ServiceError, ServiceResult


def _make_controller(cls: type) -> Any:
    """Create a controller instance with a mock vault."""
    vault = MagicMock()
    vault.plugin_manager = None  # hooks become no-ops by default
    return cls(vault)


def _ok_result(op: str) -> ServiceResult:
    return ServiceResult(ok=True, op=op, data={})
```

**Test cases (one per controller, 7 tests):**

1. `test_check_controller_check_dispatches_hooks()` — CheckController.check(min_severity="error")
   - Mock `_dispatch_pre_action` to return `({"min_severity": "error"}, None)`
   - Mock CheckService(vault).check to return `_ok_result("check")`
   - Assert `_dispatch_pre_action` called with `("check", {"min_severity": "error"})`
   - Assert `_dispatch_post_action` called with `("check", {"min_severity": "error"}, result)`

2. `test_create_controller_create_note_excludes_dispatch_flag()` — CreateController.create_note("Test")
   - Mock `_dispatch_pre_action` to return `({"title": "Test", ...defaults...}, None)`
   - Assert kwargs dict passed to `_dispatch_pre_action` does NOT contain key `"dispatch_post_create"`
   - Assert action_name is `"create_note"`

3. `test_discovery_controller_activate_dispatches_hooks()` — DiscoveryController.activate_category(category="export")
   - Assert `_dispatch_pre_action` called with `("activate_category", {"category": "export"})`

4. `test_export_controller_export_markdown_dispatches_hooks()` — ExportController.export_markdown("/tmp/out")
   - Assert `_dispatch_pre_action` called with `("export_markdown", {"output_dir": "/tmp/out", "filters": None})`

5. `test_graph_controller_related_dispatches_hooks()` — GraphController.related("ZTL-001")
   - Assert `_dispatch_pre_action` called with `("related", {"content_id": "ZTL-001", "depth": 2, "top": 20})`

6. `test_ingest_controller_ingest_text_dispatches_hooks()` — IngestController.ingest_text("T", "body")
   - Assert action_name is `"ingest_text"` and kwargs contains `"title": "T"` and `"body_text": "body"`

7. `test_init_controller_init_vault_dispatches_hooks()` — InitController.init_vault(Path("/tmp"), name="test")
   - Assert `_dispatch_pre_action` called with `("init_vault", {...})`

**Add one rejection test:**

8. `test_rejection_prevents_service_call()` — Using CheckController.check as representative.
   - Mock `_dispatch_pre_action` to return `(kwargs, ActionRejection(reason="blocked", code="test-plugin"))`
   - Call controller.check()
   - Assert result.ok is False
   - Assert result.error.code == "ACTION_REJECTED"
   - Assert result.error.message == "blocked"
   - Assert `_dispatch_post_action` was NOT called (rejection skips post_action AND service call)
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/controllers/test_hook_wiring.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - tests/controllers/test_hook_wiring.py exists with at least 8 test functions
    - All tests in test_hook_wiring.py pass
    - test_create_controller_create_note_excludes_dispatch_flag asserts "dispatch_post_create" not in kwargs
    - test_rejection_prevents_service_call asserts result.error.code == "ACTION_REJECTED"
    - `uv run pytest tests/controllers/test_hook_wiring.py -x` exits 0
  </acceptance_criteria>
  <done>8 spot-check tests confirm hook wiring for batch 1 controllers, including rejection behavior and kwargs correctness</done>
</task>

</tasks>

<verification>
- All 7 controller files have _dispatch_pre_action calls matching their method count
- `uv run ruff check src/ztlctl/controllers/` clean
- `uv run mypy src/ztlctl/controllers/` clean
- `uv run pytest tests/controllers/test_hook_wiring.py -x -q` all pass
- `uv run pytest tests/controllers/ -x -q` all existing + new tests pass
</verification>

<success_criteria>
- 30 methods across 7 controllers wired with pre/post-action hooks
- Action names match _register_core.py exactly
- dispatch_post_create excluded from CreateController kwargs
- Rejection path returns ACTION_REJECTED ServiceResult error
- All hook wiring tests pass
- Existing controller tests unbroken
</success_criteria>

<output>
After completion, create `.planning/phases/07-plugin-agentic-wiring-fixes/07-02-SUMMARY.md`
</output>
