# YAML Frontmatter + Wikilinks Implementation Plan

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

**Goal:** Migrate kbx entity files from `**Key:** Value` markdown metadata to YAML frontmatter and add `[[wikilinks]]` for Obsidian compatibility.

**Architecture:** Three layers of change: (1) a `strip_wikilinks()` utility used everywhere kbx reads values, (2) YAML frontmatter parsing/writing replacing the `**Key:** Value` format in entities.py/writeback.py/crud.py, (3) wikilink emission on write and a batch migration script. The title stays as `# H1` heading (not in frontmatter). Entity-reference fields (`team`, `reports_to`, `lead`, `company`) get `[[wikilinks]]`; plain fields (`email`, `role`, `status`, `pcm_base`) stay plain.

**Tech Stack:** Python 3.10+, PyYAML (`yaml.safe_load`/`yaml.dump`), regex for wikilink stripping, pytest TDD.

**Design doc:** `docs/plans/2026-03-01-yaml-frontmatter-wikilinks-design.md`

---

## Task 1: Add `strip_wikilinks()` utility

The foundation utility. Used by entity parsing, chunk indexing, and search snippets.

**Files:**
- Modify: `src/kb/entities.py` — add `strip_wikilinks()` function (top of file, after imports)
- Test: `tests/test_entities.py`

**Step 1: Write failing tests**

Add to `tests/test_entities.py`:

```python
class TestStripWikilinks:
    def test_strip_simple(self):
        from kb.entities import strip_wikilinks
        assert strip_wikilinks("[[Foo]]") == "Foo"

    def test_strip_multiple(self):
        from kb.entities import strip_wikilinks
        assert strip_wikilinks("[[Foo]] and [[Bar]]") == "Foo and Bar"

    def test_strip_in_sentence(self):
        from kb.entities import strip_wikilinks
        assert strip_wikilinks("Reports to [[Jeremy Brown]] (CTO)") == "Reports to Jeremy Brown (CTO)"

    def test_no_wikilinks(self):
        from kb.entities import strip_wikilinks
        assert strip_wikilinks("plain text") == "plain text"

    def test_empty_string(self):
        from kb.entities import strip_wikilinks
        assert strip_wikilinks("") == ""

    def test_nested_brackets(self):
        from kb.entities import strip_wikilinks
        # Edge case: only strips [[...]], not single brackets
        assert strip_wikilinks("[not a link]") == "[not a link]"
```

**Step 2:** Run `uv run pytest tests/test_entities.py::TestStripWikilinks -x -v` — expect FAIL (ImportError).

**Step 3: Implement**

In `src/kb/entities.py`, add after the existing imports (around line 16):

```python
import re

def strip_wikilinks(text: str) -> str:
    """Strip [[wikilinks]] from text: '[[Foo]]' → 'Foo'.

    Used when reading values for DB storage, FTS indexing, and snippet display.
    """
    return re.sub(r"\[\[([^\]]+)\]\]", r"\1", text)
```

Note: `re` is already imported in entities.py, so just add the function.

**Step 4:** Run `uv run pytest tests/test_entities.py::TestStripWikilinks -x -v` — expect PASS.

**Step 5: Commit**

```bash
git add src/kb/entities.py tests/test_entities.py
git commit -m "feat: add strip_wikilinks() utility for [[wikilink]] removal"
```

---

## Task 2: YAML frontmatter parsing in `entities.py`

Replace `_parse_person_file()` and `_parse_project_file()` to read YAML frontmatter (with fallback to old `**Key:** Value` format for backward compat during migration).

**Files:**
- Modify: `src/kb/entities.py:68-145` — rewrite `_parse_person_file()` and `_parse_project_file()`
- Test: `tests/test_entities.py`

**Step 1: Write failing tests**

Add to `tests/test_entities.py`:

```python
class TestYamlFrontmatterParsing:
    """Parse person/project files with YAML frontmatter."""

    def test_parse_person_yaml_frontmatter(self, tmp_path):
        from kb.entities import _parse_person_file

        f = tmp_path / "jane-doe.md"
        f.write_text(
            '---\n'
            'aliases: [JD, Jane D.]\n'
            'email: jane@example.com\n'
            'role: Engineering Lead\n'
            'team: "[[Core]]"\n'
            'reports_to: "[[CTO]]"\n'
            'pinned: true\n'
            '---\n'
            '# Jane Doe\n\n'
            '## Notes\n\nSome notes here.\n'
        )
        result = _parse_person_file(f)
        assert result.name == "Jane Doe"
        assert result.entity_type == "person"
        assert "JD" in result.aliases
        assert "Jane D." in result.aliases
        assert "Jane" in result.aliases  # auto first-name
        assert "jane-doe" in result.aliases  # auto file-stem
        assert result.metadata["email"] == "jane@example.com"
        assert result.metadata["role"] == "Engineering Lead"
        assert result.metadata["team"] == "Core"  # wikilinks stripped
        assert result.metadata["reports_to"] == "CTO"  # wikilinks stripped
        assert result.pinned is True

    def test_parse_person_yaml_no_aliases(self, tmp_path):
        from kb.entities import _parse_person_file

        f = tmp_path / "solo.md"
        f.write_text(
            '---\nemail: solo@example.com\nrole: Dev\n---\n# Solo Dev\n'
        )
        result = _parse_person_file(f)
        assert result.name == "Solo Dev"
        assert "Solo" in result.aliases
        assert "solo" in result.aliases

    def test_parse_person_old_format_still_works(self, tmp_path):
        """Backward compat: old **Key:** Value format still parses."""
        from kb.entities import _parse_person_file

        f = tmp_path / "old-style.md"
        f.write_text(
            "# Old Style\n\n"
            "**Also known as:** OS\n"
            "**Role:** Legacy Dev\n"
            "**Team:** Platform\n"
        )
        result = _parse_person_file(f)
        assert result.name == "Old Style"
        assert "OS" in result.aliases
        assert result.metadata["role"] == "Legacy Dev"
        assert result.metadata["team"] == "Platform"

    def test_parse_project_yaml_frontmatter(self, tmp_path):
        from kb.entities import _parse_project_file

        f = tmp_path / "my-project.md"
        f.write_text(
            '---\n'
            'aliases: [MP, MyProj]\n'
            'status: Active\n'
            'started: January 2026\n'
            'lead: "[[Alice Smith]] — Tech Lead"\n'
            '---\n'
            '# My Project\n\n'
            '## Overview\n\nProject details.\n'
        )
        result = _parse_project_file(f)
        assert result.name == "My Project"
        assert "MP" in result.aliases
        assert "MyProj" in result.aliases
        assert "my-project" in result.aliases
        assert result.metadata["status"] == "Active"
        assert result.metadata["started"] == "January 2026"
        assert result.metadata["lead"] == "Alice Smith — Tech Lead"  # wikilinks stripped

    def test_parse_project_old_format_still_works(self, tmp_path):
        from kb.entities import _parse_project_file

        f = tmp_path / "old-proj.md"
        f.write_text(
            "# Old Project\n\n"
            "**Codename/Also called:** OP\n"
            "**Status:** Done\n"
        )
        result = _parse_project_file(f)
        assert result.name == "Old Project"
        assert "OP" in result.aliases
        assert result.metadata["status"] == "Done"

    def test_yaml_custom_fields_preserved(self, tmp_path):
        """YAML fields not in the known set become metadata."""
        from kb.entities import _parse_person_file

        f = tmp_path / "custom.md"
        f.write_text(
            '---\nrole: Dev\npreferred_lang: Python\n---\n# Custom Dev\n'
        )
        result = _parse_person_file(f)
        assert result.metadata["preferred_lang"] == "Python"

    def test_yaml_pcm_fields(self, tmp_path):
        """PCM fields are plain text (no wikilinks)."""
        from kb.entities import _parse_person_file

        f = tmp_path / "pcm.md"
        f.write_text(
            '---\nrole: Dev\npcm_base: "Persister (Persévérant)"\npcm_phase: "Thinker (Analyseur)"\n---\n# PCM Person\n'
        )
        result = _parse_person_file(f)
        assert result.metadata["pcm_base"] == "Persister (Persévérant)"
        assert result.metadata["pcm_phase"] == "Thinker (Analyseur)"
```

**Step 2:** Run `uv run pytest tests/test_entities.py::TestYamlFrontmatterParsing -x -v` — expect FAIL.

**Step 3: Implement**

Rewrite `_parse_person_file()` and `_parse_project_file()` in `src/kb/entities.py`. Add a `_parse_yaml_frontmatter()` helper:

```python
import yaml  # add to imports at top of file

# Fields that are entity references and may contain [[wikilinks]]
_ENTITY_REF_FIELDS = frozenset({"team", "reports_to", "lead", "company"})

# YAML frontmatter keys that map to special handling (not general metadata)
_YAML_PERSON_SPECIAL = frozenset({"aliases", "pinned"})
_YAML_PROJECT_SPECIAL = frozenset({"aliases", "pinned"})


def _parse_yaml_frontmatter(text: str) -> dict[str, Any] | None:
    """Parse YAML frontmatter from text. Returns None if no frontmatter found."""
    m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
    if not m:
        return None
    try:
        data = yaml.safe_load(m.group(1))
        return data if isinstance(data, dict) else None
    except yaml.YAMLError:
        return None


def _parse_person_file(path: Path) -> EntityData:
    text = path.read_text(encoding="utf-8")
    name = _parse_title(text) or path.stem.replace("-", " ").title()

    fm = _parse_yaml_frontmatter(text)
    if fm is not None:
        # YAML frontmatter format
        raw_aliases = fm.get("aliases", []) or []
        aliases = [str(a).strip() for a in raw_aliases]

        is_pinned = bool(fm.get("pinned", False))

        metadata: dict[str, str] = {}
        for key, value in fm.items():
            if key in _YAML_PERSON_SPECIAL:
                continue
            str_val = str(value).strip()
            if key in _ENTITY_REF_FIELDS:
                str_val = strip_wikilinks(str_val)
            if str_val:
                metadata[key] = str_val
    else:
        # Legacy **Key:** Value format (backward compat)
        also_known = _parse_field(text, "Also known as")
        aliases = []
        if also_known:
            aliases.extend(a.strip() for a in also_known.split(","))

        pinned_str = _parse_field(text, "Pinned")
        is_pinned = bool(pinned_str and pinned_str.lower() in ("true", "yes", "1"))

        all_fields = _parse_all_fields(text)
        metadata = {
            k: v for k, v in all_fields.items() if k not in _PERSON_SPECIAL_FIELDS
        }

    # Auto-add first name and file stem aliases
    first_name = _stem_to_first_name(name)
    if first_name and first_name not in aliases:
        aliases.append(first_name)
    file_stem = path.stem
    if file_stem not in aliases:
        aliases.append(file_stem)

    return EntityData(
        name=name,
        entity_type="person",
        aliases=aliases,
        metadata=metadata,
        source_path=str(Path("memory/people") / path.name),
        pinned=is_pinned,
    )


def _parse_project_file(path: Path) -> EntityData:
    text = path.read_text(encoding="utf-8")
    name = _parse_title(text) or path.stem.replace("-", " ").title()

    fm = _parse_yaml_frontmatter(text)
    if fm is not None:
        # YAML frontmatter format
        raw_aliases = fm.get("aliases", []) or []
        aliases = [str(a).strip() for a in raw_aliases]

        is_pinned = bool(fm.get("pinned", False))

        metadata: dict[str, str] = {}
        for key, value in fm.items():
            if key in _YAML_PROJECT_SPECIAL:
                continue
            str_val = str(value).strip()
            if key in _ENTITY_REF_FIELDS:
                str_val = strip_wikilinks(str_val)
            if str_val:
                metadata[key] = str_val
    else:
        # Legacy **Key:** Value format (backward compat)
        codename = _parse_field(text, "Codename/Also called")
        aliases = []
        if codename:
            aliases.extend(a.strip() for a in codename.split(","))

        pinned_str = _parse_field(text, "Pinned")
        is_pinned = bool(pinned_str and pinned_str.lower() in ("true", "yes", "1"))

        all_fields = _parse_all_fields(text)
        metadata = {
            k: v for k, v in all_fields.items() if k not in _PROJECT_SPECIAL_FIELDS
        }

    file_stem = path.stem
    if file_stem not in aliases:
        aliases.append(file_stem)

    return EntityData(
        name=name,
        entity_type="person",  # BUG: this was "person" in original — fix to "project"
        aliases=aliases,
        metadata=metadata,
        source_path=str(Path("memory/projects") / path.name),
        pinned=is_pinned,
    )
```

Note: The `_parse_title()` function already handles both formats — it searches for `^#\s+(.+)` anywhere in the text, so it finds the `# Title` after the YAML frontmatter block.

**Step 4:** Run `uv run pytest tests/test_entities.py::TestYamlFrontmatterParsing -x -v` — expect PASS.

Also run ALL existing entity tests to ensure backward compat: `uv run pytest tests/test_entities.py -x -v`

**Step 5: Commit**

```bash
git add src/kb/entities.py tests/test_entities.py
git commit -m "feat(entities): parse YAML frontmatter with fallback to legacy format"
```

---

## Task 3: YAML frontmatter writeback in `writeback.py`

Replace `_rebuild_person_header()` and `_rebuild_project_header()` to emit YAML frontmatter. Also update `_split_header_and_body()` to handle YAML frontmatter.

**Files:**
- Modify: `src/kb/writeback.py:30-148` — rewrite header builders and splitter
- Test: `tests/test_writeback.py`

**Step 1: Write failing tests**

```python
class TestYamlFrontmatterWriteback:
    """Writeback should emit YAML frontmatter."""

    def test_person_yaml_header(self):
        from kb.types import Entity
        from kb.writeback import _rebuild_person_header

        entity = Entity(
            id=1, name="Jane Doe", entity_type="person",
            aliases=["JD", "Jane", "jane-doe"],
            metadata={"email": "jane@x.com", "role": "Lead", "team": "Core", "reports_to": "CTO"},
            source_path="memory/people/jane-doe.md", pinned=False,
        )
        header = _rebuild_person_header(entity)
        assert header.startswith("---\n")
        assert "---\n# Jane Doe" in header
        assert "aliases:" in header
        assert "JD" in header
        assert "jane-doe" not in header  # file stem excluded
        assert "Jane" not in header  # first name excluded
        assert "email: jane@x.com" in header
        assert "role: Lead" in header
        assert 'team: "[[Core]]"' in header  # wikilinked
        assert 'reports_to: "[[CTO]]"' in header  # wikilinked
        assert "pinned" not in header  # not pinned

    def test_person_yaml_header_pinned(self):
        from kb.types import Entity
        from kb.writeback import _rebuild_person_header

        entity = Entity(
            id=1, name="Boss", entity_type="person",
            aliases=["boss"], metadata={"role": "CEO"},
            source_path="memory/people/boss.md", pinned=True,
        )
        header = _rebuild_person_header(entity)
        assert "pinned: true" in header

    def test_project_yaml_header(self):
        from kb.types import Entity
        from kb.writeback import _rebuild_project_header

        entity = Entity(
            id=1, name="My Project", entity_type="project",
            aliases=["MP", "my-project"],
            metadata={"status": "Active", "lead": "Alice Smith — Tech Lead"},
            source_path="memory/projects/my-project.md", pinned=False,
        )
        header = _rebuild_project_header(entity)
        assert header.startswith("---\n")
        assert "---\n# My Project" in header
        assert "MP" in header
        assert "my-project" not in header  # file stem excluded
        assert "status: Active" in header
        assert '[[Alice Smith]]' in header  # entity name wikilinked in lead

    def test_split_header_body_yaml(self):
        from kb.writeback import _split_header_and_body

        content = '---\nrole: Dev\n---\n# Name\n\n## Notes\n\nBody here.\n'
        header, body = _split_header_and_body(content)
        assert "---" in header
        assert body.startswith("## Notes")

    def test_split_header_body_legacy(self):
        """Legacy format still splits correctly."""
        from kb.writeback import _split_header_and_body

        content = '# Name\n\n**Role:** Dev\n\n## Notes\n\nBody here.\n'
        header, body = _split_header_and_body(content)
        assert "# Name" in header
        assert body.startswith("## Notes")
```

**Step 2:** Run tests — expect FAIL.

**Step 3: Implement**

Rewrite `_rebuild_person_header()` and `_rebuild_project_header()` in `src/kb/writeback.py`:

```python
import yaml  # add to imports

from kb.entities import strip_wikilinks  # add import

# Entity-reference fields that get [[wikilinks]] on write
_ENTITY_REF_FIELDS = frozenset({"team", "reports_to", "lead", "company"})

# Person YAML fields in output order (key, is_entity_ref)
_PERSON_YAML_FIELDS = [
    ("email", False),
    ("role", False),
    ("team", True),
    ("reports_to", True),
    ("company", True),
    ("pcm_base", False),
    ("pcm_phase", False),
]

# Project YAML fields in output order
_PROJECT_YAML_FIELDS = [
    ("status", False),
    ("started", False),
    ("lead", True),
]


def _add_wikilinks_to_entity_ref(value: str, known_entities: list[str] | None = None) -> str:
    """Add [[wikilinks]] to entity-reference field values.

    If value already contains [[...]], return as-is.
    Otherwise, wrap the entire value in [[ ]] (simple approach for now).
    """
    if "[[" in value:
        return value  # already wikilinked
    return f"[[{value}]]"


def _rebuild_person_header(entity: Entity) -> str:
    aliases = _derive_aliases(entity)

    # Build YAML frontmatter dict
    fm: dict[str, object] = {}
    if aliases:
        fm["aliases"] = aliases

    registered_keys: set[str] = set()
    for key, is_ref in _PERSON_YAML_FIELDS:
        registered_keys.add(key)
        value = entity.metadata.get(key)
        if value:
            if is_ref:
                fm[key] = _add_wikilinks_to_entity_ref(value)
            else:
                fm[key] = value

    if entity.pinned:
        fm["pinned"] = True

    # Custom fields
    for key, value in entity.metadata.items():
        if key not in registered_keys and value:
            fm[key] = value

    # Emit YAML frontmatter + title
    yaml_str = yaml.dump(fm, default_flow_style=False, allow_unicode=True, sort_keys=False)
    return f"---\n{yaml_str}---\n# {entity.name}\n\n"


def _rebuild_project_header(entity: Entity) -> str:
    aliases = _derive_aliases(entity)

    fm: dict[str, object] = {}
    if aliases:
        fm["aliases"] = aliases

    registered_keys: set[str] = set()
    for key, is_ref in _PROJECT_YAML_FIELDS:
        registered_keys.add(key)
        value = entity.metadata.get(key)
        if value:
            if is_ref:
                fm[key] = _add_wikilinks_to_entity_ref(value)
            else:
                fm[key] = value

    if entity.pinned:
        fm["pinned"] = True

    for key, value in entity.metadata.items():
        if key not in registered_keys and value:
            fm[key] = value

    yaml_str = yaml.dump(fm, default_flow_style=False, allow_unicode=True, sort_keys=False)
    return f"---\n{yaml_str}---\n# {entity.name}\n\n"
```

Also update `_split_header_and_body()` to handle YAML frontmatter:

```python
def _split_header_and_body(content: str) -> tuple[str, str]:
    # Check for YAML frontmatter first
    fm_match = re.match(r"^---\s*\n.*?\n---\s*\n", content, re.DOTALL)
    if fm_match:
        after_fm = content[fm_match.end():]
        # Find first ## in the content after frontmatter
        body_match = re.search(r"^## ", after_fm, re.MULTILINE)
        if body_match:
            header_end = fm_match.end() + body_match.start()
            return content[:header_end], content[header_end:]
        return content, ""

    # Legacy format: split at first ##
    match = re.search(r"^## ", content, re.MULTILINE)
    if match:
        return content[:match.start()], content[match.start():]
    return content, ""
```

**Step 4:** Run `uv run pytest tests/test_writeback.py -x -v` — all tests should pass (new + existing).

**Step 5: Commit**

```bash
git add src/kb/writeback.py tests/test_writeback.py
git commit -m "feat(writeback): emit YAML frontmatter with wikilinks"
```

---

## Task 4: YAML frontmatter in `crud.py`

Update `_build_markdown()` to emit YAML frontmatter format for new entity creation.

**Files:**
- Modify: `src/kb/crud.py:48-125`
- Test: `tests/test_crud.py`

**Step 1: Write failing tests**

```python
class TestYamlFrontmatterCreate:
    """New entities should be created with YAML frontmatter."""

    def test_build_markdown_person_yaml(self):
        from kb.crud import _build_markdown

        md = _build_markdown(
            "Jane Doe", "person",
            metadata={"email": "j@x.com", "role": "Dev", "team": "Core"},
            aliases=["JD"],
        )
        assert md.startswith("---\n")
        assert "aliases:" in md
        assert "JD" in md
        assert "email: j@x.com" in md
        assert "role: Dev" in md
        assert 'team: "[[Core]]"' in md
        assert "# Jane Doe" in md

    def test_build_markdown_project_yaml(self):
        from kb.crud import _build_markdown

        md = _build_markdown(
            "Big Project", "project",
            metadata={"status": "Active", "lead": "Alice"},
            aliases=["BP"],
        )
        assert md.startswith("---\n")
        assert "status: Active" in md
        assert "[[Alice]]" in md
        assert "# Big Project" in md

    def test_build_markdown_no_aliases(self):
        from kb.crud import _build_markdown

        md = _build_markdown("Solo", "person", metadata={"role": "Dev"})
        assert "aliases" not in md
        assert "# Solo" in md
```

**Step 2:** Run tests — expect FAIL.

**Step 3: Implement**

Rewrite `_build_markdown()` in `src/kb/crud.py`:

```python
import yaml  # add to imports
from kb.entities import strip_wikilinks  # add import

_ENTITY_REF_FIELDS = frozenset({"team", "reports_to", "lead", "company"})

def _build_markdown(
    name: str,
    entity_type: str,
    metadata: dict[str, str] | None = None,
    aliases: list[str] | None = None,
) -> str:
    metadata = metadata or {}
    aliases = aliases or []
    reg = _TYPE_REGISTRY[entity_type]

    fm: dict[str, object] = {}
    if aliases:
        fm["aliases"] = aliases

    registered_keys = {key for _label, key in reg.fields}
    for _label, key in reg.fields:
        value = metadata.get(key)
        if value:
            if key in _ENTITY_REF_FIELDS:
                fm[key] = f"[[{value}]]" if "[[" not in value else value
            else:
                fm[key] = value

    for key, value in metadata.items():
        if key not in registered_keys and value:
            fm[key] = value

    if not fm:
        return f"# {name}\n"

    yaml_str = yaml.dump(fm, default_flow_style=False, allow_unicode=True, sort_keys=False)
    return f"---\n{yaml_str}---\n# {name}\n"
```

**Step 4:** Run `uv run pytest tests/test_crud.py -x -v` — all tests should pass.

**Step 5: Commit**

```bash
git add src/kb/crud.py tests/test_crud.py
git commit -m "feat(crud): create new entities with YAML frontmatter"
```

---

## Task 5: Strip wikilinks from chunks and snippets

Ensure `[[wikilinks]]` don't leak into FTS index or search snippets.

**Files:**
- Modify: `src/kb/chunker.py` — strip wikilinks in `chunk_notes()` and `chunk_transcript()` content
- Modify: `src/kb/search.py` — strip wikilinks in `_make_snippet()`
- Modify: `src/kb/sources/memory.py` — strip wikilinks from memory chunk content
- Test: `tests/test_search.py`, `tests/test_indexing.py`

**Step 1: Write failing tests**

In `tests/test_search.py`:

```python
class TestWikilinkStripping:
    def test_snippet_strips_wikilinks(self):
        from kb.search import _make_snippet
        content = "Reports to [[Jeremy Brown]] who leads [[Engineering]]"
        snippet = _make_snippet(content)
        assert "[[" not in snippet
        assert "Jeremy Brown" in snippet

    def test_snippet_strips_wikilinks_in_metadata(self):
        from kb.search import _make_snippet
        content = "[Meeting: Test | Date: 2026-01-01]\nDiscussed [[Cloud Migration]] with [[Alice]]"
        snippet = _make_snippet(content)
        assert "[[" not in snippet
```

In `tests/test_indexing.py`:

```python
class TestWikilinksInChunks:
    def test_memory_chunks_strip_wikilinks(self):
        """Memory file chunks should not contain [[wikilinks]]."""
        from kb.sources.memory import _parse_memory_file

        # Create a file with wikilinks in content
        import tempfile
        from pathlib import Path
        with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as f:
            f.write("# Test\n\nReports to [[Jeremy Brown]] on [[Core]] team.\n")
            f.flush()
            doc = _parse_memory_file(Path(f.name), "memory/people/test.md", "memory_person")

        assert doc.chunks
        for chunk in doc.chunks:
            assert "[[" not in chunk.content
            assert "Jeremy Brown" in chunk.content
```

**Step 2:** Run tests — expect FAIL.

**Step 3: Implement**

In `src/kb/search.py`, update `_make_snippet()`:

```python
from kb.entities import strip_wikilinks  # add import

def _make_snippet(content: str, max_chars: int = 200, query: str = "") -> str:
    clean = _strip_metadata_prefix(content)
    clean = strip_wikilinks(clean)  # ADD THIS LINE
    # ... rest unchanged
```

In `src/kb/sources/memory.py`, update `_parse_memory_file()`:

```python
from kb.entities import strip_wikilinks  # add import

def _parse_memory_file(path, rel_path, doc_type):
    # ... existing code ...
    chunk_content = text.strip()
    chunk_content = strip_wikilinks(chunk_content)  # ADD THIS LINE
    chunks = [Chunk(index=0, heading=None, content=chunk_content)] if chunk_content else []
    # ... rest unchanged
```

Note: `chunker.py`'s `chunk_notes()` and `chunk_transcript()` handle meeting files which currently don't contain wikilinks (meetings are imported from Granola/Notion). Memory files are the ones with wikilinks, and those go through `_parse_memory_file()` in `sources/memory.py`. If wikilinks are later added to meeting notes, `chunk_notes()` would need the same treatment.

**Step 4:** Run `uv run pytest tests/test_search.py::TestWikilinkStripping tests/test_indexing.py::TestWikilinksInChunks -x -v`

**Step 5: Commit**

```bash
git add src/kb/search.py src/kb/sources/memory.py tests/test_search.py tests/test_indexing.py
git commit -m "feat: strip [[wikilinks]] from FTS chunks and search snippets"
```

---

## Task 6: Migration script — convert files to YAML frontmatter

One-time script to convert all 341 person + 13 project files from `**Key:** Value` to YAML frontmatter.

**Files:**
- Create: `scripts/migrate_to_yaml_frontmatter.py`
- Test: manual + spot-check

**Step 1: Write the migration script**

```python
#!/usr/bin/env python3
"""Migrate person/project files from **Key:** Value to YAML frontmatter.

Usage:
    python scripts/migrate_to_yaml_frontmatter.py [--dry-run] [--project-root PATH]

Reads all memory/people/*.md and memory/projects/*.md files.
Converts **Key:** Value headers to YAML frontmatter.
Preserves all ## body sections verbatim.
Uses atomic writes (temp file → rename).
"""
import argparse
import os
import re
import sys
import tempfile
from pathlib import Path

import yaml


# Entity-reference fields that get [[wikilinks]]
_ENTITY_REF_FIELDS = {"team", "reports_to", "lead", "company"}

# Person field order for YAML output
_PERSON_YAML_ORDER = ["aliases", "email", "role", "team", "reports_to", "company",
                       "pcm_base", "pcm_phase", "pinned"]

# Project field order for YAML output
_PROJECT_YAML_ORDER = ["aliases", "status", "started", "lead", "pinned"]


def _parse_title(text: str) -> str | None:
    m = re.search(r"^#\s+(.+)", text, re.MULTILINE)
    return m.group(1).strip() if m else None


def _has_yaml_frontmatter(text: str) -> bool:
    return bool(re.match(r"^---\s*\n", text))


def _parse_old_header(text: str) -> tuple[dict[str, str], str, str]:
    """Parse old format. Returns (fields_dict, title, body_from_first_##)."""
    # Split at first ## heading
    match = re.search(r"^## ", text, re.MULTILINE)
    if match:
        header_text = text[:match.start()]
        body = text[match.start():]
    else:
        header_text = text
        body = ""

    title = _parse_title(header_text) or "Untitled"

    fields: dict[str, str] = {}
    for m in re.finditer(r"\*\*(.+?):\*\*\s*(.+)", header_text):
        label = m.group(1).strip()
        value = m.group(2).strip()
        key = "_".join(label.lower().split())
        fields[key] = value

    return fields, title, body


def _add_wikilink(value: str) -> str:
    """Wrap value in [[...]] if not already wikilinked."""
    if "[[" in value:
        return value
    return f"[[{value}]]"


def _convert_person(text: str) -> str:
    if _has_yaml_frontmatter(text):
        return text  # already converted

    fields, title, body = _parse_old_header(text)

    fm: dict[str, object] = {}

    # Aliases
    aka = fields.pop("also_known_as", None)
    if aka:
        fm["aliases"] = [a.strip() for a in aka.split(",")]

    # Known fields in order
    for key in ["email", "role", "team", "reports_to", "company", "pcm_base", "pcm_phase"]:
        val = fields.pop(key, None)
        if val:
            if key in _ENTITY_REF_FIELDS:
                fm[key] = _add_wikilink(val)
            else:
                fm[key] = val

    # Pinned
    pinned = fields.pop("pinned", None)
    if pinned and pinned.lower() in ("true", "yes", "1"):
        fm["pinned"] = True

    # Remaining custom fields
    for key, val in fields.items():
        if val:
            fm[key] = val

    yaml_str = yaml.dump(fm, default_flow_style=False, allow_unicode=True, sort_keys=False)
    return f"---\n{yaml_str}---\n# {title}\n\n{body}"


def _convert_project(text: str) -> str:
    if _has_yaml_frontmatter(text):
        return text

    fields, title, body = _parse_old_header(text)

    fm: dict[str, object] = {}

    # Aliases
    codename = fields.pop("codename/also_called", None)
    if codename:
        fm["aliases"] = [a.strip() for a in codename.split(",")]

    for key in ["status", "started", "lead"]:
        val = fields.pop(key, None)
        if val:
            if key in _ENTITY_REF_FIELDS:
                fm[key] = _add_wikilink(val)
            else:
                fm[key] = val

    pinned = fields.pop("pinned", None)
    if pinned and pinned.lower() in ("true", "yes", "1"):
        fm["pinned"] = True

    for key, val in fields.items():
        if val:
            fm[key] = val

    yaml_str = yaml.dump(fm, default_flow_style=False, allow_unicode=True, sort_keys=False)
    return f"---\n{yaml_str}---\n# {title}\n\n{body}"


def _atomic_write(path: Path, content: str) -> None:
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".md.tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(content)
        os.replace(tmp, str(path))
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


def main():
    parser = argparse.ArgumentParser(description="Migrate entity files to YAML frontmatter")
    parser.add_argument("--dry-run", action="store_true", help="Print changes without writing")
    parser.add_argument("--project-root", type=Path, default=Path(".").parent,
                        help="Project root (parent of kbx/)")
    args = parser.parse_args()

    root = args.project_root
    people_dir = root / "memory" / "people"
    projects_dir = root / "memory" / "projects"

    converted = 0
    skipped = 0

    for path in sorted(people_dir.glob("*.md")):
        text = path.read_text(encoding="utf-8")
        new_text = _convert_person(text)
        if new_text == text:
            skipped += 1
            continue
        if args.dry_run:
            print(f"  WOULD convert: {path.name}")
        else:
            _atomic_write(path, new_text)
            print(f"  Converted: {path.name}")
        converted += 1

    for path in sorted(projects_dir.glob("*.md")):
        text = path.read_text(encoding="utf-8")
        new_text = _convert_project(text)
        if new_text == text:
            skipped += 1
            continue
        if args.dry_run:
            print(f"  WOULD convert: {path.name}")
        else:
            _atomic_write(path, new_text)
            print(f"  Converted: {path.name}")
        converted += 1

    print(f"\nDone: {converted} converted, {skipped} skipped (already YAML or unchanged)")


if __name__ == "__main__":
    main()
```

**Step 2: Test with --dry-run**

```bash
python scripts/migrate_to_yaml_frontmatter.py --dry-run --project-root "/Users/jeremy.brown/PERSONAL Files/Documents/20-29 Personal/Control Tower"
```

Verify output looks correct. Spot-check a few files.

**Step 3: Run the migration**

```bash
python scripts/migrate_to_yaml_frontmatter.py --project-root "/Users/jeremy.brown/PERSONAL Files/Documents/20-29 Personal/Control Tower"
```

**Step 4: Verify**

```bash
# Check a person file
head -15 "/Users/jeremy.brown/PERSONAL Files/Documents/20-29 Personal/Control Tower/memory/people/eric-fourrier.md"

# Check a project file
head -15 "/Users/jeremy.brown/PERSONAL Files/Documents/20-29 Personal/Control Tower/memory/projects/ai-adoption.md"

# Re-index and verify entities still parse
cd kbx && uv run kb index run --no-embed
```

**Step 5: Commit (in parent Control Tower repo)**

```bash
git add memory/people/ memory/projects/
git commit -m "chore: migrate entity files to YAML frontmatter"
```

Also commit the script:
```bash
cd kbx
git add scripts/migrate_to_yaml_frontmatter.py
git commit -m "feat: add YAML frontmatter migration script"
```

---

## Task 7: Update existing tests for YAML format

Many existing tests write person/project files in old `**Key:** Value` format. While backward compat means they still pass, we should update the canonical test fixtures to use YAML format so they test the primary code path.

**Files:**
- Modify: `tests/test_entities.py` — update `entity_root` fixture and `TestPersonParsing`/`TestProjectParsing`
- Modify: `tests/test_writeback.py` — update existing `TestRebuild*` tests
- Modify: `tests/test_crud.py` — update `TestPersonCreate`/`TestProjectCreate` assertions

**Step 1: Update `entity_root` fixture in `test_entities.py`**

Change the fixture file contents to YAML format:

```python
@pytest.fixture
def entity_root(tmp_path):
    mem = tmp_path / "memory"
    people = mem / "people"
    people.mkdir(parents=True)
    (people / "david-marchand.md").write_text(
        '---\n'
        'aliases: [David M., David]\n'
        'email: david@example.com\n'
        'role: CEO\n'
        'team: "[[ExCom]]"\n'
        '---\n'
        '# David Marchand\n'
    )
    (people / "alice-smith.md").write_text(
        '---\n'
        'aliases: [Alice]\n'
        'role: Engineer\n'
        'team: "[[Platform]]"\n'
        '---\n'
        '# Alice Smith\n'
    )
    projects = mem / "projects"
    projects.mkdir(parents=True)
    (projects / "cloud-migration.md").write_text(
        '---\n'
        'aliases: [Core Detection Engine Rewrite]\n'
        'status: Active\n'
        'lead: "[[Camille Durand]]"\n'
        '---\n'
        '# Cloud Migration\n'
    )
    # ... rest of fixture unchanged (context/company.md stays the same)
```

Update the assertions in `TestPersonParsing` and `TestProjectParsing` to match the YAML format behavior (metadata values should have wikilinks stripped).

**Step 2:** Run `uv run pytest tests/ -x -q` — all tests should pass.

**Step 3: Commit**

```bash
git add tests/test_entities.py tests/test_writeback.py tests/test_crud.py
git commit -m "test: update fixtures to use YAML frontmatter format"
```

---

## Task 8: Full integration test + regression suite

Run the complete test suite and fix any regressions.

**Step 1:** Run full suite

```bash
uv run pytest tests/ -x -q --cov
```

**Step 2:** Fix any failures. Common issues:
- Tests that assert `**Also known as:**` in file content → update to assert YAML `aliases:` instead
- Tests that assert header doesn't start with `---` → update
- `_split_header_and_body()` tests → add YAML frontmatter cases

**Step 3:** Run mypy

```bash
uv run mypy src/
```

**Step 4: Commit**

```bash
git commit -am "fix: resolve test regressions from YAML frontmatter migration"
```

---

## Task 9: Batch wikilinks script

Scan all memory files and add `[[wikilinks]]` for known entity names in body text and facts.

**Files:**
- Create: `scripts/add_wikilinks.py`

This is a separate enhancement that can be run after the core migration. The script:
1. Loads all entity names from the DB
2. Scans `memory/**/*.md` files
3. In body text (not code blocks, not `# titles`, not `aliases:` values), wraps known entity names in `[[...]]`
4. Uses word-boundary matching to avoid partial matches
5. Atomic writes

**Implementation:** Similar structure to the migration script. Use the entity patterns from `entities.py:build_entity_patterns()` for matching.

**Step 1: Write and test with --dry-run**
**Step 2: Run on real files**
**Step 3: Commit**

```bash
git add scripts/add_wikilinks.py
git commit -m "feat: add batch wikilinks script for memory files"
```

---

## Dependency Graph

```
Task 1 (strip_wikilinks)
  ├── Task 2 (YAML parsing) ← depends on Task 1
  ├── Task 3 (YAML writeback) ← depends on Task 1
  ├── Task 4 (YAML crud) ← depends on Task 1
  └── Task 5 (strip in chunks/snippets) ← depends on Task 1
Task 6 (migration script) ← depends on Tasks 2, 3
Task 7 (update tests) ← depends on Tasks 2, 3, 4
Task 8 (integration) ← depends on all above
Task 9 (wikilinks script) ← depends on Tasks 1, 6
```

Tasks 2-5 are independent of each other (all depend only on Task 1).
Task 6 can run after Tasks 2+3.
Tasks 7+8 are cleanup/verification.
Task 9 is a standalone enhancement.
