---
phase: 12-doc-search-integration
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/services/docs.py
  - tests/services/test_docs.py
autonomous: true
requirements:
  - AGNT-03
  - AGNT-04

must_haves:
  truths:
    - "_docs_search_impl returns a list of dicts with title, path, score, excerpt for matching pages"
    - "AND logic: multi-word queries only return pages where ALL terms appear"
    - "Title matches score 3x, heading matches 2x, body matches 1x"
    - "_resolve_docs_path returns docs/ directory in dev/editable install context"
    - "_resolve_docs_path respects ZTLCTL_DOCS_PATH env var override"
    - "_resolve_docs_path returns None when docs/ not found (no crash)"
    - "_docs_search_impl returns empty list with error dict when docs path cannot be resolved"
    - "_docs_index_impl returns the raw text content of docs/llms.txt"
  artifacts:
    - path: "src/ztlctl/services/docs.py"
      provides: "Pure search functions — _resolve_docs_path, _docs_search_impl, _docs_index_impl"
      exports:
        - "_resolve_docs_path"
        - "_docs_search_impl"
        - "_docs_index_impl"
    - path: "tests/services/test_docs.py"
      provides: "Unit tests for all three functions"
      contains: "test_and_logic, test_scoring_weights, test_env_override, test_resolve_path"
  key_links:
    - from: "tests/services/test_docs.py"
      to: "src/ztlctl/services/docs.py"
      via: "direct import of _docs_search_impl, _docs_index_impl, _resolve_docs_path"
---

<objective>
Implement the pure documentation search module with TDD.

Purpose: This is the foundation for both CLI and MCP surfaces. `_docs_search_impl` and `_docs_index_impl` are the shared `_impl` functions called from both `commands/docs.py` and `resources.py`. No CLI or MCP coupling in this module.
Output: `src/ztlctl/services/docs.py` with three functions. `tests/services/test_docs.py` with full coverage of search logic, path resolution, AND logic, and scoring weights.
</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/phases/12-doc-search-integration/12-CONTEXT.md
@.planning/phases/12-doc-search-integration/12-RESEARCH.md

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

From src/ztlctl/mcp/resources.py — existing _impl function pattern:
```python
def capture_spec_impl(_vault: Any) -> dict[str, Any]:
    """Return the agent-facing capture contract for source bundle ingest."""
    return { ... }  # pure function, no MCP/CLI coupling
```

From src/ztlctl/mcp/resources.py — _impl functions that do NOT need vault:
```python
def recipe_research_capture_impl(_vault: Any) -> dict[str, Any]:
    """Return the research-capture orchestration recipe."""
    ...
```

Key insight: `_docs_search_impl` and `_docs_index_impl` take NO vault arg (docs search is vault-independent).
They match the `_impl` naming convention but live in services/ not mcp/.

Locked function signatures (from CONTEXT.md and RESEARCH.md):
```python
def _resolve_docs_path() -> Path | None:
    """Resolve the docs/ directory. Checks ZTLCTL_DOCS_PATH first, then package-relative."""
    # env override: os.environ.get("ZTLCTL_DOCS_PATH")
    # package-relative: Path(__file__).parent.parent.parent.parent / "docs"
    # returns None if not found (caller handles error)

def _docs_search_impl(
    query: str,
    limit: int = 5,
    docs_path: Path | None = None,
) -> list[dict]:
    """Search the docs corpus. Pure function — no MCP or CLI coupling.
    Returns: list of {"title": str, "path": str, "score": float, "excerpt": str}
    Returns [{"error": "..."}] if docs path cannot be resolved.
    """

def _docs_index_impl(docs_path: Path | None = None) -> str:
    """Return content of docs/llms.txt as a string.
    Returns error string if llms.txt not found.
    """
```

Scoring algorithm (from RESEARCH.md code examples):
```python
_HEADING_RE = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
_FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)

def _score_page(text: str, title: str, terms: list[str]) -> float:
    """Weighted term frequency: title=3x, headings=2x, body=1x."""
    text_lower = text.lower()
    title_lower = title.lower()
    headings = [m.group(1).lower() for m in _HEADING_RE.finditer(text)]
    score = 0.0
    for term in terms:
        t = term.lower()
        title_count = title_lower.count(t)
        heading_count = sum(h.count(t) for h in headings)
        body_count = text_lower.count(t)
        score += title_count * 3 + heading_count * 2 + body_count * 1
    return score

def _extract_excerpt(text: str, terms: list[str]) -> str:
    """Return first paragraph containing any search term, max 200 chars."""
    paragraphs = re.split(r"\n\n+", _FRONTMATTER_RE.sub("", text))
    for para in paragraphs:
        para_stripped = para.strip()
        if not para_stripped or para_stripped.startswith("#"):
            continue
        para_lower = para_stripped.lower()
        if any(t.lower() in para_lower for t in terms):
            excerpt = re.sub(r"[*_`#\[\]()]", "", para_stripped)
            return excerpt[:200].strip()
    return ""
```

AND logic: for each page, compute per-term score; if ANY term has zero occurrences anywhere in title+headings+body, skip that page entirely.

Docs corpus: docs/*.md, docs/guide/*.md, docs/dev/*.md (18 pages).
Each file's title = first H1 heading found, or stem of filename if no H1.

pyproject.toml wheel config: packages = ["src/ztlctl"] — docs/ is NOT package data.
_resolve_docs_path() will work in editable install (Path(__file__).parent.parent.parent.parent / "docs").
In production installs, docs/ won't be present — _resolve_docs_path() returns None, caller returns error.
</interfaces>
</context>

<feature>
  <name>Pure documentation search module</name>
  <files>src/ztlctl/services/docs.py, tests/services/test_docs.py</files>
  <behavior>
    - _resolve_docs_path() with no env var: in dev (editable) install returns Path to docs/ that is_dir() == True
    - _resolve_docs_path() with ZTLCTL_DOCS_PATH="/tmp/notexist": returns None (dir doesn't exist)
    - _resolve_docs_path() with ZTLCTL_DOCS_PATH pointing to a real temp dir: returns that path
    - _docs_search_impl("session", docs_path=fixture_docs_path) returns list with title/path/score/excerpt dicts
    - _docs_search_impl("create note", docs_path=fixture_docs_path) uses AND logic: only pages with BOTH "create" AND "note"
    - _docs_search_impl("session", limit=2, docs_path=fixture_docs_path) returns at most 2 results
    - _docs_search_impl("session", docs_path=None) when _resolve_docs_path returns None: returns [{"error": "..."}]
    - Scoring: a page whose title contains the query term scores higher than one where only the body matches
    - Results sorted descending by score
    - _docs_index_impl(docs_path=fixture_docs_path) returns string content of docs/llms.txt
    - _docs_index_impl(docs_path=path_without_llms_txt) returns error string
  </behavior>
  <implementation>
    Write tests first (RED), then implement to make them pass (GREEN).

    Test fixture strategy: create a small in-memory temp directory with 3-4 minimal .md files
    and a llms.txt. Tests use docs_path= parameter to inject the fixture path directly —
    no need for ZTLCTL_DOCS_PATH in most tests. Use monkeypatch for env var tests.

    Module placement: src/ztlctl/services/docs.py (not a new package, just a new file in services/).
    No __init__.py changes needed — services/ already exists.

    Implementation order in docs.py:
    1. _resolve_docs_path() — ZTLCTL_DOCS_PATH first, then Path(__file__).parent.parent.parent.parent / "docs"
    2. _HEADING_RE, _FRONTMATTER_RE module-level regex constants
    3. _score_page(text, title, terms) — helper
    4. _extract_excerpt(text, terms) — helper
    5. _collect_docs_files(docs_path) — returns list of Path for docs/*.md, docs/guide/*.md, docs/dev/*.md
    6. _docs_search_impl(query, limit=5, docs_path=None) — main search function
    7. _docs_index_impl(docs_path=None) — reads llms.txt

    AND logic implementation: split query.strip() by whitespace for terms list.
    For each candidate page: compute score. Then check AND requirement: for each term,
    count total occurrences (title + headings + body). If any term has 0 total occurrences,
    set score = 0 and skip.
  </implementation>
</feature>

<verification>
<automated>uv run pytest tests/services/test_docs.py -x -q</automated>
</verification>

<success_criteria>
All tests in tests/services/test_docs.py pass. `uv run pytest tests/services/test_docs.py -x -q` exits 0. Full suite `uv run pytest` still passes (no regressions). `uv run mypy src/ztlctl/services/docs.py` exits 0.
</success_criteria>

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