---
phase: 22-name-filtering
plan: 02
type: tdd
wave: 2
depends_on:
  - 22-01
files_modified:
  - src/corpus_analyzer/cli.py
  - src/corpus_analyzer/mcp/server.py
  - tests/cli/test_search_status.py
  - tests/mcp/test_server.py
autonomous: true
requirements:
  - NAME-01
  - NAME-02
  - NAME-03

must_haves:
  truths:
    - "corpus search --name foo <query> returns only results whose chunk_name contains 'foo'"
    - "corpus search --name foo (no query) is valid and returns name-matching results"
    - "corpus search --name foo --source src --min-score 0.01 composes all filters"
    - "MCP corpus_search with name='foo' passes name= through to hybrid_search"
    - "--name appears in corpus search --help output"
  artifacts:
    - path: "src/corpus_analyzer/cli.py"
      provides: "--name CLI flag; query becomes Optional"
      contains: "name_filter"
    - path: "src/corpus_analyzer/mcp/server.py"
      provides: "name: Optional[str] MCP parameter"
      contains: "name: Optional[str]"
    - path: "tests/cli/test_search_status.py"
      provides: "TDD tests for --name CLI flag and optional query"
    - path: "tests/mcp/test_server.py"
      provides: "TDD tests for MCP name parameter"
  key_links:
    - from: "src/corpus_analyzer/cli.py"
      to: "src/corpus_analyzer/search/engine.py"
      via: "search.hybrid_search(..., name=name_filter)"
      pattern: "hybrid_search.*name="
    - from: "src/corpus_analyzer/mcp/server.py"
      to: "src/corpus_analyzer/search/engine.py"
      via: "engine.hybrid_search(..., name=name)"
      pattern: "hybrid_search.*name="
---

<objective>
Wire the `name` engine parameter (added in Plan 01) into both the CLI `search` command and the MCP `corpus_search` tool. The CLI gains a `--name` option and the query argument becomes optional (NAME-03). The MCP tool gains a `name: Optional[str]` parameter (NAME-02).

Purpose: Surface the engine name filter through all user-facing search interfaces.
Output: Updated `cli.py` and `mcp/server.py` with TDD coverage in both test files.
</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/22-name-filtering/22-name-filtering-01-SUMMARY.md
@src/corpus_analyzer/cli.py
@src/corpus_analyzer/mcp/server.py
@tests/cli/test_search_status.py
@tests/mcp/test_server.py
</context>

<feature>
  <name>CLI --name flag + optional query; MCP name parameter</name>
  <files>
    src/corpus_analyzer/cli.py
    src/corpus_analyzer/mcp/server.py
    tests/cli/test_search_status.py
    tests/mcp/test_server.py
  </files>
  <behavior>
    CLI changes (NAME-01, NAME-03):
    - `query` argument changes from `typer.Argument(...)` (required) to `Optional[str]` with default None
    - New option: `--name / -N` accepting `str | None`, default None
    - If query is None and name is None: print error "Provide a query or --name filter" and exit(1)
    - If query is None and name is provided: pass query="" to hybrid_search (NAME-03)
    - Pass name=name_filter to hybrid_search()
    - No-results message when name was provided: include the name fragment in the message

    MCP changes (NAME-02):
    - Add `name: Optional[str] = None` parameter to corpus_search() tool function
    - Pass name= to engine.hybrid_search()
    - Update the tool docstring to document `name` parameter
    - Empty results message must include name if provided (use the query for now — existing message is acceptable)

    Compose verification:
    - corpus search "query" --name foo --source src --min-score 0.01 --limit 5 all pass through correctly
  </behavior>
  <implementation>
    CLI (`cli.py`):
    1. Change `query` from `Annotated[str, typer.Argument(...)]` to:
       ```python
       query: Annotated[
           str | None, typer.Argument(help="Natural language search query (optional when --name is used)")
       ] = None,
       ```
    2. Add after the `sort_by` parameter:
       ```python
       name_filter: Annotated[
           str | None,
           typer.Option("--name", "-N", help="Filter results by chunk name (case-insensitive substring match)"),
       ] = None,
       ```
    3. At the top of search_command body, add guard:
       ```python
       if query is None and name_filter is None:
           console.print("[red]Error:[/] Provide a query or --name to filter by chunk name.")
           raise typer.Exit(code=1)
       effective_query = query if query is not None else ""
       ```
    4. Replace `query` with `effective_query` in all subsequent uses within the function (embedder call, hybrid_search call, no-results messages).
    5. Pass `name=name_filter` to hybrid_search().
    6. Update no-results message to mention name_filter when present:
       ```python
       if not results:
           if min_score > 0.0:
               console.print(f"No results above {min_score:.3f}. Run without --min-score to see available scores.")
           elif name_filter:
               console.print(f'[yellow]No chunks with name containing "[bold]{name_filter}[/bold]"[/yellow]')
           else:
               console.print(f'[yellow]No results for "[bold]{effective_query}[/bold]"[/yellow]')
           return
       ```

    MCP (`server.py`):
    1. Add `name: Optional[str] = None,  # noqa: UP045` to corpus_search() signature.
    2. Pass `name=name` to engine.hybrid_search() call.
    3. Add `name` to the docstring parameter list:
       ```
       'name' filters results to chunks whose chunk_name contains the given fragment
       (case-insensitive substring match). None means no name filter.
       ```
  </implementation>
</feature>

<tasks>

<task type="tdd">
  <name>Task 1: RED — write failing tests for CLI --name and MCP name parameter</name>
  <files>tests/cli/test_search_status.py, tests/mcp/test_server.py</files>
  <action>
    Add tests to `tests/cli/test_search_status.py`:

    ```python
    def test_search_name_flag_in_help() -> None:
        """NAME-01: --name option appears in corpus search --help output."""
        result = runner.invoke(app, ["search", "--help"])
        assert result.exit_code == 0
        assert "--name" in result.stdout


    def test_search_name_filter_passed_to_engine() -> None:
        """NAME-01: --name value is passed as name= keyword to hybrid_search()."""
        with (
            patch("corpus_analyzer.cli.load_config", return_value=_config()),
            patch("corpus_analyzer.cli.OllamaEmbedder") as mock_embedder_cls,
            patch("corpus_analyzer.cli.CorpusIndex.open") as mock_open,
            patch("corpus_analyzer.cli.CorpusSearch") as mock_search_cls,
        ):
            mock_embedder = MagicMock()
            mock_embedder.validate_connection.return_value = None
            mock_embedder_cls.return_value = mock_embedder
            mock_open.return_value = MagicMock(table=MagicMock())

            mock_search = MagicMock()
            mock_search.hybrid_search.return_value = []
            mock_search_cls.return_value = mock_search

            runner.invoke(app, ["search", "myquery", "--name", "SearchEngine.search"])

        call_kwargs = mock_search.hybrid_search.call_args.kwargs
        assert call_kwargs.get("name") == "SearchEngine.search"


    def test_search_name_only_no_query_is_valid() -> None:
        """NAME-03: corpus search --name foo (no positional query) exits 0."""
        with (
            patch("corpus_analyzer.cli.load_config", return_value=_config()),
            patch("corpus_analyzer.cli.OllamaEmbedder") as mock_embedder_cls,
            patch("corpus_analyzer.cli.CorpusIndex.open") as mock_open,
            patch("corpus_analyzer.cli.CorpusSearch") as mock_search_cls,
        ):
            mock_embedder = MagicMock()
            mock_embedder.validate_connection.return_value = None
            mock_embedder_cls.return_value = mock_embedder
            mock_open.return_value = MagicMock(table=MagicMock())

            mock_search = MagicMock()
            mock_search.hybrid_search.return_value = []
            mock_search_cls.return_value = mock_search

            result = runner.invoke(app, ["search", "--name", "foo"])

        assert result.exit_code == 0


    def test_search_name_only_passes_empty_query_to_engine() -> None:
        """NAME-03: --name only (no query) passes query='' to hybrid_search."""
        with (
            patch("corpus_analyzer.cli.load_config", return_value=_config()),
            patch("corpus_analyzer.cli.OllamaEmbedder") as mock_embedder_cls,
            patch("corpus_analyzer.cli.CorpusIndex.open") as mock_open,
            patch("corpus_analyzer.cli.CorpusSearch") as mock_search_cls,
        ):
            mock_embedder = MagicMock()
            mock_embedder.validate_connection.return_value = None
            mock_embedder_cls.return_value = mock_embedder
            mock_open.return_value = MagicMock(table=MagicMock())

            mock_search = MagicMock()
            mock_search.hybrid_search.return_value = []
            mock_search_cls.return_value = mock_search

            runner.invoke(app, ["search", "--name", "foo"])

        positional_query = mock_search.hybrid_search.call_args.args[0]
        assert positional_query == ""


    def test_search_no_query_no_name_exits_error() -> None:
        """NAME-03: corpus search with no query and no --name prints error and exits 1."""
        result = runner.invoke(app, ["search"])
        assert result.exit_code == 1
        assert "Provide a query or --name" in result.stdout


    def test_search_name_composes_with_source_filter() -> None:
        """NAME-01: --name composes with --source as AND predicate passed to engine."""
        with (
            patch("corpus_analyzer.cli.load_config", return_value=_config()),
            patch("corpus_analyzer.cli.OllamaEmbedder") as mock_embedder_cls,
            patch("corpus_analyzer.cli.CorpusIndex.open") as mock_open,
            patch("corpus_analyzer.cli.CorpusSearch") as mock_search_cls,
        ):
            mock_embedder = MagicMock()
            mock_embedder.validate_connection.return_value = None
            mock_embedder_cls.return_value = mock_embedder
            mock_open.return_value = MagicMock(table=MagicMock())

            mock_search = MagicMock()
            mock_search.hybrid_search.return_value = []
            mock_search_cls.return_value = mock_search

            runner.invoke(app, ["search", "q", "--name", "foo", "--source", "my-src"])

        call_kwargs = mock_search.hybrid_search.call_args.kwargs
        assert call_kwargs.get("name") == "foo"
        assert call_kwargs.get("source") == "my-src"
    ```

    Add tests to `tests/mcp/test_server.py`:

    ```python
    def test_corpus_search_name_parameter_accepted() -> None:
        """NAME-02: corpus_search() tool accepts name parameter without TypeError."""
        from corpus_analyzer.mcp.server import corpus_search

        engine = _make_engine([])
        ctx = _make_ctx(engine)

        async def _run_test() -> None:
            await corpus_search(query="q", name="foo", ctx=ctx)

        asyncio.run(_run_test())  # must not raise


    def test_corpus_search_name_forwarded_to_hybrid_search() -> None:
        """NAME-02: name parameter is forwarded as name= to engine.hybrid_search()."""
        from corpus_analyzer.mcp.server import corpus_search

        engine = _make_engine([])
        ctx = _make_ctx(engine)

        async def _run_test() -> None:
            await corpus_search(query="q", name="SearchEngine.search", ctx=ctx)

        asyncio.run(_run_test())

        call_kwargs = engine.hybrid_search.call_args[1]
        assert call_kwargs.get("name") == "SearchEngine.search"


    def test_corpus_search_name_none_forwards_none() -> None:
        """NAME-02: name=None is forwarded as name=None to engine (backward compatible)."""
        from corpus_analyzer.mcp.server import corpus_search

        engine = _make_engine([])
        ctx = _make_ctx(engine)

        async def _run_test() -> None:
            await corpus_search(query="q", name=None, ctx=ctx)

        asyncio.run(_run_test())

        call_kwargs = engine.hybrid_search.call_args[1]
        assert call_kwargs.get("name") is None
    ```

    Run both test files — all new tests must FAIL (RED):
    `uv run pytest tests/cli/test_search_status.py -k "name" -x`
    `uv run pytest tests/mcp/test_server.py -k "name" -x`
  </action>
  <verify>
    <automated>uv run pytest tests/cli/test_search_status.py -k "name" -x 2>&1 | tail -10 && uv run pytest tests/mcp/test_server.py -k "name" -x 2>&1 | tail -10</automated>
    <manual>Confirm new tests fail RED (TypeError or assertion failure — name= not yet wired)</manual>
    <sampling_rate>run after this task, before Task 2</sampling_rate>
  </verify>
  <done>All new name-related tests in both test files fail RED</done>
</task>

<task type="tdd">
  <name>Task 2: GREEN — wire --name into CLI and name into MCP server</name>
  <files>src/corpus_analyzer/cli.py, src/corpus_analyzer/mcp/server.py</files>
  <action>
    **cli.py changes:**

    1. Change `query` argument in `search_command` from required to optional:
       ```python
       query: Annotated[
           str | None,
           typer.Argument(help="Natural language search query (optional when --name is used)"),
       ] = None,
       ```

    2. Add `name_filter` option after the `sort_by` option:
       ```python
       name_filter: Annotated[
           str | None,
           typer.Option(
               "--name",
               "-N",
               help="Filter results by chunk name (case-insensitive substring match). "
                    "Can be used without a query to list all matching chunks.",
           ),
       ] = None,
       ```

    3. At the top of the function body (after the embedder setup, before the hybrid_search call), add:
       ```python
       if query is None and name_filter is None:
           console.print("[red]Error:[/] Provide a query or --name to filter by chunk name.")
           raise typer.Exit(code=1)
       effective_query = query if query is not None else ""
       ```

    4. Replace all uses of `query` in the function body with `effective_query` EXCEPT the import/assignment lines. Specifically:
       - `search.hybrid_search(effective_query, ...)` instead of `search.hybrid_search(query, ...)`
       - In no-results messages, use `effective_query`

    5. Pass `name=name_filter` in the `hybrid_search(...)` call.

    6. Update no-results block:
       ```python
       if not results:
           if min_score > 0.0:
               console.print(
                   f"No results above {min_score:.3f}. "
                   "Run without --min-score to see available scores."
               )
           elif name_filter:
               console.print(
                   f'[yellow]No chunks with name containing "[bold]{name_filter}[/bold]"[/yellow]'
               )
           else:
               console.print(
                   f'[yellow]No results for "[bold]{effective_query}[/bold]"[/yellow]'
               )
           return
       ```

    **mcp/server.py changes:**

    1. Add `name: Optional[str] = None,  # noqa: UP045` to `corpus_search()` signature, after `min_score`.

    2. Update the tool docstring to add:
       ```
       'name' is an optional case-insensitive substring filter on chunk_name.
       Use to narrow results to a specific method or construct (e.g. 'ClassName.method').
       ```

    3. Pass `name=name` in the `engine.hybrid_search(...)` call.

    After implementation run the full test suite:
    `uv run pytest tests/cli/test_search_status.py tests/mcp/test_server.py -x`
    Then lint and type check:
    `uv run ruff check src/corpus_analyzer/cli.py src/corpus_analyzer/mcp/server.py`
    `uv run mypy src/corpus_analyzer/cli.py src/corpus_analyzer/mcp/server.py`
    Then run the complete test suite for regressions:
    `uv run pytest -x`
  </action>
  <verify>
    <automated>uv run pytest tests/cli/test_search_status.py tests/mcp/test_server.py -x && uv run ruff check src/corpus_analyzer/cli.py src/corpus_analyzer/mcp/server.py && uv run mypy src/corpus_analyzer/cli.py src/corpus_analyzer/mcp/server.py && uv run pytest -x</automated>
    <sampling_rate>run after this task commits, before summary is written</sampling_rate>
  </verify>
  <done>
    All new name-related CLI and MCP tests pass GREEN.
    All pre-existing tests continue to pass (full suite green).
    ruff and mypy clean on both modified files.
    corpus search --help shows --name option.
    corpus search --name foo (no query) exits 0.
    corpus search (no query, no --name) exits 1 with error message.
  </done>
</task>

</tasks>

<verification>
uv run pytest tests/cli/test_search_status.py tests/mcp/test_server.py -v
uv run pytest -x
uv run ruff check src/corpus_analyzer/cli.py src/corpus_analyzer/mcp/server.py
uv run mypy src/corpus_analyzer/cli.py src/corpus_analyzer/mcp/server.py

Manual spot-check (no live Ollama needed for these):
  uv run corpus search --help   # should show --name in options list
</verification>

<success_criteria>
- corpus search --name foo returns only chunks with matching chunk_name
- corpus search --name foo (no query) is valid, exits 0, passes query="" to engine
- corpus search (no query, no --name) exits 1 with "Provide a query or --name" message
- --name composes with --source, --type, --construct, --min-score, --sort-by, --limit
- MCP corpus_search accepts name: Optional[str] and forwards to hybrid_search
- Full test suite green (no regressions)
- ruff and mypy clean
</success_criteria>

<output>
After completion, create `.planning/phases/22-name-filtering/22-name-filtering-02-SUMMARY.md`
</output>
