# Spike 6 Implementation Plan — Bug Sweep + Fuzzy Tags + Cross-Project Search

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Fix all outstanding bugs from spike 4/5 code reviews, fill test coverage gaps, then ship fuzzy tag matching and cross-project search.

**Architecture:** Bug fixes touch `store.py`, `pool.py`, and `server.py`. Fuzzy tag matching adds an `_expand_tags()` method to `MemoryStore` using ChromaDB's ONNX embeddings with a cached tag→embedding map. Cross-project search adds fan-out logic in `server.py`'s `search_memories` tool. TDD throughout — tests first, then implementation.

**Tech Stack:** Python 3.12, ChromaDB (ONNX embeddings), pytest

---

### Task 1: Commit uncommitted install enhancements

The `cli.py` and `test_cli.py` changes are already written. Just commit them.

**Step 1: Commit**

```bash
git add src/annal/cli.py tests/test_cli.py README.md docs/plans/2026-02-20-feature-backlog.md
git commit -m "feat: annal install writes CLAUDE.md instructions and post-commit hook"
```

---

### Task 2: Fix `before` date filter losing last day

**Files:**
- Test: `tests/test_store.py`
- Modify: `src/annal/store.py:44-96` (search method)

**Step 1: Write the failing test**

Add to `tests/test_store.py`:

```python
def test_search_before_date_only_includes_full_day(tmp_data_dir):
    """before='2026-02-21' should include memories created on 2026-02-21."""
    from unittest.mock import patch
    from datetime import datetime, timezone

    store = MemoryStore(data_dir=tmp_data_dir, project="date_edge")
    # Store a memory with a known timestamp mid-day
    with patch("annal.store.datetime") as mock_dt:
        mock_dt.now.return_value = datetime(2026, 2, 21, 14, 30, 0, tzinfo=timezone.utc)
        mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
        store.store(content="Created on Feb 21 afternoon", tags=["test"])

    # before='2026-02-21' (date only) should include it
    results = store.search("Feb 21", before="2026-02-21")
    assert len(results) == 1

    # after='2026-02-21' (date only) should also include it
    results = store.search("Feb 21", after="2026-02-21")
    assert len(results) == 1
```

**Step 2: Run test to verify it fails**

Run: `pytest tests/test_store.py::test_search_before_date_only_includes_full_day -v`
Expected: FAIL — the `before` filter excludes the memory because `"2026-02-21T14:30:00" > "2026-02-21"` lexicographically.

**Step 3: Write minimal implementation**

In `src/annal/store.py`, add a helper and modify `search()`:

```python
def _normalize_date_bound(value: str, end_of_day: bool) -> str:
    """Normalize a date-only string to include time for correct comparison.

    If value already contains 'T' (is a datetime), return as-is.
    Otherwise append T23:59:59 (for before) or T00:00:00 (for after).
    """
    if "T" in value:
        return value
    return value + ("T23:59:59" if end_of_day else "T00:00:00")
```

In the `search()` method, normalize before use (at the top, before the query):

```python
if after:
    after = _normalize_date_bound(after, end_of_day=False)
if before:
    before = _normalize_date_bound(before, end_of_day=True)
```

**Step 4: Run test to verify it passes**

Run: `pytest tests/test_store.py::test_search_before_date_only_includes_full_day -v`
Expected: PASS

**Step 5: Commit**

```bash
git add src/annal/store.py tests/test_store.py
git commit -m "fix: before date filter includes full last day via T23:59:59 normalization"
```

---

### Task 3: Fix dual `AnnalConfig`

**Files:**
- Modify: `src/annal/server.py:139-154` (create_server)

**Step 1: Modify `create_server` to accept config object**

Change the signature and body of `create_server()`:

```python
def create_server(
    config_path: str = DEFAULT_CONFIG_PATH,
    pool: StorePool | None = None,
    config: AnnalConfig | None = None,
) -> tuple[FastMCP, StorePool]:
    """Create and configure the Annal MCP server."""
    if config is None:
        config = AnnalConfig.load(config_path)

    mcp = FastMCP(
        "annal",
        instructions=SERVER_INSTRUCTIONS,
        host="127.0.0.1",
        port=config.port,
    )

    if pool is None:
        pool = StorePool(config)
```

The rest of the function body stays the same — the `config` variable is already used in the closures for `init_project`, `index_files`, etc.

**Step 2: Update `main()` to pass config**

In `main()` (line 602-604), change:

```python
    config = AnnalConfig.load(config_path)
    pool = StorePool(config)
    mcp, _ = create_server(config_path=config_path, pool=pool, config=config)
```

**Step 3: Run all tests**

Run: `pytest -v`
Expected: all pass (no test specifically exercises this, but nothing should break)

**Step 4: Commit**

```bash
git add src/annal/server.py
git commit -m "fix: share single AnnalConfig between main() and create_server()"
```

---

### Task 4: Fix startup reconcile skipping index lock

**Files:**
- Modify: `src/annal/server.py:158-171` (_startup_reconcile)

**Step 1: Rewrite `_startup_reconcile` to use `reconcile_project_async`**

Replace the `_startup_reconcile` function:

```python
    def _startup_reconcile() -> None:
        for project_name in config.projects:
            try:
                logger.info("Reconciling project '%s'...", project_name)
                event_bus.push(Event(type="index_started", project=project_name))

                def _make_complete_callback(pname: str):
                    def on_complete(count: int) -> None:
                        event_bus.push(Event(type="index_complete", project=pname, detail=f"{count} files"))
                        pool.start_watcher(pname)
                    return on_complete

                pool.reconcile_project_async(
                    project_name,
                    on_complete=_make_complete_callback(project_name),
                )
            except Exception:
                logger.exception("Startup reconciliation failed for project '%s'", project_name)
                event_bus.push(Event(type="index_failed", project=project_name, detail="startup reconciliation failed"))
        logger.info("Startup reconciliation dispatched")
```

This delegates to `reconcile_project_async` which acquires the per-project index lock. The `_make_complete_callback` closure factory avoids the loop variable capture bug.

**Step 2: Run all tests**

Run: `pytest -v`
Expected: all pass

**Step 3: Commit**

```bash
git add src/annal/server.py
git commit -m "fix: startup reconcile uses async path to acquire index lock"
```

---

### Task 5: Fix `_index_started`/`_last_reconcile` reads without locks

**Files:**
- Modify: `src/annal/pool.py:52-65,91-124`

**Step 1: Protect writes in `reconcile_project` and `reconcile_project_async`**

In `reconcile_project()`, wrap the `_last_reconcile` write:

```python
    def reconcile_project(self, project: str) -> int:
        if project not in self._config.projects:
            return 0
        store = self.get_store(project)
        proj_config = self._config.projects[project]
        watcher = FileWatcher(store=store, project_config=proj_config)
        count = watcher.reconcile()
        with self._lock:
            self._last_reconcile[project] = {
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "file_count": count,
            }
        logger.info("Reconciled %d files for project '%s'", count, project)
        return count
```

In `reconcile_project_async`'s `_run()`, wrap the `_index_started` writes and `_last_reconcile` write:

```python
            try:
                with self._lock:
                    self._index_started[project] = datetime.now(timezone.utc)
                # ... rest of reconciliation ...
                with self._lock:
                    self._last_reconcile[project] = {
                        "timestamp": datetime.now(timezone.utc).isoformat(),
                        "file_count": count,
                    }
            # ...
            finally:
                with self._lock:
                    self._index_started.pop(project, None)
                lock.release()
```

**Step 2: Protect reads**

In `get_last_reconcile()` and `get_index_started()`:

```python
    def get_last_reconcile(self, project: str) -> dict | None:
        with self._lock:
            return self._last_reconcile.get(project)

    def get_index_started(self, project: str) -> datetime | None:
        with self._lock:
            return self._index_started.get(project)
```

**Step 3: Run all tests**

Run: `pytest -v`
Expected: all pass

**Step 4: Commit**

```bash
git add src/annal/pool.py
git commit -m "fix: protect _index_started and _last_reconcile with pool lock"
```

---

### Task 6: Fix `config.save()` under `_lock`

**Files:**
- Modify: `src/annal/pool.py:37-50` (get_store)

**Step 1: Move save outside lock**

```python
    def get_store(self, project: str) -> MemoryStore:
        need_save = False
        with self._lock:
            if project not in self._stores:
                logger.info("Creating store for project '%s'", project)
                self._stores[project] = MemoryStore(
                    data_dir=self._config.data_dir, project=project
                )
                if project not in self._config.projects:
                    self._config.add_project(project)
                    need_save = True
                    logger.info("Auto-registered project '%s' in config", project)
            store = self._stores[project]
        if need_save:
            self._config.save()
        return store
```

**Step 2: Run all tests**

Run: `pytest -v`
Expected: all pass

**Step 3: Commit**

```bash
git add src/annal/pool.py
git commit -m "fix: move config.save() outside pool lock to avoid blocking I/O"
```

---

### Task 7: Fix `browse()` loading entire collection

**Files:**
- Test: `tests/test_store.py`
- Modify: `src/annal/store.py:209-254` (browse method)

**Step 1: Write a test that verifies browse uses offset/limit**

Add to `tests/test_store.py`:

```python
def test_browse_offset_limit_pages_correctly(tmp_data_dir):
    """browse should return correct pages using offset and limit."""
    store = MemoryStore(data_dir=tmp_data_dir, project="paginate")
    for i in range(10):
        store.store(content=f"Memory number {i}", tags=["test"])

    page1, total = store.browse(offset=0, limit=3)
    assert len(page1) == 3
    assert total == 10

    page2, total = store.browse(offset=3, limit=3)
    assert len(page2) == 3
    assert total == 10

    # Pages should have different items
    ids1 = {r["id"] for r in page1}
    ids2 = {r["id"] for r in page2}
    assert ids1.isdisjoint(ids2)
```

**Step 2: Run test to verify it passes (current impl works, just inefficiently)**

Run: `pytest tests/test_store.py::test_browse_offset_limit_pages_correctly -v`
Expected: PASS (functionally correct, just slow)

**Step 3: Optimize browse for unfiltered path**

Replace the `browse()` method in `src/annal/store.py`:

```python
    def browse(
        self,
        offset: int = 0,
        limit: int = 50,
        chunk_type: str | None = None,
        source_prefix: str | None = None,
        tags: list[str] | None = None,
    ) -> tuple[list[dict], int]:
        """Paginated retrieval with optional filters. Returns (results, total_matching)."""
        if self._collection.count() == 0:
            return [], 0

        has_post_filters = bool(source_prefix or tags)
        where = {"chunk_type": chunk_type} if chunk_type else None

        if not has_post_filters:
            # Fast path: let ChromaDB handle offset/limit directly
            total = self._collection.count()
            if where:
                # ChromaDB doesn't expose a filtered count, so we count manually
                all_filtered = self._collection.get(include=["metadatas"], where=where)
                total = len(all_filtered["ids"])

            batch = self._collection.get(
                include=["documents", "metadatas"],
                limit=limit,
                offset=offset,
                where=where,
            )
            results = []
            for i, doc_id in enumerate(batch["ids"]):
                meta = batch["metadatas"][i]
                results.append({
                    "id": doc_id,
                    "content": batch["documents"][i],
                    "tags": json.loads(meta.get("tags", "[]")),
                    "source": meta.get("source", ""),
                    "chunk_type": meta.get("chunk_type", ""),
                    "created_at": meta.get("created_at", ""),
                    "updated_at": meta.get("updated_at", ""),
                })
            return results, total

        # Slow path: post-query filtering requires scanning
        batch_size = 5000
        total_docs = self._collection.count()
        all_items: list[dict] = []
        for batch_offset in range(0, total_docs, batch_size):
            batch = self._collection.get(
                include=["documents", "metadatas"],
                limit=batch_size,
                offset=batch_offset,
                where=where,
            )
            for i, doc_id in enumerate(batch["ids"]):
                meta = batch["metadatas"][i]
                mem_tags = json.loads(meta.get("tags", "[]"))

                if source_prefix and not meta.get("source", "").startswith(source_prefix):
                    continue
                if tags and not any(t in mem_tags for t in tags):
                    continue

                all_items.append({
                    "id": doc_id,
                    "content": batch["documents"][i],
                    "tags": mem_tags,
                    "source": meta.get("source", ""),
                    "chunk_type": meta.get("chunk_type", ""),
                    "created_at": meta.get("created_at", ""),
                    "updated_at": meta.get("updated_at", ""),
                })

        filtered_total = len(all_items)
        page = all_items[offset:offset + limit]
        return page, filtered_total
```

**Step 4: Run all browse tests**

Run: `pytest tests/test_store.py -k browse -v`
Expected: all pass

**Step 5: Commit**

```bash
git add src/annal/store.py tests/test_store.py
git commit -m "fix: browse uses ChromaDB offset/limit for unfiltered queries"
```

---

### Task 8: Test coverage — invalid date formats

**Files:**
- Test: `tests/test_store.py`
- Modify: `src/annal/store.py` (search method, add validation)

**Step 1: Write failing tests**

```python
def test_search_rejects_invalid_date_format(tmp_data_dir):
    """Non-ISO-8601 date strings should not silently produce wrong results."""
    store = MemoryStore(data_dir=tmp_data_dir, project="date_validate")
    store.store(content="Some memory", tags=["test"])

    # These should return empty or raise, not silently filter incorrectly
    results = store.search("memory", after="yesterday")
    assert len(results) == 0

    results = store.search("memory", before="not-a-date")
    assert len(results) == 0
```

**Step 2: Run test to verify it fails**

Run: `pytest tests/test_store.py::test_search_rejects_invalid_date_format -v`
Expected: FAIL — `"yesterday"` is compared lexicographically and might match.

**Step 3: Add validation to `_normalize_date_bound`**

Update the helper in `src/annal/store.py`:

```python
import re

_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?")

def _normalize_date_bound(value: str, end_of_day: bool) -> str | None:
    """Normalize a date-only string to include time for correct comparison.

    Returns None if the value is not a valid ISO 8601 date/datetime prefix.
    """
    if not _ISO_DATE_RE.match(value):
        return None
    if "T" in value:
        return value
    return value + ("T23:59:59" if end_of_day else "T00:00:00")
```

Update `search()` to handle `None` returns (treat invalid dates as "no filter"):

```python
if after:
    after = _normalize_date_bound(after, end_of_day=False)
if before:
    before = _normalize_date_bound(before, end_of_day=True)
```

Since `None` is falsy, invalid dates effectively disable that filter, which means the search returns results unfiltered. But the test expects 0 results for `after="yesterday"` — `"yesterday"` < any ISO timestamp, so it would include everything. Let me reconsider: invalid dates should be treated as maximally restrictive (return nothing) to avoid silent wrong results. Better approach — return empty results immediately when a date is invalid:

```python
if after:
    after = _normalize_date_bound(after, end_of_day=False)
    if after is None:
        return []
if before:
    before = _normalize_date_bound(before, end_of_day=True)
    if before is None:
        return []
```

**Step 4: Run test to verify it passes**

Run: `pytest tests/test_store.py::test_search_rejects_invalid_date_format -v`
Expected: PASS

**Step 5: Commit**

```bash
git add src/annal/store.py tests/test_store.py
git commit -m "fix: validate ISO 8601 date format, return empty on invalid input"
```

---

### Task 9: Test coverage — empty JSON results, combined filters, over-fetch, heading context, concurrent lock

**Files:**
- Test: `tests/test_store.py`, `tests/test_pool.py`, `tests/test_indexer.py`

**Step 1: Write all five tests**

Add to `tests/test_store.py`:

```python
def test_search_json_empty_results(tmp_data_dir):
    """search with output='json' and no results returns correct structure."""
    import json as json_mod
    store = MemoryStore(data_dir=tmp_data_dir, project="json_empty")
    # Don't store anything — collection is empty
    # We need to test via the server tool, but we can test the store returns []
    results = store.search("anything", limit=5)
    assert results == []


def test_search_combined_tags_and_temporal(tmp_data_dir):
    """Tags and temporal filters should compose correctly."""
    from unittest.mock import patch
    from datetime import datetime, timezone

    store = MemoryStore(data_dir=tmp_data_dir, project="combined")

    # Store memories with different tags and simulated timestamps
    with patch("annal.store.datetime") as mock_dt:
        mock_dt.now.return_value = datetime(2026, 1, 15, tzinfo=timezone.utc)
        mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
        store.store(content="Old billing decision", tags=["billing", "decision"])

    with patch("annal.store.datetime") as mock_dt:
        mock_dt.now.return_value = datetime(2026, 2, 15, tzinfo=timezone.utc)
        mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
        store.store(content="New billing decision", tags=["billing", "decision"])

    with patch("annal.store.datetime") as mock_dt:
        mock_dt.now.return_value = datetime(2026, 2, 15, tzinfo=timezone.utc)
        mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
        store.store(content="New auth pattern", tags=["auth", "pattern"])

    # Tags=billing AND after=Feb 1 should return only the new billing decision
    results = store.search("decision", tags=["billing"], after="2026-02-01")
    assert len(results) == 1
    assert "New billing" in results[0]["content"]


def test_search_overfetch_with_tag_filter(tmp_data_dir):
    """With many memories, tag filtering should still return correct results."""
    store = MemoryStore(data_dir=tmp_data_dir, project="overfetch")

    # Store 20 memories with "noise" tag and 3 with "signal" tag
    for i in range(20):
        store.store(content=f"Noise memory about topic {i}", tags=["noise"])
    for i in range(3):
        store.store(content=f"Signal memory about finding {i}", tags=["signal"])

    results = store.search("memory", tags=["signal"], limit=5)
    assert len(results) == 3
    assert all("Signal" in r["content"] for r in results)
```

Add to `tests/test_indexer.py`:

```python
def test_heading_context_uses_full_path(tmp_data_dir, tmp_path):
    """Stored content should start with 'filename > Heading > Subheading' format."""
    md_file = tmp_path / "doc.md"
    md_file.write_text("# Project\nIntro\n\n## Design\n### Backend\nUses Python.\n")

    store = MemoryStore(data_dir=tmp_data_dir, project="heading_strict")
    index_file(store, str(md_file))

    results = store.search("Python", limit=5)
    backend_chunk = [r for r in results if "Backend" in r["source"]]
    assert len(backend_chunk) > 0
    # Content should start with the full heading path
    assert backend_chunk[0]["content"].startswith("doc.md > Design > Backend")
```

Add to `tests/test_pool.py`:

```python
def test_get_index_lock_concurrent_same_lock(tmp_data_dir, tmp_config_path):
    """Two threads calling _get_index_lock for the same project get the same lock."""
    config = AnnalConfig(config_path=tmp_config_path, data_dir=tmp_data_dir)
    config.save()
    pool = StorePool(config)

    locks = []
    def get_lock():
        locks.append(pool._get_index_lock("concurrent_test"))

    threads = [threading.Thread(target=get_lock) for _ in range(10)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    assert len(locks) == 10
    assert len(set(id(l) for l in locks)) == 1
```

**Step 2: Run all new tests**

Run: `pytest tests/test_store.py::test_search_json_empty_results tests/test_store.py::test_search_combined_tags_and_temporal tests/test_store.py::test_search_overfetch_with_tag_filter tests/test_indexer.py::test_heading_context_uses_full_path tests/test_pool.py::test_get_index_lock_concurrent_same_lock -v`

Expected: all PASS (these test existing behaviour that works but was uncovered)

**Step 3: Commit**

```bash
git add tests/test_store.py tests/test_indexer.py tests/test_pool.py
git commit -m "test: fill coverage gaps — combined filters, overfetch, heading path, concurrent lock"
```

---

### Task 10: Test coverage — empty JSON via server tool

**Files:**
- Test: `tests/test_server.py`

**Step 1: Check how server tests work**

Read `tests/test_server.py` to understand the testing pattern for MCP tools. The test for empty JSON needs to call `search_memories` with `output="json"` on an empty project.

**Step 2: Write the test**

Follow the existing pattern in `test_server.py`. The test should store nothing and search with `output="json"`:

```python
def test_search_memories_json_empty(mcp_tools):
    """search_memories with output='json' and no results returns correct JSON structure."""
    import json
    result = mcp_tools["search_memories"](project="emptyproject", query="anything", output="json")
    data = json.loads(result)
    assert data["results"] == []
    assert data["meta"]["total"] == 0
    assert data["meta"]["project"] == "emptyproject"
```

**Step 3: Run test**

Run: `pytest tests/test_server.py::test_search_memories_json_empty -v`
Expected: PASS

**Step 4: Commit**

```bash
git add tests/test_server.py
git commit -m "test: empty JSON search results structure"
```

---

### Task 11: Fuzzy tag matching

**Files:**
- Test: `tests/test_store.py`
- Modify: `src/annal/store.py`

**Step 1: Write failing tests**

Add to `tests/test_store.py`:

```python
def test_fuzzy_tag_matching(tmp_data_dir):
    """Searching with tags=['auth'] should find memories tagged 'authentication'."""
    store = MemoryStore(data_dir=tmp_data_dir, project="fuzzy_tags")
    store.store(content="Auth decision about JWT tokens", tags=["authentication", "decision"])
    store.store(content="Frontend uses React", tags=["frontend"])

    results = store.search("decision", tags=["auth"], limit=5)
    assert len(results) == 1
    assert "JWT" in results[0]["content"]


def test_fuzzy_tag_no_false_positives(tmp_data_dir):
    """Fuzzy matching should not match unrelated tags."""
    store = MemoryStore(data_dir=tmp_data_dir, project="fuzzy_strict")
    store.store(content="Caching layer uses Redis", tags=["caching", "infrastructure"])
    store.store(content="Auth uses OAuth", tags=["authentication"])

    # 'auth' should match 'authentication' but not 'caching'
    results = store.search("uses", tags=["auth"], limit=5)
    assert len(results) == 1
    assert "OAuth" in results[0]["content"]


def test_fuzzy_tag_exact_still_works(tmp_data_dir):
    """Exact tag matches should still work."""
    store = MemoryStore(data_dir=tmp_data_dir, project="fuzzy_exact")
    store.store(content="Billing uses Stripe", tags=["billing"])
    store.store(content="Frontend uses React", tags=["frontend"])

    results = store.search("uses", tags=["billing"], limit=5)
    assert len(results) == 1
    assert "Stripe" in results[0]["content"]


def test_fuzzy_tag_in_browse(tmp_data_dir):
    """browse() with tags should also use fuzzy matching."""
    store = MemoryStore(data_dir=tmp_data_dir, project="fuzzy_browse")
    store.store(content="Auth decision", tags=["authentication"])
    store.store(content="Frontend stuff", tags=["frontend"])

    results, total = store.browse(tags=["auth"])
    assert total == 1
    assert "Auth" in results[0]["content"]
```

**Step 2: Run tests to verify they fail**

Run: `pytest tests/test_store.py -k fuzzy -v`
Expected: FAIL — current exact matching won't find `authentication` when searching for `auth`.

**Step 3: Implement fuzzy tag matching**

Add to `src/annal/store.py`:

```python
import numpy as np
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2

FUZZY_TAG_THRESHOLD = 0.75
```

Add to `MemoryStore.__init__`:

```python
    self._embed_fn = ONNXMiniLM_L6_V2()
    self._tag_cache: dict[str, np.ndarray] | None = None
```

Add methods to `MemoryStore`:

```python
    def _invalidate_tag_cache(self) -> None:
        """Clear the tag embedding cache. Called after store/update/delete."""
        self._tag_cache = None

    def _get_tag_embeddings(self) -> dict[str, np.ndarray]:
        """Get or build a cache of tag -> embedding for all tags in the store."""
        if self._tag_cache is not None:
            return self._tag_cache
        topics = self.list_topics()
        if not topics:
            self._tag_cache = {}
            return self._tag_cache
        tag_names = list(topics.keys())
        embeddings = self._embed_fn(tag_names)
        self._tag_cache = {name: np.array(emb) for name, emb in zip(tag_names, embeddings)}
        return self._tag_cache

    def _expand_tags(self, filter_tags: list[str]) -> set[str]:
        """Expand filter tags to include semantically similar known tags."""
        tag_embeddings = self._get_tag_embeddings()
        if not tag_embeddings:
            return set(filter_tags)

        expanded = set(filter_tags)
        filter_embeddings = self._embed_fn(filter_tags)

        for i, filter_tag in enumerate(filter_tags):
            filter_emb = np.array(filter_embeddings[i])
            filter_norm = np.linalg.norm(filter_emb)
            if filter_norm == 0:
                continue
            for known_tag, known_emb in tag_embeddings.items():
                known_norm = np.linalg.norm(known_emb)
                if known_norm == 0:
                    continue
                similarity = np.dot(filter_emb, known_emb) / (filter_norm * known_norm)
                if similarity >= FUZZY_TAG_THRESHOLD:
                    expanded.add(known_tag)

        return expanded
```

Invalidate cache in `store()`, `update()`, `delete()`, and `delete_many()`:

```python
    # At the end of store(), before return:
    self._invalidate_tag_cache()

    # At the end of update(), after self._collection.update():
    self._invalidate_tag_cache()

    # At the end of delete():
    self._invalidate_tag_cache()

    # At the end of delete_many():
    self._invalidate_tag_cache()
```

Modify the tag filter in `search()` to use expanded tags:

```python
        # Before the results loop, expand tags if filtering
        expanded_tags = self._expand_tags(tags) if tags else None

        # In the loop, change the tag check:
        if expanded_tags and not any(t in mem_tags for t in expanded_tags):
            continue
```

Modify the tag filter in `browse()` similarly:

```python
        # Before the scan loop (in the slow path):
        expanded_tags = self._expand_tags(tags) if tags else None

        # In the loop:
        if expanded_tags and not any(t in mem_tags for t in expanded_tags):
            continue
```

**Step 4: Run fuzzy tests**

Run: `pytest tests/test_store.py -k fuzzy -v`
Expected: all PASS

**Step 5: Run full test suite**

Run: `pytest -v`
Expected: all pass (existing exact-match tests should still pass since exact matches score >0.75)

**Step 6: Commit**

```bash
git add src/annal/store.py tests/test_store.py
git commit -m "feat: fuzzy tag matching — semantic similarity expands tag filters"
```

---

### Task 12: Cross-project search

**Files:**
- Test: `tests/test_store.py` (store-level multi-project), `tests/test_server.py` (tool-level)
- Modify: `src/annal/server.py` (search_memories tool)

**Step 1: Write failing tests**

Add to `tests/test_store.py`:

```python
def test_search_across_projects(tmp_data_dir):
    """Searching across multiple projects returns merged results."""
    store_a = MemoryStore(data_dir=tmp_data_dir, project="project_a")
    store_b = MemoryStore(data_dir=tmp_data_dir, project="project_b")

    store_a.store(content="Auth uses JWT in project A", tags=["auth"])
    store_b.store(content="Auth uses OAuth in project B", tags=["auth"])

    # Search each individually
    results_a = store_a.search("auth", limit=5)
    results_b = store_b.search("auth", limit=5)
    assert len(results_a) == 1
    assert len(results_b) == 1
```

This test validates the foundation — each store is independent. The actual cross-project fan-out is at the server level.

Add to `tests/test_server.py` (following existing patterns):

```python
def test_search_memories_cross_project(mcp_tools):
    """search_memories with projects param searches across multiple projects."""
    import json

    # Store in two projects
    mcp_tools["store_memory"](project="proj_x", content="JWT auth in X", tags=["auth"])
    mcp_tools["store_memory"](project="proj_y", content="OAuth auth in Y", tags=["auth"])

    # Cross-project search
    result = mcp_tools["search_memories"](
        project="proj_x", query="auth", projects=["proj_x", "proj_y"], output="json"
    )
    data = json.loads(result)
    assert len(data["results"]) == 2
    assert "projects_searched" in data["meta"]
    projects_found = {r["project"] for r in data["results"]}
    assert projects_found == {"proj_x", "proj_y"}


def test_search_memories_cross_project_star(mcp_tools, config):
    """projects='*' searches all configured projects."""
    import json

    config.add_project("star_a")
    config.add_project("star_b")

    mcp_tools["store_memory"](project="star_a", content="Memory in A", tags=["test"])
    mcp_tools["store_memory"](project="star_b", content="Memory in B", tags=["test"])

    result = mcp_tools["search_memories"](
        project="star_a", query="memory", projects="*", output="json"
    )
    data = json.loads(result)
    assert len(data["results"]) == 2
```

**Step 2: Run tests to verify they fail**

Run: `pytest tests/test_server.py -k cross_project -v`
Expected: FAIL — `projects` parameter doesn't exist yet.

**Step 3: Implement cross-project search**

Modify `search_memories` in `src/annal/server.py`:

```python
    @mcp.tool()
    def search_memories(
        project: str,
        query: str,
        tags: list[str] | str | None = None,
        limit: int = 5,
        mode: str = "full",
        min_score: float = 0.0,
        after: str | None = None,
        before: str | None = None,
        output: str = "text",
        projects: list[str] | str | None = None,
    ) -> str:
        """Search project memories using natural language.

        Args:
            project: Project name to search in
            query: Natural language search query
            tags: Optional tag filter — only return memories with at least one of these tags
            limit: Maximum number of results (default 5)
            mode: "full" (default) returns complete content; "probe" returns compact
                  summaries — use probe to scan relevance, then expand_memories for details
            min_score: Minimum similarity score to include (default 0.0, suppresses negative scores)
            after: Optional ISO 8601 date — only return memories created after this date
            before: Optional ISO 8601 date — only return memories created before this date
            output: "text" (default) for formatted text, "json" for structured JSON
            projects: Optional list of project names to search across, or "*" for all
                      configured projects. Results are merged by score. Each result includes
                      a project field. When omitted, searches only the primary project.
        """
```

At the top of the function body, determine which projects to search:

```python
        tags = _normalize_tags(tags)

        # Determine project list for search
        if projects == "*":
            search_projects = list(config.projects.keys())
            if project not in search_projects:
                search_projects.append(project)
        elif projects:
            search_projects = list(projects) if isinstance(projects, list) else [projects]
        else:
            search_projects = [project]

        # Fan-out search across projects
        all_results = []
        for proj_name in search_projects:
            store = pool.get_store(proj_name)
            proj_results = store.search(query=query, tags=tags, limit=limit, after=after, before=before)
            for r in proj_results:
                r["project"] = proj_name
            all_results.extend(proj_results)

        # Merge by score, take top limit
        all_results.sort(key=lambda r: r["score"], reverse=True)
        results = all_results[:limit]
```

Then the rest of the function stays mostly the same, but uses the merged `results` list. The `project` field needs to appear in output formatting.

For text output, modify probe and full modes to include the project when doing cross-project:

```python
        is_cross_project = len(search_projects) > 1
```

In probe mode formatting:

```python
                proj_label = f"({r['project']}) " if is_cross_project else ""
                lines.append(
                    f'[{r["score"]:.2f}] {proj_label}({", ".join(r["tags"])}) "{snippet}"'
                    f"\n  Source: {source_label} | {date} | ID: {r['id']}"
                )
```

In full mode formatting:

```python
                proj_label = f"({r['project']}) " if is_cross_project else ""
                entry = f"[{r['score']:.2f}] {proj_label}({', '.join(r['tags'])}) {r['content']}"
```

For JSON output, add `project` to each result and `projects_searched` to meta:

```python
                if is_cross_project:
                    entry["project"] = r["project"]
            # ...
            return json_mod.dumps({
                "results": json_results,
                "meta": {
                    "query": query,
                    "mode": mode,
                    "project": project,
                    **({"projects_searched": search_projects} if is_cross_project else {}),
                    "total": len(results),
                    "returned": len(results),
                },
            })
```

The empty-results JSON path also needs `project` in results when cross-project:

```python
        empty_meta = {"query": query, "mode": mode, "project": project, "total": 0, "returned": 0}
        if is_cross_project:
            empty_meta["projects_searched"] = search_projects
        empty_json = json_mod.dumps({"results": [], "meta": empty_meta})
```

**Step 4: Update SERVER_INSTRUCTIONS**

Add a section to the `SERVER_INSTRUCTIONS` string:

```
## Cross-project search
Search across multiple projects to find knowledge from other codebases:
  search_memories(query="auth decision", project="current", projects=["other_project"])
Use projects="*" to search all configured projects at once. Results are merged
by relevance score. Each result includes the source project name.
```

**Step 5: Run cross-project tests**

Run: `pytest tests/test_server.py -k cross_project -v`
Expected: PASS

**Step 6: Run full test suite**

Run: `pytest -v`
Expected: all pass

**Step 7: Commit**

```bash
git add src/annal/server.py tests/test_server.py tests/test_store.py
git commit -m "feat: cross-project search — fan-out queries across collections, merge by score"
```

---

### Task 13: Update backlog, bump version, final commit

**Files:**
- Modify: `docs/plans/2026-02-20-feature-backlog.md`
- Modify: `pyproject.toml`
- Modify: `README.md`

**Step 1: Update backlog**

Mark shipped items in the backlog under a new `## Shipped (spike 6)` section.

**Step 2: Bump version**

In `pyproject.toml`, bump version to `0.4.0`.

**Step 3: Update README**

Add fuzzy tag matching and cross-project search to the features section.

**Step 4: Commit**

```bash
git add docs/plans/2026-02-20-feature-backlog.md pyproject.toml README.md
git commit -m "chore: bump to 0.4.0, update backlog and README for spike 6"
```
