---
phase: 12-doc-search-integration
plan: 02
type: execute
wave: 2
depends_on:
  - 12-01
files_modified:
  - src/ztlctl/controllers/docs.py
  - src/ztlctl/commands/docs.py
  - src/ztlctl/actions/_register_core.py
  - src/ztlctl/commands/__init__.py
  - tests/controllers/test_docs_controller.py
autonomous: true
requirements:
  - AGNT-03

must_haves:
  truths:
    - "ztlctl docs search <query> returns a Rich table with Title/Score/Excerpt columns"
    - "ztlctl docs search <query> --json outputs {results: [...]} JSON to stdout"
    - "ztlctl docs search <query> --limit 2 returns at most 2 results"
    - "ztlctl docs search --help shows query argument and --limit/--json options"
    - "docs_search ActionDefinition is registered in the ActionRegistry"
    - "DocsController.search() accepts vault but does not call vault.engine or vault.db"
  artifacts:
    - path: "src/ztlctl/services/docs.py"
      provides: "_docs_search_impl (from Plan 01 — must exist before this plan)"
    - path: "src/ztlctl/controllers/docs.py"
      provides: "DocsController.search() wrapping _docs_search_impl"
      exports:
        - "DocsController"
    - path: "src/ztlctl/commands/docs.py"
      provides: "Hand-written Click command group docs with search subcommand"
      exports:
        - "docs_group"
    - path: "src/ztlctl/actions/_register_core.py"
      provides: "docs_search ActionDefinition registered (for MCP tool generation)"
    - path: "src/ztlctl/commands/__init__.py"
      provides: "docs_group added to CLI root via register_commands()"
  key_links:
    - from: "src/ztlctl/commands/docs.py"
      to: "src/ztlctl/services/docs.py"
      via: "lazy import of _docs_search_impl inside command handler"
      pattern: "from ztlctl.services.docs import _docs_search_impl"
    - from: "src/ztlctl/actions/_register_core.py"
      to: "src/ztlctl/controllers/docs.py"
      via: "lazy import inside _register_core_actions() body"
      pattern: "from ztlctl.controllers.docs import DocsController"
    - from: "src/ztlctl/commands/__init__.py"
      to: "src/ztlctl/commands/docs.py"
      via: "cli.add_command(docs_group)"
      pattern: "from ztlctl.commands.docs import docs_group"
---

<objective>
Wire the CLI surface for docs search: DocsController, hand-written Click command, ActionDefinition registration.

Purpose: `ztlctl docs search <query>` with `--limit` and `--json` flags. Uses `custom_presentation=True` pattern (same as `update`, `garden`, `workflow`, `serve`) because `--json` is a command-local flag that can't thread through `app.emit()`. ActionDefinition still registered for MCP tool auto-generation.
Output: Working `ztlctl docs search` command and `docs_search` ActionDefinition in the registry.
</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/phases/12-doc-search-integration/12-CONTEXT.md
@.planning/phases/12-doc-search-integration/12-RESEARCH.md
@.planning/phases/12-doc-search-integration/12-01-SUMMARY.md

<interfaces>
<!-- Exact patterns extracted from codebase. Executor uses these directly. -->

From src/ztlctl/commands/__init__.py — how custom_presentation commands are added:
```python
def register_commands(cli: click.Group) -> None:
    from ztlctl.commands.generator import generate_commands
    generate_commands(cli)

    # --- Custom presentation commands (hand-written) ---
    # workflow (custom_presentation: init_workflow, update_workflow, export_assets)
    from ztlctl.commands.workflow import workflow
    cli.add_command(workflow)

    # serve (custom_presentation)
    from ztlctl.commands.serve import serve
    cli.add_command(serve)
```

Pattern to follow for docs: add at end of register_commands():
```python
    from ztlctl.commands.docs import docs_group
    cli.add_command(docs_group)
```

From src/ztlctl/commands/workflow.py — top-level group structure:
```python
import click
from ztlctl.commands._base import ZtlGroup

@click.group("workflow", help="Copier workflow template management.")
def workflow() -> None:
    pass
```

From src/ztlctl/controllers/base.py — BaseController:
```python
class BaseController:
    def __init__(self, vault: Vault) -> None:
        self._vault = vault
```

DocsController accepts vault (required by ActionDefinition handler lambda) but does NOT use it.
No _dispatch_event calls — docs search has no lifecycle events.

From src/ztlctl/actions/_register_core.py — lazy import pattern (CRITICAL):
```python
def _register_core_actions() -> None:
    from ztlctl.actions.definitions import ActionDefinition, ActionParam
    from ztlctl.actions.registry import get_action_registry
    # Lazy controller imports — avoid module-level cross-layer imports
    from ztlctl.controllers.check import CheckController
    from ztlctl.controllers.create import CreateController
    # ... all other controllers ...
    registry = get_action_registry()
    registry.register(ActionDefinition(...))
```

Add DocsController import INSIDE _register_core_actions() body, NOT at module level.

ActionDefinition for docs_search (from RESEARCH.md — without json_output param since CLI is custom):
```python
registry.register(
    ActionDefinition(
        name="docs_search",
        description="Search the ztlctl documentation corpus.",
        category="docs",
        params=(
            ActionParam(
                "query",
                str,
                required=True,
                description="Search query string.",
                cli_is_argument=True,
                mcp_example="how to create a note",
            ),
            ActionParam(
                "limit",
                int,
                required=False,
                default=5,
                description="Maximum number of results to return.",
            ),
        ),
        handler=lambda vault, **kw: DocsController(vault).search(**kw),
        side_effect="read",
        mcp_when_to_use=(
            "Finding documentation pages relevant to a query without leaving the tool."
        ),
        mcp_avoid_when="You are searching vault notes, not ztlctl documentation.",
        cli_group="docs",
        cli_name="search",
        custom_presentation=True,  # CLI is hand-written in commands/docs.py
    )
)
```

Note on custom_presentation=True: The generator skips this ActionDefinition for CLI generation.
The MCP tool generator still picks it up — verify by checking generator.py for custom_presentation handling.

From src/ztlctl/services/docs.py (Plan 01 output):
```python
def _docs_search_impl(query: str, limit: int = 5, docs_path: Path | None = None) -> list[dict]:
    ...  # returns list of {"title": str, "path": str, "score": float, "excerpt": str}
```

Rich table output format (from CONTEXT.md):
```
┌──────────────────────┬───────┬────────────────────────────────┐
│ Title                │ Score │ Excerpt                        │
├──────────────────────┼───────┼────────────────────────────────┤
│ Command Reference    │  0.85 │ ...matching paragraph...       │
└──────────────────────┴───────┴────────────────────────────────┘
```

JSON output format (from CONTEXT.md):
```json
{"results": [{"title": "...", "path": "...", "score": 0.85, "excerpt": "..."}]}
```

From src/ztlctl/commands/_context.py — AppContext for @click.pass_obj:
The hand-written command uses @click.pass_obj, which receives the AppContext.
For docs search, AppContext is not needed for execution — but @click.pass_obj is the
standard pattern. Check if vault access is needed: it is NOT. Command can call
_docs_search_impl directly without going through app.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: DocsController and CLI command</name>
  <files>
    src/ztlctl/controllers/docs.py,
    src/ztlctl/commands/docs.py,
    tests/controllers/test_docs_controller.py
  </files>
  <read_first>
    - src/ztlctl/controllers/base.py (BaseController interface — vault arg required)
    - src/ztlctl/commands/workflow.py lines 1-50 (click.group pattern for custom_presentation)
    - src/ztlctl/commands/_base.py (ZtlGroup, ZtlCommand base classes)
    - src/ztlctl/commands/garden.py (simpler custom command example)
    - tests/controllers/ (any existing controller test for fixture patterns)
  </read_first>
  <behavior>
    - DocsController(vault).search(query="session") returns a dict with "results" key containing list
    - DocsController(vault).search(query="create note") uses AND logic (passes through to _docs_search_impl)
    - DocsController(vault).search(query="session", limit=2) returns at most 2 results
    - DocsController does not call self._vault.engine, self._vault.db, or any vault method
    - `ztlctl docs search session` prints a Rich table to stdout with Title/Score/Excerpt columns
    - `ztlctl docs search session --json` prints {"results": [...]} JSON to stdout
    - `ztlctl docs search session --limit 2` returns at most 2 results
    - `ztlctl docs search --help` shows QUERY argument and --limit, --json options
    - `ztlctl docs --help` shows search as a subcommand
  </behavior>
  <action>
    Write tests first (RED), implement to pass (GREEN).

    1. Create tests/controllers/test_docs_controller.py:
       - Use a tmp_path fixture with a few .md files and llms.txt as docs_path
       - Patch _resolve_docs_path to return the tmp_path fixture
       - Test DocsController(mock_vault).search() returns {"results": [...]} dict
       - Test limit parameter respected
       - Mock vault: use MagicMock() — DocsController only receives it, never uses it

    2. Create src/ztlctl/controllers/docs.py:
       ```python
       from __future__ import annotations
       from typing import TYPE_CHECKING, Any
       from ztlctl.controllers.base import BaseController
       if TYPE_CHECKING:
           from ztlctl.infrastructure.vault import Vault

       class DocsController(BaseController):
           def search(self, query: str, limit: int = 5) -> dict[str, Any]:
               from ztlctl.services.docs import _docs_search_impl
               results = _docs_search_impl(query, limit=limit)
               return {"results": results}
       ```
       No _dispatch_event, no vault access.

    3. Create src/ztlctl/commands/docs.py:
       ```python
       from __future__ import annotations
       import click

       @click.group("docs", help="Search ztlctl documentation.")
       def docs_group() -> None:
           pass

       @docs_group.command("search")
       @click.argument("query")
       @click.option("--limit", default=5, show_default=True, help="Maximum number of results.")
       @click.option("--json", "json_output", is_flag=True, help="Output results as JSON.")
       def docs_search(query: str, limit: int, json_output: bool) -> None:
           """Search the ztlctl documentation corpus."""
           from ztlctl.services.docs import _docs_search_impl
           results = _docs_search_impl(query, limit=limit)
           if not results or (len(results) == 1 and "error" in results[0]):
               click.echo(results[0].get("error", "No results found."), err=False)
               return
           if json_output:
               import json
               click.echo(json.dumps({"results": results}, indent=2))
           else:
               from rich.console import Console
               from rich.table import Table
               table = Table(show_header=True, header_style="bold")
               table.add_column("Title", style="cyan", no_wrap=True)
               table.add_column("Score", justify="right")
               table.add_column("Excerpt")
               for r in results:
                   table.add_row(r["title"], f"{r['score']:.2f}", r["excerpt"])
               Console().print(table)
       ```
       Note: no @click.pass_obj needed — docs_search calls _docs_search_impl directly, no vault.
       Note: export name must be `docs_group` for commands/__init__.py to import correctly.
  </action>
  <verify>
    <automated>uv run pytest tests/controllers/test_docs_controller.py -x -q</automated>
  </verify>
  <done>
    tests/controllers/test_docs_controller.py passes. src/ztlctl/controllers/docs.py and
    src/ztlctl/commands/docs.py exist. `uv run mypy src/ztlctl/controllers/docs.py src/ztlctl/commands/docs.py` exits 0.
  </done>
</task>

<task type="auto">
  <name>Task 2: ActionDefinition registration and CLI wiring</name>
  <files>
    src/ztlctl/actions/_register_core.py,
    src/ztlctl/commands/__init__.py
  </files>
  <read_first>
    - src/ztlctl/actions/_register_core.py (full file — copy the exact pattern for a new registry.register() call)
    - src/ztlctl/actions/definitions.py (ActionDefinition and ActionParam field reference)
    - src/ztlctl/commands/__init__.py (full file — where to add cli.add_command(docs_group))
    - src/ztlctl/commands/generator.py (check how custom_presentation=True is handled — does it skip CLI but keep MCP tool?)
  </read_first>
  <action>
    1. In src/ztlctl/actions/_register_core.py, inside _register_core_actions() function body:
       - Add lazy import: `from ztlctl.controllers.docs import DocsController`
         alongside other controller imports at the top of the function.
       - Add registry.register() call for docs_search ActionDefinition.
         Use custom_presentation=True so the generator skips CLI generation for this action.
         Include mcp_when_to_use, mcp_avoid_when for MCP tool generation.
         Do NOT include a json_output ActionParam — CLI is hand-written.

    2. In src/ztlctl/commands/__init__.py, inside register_commands(), after the `workflow` add_command:
       ```python
       # docs (custom_presentation: --json flag requires hand-written command)
       from ztlctl.commands.docs import docs_group
       cli.add_command(docs_group)
       ```

    3. Verify `ztlctl docs search --help` works end-to-end:
       `uv run ztlctl docs search --help` should show QUERY argument and --limit, --json options.

    4. Verify ActionDefinition is in registry:
       `uv run python -c "import ztlctl.actions; from ztlctl.actions.registry import get_action_registry; r = get_action_registry(); print(r.get_action('docs_search').name)"`
       should print "docs_search".
  </action>
  <verify>
    <automated>uv run pytest tests/actions/test_registry.py -x -q -k "docs" 2>/dev/null || uv run python -c "import ztlctl.actions; from ztlctl.actions.registry import get_action_registry; r=get_action_registry(); a=r.get_action('docs_search'); print('OK:', a.name)"</automated>
  </verify>
  <done>
    docs_search ActionDefinition is in registry. `uv run ztlctl docs search --help` exits 0.
    `uv run ztlctl docs search session` runs without ImportError or AttributeError.
    `uv run pytest` still passes (no regressions from _register_core.py change).
    `uv run mypy src/` exits 0.
  </done>
</task>

</tasks>

<verification>
- `uv run ztlctl docs search --help` shows QUERY, --limit, --json
- `uv run ztlctl docs search "session"` prints Rich table (may show "docs not found" error if docs/ not at expected path in test environment — that is acceptable behavior)
- `uv run ztlctl docs search "session" --json` prints JSON object
- `uv run pytest tests/controllers/test_docs_controller.py tests/services/test_docs.py -q` all pass
- `uv run pytest` full suite passes
- `uv run mypy src/` exits 0
- `uv run ruff check .` exits 0
</verification>

<success_criteria>
`ztlctl docs search <query>` returns ranked Rich table. `--json` flag switches to JSON output. `--limit N` caps results. ActionDefinition registered with name "docs_search" and custom_presentation=True. No mypy or ruff errors.
</success_criteria>

<output>
After completion, create `.planning/phases/12-doc-search-integration/12-02-SUMMARY.md`
</output>
