---
phase: 03-agent-interfaces
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - src/corpus_analyzer/api/__init__.py
  - src/corpus_analyzer/api/public.py
  - src/corpus/__init__.py
  - src/corpus_analyzer/cli.py
autonomous: true
requirements:
  - API-01
  - API-02
  - API-03

must_haves:
  truths:
    - "from corpus import search returns a list of SearchResult dataclass instances"
    - "from corpus import index triggers programmatic re-index"
    - "SearchResult has path, file_type, construct_type, summary, score, snippet fields"
    - "search() uses CorpusSearch.hybrid_search() — no divergent implementation"
    - "Config discovery walks up from CWD to find corpus.toml (pyproject.toml-style)"
    - "Enhanced corpus status shows per-source staleness, model reachability, and --json output"
  artifacts:
    - path: "src/corpus_analyzer/api/__init__.py"
      provides: "Python API package initializer"
    - path: "src/corpus_analyzer/api/public.py"
      provides: "SearchResult dataclass, search(), index(), _find_config(), _open_engine()"
      min_lines: 70
    - path: "src/corpus/__init__.py"
      provides: "from corpus import search, index re-exports"
  key_links:
    - from: "src/corpus/__init__.py"
      to: "corpus_analyzer.api.public"
      via: "explicit re-export of search and index"
      pattern: "from corpus_analyzer.api.public import"
    - from: "corpus_analyzer.api.public.search"
      to: "CorpusSearch.hybrid_search"
      via: "_open_engine() returns CorpusSearch; search() calls engine.hybrid_search()"
      pattern: "hybrid_search"
    - from: "corpus_analyzer.api.public._find_config"
      to: "corpus.toml"
      via: "walks up from Path.cwd() until .git sentinel or filesystem root"
      pattern: "\\.git"
---

<objective>
Implement the Python public API (`from corpus import search`) and enhance the `corpus status`
CLI command with per-source staleness detection, model reachability check, and `--json` output.

Purpose: Lets Python scripts query the corpus index programmatically with the same search engine
as the CLI and MCP server (API-03 no-divergence rule). Enhanced status gives developers
actionable index health visibility.

Output: `src/corpus_analyzer/api/public.py`, `src/corpus/__init__.py`,
and updated `status_command` in `src/corpus_analyzer/cli.py`.
</objective>

<execution_context>
@/home/ollie/.claude/get-shit-done/workflows/execute-plan.md
@/home/ollie/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-agent-interfaces/03-CONTEXT.md
@.planning/phases/03-agent-interfaces/03-RESEARCH.md
@.planning/phases/02-search-core/02-02-SUMMARY.md
@.planning/phases/02-search-core/02-05-SUMMARY.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Implement Python public API (SearchResult, search(), index()) and corpus re-export package</name>
  <files>
    src/corpus_analyzer/api/__init__.py
    src/corpus_analyzer/api/public.py
    src/corpus/__init__.py
  </files>
  <action>
Create `src/corpus_analyzer/api/__init__.py` — module docstring only:
```python
"""Python public API package for corpus semantic search."""
```

Create `src/corpus_analyzer/api/public.py` with the following implementation:

**SearchResult dataclass (API-01):**
```python
"""Public Python API for querying the corpus index programmatically."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

from corpus_analyzer.config import load_config
from corpus_analyzer.ingest.embedder import OllamaEmbedder
from corpus_analyzer.ingest.indexer import CorpusIndex
from corpus_analyzer.search.engine import CorpusSearch
from corpus_analyzer.search.formatter import extract_snippet


@dataclass
class SearchResult:
    """A single search result from the corpus index."""

    path: str
    file_type: str
    construct_type: str
    summary: str
    score: float
    snippet: str
```

**Config discovery — walk up from CWD to .git sentinel:**
```python
def _find_config() -> Path:
    """Walk up from CWD to find corpus.toml, stopping at git root or filesystem root.

    Falls back to the XDG user config dir if no corpus.toml is found.
    """
    current = Path.cwd()
    while True:
        candidate = current / "corpus.toml"
        if candidate.exists():
            return candidate
        git_root = current / ".git"
        if git_root.exists() or current == current.parent:
            from platformdirs import user_config_dir
            return Path(user_config_dir("corpus")) / "corpus.toml"
        current = current.parent
```

**Engine caching — avoid re-opening on every call (RESEARCH.md Pitfall 4):**
```python
_ENGINE_CACHE: dict[str, tuple[CorpusSearch, Any]] = {}

def _open_engine() -> tuple[CorpusSearch, Any]:
    """Open and cache (CorpusSearch, config) keyed by config path + mtime.

    Cache is invalidated if the corpus.toml file changes on disk.
    """
    from platformdirs import user_data_dir

    config_path = _find_config()
    cache_key = str(config_path)
    mtime = config_path.stat().st_mtime if config_path.exists() else 0.0

    cached = _ENGINE_CACHE.get(cache_key)
    if cached is not None:
        # Validate cache by checking stored mtime
        stored_mtime = _ENGINE_CACHE.get(cache_key + "_mtime")
        if stored_mtime == mtime:  # type: ignore[comparison-overlap]
            return cached

    config = load_config(config_path)
    data_dir = Path(user_data_dir("corpus"))
    embedder = OllamaEmbedder(model=config.embedding.model, host=config.embedding.host)
    index = CorpusIndex.open(data_dir, embedder)
    engine = CorpusSearch(index.table, embedder)
    _ENGINE_CACHE[cache_key] = (engine, config)
    _ENGINE_CACHE[cache_key + "_mtime"] = mtime  # type: ignore[assignment]
    return engine, config
```

Note: A simpler and cleaner approach for the cache is to store `(engine, config, mtime)` in a
single tuple; simplify the above pattern if it produces mypy strict errors. The key constraint
is: don't re-open on every call.

**search() function (API-01, API-03):**
```python
def search(
    query: str,
    *,
    source: str | None = None,
    file_type: str | None = None,
    construct_type: str | None = None,
    limit: int = 10,
) -> list[SearchResult]:
    """Search the corpus index and return structured results.

    Config is discovered by walking up from CWD to find corpus.toml.
    Uses the same CorpusSearch.hybrid_search() engine as the CLI and MCP server (API-03).

    Args:
        query: Natural language search query.
        source: Optional source name filter.
        file_type: Optional file type filter (e.g. ".md").
        construct_type: Optional construct type filter.
        limit: Maximum number of results.

    Returns:
        List of SearchResult dataclass instances.
    """
    engine, _ = _open_engine()
    raw = engine.hybrid_search(
        query,
        source=source,
        file_type=file_type,
        construct_type=construct_type,
        limit=limit,
    )
    return [
        SearchResult(
            path=str(r.get("file_path", "")),
            file_type=str(r.get("file_type", "")),
            construct_type=str(r.get("construct_type") or "documentation"),
            summary=str(r.get("summary") or ""),
            score=float(r.get("_relevance_score", 0.0)),
            snippet=extract_snippet(str(r.get("text", "")), query),
        )
        for r in raw
    ]
```

**index() function (API-02):**
```python
def index(*, verbose: bool = False) -> list[dict[str, Any]]:
    """Trigger incremental re-index of all configured sources programmatically.

    Uses the same CorpusIndex.index_source() pipeline as the CLI (API-03).

    Args:
        verbose: If True, print per-source results to stdout.

    Returns:
        List of dicts with keys: source_name, files_indexed, chunks_written,
        files_skipped, elapsed.
    """
    from platformdirs import user_data_dir

    config_path = _find_config()
    config = load_config(config_path)
    data_dir = Path(user_data_dir("corpus"))
    embedder = OllamaEmbedder(model=config.embedding.model, host=config.embedding.host)
    corpus_index = CorpusIndex.open(data_dir, embedder)

    results = []
    for source in config.sources:
        result = corpus_index.index_source(source)
        entry = {
            "source_name": source.name,
            "files_indexed": result.files_indexed,
            "chunks_written": result.chunks_written,
            "files_skipped": result.files_skipped,
            "elapsed": result.elapsed,
        }
        results.append(entry)
        if verbose:
            print(f"{source.name}: {result.files_indexed} files, {result.chunks_written} chunks")  # noqa: T201
    return results
```

Create `src/corpus/__init__.py` — thin re-export (RESEARCH.md Pitfall 6 fix):
```python
"""Corpus public API re-exports.

Usage:
    from corpus import search, index
    results = search("my query")
"""

from corpus_analyzer.api.public import SearchResult, index, search

__all__ = ["SearchResult", "search", "index"]
```

**IMPORTANT:** `src/corpus/` package must be listed in `pyproject.toml` wheel packages (done in
Plan 01, Task 1). If Plan 01 hasn't run yet, note it as a dependency. After writing the files:

```bash
uv run python -c "from corpus import search; print('OK')"
uv run python -c "from corpus_analyzer.api.public import SearchResult; print(SearchResult.__dataclass_fields__.keys())"
```
  </action>
  <verify>
    <automated>uv run python -c "from corpus import search, index; from corpus_analyzer.api.public import SearchResult; print(list(SearchResult.__dataclass_fields__.keys()))"</automated>
    <manual>Confirm output shows: ['path', 'file_type', 'construct_type', 'summary', 'score', 'snippet']</manual>
    <sampling_rate>run after this task, before Task 2</sampling_rate>
  </verify>
  <done>
`from corpus import search` imports without error.
`SearchResult.__dataclass_fields__` contains path, file_type, construct_type, summary, score, snippet.
`uv run mypy src/corpus_analyzer/api/ src/corpus/` passes strict mode (or with acceptable type:ignore for lancedb).
  </done>
</task>

<task type="auto">
  <name>Task 2: Enhance corpus status command (per-source staleness, model reachability, --json)</name>
  <files>src/corpus_analyzer/cli.py</files>
  <action>
Replace the existing `status_command()` in `src/corpus_analyzer/cli.py` with an enhanced version.

**New signature:**
```python
@app.command("status")
def status_command(
    json_output: Annotated[bool, typer.Option("--json", help="Output as JSON for scripting")] = False,
) -> None:
    """Show index health: sources, staleness, chunk count, embedding model status."""
```

**Implementation plan (add imports at top of file):**
Add to imports:
```python
import json
from datetime import UTC, datetime
```

**Helper functions to add before `status_command` (or inline as local defs):**
```python
def _human_age(ts: str) -> str:
    """Convert ISO timestamp to human-readable age string ('2 hours ago')."""
    try:
        dt = datetime.fromisoformat(ts).replace(tzinfo=UTC)
        delta = datetime.now(UTC) - dt
        secs = int(delta.total_seconds())
        if secs < 60:
            return f"{secs} seconds ago"
        elif secs < 3600:
            return f"{secs // 60} minutes ago"
        elif secs < 86400:
            return f"{secs // 3600} hours ago"
        else:
            return f"{secs // 86400} days ago"
    except Exception:
        return ts


def _count_stale_files(source: SourceConfig, indexed_at: str) -> int:
    """Count files in source directory modified after the last index time."""
    try:
        indexed_dt = datetime.fromisoformat(indexed_at)
        source_path = Path(source.path).expanduser()
        return sum(
            1 for fp in walk_source(source_path, source.include, source.exclude)
            if fp.stat().st_mtime > indexed_dt.timestamp()
        )
    except Exception:
        return 0
```

**Enhanced status_command body:**
```python
config = load_config(CONFIG_PATH)
embedder = OllamaEmbedder(model=config.embedding.model, host=config.embedding.host)

# Test model reachability
try:
    embedder.validate_connection()
    model_status = "connected"
except RuntimeError:
    model_status = "unreachable"

# Open index and get stats
try:
    index = CorpusIndex.open(DATA_DIR, embedder)
    search_engine = CorpusSearch(index.table, embedder)
    stats = search_engine.status(config.embedding.model)
except Exception:
    if json_output:
        console.print(json.dumps({"error": "Index not found. Run 'corpus index' first."}))
    else:
        console.print("[yellow]Index not found. Run 'corpus index' first.[/]")
    raise typer.Exit(code=0) from None

last_indexed = str(stats.get("last_indexed", "never"))
files_total = int(stats.get("files", 0))
chunks_total = int(stats.get("chunks", 0))

# Per-source staleness
source_rows = []
total_stale = 0
for source in config.sources:
    stale_count = _count_stale_files(source, last_indexed) if last_indexed != "never" else 0
    total_stale += stale_count
    health_icon = "OK" if stale_count == 0 else f"{stale_count} stale"
    source_rows.append({
        "name": source.name,
        "path": source.path,
        "stale_files": stale_count,
        "health": "current" if stale_count == 0 else "stale",
    })

db_path = DATA_DIR / "corpus.lance"
db_size = db_path.stat().st_size if db_path.exists() else 0

if json_output:
    output_data = {
        "health": "current" if total_stale == 0 else "stale",
        "files": files_total,
        "chunks": chunks_total,
        "last_indexed": last_indexed,
        "stale_files": total_stale,
        "model": {
            "name": config.embedding.model,
            "status": model_status,
        },
        "sources": source_rows,
        "database": {
            "path": str(DATA_DIR),
            "size_bytes": db_size,
        },
    }
    console.print(json.dumps(output_data, indent=2))
    return

# Rich table output
age_str = _human_age(last_indexed) if last_indexed != "never" else "never"
health_str = "[green]OK[/green]" if total_stale == 0 else f"[yellow]{total_stale} files changed since last index[/yellow]"
model_str = f"{config.embedding.model}  [green]connected[/]" if model_status == "connected" else f"{config.embedding.model}  [red]unreachable[/]"

table = Table(title="[bold]Index Status[/]", show_header=True)
table.add_column("Metric", style="bold", min_width=18)
table.add_column("Value")
table.add_row("Health", health_str)
table.add_row("Files", str(files_total))
table.add_row("Chunks", str(chunks_total))
table.add_row("Last indexed", f"{last_indexed}  [dim]({age_str})[/dim]")
table.add_row("Model", model_str)
table.add_row("Database", str(DATA_DIR))
console.print(table)

if source_rows:
    src_table = Table(title="[bold]Sources[/]", show_header=True)
    src_table.add_column("Name", style="bold")
    src_table.add_column("Path")
    src_table.add_column("Status")
    for row in source_rows:
        icon = "[green]current[/]" if row["stale_files"] == 0 else f"[yellow]{row['stale_files']} stale[/]"
        src_table.add_row(row["name"], row["path"], icon)
    console.print(src_table)
```

**Staleness threshold:** A source is "stale" if any files have mtime > last_indexed timestamp.
Zero-file-changed sources show as "current". This is the correct definition per CONTEXT.md.

**Important:** The `json_output` parameter uses `--json` flag name. Typer renames `json_output`
arg to `--json` via `typer.Option("--json", ...)`. Verify this doesn't conflict with Python's
`json` module import (rename local import alias if needed).

After editing, verify:
```bash
uv run corpus status --help
uv run mypy src/corpus_analyzer/cli.py
```
  </action>
  <verify>
    <automated>uv run corpus status --help && uv run ruff check src/corpus_analyzer/cli.py</automated>
    <manual>Confirm --json flag appears in help output</manual>
    <sampling_rate>run after this task commits</sampling_rate>
  </verify>
  <done>
`uv run corpus status --help` shows `--json` flag.
`uv run mypy src/corpus_analyzer/cli.py` passes (no new strict errors beyond pre-existing E501).
`status_command` shows per-source staleness table in Rich output and emits JSON with `--json` flag.
  </done>
</task>

</tasks>

<verification>
After both tasks complete:
1. `uv run python -c "from corpus import search, index; print('OK')"` — re-exports work
2. `uv run python -c "from corpus_analyzer.api.public import SearchResult; fields=list(SearchResult.__dataclass_fields__); assert fields == ['path', 'file_type', 'construct_type', 'summary', 'score', 'snippet'], fields"` — correct field order
3. `uv run corpus status --help` — shows `--json` flag
4. `uv run mypy src/corpus_analyzer/api/ src/corpus/` — clean
5. `uv run ruff check src/corpus_analyzer/api/ src/corpus/` — clean
</verification>

<success_criteria>
- `from corpus import search` resolves to `corpus_analyzer.api.public.search`
- `search()` calls `CorpusSearch.hybrid_search()` — no divergent search implementation
- `SearchResult` dataclass has the 6 fields from API-01: path, file_type, construct_type, summary, score, snippet
- `index()` triggers `CorpusIndex.index_source()` for each configured source
- `corpus status` shows per-source staleness, model reachability, human-readable age
- `corpus status --json` emits structured JSON with health, files, chunks, model, sources, database keys
</success_criteria>

<output>
After completion, create `.planning/phases/03-agent-interfaces/03-02-SUMMARY.md`
</output>
