---
phase: 02-search-core
plan: 05
type: execute
wave: 4
depends_on:
  - 02-02
  - 02-03
  - 02-04
files_modified:
  - src/corpus_analyzer/cli.py
  - src/corpus_analyzer/ingest/indexer.py
autonomous: true
requirements:
  - CLI-01
  - CLI-02
  - CLI-03
  - CLI-04
  - CLI-05
  - SEARCH-01
  - SEARCH-02
  - SEARCH-03
  - SEARCH-04
  - SEARCH-05

must_haves:
  truths:
    - "corpus search <query> prints ranked results with file path, snippet, construct type, and score"
    - "corpus search returns 'No results for ...' message when no matches found"
    - "corpus search supports --source, --type, --construct, and --limit flags"
    - "corpus status shows file count, chunk count, last indexed timestamp, and embedding model"
    - "corpus index continues to work (unchanged behavior) and now also calls create_fts_index at the end"
    - "Rich output degrades gracefully when piped (no ANSI codes in non-TTY mode)"
  artifacts:
    - path: "src/corpus_analyzer/cli.py"
      provides: "search_command() and status_command() Typer commands"
      contains: "search_command"
    - path: "src/corpus_analyzer/ingest/indexer.py"
      provides: "create_fts_index('text', replace=True) called at end of index_source()"
      contains: "create_fts_index"
  key_links:
    - from: "src/corpus_analyzer/cli.py"
      to: "src/corpus_analyzer/search/engine.py"
      via: "CorpusSearch instantiated with table from CorpusIndex and embedder"
      pattern: "CorpusSearch"
    - from: "src/corpus_analyzer/cli.py"
      to: "src/corpus_analyzer/search/formatter.py"
      via: "extract_snippet() called per result to produce 2-4 line snippet"
      pattern: "extract_snippet"
    - from: "src/corpus_analyzer/ingest/indexer.py"
      to: "lancedb table"
      via: "table.create_fts_index('text', replace=True) at end of index_source()"
      pattern: "create_fts_index"
---

<objective>
Wire the search engine, classifier, and summarizer into the CLI — adding `corpus search` and `corpus status` commands, and adding the FTS index call to `corpus index`.

Purpose: Delivers the user-visible search experience that is the primary goal of Phase 2.

Output: Two new working CLI commands and an updated `corpus index` that maintains the FTS index.
</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/phases/02-search-core/02-CONTEXT.md
@.planning/phases/02-search-core/02-RESEARCH.md
@.planning/phases/02-search-core/02-02-SUMMARY.md
@.planning/phases/02-search-core/02-03-SUMMARY.md
@.planning/phases/02-search-core/02-04-SUMMARY.md
@src/corpus_analyzer/cli.py
@src/corpus_analyzer/ingest/indexer.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add FTS index call to index_source() and add corpus search command</name>
  <files>
    src/corpus_analyzer/ingest/indexer.py
    src/corpus_analyzer/cli.py
  </files>
  <action>
**src/corpus_analyzer/ingest/indexer.py** — Add FTS index creation at end of index_source():

After the `self._table.optimize()` call (near end of index_source), add:
```python
# Rebuild FTS index so hybrid search works on newly indexed content
self._table.create_fts_index("text", replace=True)
```

This must use `replace=True` to avoid errors on re-runs. See research Anti-Patterns: "Calling create_fts_index without replace=True raises an error on an existing index."

**src/corpus_analyzer/cli.py** — Add `corpus search` command:

Add these imports near the existing Rich/Typer imports:
```python
from corpus_analyzer.search.engine import CorpusSearch
from corpus_analyzer.search.formatter import extract_snippet
```

Add the search command after the `index_command` function:

```python
@app.command("search")
def search_command(
    query: Annotated[str, typer.Argument(help="Natural language search query")],
    source: Annotated[str | None, typer.Option("--source", "-s", help="Filter by source name")] = None,
    type_: Annotated[str | None, typer.Option("--type", "-t", help="Filter by file type (.md, .py, etc.)")] = None,
    construct: Annotated[str | None, typer.Option("--construct", "-c", help="Filter by construct type")] = None,
    limit: Annotated[int, typer.Option("--limit", "-n", help="Maximum number of results")] = 10,
) -> None:
    """Search the indexed corpus with a natural language query."""
```

Implementation inside search_command:
1. Load config from CONFIG_PATH (load_config)
2. Create OllamaEmbedder(model=config.embedding.model, host=config.embedding.host)
3. Validate connection with embedder.validate_connection(); on RuntimeError print error and raise typer.Exit(1)
4. Call CorpusIndex.open(DATA_DIR, embedder) to get the index
5. Create CorpusSearch(index.table, embedder)
6. Call corpus_search.hybrid_search(query, source=source, file_type=type_, construct_type=construct, limit=limit)
7. If no results: console.print(f'[yellow]No results for "[bold]{query}[/bold]"[/yellow]') and return
8. For each result, print using Rich formatting (per locked decision from CONTEXT.md):
   - Line 1: `[bold blue]{result['file_path']}[/]  [dim]{result.get('construct_type') or 'documentation'}[/]  [dim]score: {result['_relevance_score']:.3f}[/]`
   - Line 2 (if summary non-empty): `  [italic]{result['summary']}[/italic]`
   - Line 3: `  {extract_snippet(result['text'], query)}`
   - Blank line separator between results
9. Catch ValueError from hybrid_search (invalid filter values) and print error message + typer.Exit(1)

Rich console auto-detects TTY — no extra code needed for graceful pipe degradation.
  </action>
  <verify>
    <automated>uv run pytest tests/ingest/test_indexer.py -x -q && uv run python -c "from corpus_analyzer.cli import search_command; print('import ok')"</automated>
    <manual>Verify search_command is importable and indexer still has create_fts_index call.</manual>
    <sampling_rate>run after this task, before Task 2</sampling_rate>
  </verify>
  <done>
    - create_fts_index("text", replace=True) added to index_source() after optimize()
    - search_command function exists in cli.py with all 5 parameters
    - search_command imports CorpusSearch and extract_snippet
    - Existing indexer tests still pass
  </done>
</task>

<task type="auto">
  <name>Task 2: Add corpus status command and finalize CLI</name>
  <files>
    src/corpus_analyzer/cli.py
  </files>
  <action>
Add `corpus status` command to cli.py after search_command:

```python
@app.command("status")
def status_command() -> None:
    """Show index statistics: file count, chunk count, last indexed, and embedding model."""
```

Implementation inside status_command:
1. Load config from CONFIG_PATH
2. Create OllamaEmbedder(model=config.embedding.model, host=config.embedding.host)
3. Open CorpusIndex(DATA_DIR, embedder) — if this raises (e.g. no index yet), catch and print "Index not found. Run 'corpus index' first." then Exit(0).
4. Create CorpusSearch(index.table, embedder)
5. Call corpus_search.status(config.embedding.model)
6. Display output using Rich formatting (layout at Claude's discretion — use a clean table or panel):

Suggested output format (Claude may adjust for readability):
```
Index Status
  Files:        42
  Chunks:       187
  Last indexed: 2026-02-23T14:30:00+00:00
  Model:        nomic-embed-text
```

Use `rich.table.Table` or direct console.print() with aligned columns. The model name should be highlighted; the "never" value for last_indexed when empty should be shown in dim/yellow.

After both commands are added, run a final lint + type check on cli.py:
`uv run ruff check src/corpus_analyzer/cli.py && uv run mypy src/corpus_analyzer/cli.py`

Fix any mypy errors (commonly: missing return type annotations, untyped Typer annotations).
  </action>
  <verify>
    <automated>uv run ruff check src/corpus_analyzer/cli.py && uv run mypy src/corpus_analyzer/cli.py && uv run pytest tests/ -x -q --ignore=tests/llm --ignore=tests/core --ignore=tests/test_core 2>&1 | tail -20</automated>
    <manual>Optionally run: uv run corpus-analyzer search --help and uv run corpus-analyzer status --help to confirm commands appear in help output.</manual>
    <sampling_rate>run after this task commits</sampling_rate>
  </verify>
  <done>
    - status_command exists in cli.py
    - corpus search --help shows query, --source, --type, --construct, --limit options
    - corpus status --help shows the command
    - ruff and mypy pass on cli.py
    - Full test suite (excluding pre-Phase-2 legacy test dirs) passes
  </done>
</task>

</tasks>

<verification>
Run the full Phase 2 test suite:
`uv run pytest tests/search/ tests/ingest/ tests/store/ tests/config/ -v --no-header 2>&1 | tail -30`

All search tests must be PASSED (not XFAIL).
All pre-existing tests must still PASSED.

Run CLI smoke test (requires no Ollama):
`uv run corpus-analyzer search --help && uv run corpus-analyzer status --help`

Both commands must appear in help output without errors.
</verification>

<success_criteria>
- corpus search command exists with --source, --type, --construct, --limit flags
- corpus status command exists and shows index stats
- corpus index (existing) now calls create_fts_index at end of each source index run
- Rich output format matches locked decisions: path highlighted, score dimmed, summary in italic
- Zero results shows clear message echoing the query
- ruff and mypy pass on cli.py and indexer.py
- All Phase 2 tests pass (none left as XFAIL)
</success_criteria>

<output>
After completion, create `.planning/phases/02-search-core/02-05-SUMMARY.md` with:
- New CLI commands added and their full signatures
- FTS index integration point in indexer
- Phase 2 completion status (all requirements satisfied)
- Any known limitations or follow-up items
</output>
