# Notion Transcript Sync — Implementation Plan

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

**Goal:** Pull meeting transcripts from Notion's AI Meeting Notes into kbx via the internal API, with calendar UID-based file naming, speaker attribution, and a unified `kb sync` command.

**Architecture:** NotionClient decrypts `token_v2` from the Notion Electron app's cookie store (or `NOTION_TOKEN_V2` env var), hits `/api/v3/search` to find meeting pages, `/api/v3/loadPageChunk` to get transcription blocks, and `/api/v3/syncRecordValues` to resolve users. Writes markdown files to `meetings/organised/YYYY/MM/DD/` in the new `{uid_prefix}_{Title}.{source}.{type}.md` naming convention.

**Tech Stack:** Python 3.10+, httpx, cryptography (new dep for AES cookie decryption), Click CLI, PyYAML, existing kbx infrastructure.

**Design doc:** `docs/plans/2026-02-23-notion-sync-design.md`

---

## Task 1: Add `cryptography` dependency

**Files:**
- Modify: `pyproject.toml:23-31` (dependencies list)

**Step 1: Add cryptography to dependencies**

In `pyproject.toml`, add `"cryptography>=43.0"` to the `dependencies` list (after `httpx`). This is needed for AES-128-CBC decryption of Notion's Electron cookie store.

**Step 2: Sync the lock file**

Run: `uv sync`

**Step 3: Commit**

```
git add pyproject.toml uv.lock
git commit -m "build: add cryptography dependency for Notion cookie decryption"
```

---

## Task 2: NotionClient — cookie decryption and auth

**Files:**
- Create: `src/kb/sync/notion.py`
- Create: `tests/test_sync_notion.py`

**Step 1: Write failing tests for token reading**

In `tests/test_sync_notion.py`, create test fixtures and auth tests:

```python
"""Tests for Notion sync — token reading and API client."""

from __future__ import annotations

import json
import sqlite3
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest


@pytest.fixture
def tmp_dir():
    """Provide a temporary directory."""
    with tempfile.TemporaryDirectory() as d:
        yield Path(d)


class TestTokenReading:
    """Phase 1: Token decryption and reading."""

    def test_token_from_env_var(self, tmp_dir, monkeypatch):
        """NOTION_TOKEN_V2 env var takes priority over cookie store."""
        from kb.sync.notion import NotionClient

        monkeypatch.setenv("NOTION_TOKEN_V2", "test-token-from-env")
        client = NotionClient(cookie_path=tmp_dir / "nonexistent")
        assert client._get_token() == "test-token-from-env"

    def test_missing_cookie_file_raises(self, tmp_dir):
        """Missing cookie DB raises FileNotFoundError."""
        from kb.sync.notion import NotionClient

        client = NotionClient(cookie_path=tmp_dir / "missing.db")
        with pytest.raises(FileNotFoundError, match="Notion cookie"):
            client._get_token()

    def test_missing_token_in_cookie_db_raises(self, tmp_dir):
        """Cookie DB without token_v2 row raises ValueError."""
        from kb.sync.notion import NotionClient

        # Create empty cookies DB with correct schema
        db_path = tmp_dir / "Cookies"
        conn = sqlite3.connect(db_path)
        conn.execute(
            "CREATE TABLE cookies (name TEXT, host_key TEXT, encrypted_value BLOB, value TEXT)"
        )
        conn.commit()
        conn.close()

        client = NotionClient(cookie_path=db_path)
        with pytest.raises(ValueError, match="token_v2 not found"):
            client._get_token()

    def test_decrypt_cookie_v10(self, tmp_dir):
        """Decrypt a v10-prefixed AES-128-CBC cookie value."""
        from kb.sync.notion import _decrypt_electron_cookie

        # Create a known encrypted value using the same algorithm
        import hashlib
        from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
        from cryptography.hazmat.primitives import padding

        password = b"test-safe-storage-key"
        key = hashlib.pbkdf2_hmac("sha1", password, b"saltysalt", 1003, dklen=16)
        iv = b" " * 16
        plaintext = b"v03:eyJhbGciOiJ0ZXN0In0"

        padder = padding.PKCS7(128).padder()
        padded = padder.update(plaintext) + padder.finalize()
        cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
        encryptor = cipher.encryptor()
        ciphertext = encryptor.update(padded) + encryptor.finalize()
        encrypted = b"v10" + ciphertext

        result = _decrypt_electron_cookie(encrypted, password)
        assert result == "v03:eyJhbGciOiJ0ZXN0In0"
```

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

Run: `uv run pytest tests/test_sync_notion.py -x -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'kb.sync.notion'`

**Step 3: Implement NotionClient auth**

Create `src/kb/sync/notion.py`:

```python
"""Notion internal API sync — client, transform, and write.

Pulls meeting transcripts from Notion's AI Meeting Notes via the
internal /api/v3/ API, produces enriched markdown files, and
optionally triggers indexing.
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import sqlite3
import subprocess
import time
import urllib.parse
from datetime import datetime
from pathlib import Path
from typing import Any, cast

# httpx and cryptography are imported lazily inside methods that use them.

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

NOTION_API_BASE = "https://www.notion.so/api/v3"

# Default cookie path on macOS (Notion Electron app)
DEFAULT_COOKIE_PATH = (
    Path.home() / "Library" / "Application Support" / "Notion" / "Partitions" / "notion" / "Cookies"
)

KEYCHAIN_SERVICE = "Notion Safe Storage"
KEYCHAIN_ACCOUNT = "Notion Key"

# Rate limiting
BATCH_SIZE = 15
BATCH_DELAY = 0.5


# ---------------------------------------------------------------------------
# Cookie decryption
# ---------------------------------------------------------------------------


def _get_keychain_password(service: str = KEYCHAIN_SERVICE) -> bytes:
    """Read the Electron safe storage password from macOS Keychain."""
    result = subprocess.run(
        ["security", "find-generic-password", "-s", service, "-w"],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise FileNotFoundError(
            f"Keychain entry '{service}' not found. Is the Notion desktop app installed?"
        )
    return result.stdout.strip().encode("utf-8")


def _decrypt_electron_cookie(encrypted_value: bytes, password: bytes) -> str:
    """Decrypt an Electron v10-prefixed AES-128-CBC cookie.

    Electron on macOS uses:
    - PBKDF2(SHA1, password, 'saltysalt', 1003 iters, 16 byte key)
    - AES-128-CBC with IV of 16 spaces
    - PKCS7 padding
    - 'v10' prefix on the ciphertext
    """
    from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

    if not encrypted_value.startswith(b"v10"):
        raise ValueError(f"Unsupported cookie encryption version: {encrypted_value[:3]!r}")

    key = hashlib.pbkdf2_hmac("sha1", password, b"saltysalt", 1003, dklen=16)
    iv = b" " * 16
    ciphertext = encrypted_value[3:]

    cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
    decryptor = cipher.decryptor()
    decrypted = decryptor.update(ciphertext) + decryptor.finalize()

    # Strip PKCS7 padding
    pad_len = decrypted[-1]
    if 1 <= pad_len <= 16 and all(b == pad_len for b in decrypted[-pad_len:]):
        decrypted = decrypted[:-pad_len]

    # The raw bytes may have a non-UTF8 prefix before the actual token.
    # Find the token start (URL-encoded "v0" prefix).
    text = decrypted.decode("latin-1")
    idx = text.find("v0")
    if idx < 0:
        raise ValueError("Could not find token in decrypted cookie value")

    return urllib.parse.unquote(text[idx:])


# ---------------------------------------------------------------------------
# API Client
# ---------------------------------------------------------------------------


class NotionClient:
    """Client for Notion's internal API."""

    def __init__(self, cookie_path: Path | None = None) -> None:
        self._cookie_path = cookie_path or DEFAULT_COOKIE_PATH
        self._token: str | None = None

    def _get_token(self) -> str:
        """Get the token_v2, preferring env var over cookie store."""
        if self._token:
            return self._token

        # 1. Try env var
        env_token = os.environ.get("NOTION_TOKEN_V2")
        if env_token:
            self._token = env_token
            return self._token

        # 2. Decrypt from cookie store
        if not self._cookie_path.exists():
            raise FileNotFoundError(
                f"Notion cookie store not found at {self._cookie_path}. "
                "Is the Notion desktop app installed?"
            )

        conn = sqlite3.connect(str(self._cookie_path))
        try:
            row = conn.execute(
                "SELECT encrypted_value FROM cookies "
                "WHERE name='token_v2' AND host_key='.www.notion.so'"
            ).fetchone()
        finally:
            conn.close()

        if not row:
            raise ValueError("token_v2 not found in Notion cookie store")

        password = _get_keychain_password()
        self._token = _decrypt_electron_cookie(row[0], password)
        return self._token

    def _request(
        self,
        path: str,
        json_data: dict[str, Any] | None = None,
    ) -> Any:
        """Make an authenticated API request to Notion's internal API."""
        import httpx

        token = self._get_token()
        response = httpx.request(
            "POST",
            f"{NOTION_API_BASE}{path}",
            json=json_data or {},
            headers={"Content-Type": "application/json"},
            cookies={"token_v2": token},
            timeout=30.0,
        )
        response.raise_for_status()
        return response.json()
```

**Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_sync_notion.py -x -v`
Expected: 4 PASS

**Step 5: Run mypy**

Run: `uv run mypy src/kb/sync/notion.py`
Expected: clean

**Step 6: Commit**

```
git add src/kb/sync/notion.py tests/test_sync_notion.py
git commit -m "feat(sync): add NotionClient with cookie decryption and auth"
```

---

## Task 3: NotionClient — API methods (search, loadPageChunk, syncRecordValues)

**Files:**
- Modify: `src/kb/sync/notion.py`
- Modify: `tests/test_sync_notion.py`

**Step 1: Write failing tests for API methods**

Add to `tests/test_sync_notion.py`:

```python
class TestAPIClient:
    """Phase 2: API request methods."""

    @pytest.fixture
    def client(self, monkeypatch):
        """Create a NotionClient with mock token."""
        monkeypatch.setenv("NOTION_TOKEN_V2", "test-token")
        from kb.sync.notion import NotionClient
        return NotionClient()

    def test_search_pages(self, client):
        """Search returns page results with pagination."""
        mock_response = {
            "results": [{"id": "page-1"}, {"id": "page-2"}],
            "total": 2,
            "recordMap": {"block": {}},
        }
        with patch.object(client, "_request", return_value=mock_response) as mock_req:
            results, record_map = client.search_pages(space_id="space-1", limit=10)
            assert len(results) == 2
            mock_req.assert_called_once()
            call_args = mock_req.call_args
            assert call_args[0][0] == "/search"

    def test_load_page_chunk(self, client):
        """loadPageChunk returns block record map."""
        mock_response = {
            "recordMap": {
                "block": {
                    "page-1": {"value": {"type": "page", "content": ["trans-1"]}},
                    "trans-1": {"value": {"type": "transcription", "format": {}}},
                }
            }
        }
        with patch.object(client, "_request", return_value=mock_response):
            blocks = client.load_page_chunk("page-1", limit=5)
            assert "page-1" in blocks
            assert blocks["trans-1"]["value"]["type"] == "transcription"

    def test_sync_record_values(self, client):
        """syncRecordValues batch-fetches records."""
        mock_response = {
            "recordMap": {
                "notion_user": {
                    "user-1": {"value": {"name": "Alice", "email": "alice@example.com"}}
                }
            }
        }
        with patch.object(client, "_request", return_value=mock_response):
            users = client.get_users(["user-1"])
            assert users["user-1"]["name"] == "Alice"
```

**Step 2: Run tests, verify fail**

Run: `uv run pytest tests/test_sync_notion.py::TestAPIClient -x -v`
Expected: FAIL with `AttributeError: 'NotionClient' object has no attribute 'search_pages'`

**Step 3: Implement API methods**

Add to `NotionClient` in `src/kb/sync/notion.py`:

```python
    def search_pages(
        self,
        space_id: str,
        since: str | None = None,
        created_by: str | None = None,
        limit: int = 100,
        pagination_token: str | None = None,
    ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
        """Search for pages in a workspace.

        Returns (results_list, block_record_map).
        """
        filters: dict[str, Any] = {
            "isDeletedOnly": False,
            "excludeTemplates": True,
            "navigableBlockContentOnly": True,
            "requireEditPermissions": False,
            "ancestors": [],
            "createdBy": [created_by] if created_by else [],
            "editedBy": [],
            "lastEditedTime": {},
            "createdTime": {},
        }
        if since:
            filters["createdTime"] = {
                "type": "after",
                "value": {"type": "exact", "value": since},
            }

        payload: dict[str, Any] = {
            "type": "BlocksInSpace",
            "query": "",
            "spaceId": space_id,
            "limit": limit,
            "filters": filters,
            "sort": {"field": "created", "direction": "desc"},
            "source": "quick_find_input_change",
        }
        if pagination_token:
            payload["paginationToken"] = pagination_token

        data = self._request("/search", json_data=payload)
        results = data.get("results", [])
        record_map = data.get("recordMap", {}).get("block", {})
        return results, record_map

    def load_page_chunk(
        self, page_id: str, limit: int = 5
    ) -> dict[str, Any]:
        """Load a page's blocks. Returns block record map."""
        data = self._request(
            "/loadPageChunk",
            json_data={
                "pageId": page_id,
                "limit": limit,
                "cursor": {"stack": []},
                "chunkNumber": 0,
                "verticalColumns": False,
            },
        )
        return cast("dict[str, Any]", data.get("recordMap", {}).get("block", {}))

    def load_block_children(
        self, block_id: str, limit: int = 200
    ) -> dict[str, Any]:
        """Load a block's children (e.g. transcript segments). Returns block record map."""
        return self.load_page_chunk(block_id, limit=limit)

    def get_users(self, user_ids: list[str]) -> dict[str, dict[str, Any]]:
        """Batch-resolve Notion user IDs to name/email dicts."""
        requests = [
            {"pointer": {"table": "notion_user", "id": uid}, "version": -1}
            for uid in user_ids
        ]
        data = self._request("/syncRecordValues", json_data={"requests": requests})
        users = data.get("recordMap", {}).get("notion_user", {})
        return {
            uid: record["value"]
            for uid, record in users.items()
            if "value" in record
        }

    def get_space_id(self) -> str:
        """Get the current user's primary space ID."""
        data = self._request("/getSpaces", json_data={})
        # Response is keyed by user ID, each containing space data
        for user_data in data.values():
            space_views = user_data.get("space_view", {})
            for sv in space_views.values():
                space_id = sv.get("value", {}).get("space_id")
                if space_id:
                    return cast("str", space_id)
        raise ValueError("No workspace found for current user")

    def get_current_user_id(self) -> str:
        """Get the current authenticated user's ID."""
        data = self._request("/getSpaces", json_data={})
        user_ids = list(data.keys())
        if not user_ids:
            raise ValueError("No user found in API response")
        return user_ids[0]
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_sync_notion.py -x -v`
Expected: All PASS

**Step 5: Run mypy**

Run: `uv run mypy src/kb/sync/notion.py`

**Step 6: Commit**

```
git add src/kb/sync/notion.py tests/test_sync_notion.py
git commit -m "feat(sync): add Notion API methods (search, loadPageChunk, syncRecordValues)"
```

---

## Task 4: Transcript and notes extraction

**Files:**
- Modify: `src/kb/sync/notion.py`
- Modify: `tests/test_sync_notion.py`

**Step 1: Write failing tests for content extraction**

```python
class TestContentExtraction:
    """Phase 3: Extract transcript, notes, and summary from blocks."""

    def test_transcript_to_markdown_with_speakers(self):
        """Transcript segments produce speaker-attributed markdown."""
        from kb.sync.notion import transcript_to_markdown

        blocks = {
            "seg-1": {"value": {
                "type": "text",
                "properties": {"title": [["Hello, how are you?"]]},
                "format": {"transcript_metadata": {"speaker_name": "speaker0"}},
                "parent_id": "transcript-block",
            }},
            "seg-2": {"value": {
                "type": "text",
                "properties": {"title": [["I'm good, thanks."]]},
                "format": {"transcript_metadata": {"speaker_name": "speaker1"}},
                "parent_id": "transcript-block",
            }},
        }
        # Content order: list of child IDs
        child_order = ["seg-1", "seg-2"]

        result = transcript_to_markdown(blocks, child_order)
        assert "**Speaker 1**: Hello, how are you?" in result
        assert "**Speaker 2**: I'm good, thanks." in result

    def test_transcript_merges_consecutive_same_speaker(self):
        """Consecutive segments from the same speaker are merged."""
        from kb.sync.notion import transcript_to_markdown

        blocks = {
            "seg-1": {"value": {
                "type": "text",
                "properties": {"title": [["First part."]]},
                "format": {"transcript_metadata": {"speaker_name": "speaker0"}},
                "parent_id": "t",
            }},
            "seg-2": {"value": {
                "type": "text",
                "properties": {"title": [["Second part."]]},
                "format": {"transcript_metadata": {"speaker_name": "speaker0"}},
                "parent_id": "t",
            }},
        }
        result = transcript_to_markdown(blocks, ["seg-1", "seg-2"])
        assert result.count("**Speaker 1**") == 1
        assert "First part. Second part." in result

    def test_notes_blocks_to_markdown(self):
        """Notes child blocks produce markdown text."""
        from kb.sync.notion import notes_to_markdown

        blocks = {
            "n1": {"value": {
                "type": "sub_sub_header",
                "properties": {"title": [["Action Items"]]},
                "parent_id": "notes-block",
            }},
            "n2": {"value": {
                "type": "to_do",
                "properties": {"title": [["Follow up with team"]], "checked": [["Yes"]]},
                "parent_id": "notes-block",
            }},
        }
        result = notes_to_markdown(blocks, ["n1", "n2"])
        assert "### Action Items" in result
        assert "- [x] Follow up with team" in result

    def test_extract_text_from_title_property(self):
        """Notion's title property array is flattened to text."""
        from kb.sync.notion import _extract_text

        # Simple text
        assert _extract_text([["Hello world"]]) == "Hello world"
        # Multi-part with formatting markers
        assert _extract_text([["Hello "], ["world"]]) == "Hello world"
        # Empty
        assert _extract_text([]) == ""
```

**Step 2: Run tests, verify fail**

Run: `uv run pytest tests/test_sync_notion.py::TestContentExtraction -x -v`

**Step 3: Implement content extraction**

Add to `src/kb/sync/notion.py`:

```python
# ---------------------------------------------------------------------------
# Content extraction helpers
# ---------------------------------------------------------------------------


def _extract_text(title_prop: list[Any]) -> str:
    """Extract plain text from Notion's title property array.

    Notion stores text as: [["Hello "], ["world", [["b"]]]]
    We extract just the text parts (index 0 of each sub-array).
    """
    parts: list[str] = []
    for part in title_prop:
        if isinstance(part, list) and part and isinstance(part[0], str):
            parts.append(part[0])
    return "".join(parts)


def transcript_to_markdown(
    blocks: dict[str, Any], child_order: list[str]
) -> str:
    """Convert transcript blocks to speaker-attributed markdown.

    Speaker names like 'speaker0' become 'Speaker 1', 'speaker1' → 'Speaker 2', etc.
    Consecutive segments from the same speaker are merged.
    """
    SPEAKER_LABEL_RE = re.compile(r"^speaker(\d+)$")

    merged: list[dict[str, str]] = []
    for child_id in child_order:
        record = blocks.get(child_id, {})
        val = record.get("value", {})
        if val.get("type") != "text":
            continue

        props = val.get("properties", {})
        text = _extract_text(props.get("title", [])).strip()
        if not text:
            continue

        fmt = val.get("format", {})
        raw_speaker = fmt.get("transcript_metadata", {}).get("speaker_name", "unknown")
        m = SPEAKER_LABEL_RE.match(raw_speaker)
        speaker = f"Speaker {int(m.group(1)) + 1}" if m else raw_speaker

        if merged and merged[-1]["speaker"] == speaker:
            merged[-1]["text"] += " " + text
        else:
            merged.append({"speaker": speaker, "text": text})

    return "\n\n".join(f"**{seg['speaker']}**: {seg['text']}" for seg in merged)


def notes_to_markdown(
    blocks: dict[str, Any], child_order: list[str]
) -> str:
    """Convert Notion notes blocks to markdown.

    Handles: sub_sub_header (###), to_do, bulleted_list, text, header_4 (####).
    """
    lines: list[str] = []
    for child_id in child_order:
        record = blocks.get(child_id, {})
        val = record.get("value", {})
        btype = val.get("type", "")
        props = val.get("properties", {})
        text = _extract_text(props.get("title", []))

        if btype == "sub_sub_header":
            lines.append(f"### {text}")
        elif btype == "header_4":
            lines.append(f"#### {text}")
        elif btype == "to_do":
            checked = _extract_text(props.get("checked", [])) == "Yes"
            marker = "[x]" if checked else "[ ]"
            lines.append(f"- {marker} {text}")
        elif btype == "bulleted_list":
            lines.append(f"- {text}")
        elif btype == "numbered_list":
            lines.append(f"1. {text}")
        elif btype == "text" and text:
            lines.append(text)

    return "\n\n".join(lines)
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_sync_notion.py -x -v`
Expected: All PASS

**Step 5: Run mypy**

Run: `uv run mypy src/kb/sync/notion.py`

**Step 6: Commit**

```
git add src/kb/sync/notion.py tests/test_sync_notion.py
git commit -m "feat(sync): add Notion transcript and notes extraction"
```

---

## Task 5: Frontmatter building and file writing

**Files:**
- Modify: `src/kb/sync/notion.py`
- Modify: `tests/test_sync_notion.py`

**Step 1: Write failing tests for frontmatter and file writing**

```python
class TestFrontmatterAndWrite:
    """Phase 4: Frontmatter building and file writing."""

    def test_build_frontmatter(self):
        """Frontmatter includes all required fields."""
        from kb.sync.notion import build_frontmatter

        fm = build_frontmatter(
            title="Weekly Sync",
            page_id="page-123",
            last_edited_time=1771881551056,
            calendar_event={
                "uid": "abc123@google.com",
                "startTime": "2026-02-23T10:00:00.000+01:00",
                "endTime": "2026-02-23T10:30:00.000+01:00",
            },
            attendees=[
                {"name": "Alice", "email": "alice@example.com"},
                {"name": "Bob", "email": "bob@example.com"},
            ],
        )
        assert fm["title"] == "Weekly Sync"
        assert fm["source"] == "notion-api"
        assert fm["notion_page_id"] == "page-123"
        assert fm["calendar_uid"] == "abc123@google.com"
        assert len(fm["attendees"]) == 2
        assert fm["calendar_event"]["scheduled_start"] == "2026-02-23T10:00:00.000+01:00"

    def test_make_filename_with_calendar_uid(self):
        """Filename uses calendar UID prefix when available."""
        from kb.sync.notion import make_filename

        result = make_filename("Weekly Sync", calendar_uid="abc123def@google.com", source="notion")
        assert result == "abc123de_Weekly_Sync"

    def test_make_filename_without_calendar_uid(self):
        """Filename falls back to source ID prefix."""
        from kb.sync.notion import make_filename

        result = make_filename("Weekly Sync", source_id="page-123-456-789", source="notion")
        assert result == "page-123_Weekly_Sync"

    def test_write_meeting_creates_files(self, tmp_dir):
        """write_meeting creates notes and transcript files."""
        from kb.sync.notion import write_meeting

        result = write_meeting(
            frontmatter={"title": "Test", "date": "2026-02-23", "source": "notion-api"},
            notes_md="## Notes\nSome notes",
            transcript_md="**Speaker 1**: Hello",
            base_name="abc12345_Test",
            source="notion",
            project_root=tmp_dir,
        )
        assert result["status"] == "created"
        assert result["notes_path"].exists()
        assert result["transcript_path"].exists()
        assert ".notion.notes.md" in result["notes_path"].name
        assert ".notion.transcript.md" in result["transcript_path"].name
```

**Step 2: Run tests, verify fail**

**Step 3: Implement frontmatter and file writing**

Add to `src/kb/sync/notion.py`:

```python
# ---------------------------------------------------------------------------
# Frontmatter
# ---------------------------------------------------------------------------


def build_frontmatter(
    title: str,
    page_id: str,
    last_edited_time: int,
    calendar_event: dict[str, Any] | None = None,
    attendees: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
    """Build YAML frontmatter dict from Notion meeting data."""
    from datetime import datetime, timezone

    dt = datetime.fromtimestamp(last_edited_time / 1000, tz=timezone.utc)
    date = dt.strftime("%Y-%m-%d")

    tags = []
    for a in attendees or []:
        name = a.get("name", "")
        if name:
            parts = name.split()
            tags.append(parts[0] if len(parts) >= 2 else name)

    fm: dict[str, Any] = {
        "title": title,
        "date": date,
        "type": "notes",
        "source": "notion-api",
        "notion_page_id": page_id,
        "notion_updated_at": dt.isoformat(),
        "tags": tags,
        "attendees": attendees or [],
    }

    if calendar_event:
        fm["calendar_uid"] = calendar_event.get("uid", "")
        fm["calendar_event"] = {
            "title": title,
            "scheduled_start": calendar_event.get("startTime", ""),
            "scheduled_end": calendar_event.get("endTime", ""),
        }
    else:
        fm["calendar_event"] = None

    return fm


# ---------------------------------------------------------------------------
# File naming
# ---------------------------------------------------------------------------


def _sanitize_title(title: str) -> str:
    """Sanitize a title for use in filenames."""
    sanitized = re.sub(r"\s*/\s*", "___", title)
    sanitized = re.sub(r"[^\w\-]", "_", sanitized)
    sanitized = sanitized.replace("___", "\x00")
    sanitized = re.sub(r"_+", "_", sanitized)
    sanitized = sanitized.replace("\x00", "___")
    return sanitized.strip("_")


def make_filename(
    title: str,
    calendar_uid: str | None = None,
    source_id: str | None = None,
    source: str = "notion",
) -> str:
    """Create a filename base from title and UID.

    Format: {uid_prefix}_{Sanitized_Title}
    uid_prefix: first 8 chars of calendar_uid, or first 8 of source_id.
    """
    if calendar_uid:
        prefix = calendar_uid[:8]
    elif source_id:
        prefix = source_id[:8]
    else:
        prefix = "unknown"

    sanitized = _sanitize_title(title)
    return f"{prefix}_{sanitized}"


# ---------------------------------------------------------------------------
# Write meeting files
# ---------------------------------------------------------------------------


def _frontmatter_to_yaml(fm: dict[str, Any]) -> str:
    """Serialize frontmatter dict to YAML string."""
    import yaml

    clean = {k: v for k, v in fm.items() if v is not None}
    yaml_str: str = yaml.dump(clean, default_flow_style=False, allow_unicode=True, sort_keys=False)
    return "---\n" + yaml_str + "---"


def write_meeting(
    frontmatter: dict[str, Any],
    notes_md: str,
    transcript_md: str,
    base_name: str,
    source: str,
    project_root: Path,
    force: bool = False,
) -> dict[str, Any]:
    """Write meeting files to organised directory.

    Returns dict with notes_path, transcript_path, status.
    """
    date = frontmatter.get("date", "")
    parts = date.split("-")
    if len(parts) == 3:
        year, month, day = parts
    else:
        year, month, day = "unknown", "00", "00"

    out_dir = project_root / "meetings" / "organised" / year / month / day

    notes_path = out_dir / f"{base_name}.{source}.notes.md"
    transcript_path = out_dir / f"{base_name}.{source}.transcript.md"

    # Check if we should skip
    if notes_path.exists() and not force:
        existing = notes_path.read_text(encoding="utf-8")
        existing_updated = _extract_notion_updated_at(existing)
        new_updated = frontmatter.get("notion_updated_at", "")
        if existing_updated and new_updated and new_updated <= existing_updated:
            return {"notes_path": notes_path, "transcript_path": transcript_path, "status": "skipped"}

    status = "updated" if notes_path.exists() else "created"

    # Build content
    notes_fm = _frontmatter_to_yaml(frontmatter)
    notes_content = f"{notes_fm}\n\n{notes_md}"

    transcript_fm_dict = {**frontmatter, "type": "transcript"}
    transcript_fm = _frontmatter_to_yaml(transcript_fm_dict)
    transcript_content = f"{transcript_fm}\n\n{transcript_md}"

    # Write atomically
    _write_file(notes_path, notes_content)
    _write_file(transcript_path, transcript_content)

    return {"notes_path": notes_path, "transcript_path": transcript_path, "status": status}


def _extract_notion_updated_at(content: str) -> str | None:
    """Extract notion_updated_at from frontmatter."""
    m = re.search(r"notion_updated_at:\s*['\"]?([^'\"\n]+)", content)
    return m.group(1).strip() if m else None


def _write_file(path: Path, content: str) -> None:
    """Write content to file, creating parent directories."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_sync_notion.py -x -v`

**Step 5: Run mypy**

Run: `uv run mypy src/kb/sync/notion.py`

**Step 6: Commit**

```
git add src/kb/sync/notion.py tests/test_sync_notion.py
git commit -m "feat(sync): add Notion frontmatter building and file writing"
```

---

## Task 6: High-level sync orchestration

**Files:**
- Modify: `src/kb/sync/notion.py`
- Modify: `tests/test_sync_notion.py`

**Step 1: Write failing tests for sync_notion orchestrator**

```python
class TestSyncOrchestration:
    """Phase 5: High-level sync pipeline."""

    def test_sync_notion_dry_run(self, tmp_dir, monkeypatch):
        """Dry run reports counts without writing files."""
        monkeypatch.setenv("NOTION_TOKEN_V2", "test-token")
        from kb.sync.notion import sync_notion

        with patch("kb.sync.notion.NotionClient") as MockClient:
            client = MockClient.return_value
            client.get_current_user_id.return_value = "user-1"
            client.get_space_id.return_value = "space-1"
            client.search_pages.return_value = (
                [{"id": "page-1"}],
                {"page-1": {"value": {"type": "page", "content": ["trans-1"],
                    "properties": {"title": [["Test Meeting"]]}}}},
            )
            # Search returns no more pages
            client.search_pages.side_effect = [
                ([{"id": "page-1"}],
                 {"page-1": {"value": {"type": "page", "content": ["trans-1"],
                     "properties": {"title": [["Test Meeting"]]}}}}),
            ]
            client.load_page_chunk.return_value = {
                "page-1": {"value": {"type": "page", "content": ["trans-1"]}},
                "trans-1": {"value": {
                    "type": "transcription",
                    "format": {
                        "transcription_state": {"state": "idle"},
                        "transcription_transcript_id": "t-1",
                        "transcription_summary_id": "s-1",
                        "transcription_notes_id": "n-1",
                        "transcription_calendar_event": {
                            "uid": "cal123@google.com",
                            "startTime": "2026-02-23T10:00:00+01:00",
                            "endTime": "2026-02-23T10:30:00+01:00",
                            "attendeeIds": [],
                        },
                    },
                    "created_time": 1771881551056,
                    "last_edited_time": 1771881551056,
                }},
            }

            result = sync_notion(
                project_root=tmp_dir,
                data_dir=tmp_dir,
                dry_run=True,
            )
            assert result["dry_run"] is True
            assert result["total"] >= 1

    def test_sync_state_persistence(self, tmp_dir, monkeypatch):
        """Sync state is saved after sync."""
        from kb.sync.notion import save_sync_state, load_sync_state

        state_path = tmp_dir / ".notion_sync_state.json"
        save_sync_state(state_path, last_sync="2026-02-23T10:00:00Z", docs_synced=5)

        state = load_sync_state(state_path)
        assert state["last_sync"] == "2026-02-23T10:00:00Z"
        assert state["docs_synced"] == 5
```

**Step 2: Run tests, verify fail**

**Step 3: Implement sync_notion orchestrator**

Add to `src/kb/sync/notion.py`:

```python
# ---------------------------------------------------------------------------
# Sync state persistence (reuses pattern from granola.py)
# ---------------------------------------------------------------------------


def save_sync_state(path: Path, **kwargs: Any) -> None:
    """Save sync state to a JSON file."""
    state: dict[str, Any] = {}
    if path.exists():
        state = json.loads(path.read_text(encoding="utf-8"))
    state.update(kwargs)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state, indent=2), encoding="utf-8")


def load_sync_state(path: Path) -> dict[str, Any]:
    """Load sync state from a JSON file."""
    if not path.exists():
        return {}
    try:
        return cast("dict[str, Any]", json.loads(path.read_text(encoding="utf-8")))
    except (json.JSONDecodeError, OSError):
        return {}


# ---------------------------------------------------------------------------
# High-level sync orchestration
# ---------------------------------------------------------------------------


def sync_notion(
    project_root: Path,
    data_dir: Path,
    since: str | None = None,
    dry_run: bool = False,
    force: bool = False,
    on_progress: Any = None,
) -> dict[str, Any]:
    """Run the full Notion sync pipeline."""
    import sys

    def _log(msg: str) -> None:
        if on_progress:
            on_progress(msg)
        else:
            print(msg, file=sys.stderr)

    client = NotionClient()

    # Get workspace and user info
    user_id = client.get_current_user_id()
    space_id = client.get_space_id()
    _log(f"User: {user_id}, Space: {space_id}")

    # Load sync state
    state_path = data_dir / ".notion_sync_state.json"
    state = load_sync_state(state_path)

    effective_since = since
    if not effective_since and state.get("last_sync"):
        effective_since = state["last_sync"][:10]

    _log(f"Searching pages{' since ' + effective_since if effective_since else ' (full sync)'}...")

    # Discover all pages
    all_pages: list[dict[str, Any]] = []
    pagination_token = None
    while True:
        results, _ = client.search_pages(
            space_id=space_id,
            since=effective_since,
            created_by=user_id,
            pagination_token=pagination_token,
        )
        all_pages.extend(results)

        if len(results) < 100:
            break
        # Get pagination token for next page
        pagination_token = results[-1].get("id")
        time.sleep(BATCH_DELAY)

    _log(f"Found {len(all_pages)} pages. Checking for meeting transcripts...")

    if dry_run:
        _log("Dry run — not writing files.")
        return {"total": len(all_pages), "created": 0, "updated": 0, "skipped": 0, "dry_run": True}

    # Resolve users in bulk (collect all unique attendee IDs first)
    all_attendee_ids: set[str] = set()
    meetings: list[dict[str, Any]] = []

    for i, page_result in enumerate(all_pages):
        page_id = page_result["id"]
        blocks = client.load_page_chunk(page_id, limit=5)

        # Find transcription block
        transcription = None
        for block_data in blocks.values():
            val = block_data.get("value", {})
            if val.get("type") == "transcription":
                state_info = val.get("format", {}).get("transcription_state", {})
                if state_info.get("state") == "idle":
                    transcription = val
                break

        if not transcription:
            continue

        fmt = transcription.get("format", {})
        cal = fmt.get("transcription_calendar_event", {})
        attendee_ids = cal.get("attendeeIds", [])
        all_attendee_ids.update(attendee_ids)

        # Get page title
        page_block = blocks.get(page_id, {}).get("value", {})
        title_raw = page_block.get("properties", {}).get("title", [])
        title = _extract_text(title_raw).replace("\u2023", "").strip()

        meetings.append({
            "page_id": page_id,
            "title": title or "Untitled",
            "transcription": transcription,
            "last_edited_time": transcription.get("last_edited_time", 0),
        })

        if (i + 1) % BATCH_SIZE == 0:
            time.sleep(BATCH_DELAY)

    _log(f"Found {len(meetings)} meetings with transcripts.")

    # Batch-resolve all attendee IDs
    user_map: dict[str, dict[str, str]] = {}
    if all_attendee_ids:
        raw_users = client.get_users(list(all_attendee_ids))
        for uid, udata in raw_users.items():
            user_map[uid] = {"name": udata.get("name", ""), "email": udata.get("email", "")}

    created = 0
    updated = 0
    skipped = 0

    for i, meeting in enumerate(meetings):
        title = meeting["title"]
        _log(f"[{i + 1}/{len(meetings)}] {title}")

        fmt = meeting["transcription"].get("format", {})
        cal = fmt.get("transcription_calendar_event", {})
        attendee_ids = cal.get("attendeeIds", [])

        attendees = [user_map.get(uid, {"name": "Unknown", "email": ""}) for uid in attendee_ids]

        fm = build_frontmatter(
            title=title,
            page_id=meeting["page_id"],
            last_edited_time=meeting["last_edited_time"],
            calendar_event=cal if cal.get("uid") else None,
            attendees=attendees,
        )

        base_name = make_filename(
            title=title,
            calendar_uid=cal.get("uid"),
            source_id=meeting["page_id"],
        )

        # Fetch transcript segments
        transcript_id = fmt.get("transcription_transcript_id", "")
        transcript_md = ""
        if transcript_id:
            transcript_blocks = client.load_block_children(transcript_id, limit=500)
            parent_block = transcript_blocks.get(transcript_id, {}).get("value", {})
            child_order = parent_block.get("content", [])
            transcript_md = transcript_to_markdown(transcript_blocks, child_order)

        # Fetch notes
        notes_id = fmt.get("transcription_notes_id", "")
        notes_md = ""
        if notes_id:
            notes_blocks = client.load_block_children(notes_id, limit=200)
            parent_block = notes_blocks.get(notes_id, {}).get("value", {})
            child_order = parent_block.get("content", [])
            notes_md = notes_to_markdown(notes_blocks, child_order)

        result = write_meeting(
            frontmatter=fm,
            notes_md=notes_md,
            transcript_md=transcript_md,
            base_name=base_name,
            source="notion",
            project_root=project_root,
            force=force,
        )

        if result["status"] == "created":
            created += 1
        elif result["status"] == "updated":
            updated += 1
        else:
            skipped += 1

        if (i + 1) % BATCH_SIZE == 0:
            time.sleep(BATCH_DELAY)

    # Save sync state
    save_sync_state(
        state_path,
        last_sync=datetime.now().isoformat(),
        docs_synced=len(meetings),
        last_run=datetime.now().isoformat(),
    )

    return {"total": len(meetings), "created": created, "updated": updated, "skipped": skipped}
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_sync_notion.py -x -v`

**Step 5: Run mypy + full test suite**

Run: `uv run mypy src/kb/sync/notion.py && uv run pytest -x -q`

**Step 6: Commit**

```
git add src/kb/sync/notion.py tests/test_sync_notion.py
git commit -m "feat(sync): add sync_notion orchestrator with full pipeline"
```

---

## Task 7: CLI commands — `kb sync notion` and `kb sync` (all)

**Files:**
- Modify: `src/kb/cli.py:1974-2019`
- Modify: `tests/test_sync_notion.py`

**Step 1: Write failing tests for CLI**

```python
class TestCLI:
    """Phase 6: CLI integration."""

    def test_sync_notion_help(self):
        """kb sync notion --help works."""
        from click.testing import CliRunner
        from kb.cli import cli

        result = CliRunner().invoke(cli, ["sync", "notion", "--help"])
        assert result.exit_code == 0
        assert "--since" in result.output
        assert "--dry-run" in result.output

    def test_sync_all_help(self):
        """kb sync --help shows all subcommands."""
        from click.testing import CliRunner
        from kb.cli import cli

        result = CliRunner().invoke(cli, ["sync", "--help"])
        assert result.exit_code == 0
        assert "granola" in result.output
        assert "notion" in result.output
        assert "all" in result.output or "Run all" in result.output
```

**Step 2: Run tests, verify fail**

**Step 3: Implement CLI commands**

In `src/kb/cli.py`, after the existing `sync_granola` command (around line 2019), add:

```python
@sync.command("notion")
@click.option("--since", default=None, help="Sync documents since date (YYYY-MM-DD).")
@click.option("--dry-run", is_flag=True, help="Preview only, no file writes.")
@click.option("--force", is_flag=True, help="Overwrite files even if timestamps match.")
@click.option("--no-index", is_flag=True, help="Skip indexing after sync.")
def sync_notion(since: str | None, dry_run: bool, force: bool, no_index: bool) -> None:
    """Sync meetings from Notion AI Meeting Notes."""
    from kb.sync.notion import sync_notion as do_sync

    project_root = _find_project_root()
    data_dir = _get_data_dir()

    result = do_sync(
        project_root=project_root,
        data_dir=data_dir,
        since=since,
        dry_run=dry_run,
        force=force,
        on_progress=lambda msg: click.echo(msg, err=True),
    )

    click.echo(
        f"Sync complete: {result['created']} created, {result['updated']} updated, "
        f"{result['skipped']} skipped (total: {result['total']}).",
        err=True,
    )

    if not dry_run and not no_index:
        click.echo("Tip: Run `kb index run` to index new files.", err=True)
```

Also modify the `sync` group to support `invoke_without_command=True`:

```python
@cli.group(invoke_without_command=True)
@click.pass_context
def sync(ctx: click.Context) -> None:
    """Sync commands — pull data from external sources."""
    if ctx.invoked_subcommand is None:
        # Run all sync plugins
        _sync_all(ctx)


def _sync_all(ctx: click.Context) -> None:
    """Run all sync plugins sequentially."""
    totals = {"created": 0, "updated": 0, "skipped": 0}

    for name, cmd in [("granola", sync_granola), ("notion", sync_notion)]:
        click.echo(f"Syncing {name}...", err=True)
        try:
            ctx.invoke(cmd, since=None, dry_run=False, force=False, no_index=True)
        except Exception as e:
            click.echo(f"  {name} failed: {e}", err=True)

    click.echo("Tip: Run `kb index run` to index new files.", err=True)
```

Also update the `usage()` function to document sync commands.

**Step 4: Run tests**

Run: `uv run pytest tests/test_sync_notion.py::TestCLI -x -v`

**Step 5: Run full test suite**

Run: `uv run pytest -x -q`

**Step 6: Commit**

```
git add src/kb/cli.py tests/test_sync_notion.py
git commit -m "feat(cli): add 'kb sync notion' command and 'kb sync' (all) runner"
```

---

## Task 8: Granola frontmatter update — add `calendar_uid`

**Files:**
- Modify: `src/kb/sync/granola.py:536-575` (build_frontmatter)
- Modify: `tests/test_sync_granola.py`

**Step 1: Write failing test**

Add to `tests/test_sync_granola.py` in the `TestBuildFrontmatter` class:

```python
def test_calendar_uid_from_icaluid(self):
    """calendar_uid is extracted from google_calendar_event.iCalUID."""
    from kb.sync.granola import build_frontmatter

    doc = {
        "id": "doc-1",
        "title": "Test",
        "created_at": "2026-02-23T10:00:00Z",
        "updated_at": "2026-02-23T10:30:00Z",
        "google_calendar_event": {
            "summary": "Test",
            "iCalUID": "abc123@google.com",
            "organizer": {"email": "a@example.com"},
            "start": {"dateTime": "2026-02-23T10:00:00+01:00"},
            "end": {"dateTime": "2026-02-23T10:30:00+01:00"},
        },
    }
    fm = build_frontmatter(doc, {})
    assert fm["calendar_uid"] == "abc123@google.com"
```

**Step 2: Run test, verify fail**

**Step 3: Implement**

In `src/kb/sync/granola.py`, in `build_frontmatter()`, after the `calendar_event` block (~line 567), add:

```python
    # Calendar UID for cross-source dedup
    gcal = doc.get("google_calendar_event")
    if gcal and gcal.get("iCalUID"):
        fm["calendar_uid"] = gcal["iCalUID"]
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_sync_granola.py -x -v`

**Step 5: Commit**

```
git add src/kb/sync/granola.py tests/test_sync_granola.py
git commit -m "feat(sync): add calendar_uid to Granola frontmatter from iCalUID"
```

---

## Task 9: File naming migration for Granola

**Files:**
- Modify: `src/kb/sync/granola.py` (_make_filename, write_meeting)
- Modify: `tests/test_sync_granola.py`

**Step 1: Write failing test**

```python
def test_new_filename_format_with_calendar_uid(self):
    """New files use {uid_prefix}_{Title}.granola.notes.md format."""
    from kb.sync.granola import _make_filename_v2

    result = _make_filename_v2("Weekly Sync", calendar_uid="abc123@google.com", granola_id="xxx")
    assert result == "abc12345_Weekly_Sync"  # first 8 chars of uid... but wait,
    # calendar UIDs can have special chars. We use first 8 alphanumeric chars.
```

**Note:** This task is more involved — it changes existing Granola behavior. The plan should include:
1. A new `_make_filename_v2()` that uses calendar UID prefix
2. Updated `write_meeting()` to use `.granola.notes.md` extension
3. Migration logic to detect and rename old-format files

**This is a larger refactor.** Consider doing it as a separate PR after the Notion sync is working. For now, the Notion sync uses the new naming convention independently.

**Step 2: Decide scope**

For the initial PR, Notion sync uses the new naming convention (`{uid}_{title}.notion.{type}.md`). Granola migration to the new naming is a follow-up task.

**Step 3: Commit (if any changes)**

Skip this task for the initial PR — file as a follow-up issue.

---

## Task 10: Update `usage()` and final integration test

**Files:**
- Modify: `src/kb/cli.py` (usage function)
- Modify: `tests/test_sync_notion.py`

**Step 1: Update usage()**

In the `usage()` function in `cli.py`, add sync documentation in the appropriate section.

**Step 2: Write integration test**

Add an end-to-end test that mocks the full API and verifies files are written:

```python
class TestEndToEnd:
    """Phase 7: Full pipeline integration test."""

    def test_full_sync_writes_files(self, tmp_dir, monkeypatch):
        """Full sync pipeline writes notes and transcript files."""
        monkeypatch.setenv("NOTION_TOKEN_V2", "test-token")

        # ... mock all API calls ...
        # Verify files exist in meetings/organised/YYYY/MM/DD/
        # Verify frontmatter has all required fields
        # Verify transcript has speaker attribution
        # Verify .notion_sync_state.json is written
```

**Step 3: Run full test suite + mypy + coverage**

Run: `uv run pytest -x -q --cov && uv run mypy src/`
Expected: All pass, coverage >= 90%

**Step 4: Commit**

```
git add src/kb/cli.py tests/test_sync_notion.py
git commit -m "feat(sync): update usage docs and add Notion sync integration test"
```

---

## Summary of all commits

1. `build: add cryptography dependency for Notion cookie decryption`
2. `feat(sync): add NotionClient with cookie decryption and auth`
3. `feat(sync): add Notion API methods (search, loadPageChunk, syncRecordValues)`
4. `feat(sync): add Notion transcript and notes extraction`
5. `feat(sync): add Notion frontmatter building and file writing`
6. `feat(sync): add sync_notion orchestrator with full pipeline`
7. `feat(cli): add 'kb sync notion' command and 'kb sync' (all) runner`
8. `feat(sync): add calendar_uid to Granola frontmatter from iCalUID`
9. _(deferred)_ Granola file naming migration
10. `feat(sync): update usage docs and add Notion sync integration test`
