# Agent Pack Architecture Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extract Reddit Lead Hunter Pro into a paid agent pack, ship the pack plugin infrastructure (interface, loader, installer, EULA), refactor `src/cognithor/social/` into a generic `cognithor.leads.sdk`, create a private `cognithor-packs` repo as source of truth, auto-sync the site catalog via build-time fetch + Vercel deploy hook, and plumb Lemon Squeezy delivery with in-app upsell UX.

**Architecture:** Three repos work together. Core (public, Apache-2.0) ships pack interface + loader + leads SDK + generic UI. `cognithor-packs` (private) holds pack source + `index.json` generated by CI. `cognithor-site` (public) fetches `index.json` at build time and renders the catalog.

**Tech Stack:** Python 3.13 + Pydantic v2 (Core), Flutter 3 (Command Center), Next.js 15 + Octokit (site), GitHub Actions, Lemon Squeezy (commerce), Vercel (hosting).

**Spec:** [`docs/superpowers/specs/2026-04-15-agent-pack-architecture-design.md`](../specs/2026-04-15-agent-pack-architecture-design.md)

---

## File Structure

### New files in Core (`D:\Jarvis\jarvis complete v20`)

```
src/cognithor/
├── leads/
│   ├── __init__.py          # re-exports Lead, LeadSource, LeadService, ...
│   ├── models.py            # Lead, LeadStatus, LeadStats, ScanResult
│   ├── store.py             # LeadStore (SQLCipher-backed)
│   ├── source.py            # LeadSource ABC
│   ├── registry.py          # SourceRegistry
│   └── service.py           # LeadService (source-agnostic orchestrator)
├── packs/
│   ├── __init__.py
│   ├── interface.py         # Publisher, RevenueShare, PricingTier, PackManifest, AgentPack, PackContext
│   ├── loader.py            # PackLoader
│   ├── installer.py         # PackInstaller — zip/URL install, EULA flow
│   ├── cli.py               # cognithor pack <install|list|remove|update|accept-eula>
│   └── errors.py            # PackLoadError, PackInstallError, PackValidationError
└── _bundled_packs/          # NEW directory, populated in Phase 4
    └── cognithor-official/
        ├── hn-lead-hunter/
        ├── discord-lead-hunter/
        └── rss-lead-hunter/
```

### Files to modify in Core

```
pyproject.toml                                   # version bump 0.82.x → 0.83.0
src/cognithor/__init__.py                        # __version__ bump
src/cognithor/gateway/gateway.py                 # wire PackLoader in Phase F
scripts/bootstrap_windows.py                     # bundled-pack copy step
src/cognithor/channels/config_routes.py          # replace /leads/engine-status with /leads/sources
src/cognithor/cli.py or __main__.py              # add `pack` subcommand
tests/test_skill_tools_coverage.py               # update expected tool count after Reddit tools leave
```

### Files to delete from Core (Phase 4)

```
src/cognithor/social/                            # entire directory
src/cognithor/mcp/reddit_tools.py
tests/test_social/test_scanner.py                # moves to pack repo
tests/test_social/test_reply.py                  # moves to pack repo
tests/test_social/test_refiner.py                # moves to pack repo
tests/test_social/test_templates.py              # moves to pack repo
tests/test_social/test_discovery.py              # moves to pack repo
tests/test_social/test_learner.py                # moves to pack repo
tests/test_social/test_tracker.py                # moves to pack repo
flutter_app/lib/screens/reddit_leads_screen.dart # replaced by leads_screen.dart
```

### New files in Flutter (`flutter_app/lib/`)

```
lib/data/known_packs.dart                        # hardcoded pack catalog
lib/screens/leads_screen.dart                    # source-agnostic replacement
lib/widgets/packs/locked_pack_card.dart          # upsell card widget
lib/widgets/packs/pack_badge.dart                # "launch price · 73 of 100 left"
lib/providers/sources_provider.dart              # fetches /api/v1/leads/sources
test/widgets/locked_pack_card_test.dart
test/screens/leads_screen_test.dart
```

### New repo: `Alex8791-cyber/cognithor-packs` (private, Phase 3)

```
cognithor-packs/
├── README.md
├── LICENSE                                       # proprietary for the repo root
├── .github/workflows/build-index-and-deploy.yml
├── scripts/
│   ├── build_index.py
│   └── validate_pack.py
├── _template/
│   ├── pack.py
│   ├── pack_manifest.json
│   ├── eula.md                                   # shared template
│   └── catalog/catalog.mdx
├── reddit-lead-hunter-pro/                       # paid
│   ├── pack.py
│   ├── pack_manifest.json
│   ├── eula.md
│   ├── src/
│   │   ├── __init__.py
│   │   ├── scanner.py
│   │   ├── reply.py
│   │   ├── refiner.py
│   │   ├── templates.py
│   │   ├── discovery.py
│   │   ├── learner.py
│   │   ├── tracker.py
│   │   ├── tools.py
│   │   └── reddit_source.py                      # LeadSource adapter
│   ├── tests/                                    # migrated test files
│   └── catalog/
│       ├── catalog.mdx
│       └── og-image.png
├── hn-lead-hunter/                               # free, apache-2.0
│   └── ... (similar shape)
├── discord-lead-hunter/                          # free, apache-2.0
├── rss-lead-hunter/                              # free, apache-2.0
└── index.json                                    # CI-generated, committed
```

### Files to modify in `cognithor-site`

```
package.json                                     # add @octokit/rest
lib/data/fetch-packs.ts                          # NEW build-time fetcher
lib/data/_packs_cache.json                       # NEW committed cache for local dev
scripts/fetch-packs-local.ts                     # NEW local dev helper
components/sections/home/PackSpotlightSection.tsx    # fetcher-driven
components/sections/packs/PackStoreGrid.tsx          # fetcher-driven
app/[locale]/(marketing)/packs/[slug]/page.tsx       # generateStaticParams from fetcher
app/[locale]/(marketing)/packs/[slug]/installed/page.tsx   # NEW thank-you route
```

### Files to delete from `cognithor-site`

```
content/packs/reddit-lead-hunter.mdx              # moves to pack repo's catalog/
lib/data/home-packs.ts                            # replaced by fetcher
```

---

## Prerequisites Before Starting

Confirm these are true before executing Phase 1. These are assumptions the plan depends on.

- [ ] **Working tree clean.** `git status` in `D:\Jarvis\jarvis complete v20` shows only the in-progress branch state from the brainstorming session. No stray uncommitted changes in `src/`.
- [ ] **Test suite green.** `python -m pytest tests/ -q` completes with all passing (`2249 passed, 1 skipped` as of the last commit).
- [ ] **GitHub token available.** The credential manager has a PAT with `repo` scope — verified in the previous session via `git credential fill` for `github.com`. The same token is used in Phase 3 for repo creation.
- [ ] **Node + pnpm available.** `D:\Jarvis\cognithor-site` is a pnpm project (`pnpm-workspace.yaml` present). `pnpm -v` works from the site directory.
- [ ] **Flutter available.** `flutter --version` returns 3.x. The Flutter app in `D:\Jarvis\jarvis complete v20\flutter_app\` builds via `flutter pub get` + `flutter analyze`.
- [ ] **Lemon Squeezy account.** Not required for Phase 1-6 code. Required manually before the RLH Pro pack can actually be sold. Documented as a separate checklist in Phase 7.

---

## Phase 1 — Leads SDK refactor in Core

**Goal:** Create `cognithor.leads.sdk` as a generic multi-source lead engine. The old `src/cognithor/social/` directory continues to work via a shim, so no tests break during this phase. Tests stay green after every task.

**Outcome:** At the end of Phase 1, `src/cognithor/leads/` exists with `LeadSource`, `SourceRegistry`, `LeadService`, `LeadStore`, and `Lead` model. `src/cognithor/social/service.py` is a thin compatibility layer that registers the existing Reddit/HN/Discord/RSS scanners as `LeadSource`s and delegates to `LeadService`. The full existing test suite still passes.

### Task 1.1 — Bump Cognithor version to 0.83.0

The pack SDK requires Core 0.83.0 as a min version. Bump it now so later tasks have the correct number to reference.

**Files:**
- Modify: `pyproject.toml`
- Modify: `src/cognithor/__init__.py`

- [ ] **Step 1: Read current version**

```bash
grep -n '^version' "D:/Jarvis/jarvis complete v20/pyproject.toml"
```

Expected: one line like `version = "0.82.x"`.

- [ ] **Step 2: Edit `pyproject.toml`**

Replace the `version = "0.82.x"` line with:

```toml
version = "0.83.0"
```

- [ ] **Step 3: Edit `src/cognithor/__init__.py`**

Find the `__version__ = "..."` line and replace with:

```python
__version__ = "0.83.0"
```

- [ ] **Step 4: Verify version is consistent**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "import cognithor; print(cognithor.__version__)"
```

Expected output: `0.83.0`

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add pyproject.toml src/cognithor/__init__.py && git commit -m "chore: bump version to 0.83.0 for agent pack architecture"
```

---

### Task 1.2 — Create `cognithor/leads/` scaffold

Just the directory + empty `__init__.py` so later tasks can import from `cognithor.leads`.

**Files:**
- Create: `src/cognithor/leads/__init__.py`

- [ ] **Step 1: Create the package**

Write `src/cognithor/leads/__init__.py` with this exact content:

```python
"""Generic multi-source lead engine SDK.

This package is the source-agnostic replacement for the Reddit-specific
``cognithor.social`` module. Lead sources (Reddit, Hacker News, Discord,
RSS, etc.) live in agent packs and register themselves with the
``SourceRegistry`` at pack load time.

Public API (re-exported here for convenience):

- ``Lead``, ``LeadStatus``, ``LeadStats``, ``ScanResult`` — data models
- ``LeadStore`` — SQLCipher-backed persistence
- ``LeadSource`` — abstract base for source implementations
- ``SourceRegistry`` — runtime registry of registered sources
- ``LeadService`` — orchestrator (scan, store, list, draft, post)
"""

from __future__ import annotations

# Re-exports wired up once the submodules land. Kept as TYPE_CHECKING
# imports so this file is importable the moment the package exists.
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from cognithor.leads.models import Lead, LeadStats, LeadStatus, ScanResult
    from cognithor.leads.registry import SourceRegistry
    from cognithor.leads.service import LeadService
    from cognithor.leads.source import LeadSource
    from cognithor.leads.store import LeadStore

__all__ = [
    "Lead",
    "LeadStats",
    "LeadStatus",
    "ScanResult",
    "LeadService",
    "LeadSource",
    "LeadStore",
    "SourceRegistry",
]
```

- [ ] **Step 2: Verify the package imports**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "import cognithor.leads; print(cognithor.leads.__all__)"
```

Expected: `['Lead', 'LeadStats', 'LeadStatus', 'ScanResult', 'LeadService', 'LeadSource', 'LeadStore', 'SourceRegistry']`

- [ ] **Step 3: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/leads/__init__.py && git commit -m "feat(leads): scaffold cognithor.leads package"
```

---

### Task 1.3 — Port data models from `social/models.py`

Copy the data models (`Lead`, `LeadStatus`, `LeadStats`, `ScanResult`) into `cognithor.leads.models`. Add a `source_id` field to `Lead` so the record knows which source produced it — the current model assumes Reddit and stores `subreddit`. We keep `subreddit` for back-compat but add `source_id` as the generic field and mark `subreddit` as Reddit-only in the docstring.

**Files:**
- Create: `src/cognithor/leads/models.py`
- Test: `tests/test_leads/test_models.py`

- [ ] **Step 1: Read the current models file to understand the starting shape**

```bash
cat "D:/Jarvis/jarvis complete v20/src/cognithor/social/models.py"
```

Expected: file exists, contains `Lead`, `LeadStatus`, `LeadStats`, `ScanResult` dataclasses or Pydantic models.

- [ ] **Step 2: Write the failing test**

Create `tests/test_leads/test_models.py`:

```python
"""Tests for cognithor.leads.models — generic lead data structures."""

from __future__ import annotations

import pytest

from cognithor.leads.models import Lead, LeadStats, LeadStatus, ScanResult


class TestLead:
    def test_defaults(self) -> None:
        lead = Lead(
            post_id="abc",
            source_id="reddit",
            title="Looking for local LLM",
            url="https://reddit.com/r/LocalLLaMA/abc",
            intent_score=85,
        )
        assert lead.post_id == "abc"
        assert lead.source_id == "reddit"
        assert lead.status == LeadStatus.NEW
        assert lead.subreddit == ""  # back-compat default

    def test_source_id_required(self) -> None:
        with pytest.raises(TypeError):
            Lead(post_id="a", title="t", url="u", intent_score=10)  # type: ignore[call-arg]

    def test_to_dict_round_trip(self) -> None:
        lead = Lead(
            post_id="xyz",
            source_id="hn",
            title="Ask HN: LLM?",
            url="https://news.ycombinator.com/item?id=xyz",
            intent_score=72,
        )
        d = lead.to_dict()
        assert d["post_id"] == "xyz"
        assert d["source_id"] == "hn"
        assert d["status"] == "new"

    def test_subreddit_legacy_field_still_works(self) -> None:
        """Back-compat: old Reddit records set subreddit, new generic code reads source_id."""
        lead = Lead(
            post_id="r1",
            source_id="reddit",
            subreddit="LocalLLaMA",
            title="t",
            url="u",
            intent_score=50,
        )
        assert lead.subreddit == "LocalLLaMA"
        assert lead.source_id == "reddit"


class TestLeadStatus:
    def test_enum_values(self) -> None:
        assert LeadStatus.NEW.value == "new"
        assert LeadStatus.REVIEWED.value == "reviewed"
        assert LeadStatus.REPLIED.value == "replied"
        assert LeadStatus.ARCHIVED.value == "archived"


class TestScanResult:
    def test_summary_format(self) -> None:
        result = ScanResult(
            subreddits_scanned=["LocalLLaMA"],
            posts_checked=30,
            leads_found=4,
            posts_skipped_duplicate=2,
            posts_skipped_low_score=24,
            trigger="cli",
        )
        summary = result.summary()
        assert "30" in summary
        assert "4" in summary
```

- [ ] **Step 3: Run the test, verify it fails for the right reason**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_models.py -x -q
```

Expected: `ImportError: cannot import name 'Lead' from 'cognithor.leads.models'` (module not found or attribute missing).

- [ ] **Step 4: Write `cognithor/leads/models.py`**

Create the file with this exact content:

```python
"""Generic lead data models.

Source-agnostic — a ``Lead`` carries a ``source_id`` that names the origin
("reddit", "hn", "discord", "rss", ...). Reddit-specific fields (``subreddit``,
``upvotes``, ``num_comments``) are preserved as optional legacy fields to
keep the on-disk SQLite schema stable across the Reddit-Lead-Hunter-Pro
extraction. New sources that don't have a notion of a subreddit leave it
empty.
"""

from __future__ import annotations

import time
import uuid
from dataclasses import dataclass, field
from enum import Enum


class LeadStatus(str, Enum):
    NEW = "new"
    REVIEWED = "reviewed"
    REPLIED = "replied"
    ARCHIVED = "archived"


@dataclass
class Lead:
    post_id: str
    source_id: str
    title: str
    url: str
    intent_score: int

    # Optional fields (default first, required fields above).
    body: str = ""
    author: str = ""
    created_utc: float = 0.0
    score_reason: str = ""
    reply_draft: str = ""
    status: LeadStatus = LeadStatus.NEW
    scan_id: str = ""
    received_at: float = field(default_factory=time.time)

    # Reddit-legacy fields. Present for schema back-compat; other sources
    # leave these empty.
    subreddit: str = ""
    upvotes: int = 0
    num_comments: int = 0

    def to_dict(self) -> dict[str, object]:
        return {
            "post_id": self.post_id,
            "source_id": self.source_id,
            "title": self.title,
            "url": self.url,
            "intent_score": self.intent_score,
            "body": self.body,
            "author": self.author,
            "created_utc": self.created_utc,
            "score_reason": self.score_reason,
            "reply_draft": self.reply_draft,
            "status": self.status.value,
            "scan_id": self.scan_id,
            "received_at": self.received_at,
            "subreddit": self.subreddit,
            "upvotes": self.upvotes,
            "num_comments": self.num_comments,
        }


@dataclass
class LeadStats:
    total: int = 0
    new: int = 0
    reviewed: int = 0
    replied: int = 0
    archived: int = 0
    avg_score: float = 0.0
    top_subreddits: list[tuple[str, int]] = field(default_factory=list)
    total_scans: int = 0


@dataclass
class ScanResult:
    id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
    subreddits_scanned: list[str] = field(default_factory=list)  # legacy name kept
    posts_checked: int = 0
    leads_found: int = 0
    posts_skipped_duplicate: int = 0
    posts_skipped_low_score: int = 0
    started_at: float = field(default_factory=time.time)
    finished_at: float = 0.0
    trigger: str = "unknown"

    def summary(self) -> str:
        return (
            f"{self.posts_checked} posts checked, {self.leads_found} leads found, "
            f"{self.posts_skipped_duplicate} dup, {self.posts_skipped_low_score} low-score"
        )
```

- [ ] **Step 5: Create the test directory and rerun**

```bash
cd "D:/Jarvis/jarvis complete v20" && mkdir -p tests/test_leads && touch tests/test_leads/__init__.py && python -m pytest tests/test_leads/test_models.py -x -q
```

Expected: all 6 tests pass.

- [ ] **Step 6: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/leads/models.py tests/test_leads/ && git commit -m "feat(leads): add source-agnostic Lead/LeadStatus/LeadStats/ScanResult models"
```

---

### Task 1.4 — Port LeadStore from `social/store.py`

The store is almost source-agnostic already. The changes are:

1. Add a `source_id TEXT NOT NULL DEFAULT 'reddit'` column to the `leads` table schema (migration-safe default for existing databases).
2. Add a `source_id` filter to `get_leads()` so callers can query by source.
3. Rename a few Reddit-specific method parameter names (`subreddit` → keep as legacy-param, but `get_profile(sub)` stays because style profiles are Reddit-specific and will move to the pack).

**Files:**
- Create: `src/cognithor/leads/store.py`
- Test: `tests/test_leads/test_store.py`

- [ ] **Step 1: Read the current store to understand the schema**

```bash
cd "D:/Jarvis/jarvis complete v20" && wc -l src/cognithor/social/store.py && head -60 src/cognithor/social/store.py
```

Expected: a Python file, ~300-600 lines, with a `LeadStore` class and a SQLite schema statement.

- [ ] **Step 2: Write the failing test**

Create `tests/test_leads/test_store.py`:

```python
"""Tests for cognithor.leads.store — generic SQLCipher-backed lead store."""

from __future__ import annotations

from pathlib import Path

import pytest

from cognithor.leads.models import Lead, LeadStatus
from cognithor.leads.store import LeadStore


@pytest.fixture
def store(tmp_path: Path) -> LeadStore:
    return LeadStore(str(tmp_path / "leads.db"))


class TestLeadStoreBasics:
    def test_save_and_retrieve_reddit_lead(self, store: LeadStore) -> None:
        lead = Lead(
            post_id="abc",
            source_id="reddit",
            subreddit="LocalLLaMA",
            title="t",
            url="u",
            intent_score=90,
        )
        store.save_lead(lead)
        fetched = store.get_leads(limit=10)
        assert len(fetched) == 1
        assert fetched[0].post_id == "abc"
        assert fetched[0].source_id == "reddit"

    def test_save_and_retrieve_hn_lead(self, store: LeadStore) -> None:
        lead = Lead(
            post_id="hn-xyz",
            source_id="hn",
            title="Ask HN",
            url="https://news.ycombinator.com/item?id=xyz",
            intent_score=70,
        )
        store.save_lead(lead)
        fetched = store.get_leads(source_id="hn")
        assert len(fetched) == 1
        assert fetched[0].source_id == "hn"

    def test_already_seen(self, store: LeadStore) -> None:
        lead = Lead(
            post_id="dup",
            source_id="reddit",
            subreddit="X",
            title="t",
            url="u",
            intent_score=50,
        )
        store.save_lead(lead)
        assert store.already_seen("dup") is True
        assert store.already_seen("fresh") is False

    def test_filter_by_source_id(self, store: LeadStore) -> None:
        store.save_lead(
            Lead(post_id="a", source_id="reddit", title="a", url="u1", intent_score=80)
        )
        store.save_lead(
            Lead(post_id="b", source_id="hn", title="b", url="u2", intent_score=80)
        )
        store.save_lead(
            Lead(post_id="c", source_id="rss", title="c", url="u3", intent_score=80)
        )
        assert len(store.get_leads(source_id="reddit")) == 1
        assert len(store.get_leads(source_id="hn")) == 1
        assert len(store.get_leads()) == 3  # no filter

    def test_filter_by_status(self, store: LeadStore) -> None:
        store.save_lead(
            Lead(
                post_id="a",
                source_id="reddit",
                title="a",
                url="u",
                intent_score=80,
                status=LeadStatus.NEW,
            )
        )
        store.save_lead(
            Lead(
                post_id="b",
                source_id="reddit",
                title="b",
                url="u",
                intent_score=80,
                status=LeadStatus.ARCHIVED,
            )
        )
        assert len(store.get_leads(status=LeadStatus.NEW)) == 1
        assert len(store.get_leads(status=LeadStatus.ARCHIVED)) == 1
```

- [ ] **Step 3: Run the failing test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_store.py -x -q
```

Expected: `ImportError` for `cognithor.leads.store`.

- [ ] **Step 4: Write `src/cognithor/leads/store.py`**

Port the existing `src/cognithor/social/store.py` with these modifications:

1. Update imports to `from cognithor.leads.models import Lead, LeadStats, LeadStatus, ScanResult`.
2. Change the `CREATE TABLE leads` statement to include:
   ```sql
   source_id TEXT NOT NULL DEFAULT 'reddit',
   ```
3. Add a migration: before the `CREATE TABLE IF NOT EXISTS leads`, run:
   ```python
   # Back-compat migration for databases created before source_id was added.
   cursor.execute("PRAGMA table_info(leads)")
   existing_cols = {row[1] for row in cursor.fetchall()}
   if existing_cols and "source_id" not in existing_cols:
       cursor.execute("ALTER TABLE leads ADD COLUMN source_id TEXT NOT NULL DEFAULT 'reddit'")
   ```
4. Update `save_lead()` to insert `source_id`.
5. Update `_row_to_lead()` to read `source_id` from the row.
6. Add `source_id: str | None = None` parameter to `get_leads()`; include `AND source_id = ?` in the WHERE clause when set.

The full file is roughly 400 lines; use `src/cognithor/social/store.py` as the base and apply the diff above. Keep the `save_scan()`, `save_profile()`, `get_top_performers()`, and `get_stats()` methods unchanged — those will be consumed by the Reddit pack's `reddit_source.py` adapter later.

- [ ] **Step 5: Run tests**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_store.py -x -q
```

Expected: 5 tests pass.

- [ ] **Step 6: Run the full existing social test suite to verify nothing regressed**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_social/ -q
```

Expected: all passing (the old `social/store.py` hasn't been touched yet).

- [ ] **Step 7: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/leads/store.py tests/test_leads/test_store.py && git commit -m "feat(leads): port LeadStore with source_id column and filter"
```

---

### Task 1.5 — Write `cognithor/leads/source.py` — LeadSource ABC

The abstract base every source adapter implements.

**Files:**
- Create: `src/cognithor/leads/source.py`
- Test: `tests/test_leads/test_source.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_leads/test_source.py`:

```python
"""Tests for cognithor.leads.source — LeadSource abstract base."""

from __future__ import annotations

from typing import Any

import pytest

from cognithor.leads.models import Lead
from cognithor.leads.source import LeadSource


class ConcreteSource(LeadSource):
    source_id = "test"
    display_name = "Test Source"
    icon = "science"
    color = "#FF00FF"
    capabilities = frozenset({"scan"})

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        return [
            Lead(
                post_id="t1",
                source_id=self.source_id,
                title="test lead",
                url="https://test.example/t1",
                intent_score=99,
            )
        ]


class TestLeadSourceBase:
    def test_cannot_instantiate_abstract_base(self) -> None:
        with pytest.raises(TypeError):
            LeadSource()  # type: ignore[abstract]

    @pytest.mark.asyncio
    async def test_concrete_subclass_works(self) -> None:
        src = ConcreteSource()
        leads = await src.scan(
            config={}, product="Cognithor", product_description="", min_score=50
        )
        assert len(leads) == 1
        assert leads[0].source_id == "test"
        assert leads[0].intent_score == 99

    def test_optional_capabilities_raise_not_implemented(self) -> None:
        """draft_reply, refine_reply, post_reply default to NotImplementedError."""
        src = ConcreteSource()
        lead = Lead(post_id="x", source_id="test", title="t", url="u", intent_score=50)
        with pytest.raises(NotImplementedError):
            import asyncio

            asyncio.run(src.draft_reply(lead, tone="helpful"))

    def test_metadata_required(self) -> None:
        """A subclass missing source_id fails at class definition time... no, we check at runtime."""

        class BadSource(LeadSource):
            async def scan(self, **kwargs: Any) -> list[Lead]:
                return []

        with pytest.raises((AttributeError, TypeError)):
            # Instantiation or descriptor access must fail because source_id is missing.
            bad = BadSource()
            _ = bad.source_id
```

- [ ] **Step 2: Run the failing test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_source.py -x -q
```

Expected: `ImportError: cannot import name 'LeadSource' from 'cognithor.leads.source'`.

- [ ] **Step 3: Write `src/cognithor/leads/source.py`**

```python
"""Abstract base class for lead sources.

A ``LeadSource`` represents one origin for leads (Reddit, Hacker News,
Discord, RSS, etc.). Source implementations live in agent packs and
register with the ``SourceRegistry`` via ``PackContext.leads.register_source``
during ``AgentPack.register()``.

Required metadata:

- ``source_id`` — stable short identifier, used as a key in the registry and
  persisted on every ``Lead`` record. Must be unique across all registered
  sources.
- ``display_name`` — human-friendly name for UI.
- ``icon`` — Material icon name (e.g. ``"forum"``, ``"rss_feed"``) OR data URL.
- ``color`` — hex color string for UI accents (e.g. ``"#FF4500"`` for Reddit).
- ``capabilities`` — frozen set of capability strings. Known values:
  ``"scan"`` (always required), ``"draft_reply"``, ``"refine_reply"``,
  ``"auto_post"``, ``"discover_targets"``.

Required methods:

- ``scan(config, product, product_description, min_score)`` — fetch posts from
  the source, score them via LLM, return ``list[Lead]``. Called from
  ``LeadService.scan()``.

Optional methods (default to ``NotImplementedError``; callers check
``capabilities`` before invoking):

- ``draft_reply(lead, tone)`` — generate a reply draft for a lead.
- ``refine_reply(lead, draft)`` — refine an existing reply draft.
- ``post_reply(lead, text)`` — actually post the reply (e.g. via browser
  automation). Sources that don't support posting (like RSS) leave this
  raising ``NotImplementedError``.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any, ClassVar

from cognithor.leads.models import Lead


class LeadSource(ABC):
    """Abstract lead source — one per origin (Reddit, HN, Discord, RSS, ...)."""

    source_id: ClassVar[str]
    display_name: ClassVar[str]
    icon: ClassVar[str]
    color: ClassVar[str]
    capabilities: ClassVar[frozenset[str]]

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        # Validate required class attributes are set on concrete subclasses
        # (but not on further abstract subclasses, which Python marks via
        # __abstractmethods__).
        if getattr(cls, "__abstractmethods__", None):
            return
        required = ("source_id", "display_name", "icon", "color", "capabilities")
        for attr in required:
            if not hasattr(cls, attr) or getattr(cls, attr) is None:
                raise TypeError(
                    f"LeadSource subclass {cls.__name__} must set class attr {attr!r}"
                )

    @abstractmethod
    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        """Fetch, score, and return leads from this source.

        ``config`` is the source-specific config dict (e.g. for Reddit it
        contains ``subreddits``, ``reply_tone``, etc.). ``product`` and
        ``product_description`` drive LLM scoring. ``min_score`` filters
        out low-intent posts.
        """

    async def draft_reply(self, lead: Lead, *, tone: str) -> str:
        """Generate a reply draft. Default: raises ``NotImplementedError``."""
        raise NotImplementedError(
            f"{self.source_id}: draft_reply not implemented (capability not declared)"
        )

    async def refine_reply(self, lead: Lead, draft: str) -> str:
        """Refine an existing draft. Default: raises ``NotImplementedError``."""
        raise NotImplementedError(
            f"{self.source_id}: refine_reply not implemented"
        )

    async def post_reply(self, lead: Lead, text: str) -> None:
        """Post a reply to the external platform. Default: raises ``NotImplementedError``."""
        raise NotImplementedError(
            f"{self.source_id}: post_reply not implemented"
        )
```

- [ ] **Step 4: Run tests**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_source.py -x -q
```

Expected: all 4 tests pass.

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/leads/source.py tests/test_leads/test_source.py && git commit -m "feat(leads): add LeadSource abstract base class"
```

---

### Task 1.6 — Write `cognithor/leads/registry.py` — SourceRegistry

Runtime registry of sources keyed by `source_id`. Thread-safe (uses a lock for register/unregister in case multiple packs load concurrently).

**Files:**
- Create: `src/cognithor/leads/registry.py`
- Test: `tests/test_leads/test_registry.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_leads/test_registry.py`:

```python
"""Tests for cognithor.leads.registry — SourceRegistry."""

from __future__ import annotations

from typing import Any

import pytest

from cognithor.leads.models import Lead
from cognithor.leads.registry import SourceRegistry
from cognithor.leads.source import LeadSource


class _FakeSource(LeadSource):
    def __init__(self, source_id: str = "fake") -> None:
        self._source_id = source_id

    # ClassVar overrides as per-instance properties via subclass factory.
    source_id = "fake"
    display_name = "Fake"
    icon = "science"
    color = "#FF00FF"
    capabilities = frozenset({"scan"})

    async def scan(self, **kwargs: Any) -> list[Lead]:
        return []


def _make_source(source_id: str) -> LeadSource:
    """Create a concrete source with a custom id for registry tests."""

    class _Concrete(LeadSource):
        pass

    _Concrete.source_id = source_id
    _Concrete.display_name = source_id.upper()
    _Concrete.icon = "forum"
    _Concrete.color = "#123456"
    _Concrete.capabilities = frozenset({"scan"})

    async def _scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        return []

    _Concrete.scan = _scan  # type: ignore[assignment]
    return _Concrete()


class TestSourceRegistry:
    def test_register_and_list(self) -> None:
        reg = SourceRegistry()
        reg.register(_make_source("reddit"))
        reg.register(_make_source("hn"))
        ids = {s.source_id for s in reg.list()}
        assert ids == {"reddit", "hn"}

    def test_get_by_id(self) -> None:
        reg = SourceRegistry()
        src = _make_source("rss")
        reg.register(src)
        assert reg.get("rss") is src
        assert reg.get("nonexistent") is None

    def test_register_duplicate_raises(self) -> None:
        reg = SourceRegistry()
        reg.register(_make_source("reddit"))
        with pytest.raises(ValueError, match="already registered"):
            reg.register(_make_source("reddit"))

    def test_unregister(self) -> None:
        reg = SourceRegistry()
        reg.register(_make_source("reddit"))
        reg.unregister("reddit")
        assert reg.get("reddit") is None
        assert reg.list() == []

    def test_unregister_nonexistent_is_noop(self) -> None:
        reg = SourceRegistry()
        reg.unregister("nothing")  # must not raise

    def test_contains(self) -> None:
        reg = SourceRegistry()
        reg.register(_make_source("hn"))
        assert "hn" in reg
        assert "reddit" not in reg
```

- [ ] **Step 2: Run the failing test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_registry.py -x -q
```

Expected: `ImportError: cannot import name 'SourceRegistry'`.

- [ ] **Step 3: Write `src/cognithor/leads/registry.py`**

```python
"""Runtime registry for lead sources.

Sources register themselves during pack load. The registry is held on
``LeadService`` and consumed by REST routes (``/api/v1/leads/sources``),
the CLI, and the Flutter LeadsScreen.

Thread-safety: register/unregister/list are guarded by a lock because
multiple packs may load concurrently during Gateway Phase F init, and
the REST API may poll the registry on an async worker thread.
"""

from __future__ import annotations

import threading

from cognithor.leads.source import LeadSource


class SourceRegistry:
    def __init__(self) -> None:
        self._sources: dict[str, LeadSource] = {}
        self._lock = threading.Lock()

    def register(self, source: LeadSource) -> None:
        """Add a source. Raises ValueError if source_id is already taken."""
        with self._lock:
            if source.source_id in self._sources:
                raise ValueError(
                    f"LeadSource {source.source_id!r} already registered"
                )
            self._sources[source.source_id] = source

    def unregister(self, source_id: str) -> None:
        """Remove a source. No-op if not registered."""
        with self._lock:
            self._sources.pop(source_id, None)

    def get(self, source_id: str) -> LeadSource | None:
        with self._lock:
            return self._sources.get(source_id)

    def list(self) -> list[LeadSource]:
        """Return a snapshot of all registered sources."""
        with self._lock:
            return list(self._sources.values())

    def __contains__(self, source_id: object) -> bool:
        with self._lock:
            return isinstance(source_id, str) and source_id in self._sources

    def __len__(self) -> int:
        with self._lock:
            return len(self._sources)
```

- [ ] **Step 4: Run tests**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_registry.py -x -q
```

Expected: all 6 tests pass.

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/leads/registry.py tests/test_leads/test_registry.py && git commit -m "feat(leads): add SourceRegistry for runtime source management"
```

---

### Task 1.7 — Write `cognithor/leads/service.py` — source-agnostic LeadService

Replaces the Reddit-only `RedditLeadService`. The service is a thin orchestrator: `scan(source_id=...)` picks a source from the registry, calls its `scan()` method, persists the returned leads, applies dedup, and returns a `ScanResult`.

**Files:**
- Create: `src/cognithor/leads/service.py`
- Test: `tests/test_leads/test_service.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_leads/test_service.py`:

```python
"""Tests for cognithor.leads.service — source-agnostic orchestrator."""

from __future__ import annotations

from pathlib import Path
from typing import Any

import pytest

from cognithor.leads.models import Lead, LeadStatus
from cognithor.leads.service import LeadService
from cognithor.leads.source import LeadSource
from cognithor.leads.store import LeadStore


class FakeRedditSource(LeadSource):
    source_id = "reddit"
    display_name = "Reddit"
    icon = "forum"
    color = "#FF4500"
    capabilities = frozenset({"scan"})

    def __init__(self) -> None:
        self.scan_calls = 0

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        self.scan_calls += 1
        return [
            Lead(
                post_id=f"r-{self.scan_calls}-1",
                source_id="reddit",
                title="pain point",
                url="https://reddit.com/r/x/1",
                intent_score=85,
            ),
            Lead(
                post_id=f"r-{self.scan_calls}-2",
                source_id="reddit",
                title="meh",
                url="https://reddit.com/r/x/2",
                intent_score=20,  # below min_score
            ),
        ]


class FakeHnSource(LeadSource):
    source_id = "hn"
    display_name = "Hacker News"
    icon = "article"
    color = "#FF6600"
    capabilities = frozenset({"scan"})

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        return [
            Lead(
                post_id="hn-1",
                source_id="hn",
                title="Show HN",
                url="https://news.ycombinator.com/item?id=1",
                intent_score=75,
            )
        ]


@pytest.fixture
def service(tmp_path: Path) -> LeadService:
    store = LeadStore(str(tmp_path / "leads.db"))
    svc = LeadService(store=store)
    svc.register_source(FakeRedditSource())
    svc.register_source(FakeHnSource())
    return svc


class TestLeadService:
    @pytest.mark.asyncio
    async def test_scan_single_source(self, service: LeadService) -> None:
        result = await service.scan(source_id="reddit", min_score=60)
        assert result.leads_found == 1  # only one lead >= 60
        assert result.posts_checked == 2

    @pytest.mark.asyncio
    async def test_scan_unknown_source_raises(self, service: LeadService) -> None:
        with pytest.raises(ValueError, match="unknown source"):
            await service.scan(source_id="twitter", min_score=60)

    @pytest.mark.asyncio
    async def test_scan_all_sources(self, service: LeadService) -> None:
        result = await service.scan(min_score=60)
        # reddit yields 1 lead (score 85), hn yields 1 lead (score 75).
        assert result.leads_found == 2

    @pytest.mark.asyncio
    async def test_scan_persists_leads(self, service: LeadService) -> None:
        await service.scan(source_id="reddit", min_score=60)
        fetched = service.get_leads(source_id="reddit")
        assert len(fetched) == 1
        assert fetched[0].source_id == "reddit"

    @pytest.mark.asyncio
    async def test_scan_dedups_across_runs(self, service: LeadService) -> None:
        await service.scan(source_id="reddit", min_score=60)
        await service.scan(source_id="reddit", min_score=60)
        # Same post_id returned twice — store dedups, so still 1 lead.
        fetched = service.get_leads(source_id="reddit")
        assert len(fetched) == 1

    def test_list_sources(self, service: LeadService) -> None:
        ids = {s.source_id for s in service.list_sources()}
        assert ids == {"reddit", "hn"}

    def test_register_unregister(self, service: LeadService) -> None:
        assert len(service.list_sources()) == 2
        service.unregister_source("reddit")
        assert len(service.list_sources()) == 1
        assert {s.source_id for s in service.list_sources()} == {"hn"}
```

- [ ] **Step 2: Run the failing test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_service.py -x -q
```

Expected: ImportError for `cognithor.leads.service`.

- [ ] **Step 3: Write `src/cognithor/leads/service.py`**

```python
"""Source-agnostic lead service — orchestrates scan, persist, query.

Replaces the Reddit-only ``RedditLeadService``. All lead sources (Reddit,
HN, Discord, RSS, future Twitter/LinkedIn/…) register with the internal
``SourceRegistry`` and the service delegates scan calls by ``source_id``.

Called from:

- Gateway REST routes (``POST /api/v1/leads/scan``, ``GET /api/v1/leads``).
- Cron jobs (``cognithor.leads.sdk.LeadService.scan()``).
- CLI commands (``cognithor leads scan``).
- MCP tools registered by packs.
"""

from __future__ import annotations

import time
from typing import Any

from cognithor.leads.models import Lead, LeadStats, LeadStatus, ScanResult
from cognithor.leads.registry import SourceRegistry
from cognithor.leads.source import LeadSource
from cognithor.leads.store import LeadStore
from cognithor.utils.logging import get_logger

log = get_logger(__name__)


class LeadService:
    def __init__(
        self,
        store: LeadStore,
        llm_fn: Any | None = None,
    ) -> None:
        self._store = store
        self._llm_fn = llm_fn
        self._registry = SourceRegistry()

    # ---------- source registration ----------

    def register_source(self, source: LeadSource) -> None:
        log.info("lead_source_registered", source_id=source.source_id)
        self._registry.register(source)

    def unregister_source(self, source_id: str) -> None:
        log.info("lead_source_unregistered", source_id=source_id)
        self._registry.unregister(source_id)

    def list_sources(self) -> list[LeadSource]:
        return self._registry.list()

    def get_source(self, source_id: str) -> LeadSource | None:
        return self._registry.get(source_id)

    # ---------- scanning ----------

    async def scan(
        self,
        *,
        source_id: str | None = None,
        min_score: int = 60,
        product: str = "",
        product_description: str = "",
        trigger: str = "cli",
        config: dict[str, Any] | None = None,
    ) -> ScanResult:
        """Run a scan on one source (by id) or all registered sources.

        Returns an aggregated ``ScanResult`` with counts across all scanned
        sources. Individual per-source errors are logged and do not abort
        the overall scan when ``source_id`` is None.
        """
        result = ScanResult(trigger=trigger)
        cfg = config or {}

        targets: list[LeadSource]
        if source_id is not None:
            src = self._registry.get(source_id)
            if src is None:
                raise ValueError(f"unknown source {source_id!r}")
            targets = [src]
        else:
            targets = self._registry.list()

        for source in targets:
            try:
                scanned_leads = await source.scan(
                    config=cfg.get(source.source_id, {}),
                    product=product,
                    product_description=product_description,
                    min_score=min_score,
                )
            except Exception as exc:
                log.warning(
                    "source_scan_failed",
                    source_id=source.source_id,
                    error=str(exc),
                    exc_info=True,
                )
                continue

            for lead in scanned_leads:
                result.posts_checked += 1

                if self._store.already_seen(lead.post_id):
                    result.posts_skipped_duplicate += 1
                    continue

                if lead.intent_score < min_score:
                    result.posts_skipped_low_score += 1
                    lead.status = LeadStatus.ARCHIVED
                    self._store.save_lead(lead)
                    continue

                lead.scan_id = result.id
                self._store.save_lead(lead)
                result.leads_found += 1

        result.finished_at = time.time()
        log.info("scan_complete", summary=result.summary(), trigger=trigger)
        return result

    # ---------- queries ----------

    def get_leads(
        self,
        status: LeadStatus | str | None = None,
        min_score: int = 0,
        limit: int = 50,
        offset: int = 0,
        source_id: str | None = None,
    ) -> list[Lead]:
        return self._store.get_leads(
            status=status,
            min_score=min_score,
            limit=limit,
            offset=offset,
            source_id=source_id,
        )

    def get_stats(self) -> LeadStats:
        return self._store.get_stats()
```

- [ ] **Step 4: Run tests**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/test_service.py -x -q
```

Expected: all 7 tests pass.

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/leads/service.py tests/test_leads/test_service.py && git commit -m "feat(leads): add source-agnostic LeadService orchestrator"
```

---

### Task 1.8 — Wire `__init__.py` re-exports

Now that all submodules exist, make the re-exports in `cognithor/leads/__init__.py` real (not TYPE_CHECKING-only).

**Files:**
- Modify: `src/cognithor/leads/__init__.py`

- [ ] **Step 1: Replace TYPE_CHECKING block with real imports**

Read the file first to preserve the docstring, then replace the body:

```python
"""Generic multi-source lead engine SDK.

(docstring unchanged)
"""

from __future__ import annotations

from cognithor.leads.models import Lead, LeadStats, LeadStatus, ScanResult
from cognithor.leads.registry import SourceRegistry
from cognithor.leads.service import LeadService
from cognithor.leads.source import LeadSource
from cognithor.leads.store import LeadStore

__all__ = [
    "Lead",
    "LeadStats",
    "LeadStatus",
    "ScanResult",
    "LeadService",
    "LeadSource",
    "LeadStore",
    "SourceRegistry",
]
```

- [ ] **Step 2: Verify imports**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "from cognithor.leads import Lead, LeadService, LeadSource, LeadStore, SourceRegistry, ScanResult, LeadStatus, LeadStats; print('ok')"
```

Expected: `ok`

- [ ] **Step 3: Run the full test_leads suite**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_leads/ -q
```

Expected: all tests pass (cumulative ~22 tests across the 5 test files).

- [ ] **Step 4: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/leads/__init__.py && git commit -m "feat(leads): wire real re-exports in __init__.py"
```

---

### Task 1.9 — Keep `social/service.py` as a compatibility shim

Existing imports from `cognithor.social.service` and the Gateway's usage of `RedditLeadService` must keep working until Phase 4 deletes everything. Turn `social/service.py` into a thin shim that creates a `LeadService` and registers Reddit-adapter sources at construction time.

**Files:**
- Modify: `src/cognithor/social/service.py`

- [ ] **Step 1: Read the current shape**

```bash
cd "D:/Jarvis/jarvis complete v20" && wc -l src/cognithor/social/service.py && head -60 src/cognithor/social/service.py
```

Expected: a ~400-line `RedditLeadService` class with a `scan()` method that takes `subreddits=...` and calls `RedditScanner.fetch_posts`.

- [ ] **Step 2: Rewrite the file as a shim**

Replace the contents with (this will compile under the existing imports; all the Reddit-specific classes are still importable from `cognithor.social.scanner`, etc.):

```python
"""Back-compat shim over cognithor.leads.sdk.

The real implementation lives in ``cognithor.leads.service.LeadService``.
This module exists so existing imports (``from cognithor.social.service
import RedditLeadService``) keep working through Phase 1–3 of the agent
pack extraction. Phase 4 deletes this file entirely.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from cognithor.leads.models import Lead, LeadStats, LeadStatus, ScanResult
from cognithor.leads.service import LeadService
from cognithor.leads.store import LeadStore
from cognithor.utils.logging import get_logger

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

log = get_logger(__name__)


class RedditLeadService:
    """Legacy facade over the generic LeadService.

    Accepts the old Reddit-shaped kwargs (default_subreddits=, auto_post_whitelist=)
    and translates them into generic LeadService calls + source registration.
    Scheduled for deletion in Phase 4 of the agent pack extraction.
    """

    def __init__(
        self,
        db_path: str,
        llm_fn: "Callable[..., Awaitable[dict[str, Any]]] | None" = None,
        product_name: str = "",
        product_description: str = "",
        reply_tone: str = "helpful, technically credible, no sales pitch",
        default_subreddits: list[str] | None = None,
        min_score: int = 60,
        browser_agent: Any = None,
        notification_callback: Any = None,
        auto_post_whitelist: list[str] | None = None,
        min_auto_score: int = 85,
    ) -> None:
        self._store = LeadStore(db_path)
        self._service = LeadService(store=self._store, llm_fn=llm_fn)
        self._product = product_name
        self._product_description = product_description
        self._reply_tone = reply_tone
        self._min_score = min_score
        self._default_subreddits = default_subreddits or []

        # Lazy-register the source adapters so this module doesn't force-import
        # scanner.py etc. at import time.
        self._register_legacy_sources(llm_fn=llm_fn, browser_agent=browser_agent)

    def _register_legacy_sources(
        self,
        llm_fn: "Callable[..., Awaitable[dict[str, Any]]] | None",
        browser_agent: Any,
    ) -> None:
        """Register the four existing scanners as LeadSources under the legacy service."""
        try:
            from cognithor.social._legacy_adapters import (
                LegacyDiscordSource,
                LegacyHnSource,
                LegacyRedditSource,
                LegacyRssSource,
            )
        except Exception as exc:
            log.warning("legacy_source_import_failed", error=str(exc))
            return

        self._service.register_source(
            LegacyRedditSource(llm_fn=llm_fn, browser_agent=browser_agent)
        )
        self._service.register_source(LegacyHnSource(llm_fn=llm_fn))
        self._service.register_source(LegacyDiscordSource(llm_fn=llm_fn))
        self._service.register_source(LegacyRssSource(llm_fn=llm_fn))

    # ---------- legacy API surface ----------

    async def scan(
        self,
        subreddits: list[str] | None = None,
        min_score: int = 60,
        trigger: str = "chat",
    ) -> ScanResult:
        cfg = {"reddit": {"subreddits": subreddits or self._default_subreddits}}
        return await self._service.scan(
            source_id="reddit",
            min_score=min_score,
            product=self._product,
            product_description=self._product_description,
            trigger=trigger,
            config=cfg,
        )

    async def scan_hackernews(
        self,
        categories: list[str] | None = None,
        min_score: int = 60,
    ) -> dict[str, Any]:
        cfg = {"hn": {"categories": categories or ["top", "new"]}}
        result = await self._service.scan(
            source_id="hn",
            min_score=min_score,
            product=self._product,
            product_description=self._product_description,
            trigger="cron",
            config=cfg,
        )
        return {
            "leads_found": result.leads_found,
            "posts_checked": result.posts_checked,
        }

    async def scan_discord(
        self,
        channel_ids: list[str] | None = None,
        min_score: int = 60,
    ) -> dict[str, Any]:
        cfg = {"discord": {"channel_ids": channel_ids or []}}
        result = await self._service.scan(
            source_id="discord",
            min_score=min_score,
            product=self._product,
            product_description=self._product_description,
            trigger="cron",
            config=cfg,
        )
        return {
            "leads_found": result.leads_found,
            "posts_checked": result.posts_checked,
        }

    async def scan_rss(
        self,
        feeds: list[str] | None = None,
        min_score: int = 60,
    ) -> dict[str, Any]:
        cfg = {"rss": {"feeds": feeds or []}}
        result = await self._service.scan(
            source_id="rss",
            min_score=min_score,
            product=self._product,
            product_description=self._product_description,
            trigger="cron",
            config=cfg,
        )
        return {
            "leads_found": result.leads_found,
            "posts_checked": result.posts_checked,
        }

    def get_leads(
        self,
        status: LeadStatus | str | None = None,
        min_score: int = 0,
        limit: int = 50,
        offset: int = 0,
        platform: str | None = None,
    ) -> list[Lead]:
        return self._service.get_leads(
            status=status,
            min_score=min_score,
            limit=limit,
            offset=offset,
            source_id=platform,  # old param name → new name
        )

    def get_stats(self) -> LeadStats:
        return self._service.get_stats()
```

- [ ] **Step 3: Create the legacy adapter module**

Create `src/cognithor/social/_legacy_adapters.py`:

```python
"""Legacy LeadSource adapters that wrap the existing social scanner classes.

These exist only during Phase 1–3 of the agent pack extraction. Phase 4
moves them into the four actual pack repos and deletes this file.
"""

from __future__ import annotations

from typing import Any

from cognithor.leads.models import Lead, LeadStatus
from cognithor.leads.source import LeadSource
from cognithor.social.discord_scanner import DiscordScanner
from cognithor.social.hn_scanner import HackerNewsScanner
from cognithor.social.rss_scanner import RssFeedScanner
from cognithor.social.scanner import RedditScanner, ScanConfig


class LegacyRedditSource(LeadSource):
    source_id = "reddit"
    display_name = "Reddit"
    icon = "forum"
    color = "#FF4500"
    capabilities = frozenset({"scan", "draft_reply", "refine_reply", "auto_post"})

    def __init__(self, llm_fn: Any = None, browser_agent: Any = None) -> None:
        self._scanner = RedditScanner(llm_fn=llm_fn)
        self._browser_agent = browser_agent

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        subreddits: list[str] = list(config.get("subreddits") or [])
        if not subreddits:
            return []

        scan_cfg = ScanConfig(
            product_name=product,
            product_description=product_description,
            min_score=min_score,
        )
        leads: list[Lead] = []
        for sub in subreddits:
            posts = self._scanner.fetch_posts(sub, limit=100)
            for post in posts:
                if len(post.get("title", "")) < 15:
                    continue
                score, reasoning = await self._scanner.score_post(post, scan_cfg)
                leads.append(
                    Lead(
                        post_id=post.get("id", ""),
                        source_id="reddit",
                        subreddit=sub,
                        title=post.get("title", ""),
                        body=(post.get("selftext") or "")[:500],
                        url=f"https://reddit.com{post.get('permalink', '')}",
                        author=post.get("author", "[deleted]"),
                        created_utc=post.get("created_utc", 0),
                        upvotes=post.get("score", 0),
                        num_comments=post.get("num_comments", 0),
                        intent_score=score,
                        score_reason=reasoning,
                        status=LeadStatus.NEW,
                    )
                )
        return leads


class LegacyHnSource(LeadSource):
    source_id = "hn"
    display_name = "Hacker News"
    icon = "article"
    color = "#FF6600"
    capabilities = frozenset({"scan"})

    def __init__(self, llm_fn: Any = None) -> None:
        self._scanner = HackerNewsScanner(llm_fn=llm_fn)

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        categories = config.get("categories") or ["top", "new"]
        raw = await self._scanner.scan(
            product_name=product,
            product_description=product_description,
            categories=categories,
            min_score=min_score,
        )
        leads: list[Lead] = []
        for entry in raw.get("leads", []):
            leads.append(
                Lead(
                    post_id=f"hn-{entry.get('id', '')}",
                    source_id="hn",
                    title=entry.get("title", ""),
                    url=entry.get("url", "") or f"https://news.ycombinator.com/item?id={entry.get('id', '')}",
                    intent_score=entry.get("intent_score", 0),
                    score_reason=entry.get("score_reason", ""),
                )
            )
        return leads


class LegacyDiscordSource(LeadSource):
    source_id = "discord"
    display_name = "Discord"
    icon = "tag"
    color = "#5865F2"
    capabilities = frozenset({"scan"})

    def __init__(self, llm_fn: Any = None) -> None:
        import os

        token = os.environ.get("COGNITHOR_DISCORD_TOKEN", "")
        if token:
            self._scanner: DiscordScanner | None = DiscordScanner(
                bot_token=token, llm_fn=llm_fn
            )
        else:
            self._scanner = None

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        if self._scanner is None:
            return []
        channel_ids: list[str] = list(config.get("channel_ids") or [])
        raw = await self._scanner.scan(
            channel_ids=channel_ids,
            product_name=product,
            product_description=product_description,
            min_score=min_score,
        )
        leads: list[Lead] = []
        for entry in raw.get("leads", []):
            leads.append(
                Lead(
                    post_id=f"discord-{entry.get('id', '')}",
                    source_id="discord",
                    title=entry.get("title", "")[:200],
                    url=entry.get("url", ""),
                    intent_score=entry.get("intent_score", 0),
                    score_reason=entry.get("score_reason", ""),
                    body=entry.get("content", "")[:500],
                    author=entry.get("author", ""),
                )
            )
        return leads


class LegacyRssSource(LeadSource):
    source_id = "rss"
    display_name = "RSS Feeds"
    icon = "rss_feed"
    color = "#FFA500"
    capabilities = frozenset({"scan"})

    def __init__(self, llm_fn: Any = None) -> None:
        self._scanner = RssFeedScanner(llm_fn=llm_fn)

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        feeds: list[str] = list(config.get("feeds") or [])
        if not feeds:
            return []
        raw = await self._scanner.scan(
            feeds=feeds,
            product_name=product,
            product_description=product_description,
            min_score=min_score,
        )
        leads: list[Lead] = []
        for entry in raw.get("leads", []):
            leads.append(
                Lead(
                    post_id=entry.get("entry_hash") or entry.get("id") or entry.get("url", ""),
                    source_id="rss",
                    title=entry.get("title", ""),
                    url=entry.get("url", ""),
                    intent_score=entry.get("intent_score", 0),
                    score_reason=entry.get("score_reason", ""),
                    body=(entry.get("summary") or "")[:500],
                )
            )
        return leads
```

- [ ] **Step 4: Run the full existing social test suite**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_social/ -q
```

Expected: all passing. If some fail because they were testing private implementation details of `RedditLeadService.__init__` (like `_scanner` attribute lookup), update those assertions — the shim stores scanners under `_service._registry` now, not as direct attributes.

- [ ] **Step 5: Run the full project test suite**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/ -q
```

Expected: total count same as before (~2249 passed), maybe one or two tests updated for the new shim internals.

- [ ] **Step 6: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/social/service.py src/cognithor/social/_legacy_adapters.py && git commit -m "refactor(leads): make social.service a shim over cognithor.leads.sdk"
```

---

### Task 1.10 — Add `/api/v1/leads/sources` REST endpoint

Replace the `/leads/engine-status` endpoint from Issue #113 with the richer `/leads/sources` shape that drives Flutter's locked-card logic and generic scan UI.

**Files:**
- Modify: `src/cognithor/channels/config_routes.py`

- [ ] **Step 1: Find the existing `leads_engine_status` route**

```bash
cd "D:/Jarvis/jarvis complete v20" && grep -n "leads_engine_status\|leads/engine-status" src/cognithor/channels/config_routes.py
```

Expected: one route definition (from the #113 work).

- [ ] **Step 2: Add the new route adjacent to the existing one**

Insert this block right after the existing `leads_engine_status` route:

```python
    @app.get("/api/v1/leads/sources", dependencies=deps)
    async def list_lead_sources() -> dict[str, Any]:
        """Return the list of registered LeadSources with metadata.

        This is the feed for Flutter's LeadsScreen + locked-pack-card UX.
        If no sources are registered, the Flutter sidebar hides the Leads tab
        (gating logic in main_shell.dart).
        """
        svc = _get_service()
        if not svc:
            return {"sources": []}
        inner = getattr(svc, "_service", None)  # legacy shim exposes this
        if inner is None:
            return {"sources": []}
        sources = []
        for source in inner.list_sources():
            sources.append(
                {
                    "source_id": source.source_id,
                    "display_name": source.display_name,
                    "icon": source.icon,
                    "color": source.color,
                    "capabilities": sorted(source.capabilities),
                }
            )
        return {"sources": sources}
```

- [ ] **Step 3: Smoke test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "
from cognithor.channels.config_routes import _register_social_routes
print('import ok')
"
```

Expected: `import ok`.

- [ ] **Step 4: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/channels/config_routes.py && git commit -m "feat(leads): add GET /api/v1/leads/sources REST endpoint"
```

---

### Task 1.11 — Run the full Phase 1 validation

End-of-phase gate: lint + format + full test suite.

- [ ] **Step 1: Ruff check**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m ruff check src/cognithor/leads/ tests/test_leads/ src/cognithor/social/service.py src/cognithor/social/_legacy_adapters.py src/cognithor/channels/config_routes.py
```

Expected: `All checks passed!`

- [ ] **Step 2: Ruff format check**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m ruff format --check src/cognithor/leads/ tests/test_leads/ src/cognithor/social/service.py src/cognithor/social/_legacy_adapters.py
```

Expected: `N files already formatted`. If anything says "Would reformat", run `python -m ruff format <path>` on those files and re-commit.

- [ ] **Step 3: Full project test suite**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/ -q
```

Expected: `~2270 passed` (2249 baseline + 22 new leads tests). No failures, no errors.

- [ ] **Step 4: Tag the Phase 1 completion**

```bash
cd "D:/Jarvis/jarvis complete v20" && git tag -a phase1-leads-sdk -m "Phase 1 complete: cognithor.leads.sdk refactor"
```

Expected: tag created. Not pushed.

---

## Phase 2 — Pack Infrastructure in Core

**Goal:** Build `cognithor.packs` — the plugin interface, loader, installer, CLI — and wire it into the Gateway so packs in `~/.cognithor/packs/` load on startup. No real packs yet (those come in Phase 4); this phase builds the machinery with an in-test dummy pack.

**Outcome:** At the end of Phase 2, a user could theoretically run `cognithor pack install <test-zip>` against a hand-built zip with a `pack_manifest.json`, `eula.md`, and `pack.py`, and see the pack load on next startup. Full test suite passes.

### Task 2.1 — Scaffold `cognithor/packs/` directory

**Files:**
- Create: `src/cognithor/packs/__init__.py`
- Create: `src/cognithor/packs/errors.py`

- [ ] **Step 1: Write `src/cognithor/packs/__init__.py`**

```python
"""Cognithor agent pack plugin system.

Public API:

- ``AgentPack`` — abstract base class packs inherit from
- ``PackManifest`` — validated manifest model
- ``PackContext`` — facade exposing the subset of Gateway state a pack may touch
- ``PackLoader`` — discovers + loads packs from ``~/.cognithor/packs/``
- ``PackInstaller`` — installs/upgrades/removes packs from zip files or URLs
- ``PackLoadError``, ``PackInstallError``, ``PackValidationError`` — exception types
"""

from __future__ import annotations

from cognithor.packs.errors import (
    PackInstallError,
    PackLoadError,
    PackValidationError,
)
from cognithor.packs.interface import (
    AgentPack,
    PackContext,
    PackManifest,
    PricingTier,
    Publisher,
    RevenueShare,
)
from cognithor.packs.installer import PackInstaller
from cognithor.packs.loader import PackLoader

__all__ = [
    "AgentPack",
    "PackContext",
    "PackInstallError",
    "PackInstaller",
    "PackLoadError",
    "PackLoader",
    "PackManifest",
    "PackValidationError",
    "PricingTier",
    "Publisher",
    "RevenueShare",
]
```

- [ ] **Step 2: Write `src/cognithor/packs/errors.py`**

```python
"""Exception types for the pack system.

Loader errors are caught and logged — a broken pack must never crash Core.
Installer errors are surfaced to the CLI so the user sees what went wrong.
"""

from __future__ import annotations


class PackError(Exception):
    """Base class for all pack-system exceptions."""


class PackValidationError(PackError):
    """Raised when a manifest or bundle fails structural validation.

    Examples: missing required field, bad JSON, mismatched EULA hash,
    version range incompatibility.
    """


class PackLoadError(PackError):
    """Raised when a previously-installed pack fails to load at runtime.

    Never re-raised past the PackLoader boundary — always logged and
    swallowed so one broken pack can't stop Core from starting.
    """


class PackInstallError(PackError):
    """Raised by the installer when install/upgrade/remove fails.

    Surfaced to the CLI as a user-visible error with a hint.
    """
```

- [ ] **Step 3: Verify imports fail in the right place**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "import cognithor.packs.errors; print('errors ok')"
```

Expected: `errors ok`. Then:

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "import cognithor.packs" 2>&1 | tail -3
```

Expected: `ImportError` on `cognithor.packs.interface` — fine, that's the next task.

- [ ] **Step 4: Commit the scaffold**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/packs/__init__.py src/cognithor/packs/errors.py && git commit -m "feat(packs): scaffold cognithor.packs package with exception types"
```

---

### Task 2.2 — Write `cognithor/packs/interface.py`

The largest single file in Phase 2. Contains `Publisher`, `RevenueShare`, `PricingTier`, `PackManifest`, `PackContext`, and `AgentPack`.

**Files:**
- Create: `src/cognithor/packs/interface.py`
- Test: `tests/test_packs/__init__.py`
- Test: `tests/test_packs/test_interface.py`

- [ ] **Step 1: Create the test directory marker**

```bash
cd "D:/Jarvis/jarvis complete v20" && mkdir -p tests/test_packs && touch tests/test_packs/__init__.py
```

- [ ] **Step 2: Write the failing test**

Create `tests/test_packs/test_interface.py`:

```python
"""Tests for cognithor.packs.interface — PackManifest + AgentPack base."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pytest
from pydantic import ValidationError

from cognithor.packs.interface import (
    AgentPack,
    PackContext,
    PackManifest,
    PricingTier,
    Publisher,
    RevenueShare,
)


def _minimal_manifest_dict() -> dict[str, Any]:
    return {
        "schema_version": 1,
        "namespace": "cognithor-official",
        "pack_id": "test-pack",
        "version": "1.0.0",
        "display_name": "Test Pack",
        "description": "A test pack",
        "license": "proprietary",
        "min_cognithor_version": ">=0.83.0",
        "eula_sha256": "a" * 64,
        "publisher": {
            "id": "cognithor-official",
            "display_name": "Cognithor",
        },
    }


class TestPublisher:
    def test_minimal_publisher(self) -> None:
        p = Publisher(id="alex", display_name="Alex")
        assert p.id == "alex"
        assert p.website is None

    def test_publisher_id_required(self) -> None:
        with pytest.raises(ValidationError):
            Publisher(display_name="x")  # type: ignore[call-arg]


class TestRevenueShare:
    def test_default_is_70_30(self) -> None:
        rs = RevenueShare()
        assert rs.creator == 70
        assert rs.platform == 30

    def test_custom(self) -> None:
        rs = RevenueShare(creator=80, platform=20)
        assert rs.creator == 80


class TestPricingTier:
    def test_minimal(self) -> None:
        t = PricingTier(list_price=149, launch_price=79, post_launch_price=99, launch_cap=100)
        assert t.currency == "USD"
        assert t.launch_price == 79

    def test_launch_price_below_list(self) -> None:
        # Design intent: launch_price < list_price for the strikethrough to make sense.
        # We validate this at the manifest level, not individual tier level, because
        # free packs may have all-zero pricing. So this test just ensures plain
        # construction works.
        PricingTier(list_price=199, launch_price=99, post_launch_price=149, launch_cap=25)


class TestPackManifest:
    def test_minimal_paid_pack(self) -> None:
        data = _minimal_manifest_dict()
        m = PackManifest(**data)
        assert m.namespace == "cognithor-official"
        assert m.pack_id == "test-pack"
        assert m.revenue_share.creator == 70  # default
        assert m.entrypoint == "pack.py"

    def test_qualified_id(self) -> None:
        m = PackManifest(**_minimal_manifest_dict())
        assert m.qualified_id == "cognithor-official/test-pack"

    def test_eula_hash_must_be_hex_64(self) -> None:
        data = _minimal_manifest_dict()
        data["eula_sha256"] = "notahash"
        with pytest.raises(ValidationError):
            PackManifest(**data)

    def test_namespace_slash_forbidden(self) -> None:
        data = _minimal_manifest_dict()
        data["namespace"] = "bad/ns"
        with pytest.raises(ValidationError, match="namespace"):
            PackManifest(**data)

    def test_pack_id_slash_forbidden(self) -> None:
        data = _minimal_manifest_dict()
        data["pack_id"] = "bad/id"
        with pytest.raises(ValidationError, match="pack_id"):
            PackManifest(**data)

    def test_version_semver(self) -> None:
        data = _minimal_manifest_dict()
        data["version"] = "not-semver"
        with pytest.raises(ValidationError, match="version"):
            PackManifest(**data)

    def test_pricing_required_for_paid_license(self) -> None:
        data = _minimal_manifest_dict()
        data["license"] = "proprietary"
        # No pricing → must fail for proprietary.
        with pytest.raises(ValidationError, match="pricing"):
            PackManifest(**data)

    def test_pricing_optional_for_free_license(self) -> None:
        data = _minimal_manifest_dict()
        data["license"] = "apache-2.0"
        m = PackManifest(**data)
        assert m.pricing == {}

    def test_full_paid_manifest(self) -> None:
        data = _minimal_manifest_dict()
        data["pricing"] = {
            "indie": {
                "list_price": 149,
                "launch_price": 79,
                "post_launch_price": 99,
                "launch_cap": 100,
            },
        }
        data["checkout_url"] = "https://cognithor.lemonsqueezy.com/buy/xxx"
        m = PackManifest(**data)
        assert "indie" in m.pricing
        assert m.pricing["indie"].launch_price == 79

    def test_json_round_trip(self) -> None:
        data = _minimal_manifest_dict()
        data["license"] = "apache-2.0"  # skip pricing requirement
        m = PackManifest(**data)
        s = m.model_dump_json()
        m2 = PackManifest.model_validate_json(s)
        assert m2 == m


class _ConcretePack(AgentPack):
    def __init__(self, manifest: PackManifest) -> None:
        super().__init__(manifest)
        self.registered = False

    def register(self, context: PackContext) -> None:
        self.registered = True


class TestAgentPack:
    def test_instantiation_requires_register_override(self) -> None:
        with pytest.raises(TypeError):
            AgentPack(PackManifest(**_minimal_manifest_dict(), pricing={"indie": {"list_price": 100, "launch_price": 50, "post_launch_price": 75, "launch_cap": 10}}))  # type: ignore[abstract]

    def test_concrete_pack_can_register(self) -> None:
        data = _minimal_manifest_dict()
        data["license"] = "apache-2.0"
        manifest = PackManifest(**data)
        pack = _ConcretePack(manifest)
        pack.register(PackContext(gateway=None, config=None, mcp_client=None, leads=None))
        assert pack.registered is True

    def test_unregister_default_is_noop(self) -> None:
        data = _minimal_manifest_dict()
        data["license"] = "apache-2.0"
        pack = _ConcretePack(PackManifest(**data))
        pack.unregister(PackContext(gateway=None, config=None, mcp_client=None, leads=None))
```

- [ ] **Step 3: Run the failing test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_interface.py -x -q
```

Expected: `ImportError: cannot import name 'AgentPack' from 'cognithor.packs.interface'`.

- [ ] **Step 4: Write `src/cognithor/packs/interface.py`**

```python
"""Pack interface — the stable contract between packs and Core.

Every pack provides a ``pack_manifest.json`` validated against ``PackManifest``
and a ``pack.py`` module that exposes a top-level ``Pack`` class extending
``AgentPack``. The loader validates the manifest, imports the module,
instantiates ``Pack(manifest)``, and calls ``register(context)``.

Deliberately narrow:

- Packs receive only a ``PackContext`` facade, never the raw Gateway.
- Packs declare their contributions (tools, lead sources) in the manifest
  AND in their ``register()`` body — the manifest fields are informational
  for the catalog and loader, the real wiring happens in ``register()``.
"""

from __future__ import annotations

import re
from abc import ABC, abstractmethod
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?$")
_NS_PACK_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
_SHA256_RE = re.compile(r"^[a-f0-9]{64}$")


class Publisher(BaseModel):
    """Publisher metadata. First-party packs use ``id="cognithor-official"``.

    Multi-publisher ready: the Q4 2026 community marketplace will register
    independent publishers without any schema change.
    """

    model_config = ConfigDict(extra="forbid")

    id: str
    display_name: str
    website: str | None = None
    contact_email: str | None = None
    payout_provider: str | None = None  # "lemonsqueezy" | "stripe-connect" (Phase 2)


class RevenueShare(BaseModel):
    """Revenue split between creator and platform. Default: 70/30."""

    model_config = ConfigDict(extra="forbid")

    creator: int = Field(default=70, ge=0, le=100)
    platform: int = Field(default=30, ge=0, le=100)

    @model_validator(mode="after")
    def _sum_to_100(self) -> "RevenueShare":
        if self.creator + self.platform != 100:
            raise ValueError("creator + platform must sum to 100")
        return self


class PricingTier(BaseModel):
    """One tier (indie / commercial / ...) used for price anchoring on the site."""

    model_config = ConfigDict(extra="forbid")

    list_price: int = Field(ge=0)
    launch_price: int = Field(ge=0)
    post_launch_price: int = Field(ge=0)
    launch_cap: int = Field(ge=1)
    currency: str = "USD"


class PackManifest(BaseModel):
    """Validated pack manifest stored as ``pack_manifest.json`` at pack root."""

    model_config = ConfigDict(extra="forbid")

    schema_version: int = 1
    namespace: str
    pack_id: str
    version: str
    display_name: str
    description: str
    license: str
    min_cognithor_version: str
    max_cognithor_version: str | None = None
    entrypoint: str = "pack.py"
    eula_sha256: str
    publisher: Publisher
    revenue_share: RevenueShare = Field(default_factory=RevenueShare)

    # Declarative contributions (informational; real wiring is in register()).
    lead_sources: list[str] = Field(default_factory=list)
    tools: list[str] = Field(default_factory=list)

    # Commerce.
    checkout_url: str | None = None
    commercial_checkout_url: str | None = None
    pricing: dict[str, PricingTier] = Field(default_factory=dict)

    @field_validator("namespace")
    @classmethod
    def _validate_namespace(cls, v: str) -> str:
        if "/" in v or not _NS_PACK_RE.match(v):
            raise ValueError(
                f"namespace must match {_NS_PACK_RE.pattern!r} (lowercase, no slashes)"
            )
        return v

    @field_validator("pack_id")
    @classmethod
    def _validate_pack_id(cls, v: str) -> str:
        if "/" in v or not _NS_PACK_RE.match(v):
            raise ValueError(
                f"pack_id must match {_NS_PACK_RE.pattern!r} (lowercase, no slashes)"
            )
        return v

    @field_validator("version")
    @classmethod
    def _validate_version(cls, v: str) -> str:
        if not _SEMVER_RE.match(v):
            raise ValueError(f"version must be semver X.Y.Z, got {v!r}")
        return v

    @field_validator("eula_sha256")
    @classmethod
    def _validate_eula_hash(cls, v: str) -> str:
        if not _SHA256_RE.match(v):
            raise ValueError("eula_sha256 must be 64 lowercase hex chars")
        return v

    @model_validator(mode="after")
    def _pricing_required_for_paid(self) -> "PackManifest":
        if self.license == "proprietary" and not self.pricing:
            raise ValueError("pricing is required for proprietary-licensed packs")
        return self

    @property
    def qualified_id(self) -> str:
        """Globally unique id: ``namespace/pack_id``."""
        return f"{self.namespace}/{self.pack_id}"


class PackContext(BaseModel):
    """Facade passed to ``AgentPack.register()``.

    A narrow, stable subset of the Gateway so Gateway internals can be
    refactored without breaking installed packs.
    """

    model_config = ConfigDict(arbitrary_types_allowed=True)

    gateway: Any = None
    config: Any = None
    mcp_client: Any = None
    leads: Any = None  # cognithor.leads.LeadService


class AgentPack(ABC):
    """Abstract base class all packs inherit from.

    Concrete subclasses live in each pack's ``pack.py`` as a top-level class
    named ``Pack`` (case-sensitive; the loader looks for this exact name).
    """

    def __init__(self, manifest: PackManifest) -> None:
        self.manifest = manifest

    @abstractmethod
    def register(self, context: PackContext) -> None:
        """Wire the pack into a running Cognithor instance.

        Called once at startup after the loader has validated the manifest.
        Typical implementations register ``LeadSource`` instances via
        ``context.leads.register_source(...)``, MCP tools via
        ``context.mcp_client.register_tool(...)``, or REST routes via
        ``context.gateway._api.add_route(...)``.

        Must be idempotent: the loader may call it again on reload.
        """

    def unregister(self, context: PackContext) -> None:
        """Cleanup on unload. Default: no-op.

        Override in subclasses that hold resources (background tasks,
        database connections, subprocess handles).
        """
```

- [ ] **Step 5: Run the test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_interface.py -x -q
```

Expected: all 13 tests pass.

- [ ] **Step 6: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/packs/interface.py tests/test_packs/ && git commit -m "feat(packs): add PackManifest, AgentPack, and supporting models"
```

---

### Task 2.3 — Write `cognithor/packs/loader.py`

Discovers packs on disk, validates them, imports via `importlib`, and calls `register()`. Failures are logged and swallowed.

**Files:**
- Create: `src/cognithor/packs/loader.py`
- Test: `tests/test_packs/test_loader.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_packs/test_loader.py`:

```python
"""Tests for cognithor.packs.loader — pack discovery and lifecycle."""

from __future__ import annotations

import hashlib
import json
from pathlib import Path
from typing import Any

import pytest

from cognithor.packs.interface import PackContext, PackManifest, Publisher
from cognithor.packs.loader import PackLoader


def _write_pack(
    base: Path,
    *,
    namespace: str = "cognithor-official",
    pack_id: str = "test-pack",
    version: str = "1.0.0",
    eula_text: str = "BY INSTALLING THIS PACK YOU AGREE…",
    min_version: str = ">=0.1.0",
    license_: str = "apache-2.0",
    pricing: dict[str, dict[str, int]] | None = None,
    pack_py_body: str = "class Pack:\n    def __init__(self, manifest): self.manifest = manifest\n    def register(self, ctx): pass\n",
    write_eula_accepted: bool = True,
) -> Path:
    """Create a pack directory at base/<namespace>/<pack_id>/ with all files."""
    pack_dir = base / namespace / pack_id
    pack_dir.mkdir(parents=True, exist_ok=True)
    (pack_dir / "eula.md").write_text(eula_text, encoding="utf-8")
    eula_hash = hashlib.sha256(eula_text.encode("utf-8")).hexdigest()

    manifest = {
        "schema_version": 1,
        "namespace": namespace,
        "pack_id": pack_id,
        "version": version,
        "display_name": "Test Pack",
        "description": "test",
        "license": license_,
        "min_cognithor_version": min_version,
        "eula_sha256": eula_hash,
        "publisher": {"id": "cognithor-official", "display_name": "Cognithor"},
    }
    if pricing:
        manifest["pricing"] = pricing
    (pack_dir / "pack_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
    (pack_dir / "pack.py").write_text(pack_py_body, encoding="utf-8")

    if write_eula_accepted:
        (pack_dir / ".eula_accepted").write_text(
            json.dumps(
                {
                    "timestamp": 1234567890.0,
                    "user": "tester",
                    "eula_sha256": eula_hash,
                    "installer_version": "0.83.0",
                }
            ),
            encoding="utf-8",
        )
    return pack_dir


@pytest.fixture
def packs_dir(tmp_path: Path) -> Path:
    return tmp_path / "packs"


class TestPackLoaderDiscovery:
    def test_discover_empty_dir(self, packs_dir: Path) -> None:
        packs_dir.mkdir(parents=True)
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        assert loader.discover() == []

    def test_discover_single_pack(self, packs_dir: Path) -> None:
        _write_pack(packs_dir)
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        manifests = loader.discover()
        assert len(manifests) == 1
        assert manifests[0].qualified_id == "cognithor-official/test-pack"

    def test_discover_skips_pack_with_missing_manifest(
        self, packs_dir: Path, caplog: pytest.LogCaptureFixture
    ) -> None:
        _write_pack(packs_dir, pack_id="good")
        broken = packs_dir / "cognithor-official" / "broken"
        broken.mkdir(parents=True)
        # No manifest file
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        manifests = loader.discover()
        assert len(manifests) == 1
        assert manifests[0].pack_id == "good"

    def test_discover_skips_pack_with_bad_eula_hash(self, packs_dir: Path) -> None:
        pack_dir = _write_pack(packs_dir, pack_id="badeula")
        # Corrupt the EULA after writing
        (pack_dir / "eula.md").write_text("tampered!", encoding="utf-8")
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        manifests = loader.discover()
        assert manifests == []

    def test_discover_skips_pack_with_missing_eula_accepted(
        self, packs_dir: Path
    ) -> None:
        _write_pack(packs_dir, pack_id="unaccepted", write_eula_accepted=False)
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        manifests = loader.discover()
        assert manifests == []


class TestPackLoaderLoadAll:
    def test_load_all_calls_register(self, packs_dir: Path) -> None:
        body = """
class Pack:
    def __init__(self, manifest):
        self.manifest = manifest
        self.calls = 0
    def register(self, ctx):
        self.calls += 1
"""
        _write_pack(packs_dir, pack_id="counted", pack_py_body=body)
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        ctx = PackContext()
        loader.load_all(ctx)
        loaded = loader.get("cognithor-official/counted")
        assert loaded is not None
        assert loaded.calls == 1

    def test_broken_pack_does_not_stop_others(self, packs_dir: Path) -> None:
        body_good = "class Pack:\n    def __init__(self, m): self.m = m\n    def register(self, ctx): pass\n"
        body_broken = "raise RuntimeError('intentional')\n"
        _write_pack(packs_dir, pack_id="good", pack_py_body=body_good)
        _write_pack(packs_dir, pack_id="broken", pack_py_body=body_broken)
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        ctx = PackContext()
        loader.load_all(ctx)
        assert loader.get("cognithor-official/good") is not None
        assert loader.get("cognithor-official/broken") is None


class TestPackLoaderVersionRange:
    def test_older_cognithor_rejects_newer_pack(self, packs_dir: Path) -> None:
        _write_pack(packs_dir, pack_id="future", min_version=">=1.0.0")
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        assert loader.discover() == []

    def test_version_range_accepts_exact_min(self, packs_dir: Path) -> None:
        _write_pack(packs_dir, pack_id="exact", min_version=">=0.83.0")
        loader = PackLoader(packs_dir=packs_dir, cognithor_version="0.83.0")
        assert len(loader.discover()) == 1
```

- [ ] **Step 2: Run the failing test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_loader.py -x -q
```

Expected: `ImportError: cannot import name 'PackLoader'`.

- [ ] **Step 3: Write `src/cognithor/packs/loader.py`**

```python
"""Pack loader — discover, validate, import, register installed packs.

Scans ``~/.cognithor/packs/<namespace>/<pack_id>/`` and loads each pack
via ``importlib``. All exceptions are logged with structured context and
swallowed — a broken pack must never prevent Core from starting.

Called once during Gateway Phase F initialization via ``load_all(context)``.
"""

from __future__ import annotations

import contextlib
import hashlib
import importlib.util
import json
import sys
from pathlib import Path
from typing import Any

from cognithor.packs.errors import PackLoadError, PackValidationError
from cognithor.packs.interface import AgentPack, PackContext, PackManifest
from cognithor.utils.logging import get_logger

log = get_logger(__name__)


def _parse_version(s: str) -> tuple[int, int, int]:
    """Parse a version string like '0.83.0' into a comparable tuple."""
    core = s.lstrip(">=<~ ").split("-", 1)[0].split("+", 1)[0]
    parts = core.split(".")
    if len(parts) < 3:
        parts = parts + ["0"] * (3 - len(parts))
    return int(parts[0]), int(parts[1]), int(parts[2])


def _version_satisfies(current: str, spec: str) -> bool:
    """Minimal version-range check: supports '>=X.Y.Z' and '<X.Y.Z'.

    This avoids a full PEP 440 / semver dependency. Adequate for MVP.
    """
    current_tuple = _parse_version(current)
    spec = spec.strip()
    if spec.startswith(">="):
        return current_tuple >= _parse_version(spec[2:])
    if spec.startswith(">"):
        return current_tuple > _parse_version(spec[1:])
    if spec.startswith("<="):
        return current_tuple <= _parse_version(spec[2:])
    if spec.startswith("<"):
        return current_tuple < _parse_version(spec[1:])
    if spec.startswith("=="):
        return current_tuple == _parse_version(spec[2:])
    return current_tuple >= _parse_version(spec)


class PackLoader:
    def __init__(self, packs_dir: Path, cognithor_version: str) -> None:
        self._packs_dir = packs_dir
        self._cognithor_version = cognithor_version
        self._loaded: dict[str, AgentPack] = {}

    # ---------- discovery ----------

    def discover(self) -> list[PackManifest]:
        """Walk packs_dir, return a validated list of manifests.

        Layout: ``packs_dir/<namespace>/<pack_id>/pack_manifest.json``
        """
        if not self._packs_dir.exists():
            return []
        manifests: list[PackManifest] = []
        for ns_dir in sorted(self._packs_dir.iterdir()):
            if not ns_dir.is_dir():
                continue
            for pack_dir in sorted(ns_dir.iterdir()):
                if not pack_dir.is_dir():
                    continue
                manifest = self._validate_pack(pack_dir)
                if manifest is not None:
                    manifests.append(manifest)
        return manifests

    def _validate_pack(self, pack_dir: Path) -> PackManifest | None:
        """Run all validation steps on a pack directory. Returns None on failure."""
        try:
            manifest_path = pack_dir / "pack_manifest.json"
            if not manifest_path.exists():
                log.warning(
                    "pack_missing_manifest",
                    pack_dir=str(pack_dir),
                )
                return None
            raw = json.loads(manifest_path.read_text(encoding="utf-8"))
            manifest = PackManifest(**raw)
        except json.JSONDecodeError as exc:
            log.warning("pack_manifest_json_error", pack_dir=str(pack_dir), error=str(exc))
            return None
        except Exception as exc:
            log.warning("pack_manifest_validation_error", pack_dir=str(pack_dir), error=str(exc))
            return None

        # EULA hash check.
        eula_path = pack_dir / "eula.md"
        if not eula_path.exists():
            log.warning("pack_missing_eula", qualified_id=manifest.qualified_id)
            return None
        eula_hash = hashlib.sha256(eula_path.read_bytes()).hexdigest()
        if eula_hash != manifest.eula_sha256:
            log.warning(
                "pack_eula_hash_mismatch",
                qualified_id=manifest.qualified_id,
                expected=manifest.eula_sha256,
                actual=eula_hash,
            )
            return None

        # Version range.
        if not _version_satisfies(self._cognithor_version, manifest.min_cognithor_version):
            log.warning(
                "pack_min_version_unmet",
                qualified_id=manifest.qualified_id,
                min=manifest.min_cognithor_version,
                current=self._cognithor_version,
            )
            return None
        if manifest.max_cognithor_version and _version_satisfies(
            self._cognithor_version, f">{manifest.max_cognithor_version}"
        ):
            log.warning(
                "pack_max_version_exceeded",
                qualified_id=manifest.qualified_id,
            )
            return None

        # EULA acceptance.
        accepted_path = pack_dir / ".eula_accepted"
        if not accepted_path.exists():
            log.warning(
                "pack_eula_not_accepted",
                qualified_id=manifest.qualified_id,
                hint="run: cognithor pack accept-eula " + manifest.qualified_id,
            )
            return None
        try:
            accepted = json.loads(accepted_path.read_text(encoding="utf-8"))
            if accepted.get("eula_sha256") != manifest.eula_sha256:
                log.warning(
                    "pack_eula_acceptance_stale",
                    qualified_id=manifest.qualified_id,
                )
                return None
        except Exception as exc:
            log.warning(
                "pack_eula_acceptance_corrupt",
                qualified_id=manifest.qualified_id,
                error=str(exc),
            )
            return None

        # Entrypoint exists.
        entrypoint = pack_dir / manifest.entrypoint
        if not entrypoint.exists():
            log.warning(
                "pack_missing_entrypoint",
                qualified_id=manifest.qualified_id,
                entrypoint=manifest.entrypoint,
            )
            return None

        return manifest

    # ---------- load ----------

    def load_all(self, context: PackContext) -> None:
        """Discover + load every valid pack. Never raises."""
        for manifest in self.discover():
            with contextlib.suppress(Exception):
                self._load_one(manifest, context)

    def _load_one(self, manifest: PackManifest, context: PackContext) -> None:
        pack_dir = self._packs_dir / manifest.namespace / manifest.pack_id
        entrypoint_path = pack_dir / manifest.entrypoint
        module_name = f"cognithor_packs.{manifest.namespace.replace('-', '_')}.{manifest.pack_id.replace('-', '_')}"

        try:
            spec = importlib.util.spec_from_file_location(
                module_name, str(entrypoint_path)
            )
            if spec is None or spec.loader is None:
                raise PackLoadError("spec_from_file_location returned None")
            module = importlib.util.module_from_spec(spec)
            sys.modules[module_name] = module
            spec.loader.exec_module(module)
        except Exception as exc:
            log.warning(
                "pack_import_failed",
                qualified_id=manifest.qualified_id,
                error=str(exc),
                exc_info=True,
            )
            sys.modules.pop(module_name, None)
            return

        pack_cls = getattr(module, "Pack", None)
        if pack_cls is None:
            log.warning(
                "pack_missing_class",
                qualified_id=manifest.qualified_id,
                expected="Pack",
            )
            return

        try:
            instance = pack_cls(manifest)
            instance.register(context)
        except Exception as exc:
            log.warning(
                "pack_register_failed",
                qualified_id=manifest.qualified_id,
                error=str(exc),
                exc_info=True,
            )
            return

        self._loaded[manifest.qualified_id] = instance
        log.info(
            "pack_loaded",
            qualified_id=manifest.qualified_id,
            version=manifest.version,
        )

    def unload_all(self, context: PackContext) -> None:
        """Call unregister() on each loaded pack in reverse order. Never raises."""
        for qid in reversed(list(self._loaded.keys())):
            pack = self._loaded.pop(qid)
            with contextlib.suppress(Exception):
                pack.unregister(context)
            log.info("pack_unloaded", qualified_id=qid)

    def get(self, qualified_id: str) -> AgentPack | None:
        return self._loaded.get(qualified_id)

    def loaded(self) -> list[AgentPack]:
        return list(self._loaded.values())
```

- [ ] **Step 4: Run tests**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_loader.py -x -q
```

Expected: all 9 tests pass.

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/packs/loader.py tests/test_packs/test_loader.py && git commit -m "feat(packs): add PackLoader with discovery, validation, and import isolation"
```

---

### Task 2.4 — Write `cognithor/packs/installer.py`

The installer handles install/upgrade/remove from local zip or URL, with interactive EULA click-through.

**Files:**
- Create: `src/cognithor/packs/installer.py`
- Test: `tests/test_packs/test_installer.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_packs/test_installer.py`:

```python
"""Tests for cognithor.packs.installer."""

from __future__ import annotations

import hashlib
import json
import zipfile
from pathlib import Path

import pytest

from cognithor.packs.errors import PackInstallError, PackValidationError
from cognithor.packs.installer import PackInstaller


def _build_pack_zip(
    tmp_path: Path,
    *,
    namespace: str = "cognithor-official",
    pack_id: str = "installed-test",
    version: str = "1.0.0",
    eula_text: str = "TEST EULA TEXT",
    license_: str = "apache-2.0",
) -> Path:
    """Build a valid pack zip in tmp_path and return its path."""
    eula_hash = hashlib.sha256(eula_text.encode("utf-8")).hexdigest()
    manifest = {
        "schema_version": 1,
        "namespace": namespace,
        "pack_id": pack_id,
        "version": version,
        "display_name": "Installed Test",
        "description": "test",
        "license": license_,
        "min_cognithor_version": ">=0.1.0",
        "eula_sha256": eula_hash,
        "publisher": {"id": "cognithor-official", "display_name": "Cognithor"},
    }
    if license_ == "proprietary":
        manifest["pricing"] = {
            "indie": {
                "list_price": 149,
                "launch_price": 79,
                "post_launch_price": 99,
                "launch_cap": 100,
            }
        }

    zip_path = tmp_path / f"{pack_id}-{version}.zip"
    with zipfile.ZipFile(zip_path, "w") as zf:
        zf.writestr("pack_manifest.json", json.dumps(manifest))
        zf.writestr("eula.md", eula_text)
        zf.writestr(
            "pack.py",
            "class Pack:\n"
            "    def __init__(self, manifest): self.manifest = manifest\n"
            "    def register(self, ctx): pass\n",
        )
    return zip_path


@pytest.fixture
def packs_root(tmp_path: Path) -> Path:
    d = tmp_path / "packs"
    d.mkdir()
    return d


class TestInstallLocalZip:
    def test_install_happy_path(
        self, tmp_path: Path, packs_root: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        zip_path = _build_pack_zip(tmp_path)
        installer = PackInstaller(packs_root=packs_root)
        monkeypatch.setattr("builtins.input", lambda _prompt="": "y")

        manifest = installer.install_from_path(zip_path)

        assert manifest.pack_id == "installed-test"
        pack_dir = packs_root / "cognithor-official" / "installed-test"
        assert pack_dir.exists()
        assert (pack_dir / "pack_manifest.json").exists()
        assert (pack_dir / "eula.md").exists()
        assert (pack_dir / ".eula_accepted").exists()

    def test_install_declined_eula_aborts(
        self, tmp_path: Path, packs_root: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        zip_path = _build_pack_zip(tmp_path)
        installer = PackInstaller(packs_root=packs_root)
        monkeypatch.setattr("builtins.input", lambda _prompt="": "n")

        with pytest.raises(PackInstallError, match="EULA"):
            installer.install_from_path(zip_path)

        pack_dir = packs_root / "cognithor-official" / "installed-test"
        assert not pack_dir.exists()

    def test_install_already_installed_same_version(
        self, tmp_path: Path, packs_root: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        zip_path = _build_pack_zip(tmp_path)
        installer = PackInstaller(packs_root=packs_root)
        monkeypatch.setattr("builtins.input", lambda _prompt="": "y")

        installer.install_from_path(zip_path)

        with pytest.raises(PackInstallError, match="already installed"):
            installer.install_from_path(zip_path)

    def test_install_upgrade(
        self, tmp_path: Path, packs_root: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        old_zip = _build_pack_zip(tmp_path, version="1.0.0")
        new_zip = _build_pack_zip(tmp_path, version="1.1.0")
        installer = PackInstaller(packs_root=packs_root)
        monkeypatch.setattr("builtins.input", lambda _prompt="": "y")

        installer.install_from_path(old_zip)
        manifest = installer.install_from_path(new_zip)
        assert manifest.version == "1.1.0"

    def test_install_rejects_missing_eula(
        self, tmp_path: Path, packs_root: Path
    ) -> None:
        zip_path = tmp_path / "bad.zip"
        with zipfile.ZipFile(zip_path, "w") as zf:
            zf.writestr(
                "pack_manifest.json",
                json.dumps(
                    {
                        "schema_version": 1,
                        "namespace": "cognithor-official",
                        "pack_id": "bad",
                        "version": "1.0.0",
                        "display_name": "Bad",
                        "description": "bad",
                        "license": "apache-2.0",
                        "min_cognithor_version": ">=0.1.0",
                        "eula_sha256": "a" * 64,
                        "publisher": {"id": "x", "display_name": "x"},
                    }
                ),
            )
            zf.writestr("pack.py", "class Pack:\n    pass\n")
            # No eula.md on purpose.

        installer = PackInstaller(packs_root=packs_root)
        with pytest.raises(PackInstallError, match="eula.md"):
            installer.install_from_path(zip_path)


class TestRemove:
    def test_remove_installed(
        self, tmp_path: Path, packs_root: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        zip_path = _build_pack_zip(tmp_path)
        installer = PackInstaller(packs_root=packs_root)
        monkeypatch.setattr("builtins.input", lambda _prompt="": "y")
        installer.install_from_path(zip_path)

        installer.remove("cognithor-official/installed-test")

        pack_dir = packs_root / "cognithor-official" / "installed-test"
        assert not pack_dir.exists()

    def test_remove_nonexistent_raises(self, packs_root: Path) -> None:
        installer = PackInstaller(packs_root=packs_root)
        with pytest.raises(PackInstallError, match="not installed"):
            installer.remove("cognithor-official/does-not-exist")


class TestList:
    def test_list_returns_installed_manifests(
        self, tmp_path: Path, packs_root: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        z1 = _build_pack_zip(tmp_path, pack_id="a")
        z2 = _build_pack_zip(tmp_path, pack_id="b")
        installer = PackInstaller(packs_root=packs_root)
        monkeypatch.setattr("builtins.input", lambda _prompt="": "y")
        installer.install_from_path(z1)
        installer.install_from_path(z2)

        installed = installer.list_installed()
        ids = {m.pack_id for m in installed}
        assert ids == {"a", "b"}
```

- [ ] **Step 2: Run the failing test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_installer.py -x -q
```

Expected: `ImportError: cannot import name 'PackInstaller'`.

- [ ] **Step 3: Write `src/cognithor/packs/installer.py`**

```python
"""Pack installer — extract, validate, EULA click-through, persist.

Handles install/upgrade/remove from local zip paths and HTTPS URLs. URL
installs stream via httpx with a progress bar. All user-facing errors
raise ``PackInstallError`` with a human-readable message.
"""

from __future__ import annotations

import getpass
import hashlib
import json
import shutil
import tempfile
import time
import zipfile
from pathlib import Path

import httpx

from cognithor.packs.errors import PackInstallError
from cognithor.packs.interface import PackManifest
from cognithor.packs.loader import PackLoader
from cognithor.utils.logging import get_logger

log = get_logger(__name__)

_MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024  # 500 MB
_DOWNLOAD_TIMEOUT_S = 600


class PackInstaller:
    def __init__(self, packs_root: Path, installer_version: str = "0.83.0") -> None:
        self._packs_root = packs_root
        self._installer_version = installer_version

    # ---------- public API ----------

    def install_from_path(self, zip_path: Path) -> PackManifest:
        """Install a pack from a local zip file."""
        if not zip_path.exists():
            raise PackInstallError(f"Zip file not found: {zip_path}")
        return self._install(zip_path)

    def install_from_url(self, url: str) -> PackManifest:
        """Install a pack by downloading a zip from an HTTPS URL."""
        zip_path = self._download(url)
        try:
            return self._install(zip_path)
        finally:
            zip_path.unlink(missing_ok=True)

    def remove(self, qualified_id: str) -> None:
        namespace, pack_id = self._split_qid(qualified_id)
        pack_dir = self._packs_root / namespace / pack_id
        if not pack_dir.exists():
            raise PackInstallError(f"Pack {qualified_id!r} is not installed")
        shutil.rmtree(pack_dir)
        log.info("pack_removed", qualified_id=qualified_id)

    def list_installed(self) -> list[PackManifest]:
        loader = PackLoader(self._packs_root, cognithor_version="0.0.0")
        # Use a trivially-small cognithor_version so discover() doesn't filter
        # by version range. We're only reading metadata, not loading.
        return loader.discover()

    def accept_eula(self, qualified_id: str) -> None:
        namespace, pack_id = self._split_qid(qualified_id)
        pack_dir = self._packs_root / namespace / pack_id
        if not pack_dir.exists():
            raise PackInstallError(f"Pack {qualified_id!r} is not installed")
        manifest = self._read_manifest(pack_dir)
        eula_text = (pack_dir / "eula.md").read_text(encoding="utf-8")
        if not self._prompt_eula(manifest, eula_text):
            raise PackInstallError("EULA not accepted")
        self._write_acceptance(pack_dir, manifest)

    # ---------- internal ----------

    def _install(self, zip_path: Path) -> PackManifest:
        with tempfile.TemporaryDirectory(prefix="cognithor_pack_") as extract_root_s:
            extract_root = Path(extract_root_s)
            try:
                with zipfile.ZipFile(zip_path, "r") as zf:
                    zf.extractall(extract_root)
            except zipfile.BadZipFile as exc:
                raise PackInstallError(f"Not a valid zip: {exc}") from exc

            # Locate pack root — support both "zip root == pack root" and
            # "zip root contains a single top-level dir".
            candidates = [extract_root]
            top_entries = [p for p in extract_root.iterdir()]
            if (
                len(top_entries) == 1
                and top_entries[0].is_dir()
                and not (extract_root / "pack_manifest.json").exists()
            ):
                candidates.append(top_entries[0])
            pack_root: Path | None = None
            for c in candidates:
                if (c / "pack_manifest.json").exists():
                    pack_root = c
                    break
            if pack_root is None:
                raise PackInstallError(
                    "Zip does not contain pack_manifest.json at root or in a single top-level dir"
                )

            # Validate required files.
            eula_path = pack_root / "eula.md"
            if not eula_path.exists():
                raise PackInstallError("Zip missing eula.md")

            # Parse + validate manifest.
            try:
                manifest_raw = json.loads(
                    (pack_root / "pack_manifest.json").read_text(encoding="utf-8")
                )
                manifest = PackManifest(**manifest_raw)
            except Exception as exc:
                raise PackInstallError(f"Invalid manifest: {exc}") from exc

            # EULA hash check.
            eula_text = eula_path.read_text(encoding="utf-8")
            actual_hash = hashlib.sha256(eula_text.encode("utf-8")).hexdigest()
            if actual_hash != manifest.eula_sha256:
                raise PackInstallError(
                    f"EULA hash mismatch: manifest says {manifest.eula_sha256[:12]}... "
                    f"but file hashes to {actual_hash[:12]}..."
                )

            # Entrypoint check.
            if not (pack_root / manifest.entrypoint).exists():
                raise PackInstallError(
                    f"Zip missing entrypoint {manifest.entrypoint!r}"
                )

            target_dir = self._packs_root / manifest.namespace / manifest.pack_id

            # Already-installed check.
            if target_dir.exists():
                existing = self._read_manifest(target_dir)
                from cognithor.packs.loader import _parse_version

                new_v = _parse_version(manifest.version)
                old_v = _parse_version(existing.version)
                if new_v == old_v:
                    raise PackInstallError(
                        f"{manifest.qualified_id} {manifest.version} is already installed"
                    )
                if new_v < old_v:
                    log.warning(
                        "pack_downgrade_attempt",
                        qualified_id=manifest.qualified_id,
                        old=existing.version,
                        new=manifest.version,
                    )
                # Upgrade: we'll delete target_dir after successful EULA accept.

            # EULA click-through.
            if not self._prompt_eula(manifest, eula_text):
                raise PackInstallError(
                    "EULA not accepted; install aborted. Nothing was written to disk."
                )

            # Atomic-ish move: if target_dir exists (upgrade), swap via backup.
            target_dir.parent.mkdir(parents=True, exist_ok=True)
            if target_dir.exists():
                backup = target_dir.with_suffix(".old")
                if backup.exists():
                    shutil.rmtree(backup)
                shutil.move(str(target_dir), str(backup))
                try:
                    shutil.copytree(pack_root, target_dir)
                    shutil.rmtree(backup)
                except Exception:
                    # Restore the backup on failure.
                    if target_dir.exists():
                        shutil.rmtree(target_dir)
                    shutil.move(str(backup), str(target_dir))
                    raise
            else:
                shutil.copytree(pack_root, target_dir)

            self._write_acceptance(target_dir, manifest)

            log.info(
                "pack_installed",
                qualified_id=manifest.qualified_id,
                version=manifest.version,
            )
            return manifest

    def _download(self, url: str) -> Path:
        if not url.startswith(("http://", "https://")):
            raise PackInstallError(f"URL must be http(s): got {url!r}")
        fd, tmp_name = tempfile.mkstemp(prefix="cognithor_pack_", suffix=".zip")
        tmp_path = Path(tmp_name)
        try:
            with (
                httpx.Client(timeout=_DOWNLOAD_TIMEOUT_S, follow_redirects=True) as client,
                client.stream("GET", url) as resp,
            ):
                resp.raise_for_status()
                content_type = resp.headers.get("content-type", "")
                if content_type.startswith("text/html"):
                    raise PackInstallError(
                        "URL returned HTML, not a zip. The download link may have "
                        "expired — check your email for a fresh link."
                    )
                total = int(resp.headers.get("content-length", 0))
                if total > _MAX_DOWNLOAD_BYTES:
                    raise PackInstallError(
                        f"Pack too large: {total} bytes exceeds limit of {_MAX_DOWNLOAD_BYTES}"
                    )
                written = 0
                with open(tmp_path, "wb") as f:
                    for chunk in resp.iter_bytes(chunk_size=65536):
                        f.write(chunk)
                        written += len(chunk)
                        if written > _MAX_DOWNLOAD_BYTES:
                            raise PackInstallError("Download exceeded max size mid-stream")
        except PackInstallError:
            tmp_path.unlink(missing_ok=True)
            raise
        except Exception as exc:
            tmp_path.unlink(missing_ok=True)
            raise PackInstallError(f"Download failed: {exc}") from exc
        finally:
            import os

            try:
                os.close(fd)
            except OSError:
                pass
        return tmp_path

    def _prompt_eula(self, manifest: PackManifest, eula_text: str) -> bool:
        """Display EULA text and prompt for acceptance. Returns True on accept."""
        print("=" * 72)
        print(f"END USER LICENSE AGREEMENT — {manifest.display_name} {manifest.version}")
        print("=" * 72)
        print(eula_text)
        print("=" * 72)
        try:
            answer = input("Accept these terms? [y/N]: ").strip().lower()
        except (EOFError, KeyboardInterrupt):
            print()
            return False
        return answer in ("y", "yes")

    def _write_acceptance(self, pack_dir: Path, manifest: PackManifest) -> None:
        record = {
            "timestamp": time.time(),
            "user": getpass.getuser(),
            "eula_sha256": manifest.eula_sha256,
            "installer_version": self._installer_version,
        }
        (pack_dir / ".eula_accepted").write_text(
            json.dumps(record, indent=2), encoding="utf-8"
        )

    def _read_manifest(self, pack_dir: Path) -> PackManifest:
        raw = json.loads((pack_dir / "pack_manifest.json").read_text(encoding="utf-8"))
        return PackManifest(**raw)

    def _split_qid(self, qualified_id: str) -> tuple[str, str]:
        if "/" not in qualified_id:
            raise PackInstallError(
                f"Expected qualified id '<namespace>/<pack_id>', got {qualified_id!r}"
            )
        namespace, _, pack_id = qualified_id.partition("/")
        return namespace, pack_id
```

- [ ] **Step 4: Run tests**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_installer.py -x -q
```

Expected: all 8 tests pass.

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/packs/installer.py tests/test_packs/test_installer.py && git commit -m "feat(packs): add PackInstaller with zip/URL install, EULA click-through"
```

---

### Task 2.5 — Write `cognithor/packs/cli.py`

Thin CLI wrapper over `PackInstaller` + `PackLoader`.

**Files:**
- Create: `src/cognithor/packs/cli.py`
- Test: `tests/test_packs/test_cli.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_packs/test_cli.py`:

```python
"""Tests for cognithor.packs.cli — the `cognithor pack` subcommand."""

from __future__ import annotations

import hashlib
import json
import zipfile
from pathlib import Path

import pytest

from cognithor.packs.cli import main as pack_main


def _build_pack_zip(tmp_path: Path, pack_id: str = "cli-test") -> Path:
    eula = "EULA"
    eula_hash = hashlib.sha256(eula.encode("utf-8")).hexdigest()
    manifest = {
        "schema_version": 1,
        "namespace": "cognithor-official",
        "pack_id": pack_id,
        "version": "1.0.0",
        "display_name": "CLI Test",
        "description": "t",
        "license": "apache-2.0",
        "min_cognithor_version": ">=0.1.0",
        "eula_sha256": eula_hash,
        "publisher": {"id": "x", "display_name": "x"},
    }
    zip_path = tmp_path / f"{pack_id}.zip"
    with zipfile.ZipFile(zip_path, "w") as zf:
        zf.writestr("pack_manifest.json", json.dumps(manifest))
        zf.writestr("eula.md", eula)
        zf.writestr(
            "pack.py",
            "class Pack:\n"
            "    def __init__(self, m): self.m = m\n"
            "    def register(self, ctx): pass\n",
        )
    return zip_path


class TestCliInstall:
    def test_install_local_zip(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        packs_root = tmp_path / "packs"
        zip_path = _build_pack_zip(tmp_path)
        monkeypatch.setenv("COGNITHOR_PACKS_DIR", str(packs_root))
        monkeypatch.setattr("builtins.input", lambda _prompt="": "y")

        rc = pack_main(["install", str(zip_path)])
        assert rc == 0
        out = capsys.readouterr().out
        assert "installed" in out.lower()
        assert (packs_root / "cognithor-official" / "cli-test" / "pack_manifest.json").exists()

    def test_install_missing_path(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        packs_root = tmp_path / "packs"
        monkeypatch.setenv("COGNITHOR_PACKS_DIR", str(packs_root))
        rc = pack_main(["install", str(tmp_path / "nope.zip")])
        assert rc != 0


class TestCliList:
    def test_list_empty(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        packs_root = tmp_path / "packs"
        packs_root.mkdir()
        monkeypatch.setenv("COGNITHOR_PACKS_DIR", str(packs_root))

        rc = pack_main(["list"])
        assert rc == 0
        out = capsys.readouterr().out
        assert "no packs" in out.lower() or out.strip() == "(no packs installed)"

    def test_list_after_install(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        packs_root = tmp_path / "packs"
        zip_path = _build_pack_zip(tmp_path)
        monkeypatch.setenv("COGNITHOR_PACKS_DIR", str(packs_root))
        monkeypatch.setattr("builtins.input", lambda _prompt="": "y")
        pack_main(["install", str(zip_path)])
        capsys.readouterr()  # drain install output

        rc = pack_main(["list"])
        assert rc == 0
        out = capsys.readouterr().out
        assert "cli-test" in out
        assert "1.0.0" in out
```

- [ ] **Step 2: Run the failing test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_cli.py -x -q
```

Expected: `ImportError: cannot import name 'main'`.

- [ ] **Step 3: Write `src/cognithor/packs/cli.py`**

```python
"""CLI entry point for ``cognithor pack <command>``.

Commands:
    install <path-or-url>   Install a pack from a local zip or HTTPS URL
    list                    List installed packs
    remove <qualified_id>   Remove an installed pack
    update <qualified_id>   Print update instructions (Phase 2 will automate)
    accept-eula <qid>       Re-accept the EULA for an installed pack

The pack storage root is resolved in this order:
    1. $COGNITHOR_PACKS_DIR environment variable
    2. $COGNITHOR_HOME/packs (if COGNITHOR_HOME set)
    3. ~/.cognithor/packs
"""

from __future__ import annotations

import argparse
import os
import sys
from pathlib import Path

from cognithor.packs.errors import PackInstallError
from cognithor.packs.installer import PackInstaller


def _resolve_packs_root() -> Path:
    env = os.environ.get("COGNITHOR_PACKS_DIR")
    if env:
        return Path(env)
    home_env = os.environ.get("COGNITHOR_HOME")
    if home_env:
        return Path(home_env) / "packs"
    return Path.home() / ".cognithor" / "packs"


def _cmd_install(args: argparse.Namespace) -> int:
    installer = PackInstaller(packs_root=_resolve_packs_root())
    src = args.source
    try:
        if src.startswith(("http://", "https://")):
            manifest = installer.install_from_url(src)
        else:
            manifest = installer.install_from_path(Path(src))
    except PackInstallError as exc:
        print(f"[error] {exc}", file=sys.stderr)
        return 1

    print(f"\n[OK] installed {manifest.qualified_id} {manifest.version}")
    print("Restart Cognithor for the pack to take effect.")
    return 0


def _cmd_list(args: argparse.Namespace) -> int:
    installer = PackInstaller(packs_root=_resolve_packs_root())
    manifests = installer.list_installed()
    if not manifests:
        print("(no packs installed)")
        return 0
    for m in manifests:
        tier = ""
        if m.pricing:
            indie = m.pricing.get("indie")
            if indie:
                tier = f" [{indie.currency} {indie.launch_price}]"
        print(f"{m.qualified_id}  {m.version}{tier}  — {m.display_name}")
    return 0


def _cmd_remove(args: argparse.Namespace) -> int:
    installer = PackInstaller(packs_root=_resolve_packs_root())
    try:
        installer.remove(args.qualified_id)
    except PackInstallError as exc:
        print(f"[error] {exc}", file=sys.stderr)
        return 1
    print(f"[OK] removed {args.qualified_id}")
    return 0


def _cmd_update(args: argparse.Namespace) -> int:
    print(
        "MVP: re-install the pack with the URL from your purchase email.\n"
        "  cognithor pack install <url-from-email>\n"
        "Phase 2 will add license-key-based automatic updates."
    )
    return 0


def _cmd_accept_eula(args: argparse.Namespace) -> int:
    installer = PackInstaller(packs_root=_resolve_packs_root())
    try:
        installer.accept_eula(args.qualified_id)
    except PackInstallError as exc:
        print(f"[error] {exc}", file=sys.stderr)
        return 1
    print(f"[OK] EULA accepted for {args.qualified_id}")
    return 0


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="cognithor pack", description="Agent pack management")
    sub = p.add_subparsers(dest="command", required=True)

    install = sub.add_parser("install", help="Install a pack from a zip file or URL")
    install.add_argument("source", help="Path to a local .zip or an http(s) URL")
    install.set_defaults(func=_cmd_install)

    list_p = sub.add_parser("list", help="List installed packs")
    list_p.set_defaults(func=_cmd_list)

    rm = sub.add_parser("remove", help="Remove an installed pack")
    rm.add_argument("qualified_id", help="<namespace>/<pack_id>")
    rm.set_defaults(func=_cmd_remove)

    up = sub.add_parser("update", help="Update an installed pack (MVP: instructions only)")
    up.add_argument("qualified_id", help="<namespace>/<pack_id>")
    up.set_defaults(func=_cmd_update)

    ae = sub.add_parser("accept-eula", help="Re-accept EULA for an installed pack")
    ae.add_argument("qualified_id", help="<namespace>/<pack_id>")
    ae.set_defaults(func=_cmd_accept_eula)

    return p


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return args.func(args)


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

- [ ] **Step 4: Run tests**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_cli.py -x -q
```

Expected: all 4 tests pass.

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/packs/cli.py tests/test_packs/test_cli.py && git commit -m "feat(packs): add cognithor pack CLI (install/list/remove/update/accept-eula)"
```

---

### Task 2.6 — Wire `cognithor pack` into the main CLI entry point

The `cognithor` command needs to route `pack` subcommands to `cognithor.packs.cli.main`.

**Files:**
- Modify: `src/cognithor/__main__.py`

- [ ] **Step 1: Find the current CLI dispatcher**

```bash
cd "D:/Jarvis/jarvis complete v20" && grep -n "def main\|add_subparsers\|parse_args\|sys.argv" src/cognithor/__main__.py | head -20
```

Expected: a `main()` function and some argument parsing.

- [ ] **Step 2: Add a `pack` subcommand dispatch**

In `src/cognithor/__main__.py`, find where argv is first inspected. Before the main parser runs, intercept `cognithor pack <args>`:

```python
def main() -> int:
    argv = sys.argv[1:]
    if argv and argv[0] == "pack":
        from cognithor.packs.cli import main as pack_main
        return pack_main(argv[1:])

    # ... existing main() body ...
```

If `main()` currently doesn't return an int, wrap the existing logic in a `return 0` path and thread it through. The goal is that `python -m cognithor pack list` reaches the new dispatcher.

- [ ] **Step 3: Smoke test**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m cognithor pack --help 2>&1 | head -20
```

Expected: argparse help output from `cognithor.packs.cli` with `install | list | remove | update | accept-eula` subcommands.

- [ ] **Step 4: Smoke test list**

```bash
cd "D:/Jarvis/jarvis complete v20" && COGNITHOR_PACKS_DIR=/tmp/empty-packs-dir python -m cognithor pack list
```

Expected: `(no packs installed)`.

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/__main__.py && git commit -m "feat(packs): wire cognithor pack subcommand into main CLI"
```

---

### Task 2.7 — Wire PackLoader into Gateway Phase F initialization

The loader must run during Gateway startup after MCP tools are registered, so packs can register their own tools on top.

**Files:**
- Modify: `src/cognithor/gateway/gateway.py`

- [ ] **Step 1: Find Phase F initialization**

```bash
cd "D:/Jarvis/jarvis complete v20" && grep -n "Phase F\|_register_social_routes\|_reddit_lead_service\|Hacker News Scanner" src/cognithor/gateway/gateway.py | head -10
```

Expected: a Phase F block around line 380-460 that initializes the Reddit lead service, HN scanner, Discord scanner.

- [ ] **Step 2: Add PackLoader wiring after social tools registration**

After the existing `# Register unified social tools` block, insert:

```python
            # Agent Pack Loader — loads installed packs from ~/.cognithor/packs/
            try:
                from cognithor.packs.interface import PackContext
                from cognithor.packs.loader import PackLoader
                from cognithor import __version__ as _cog_version

                _packs_dir = _os.environ.get("COGNITHOR_PACKS_DIR")
                if _packs_dir:
                    packs_path = Path(_packs_dir)
                else:
                    _home = _os.environ.get("COGNITHOR_HOME")
                    packs_path = (
                        Path(_home) / "packs"
                        if _home
                        else Path.home() / ".cognithor" / "packs"
                    )
                self._pack_loader = PackLoader(
                    packs_dir=packs_path, cognithor_version=_cog_version
                )
                pack_context = PackContext(
                    gateway=self,
                    config=self._config,
                    mcp_client=self._mcp_client,
                    leads=getattr(self._reddit_lead_service, "_service", None),
                )
                self._pack_loader.load_all(pack_context)
                log.info(
                    "packs_loaded",
                    count=len(self._pack_loader.loaded()),
                )
            except Exception:
                log.debug("pack_loader_init_failed", exc_info=True)
```

(Imports `import os as _os` and `from pathlib import Path` are usually already in the gateway file. If not, add them.)

- [ ] **Step 3: Smoke test Gateway import**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "from cognithor.gateway.gateway import Gateway; print('import ok')"
```

Expected: `import ok`.

- [ ] **Step 4: Run the full test suite**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/ -q
```

Expected: all passing (the gateway wiring does not change behavior for tests that don't have any installed packs).

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/gateway/gateway.py && git commit -m "feat(packs): wire PackLoader into Gateway Phase F initialization"
```

---

### Task 2.8 — Add bundled-default pack copy step to bootstrap

On first startup, copy the three bundled free packs (HN/Discord/RSS — populated in Phase 4) from `<site-packages>/cognithor/_bundled_packs/` to `~/.cognithor/packs/`. For now, the `_bundled_packs/` directory is empty, so this step is a no-op in Phase 2; Phase 4 populates it.

**Files:**
- Modify: `scripts/bootstrap_windows.py`

- [ ] **Step 1: Find a good insertion point**

```bash
cd "D:/Jarvis/jarvis complete v20" && grep -n "ensure_directory_structure\|config.yaml\|Phase" scripts/bootstrap_windows.py | head -10
```

Expected: after the config.yaml creation step, before model downloads.

- [ ] **Step 2: Add the bundled-pack copy function**

Add near the top of the file (after `_read_version`):

```python
def _copy_bundled_packs(jarvis_home: Path) -> None:
    """Copy bundled free packs into ~/.cognithor/packs/ on first run.

    Idempotent — skips packs that already exist at the target.
    """
    try:
        import cognithor

        bundled_root = Path(cognithor.__file__).parent / "_bundled_packs"
        if not bundled_root.exists():
            return
        target_root = jarvis_home / "packs"
        target_root.mkdir(parents=True, exist_ok=True)
        for ns_dir in bundled_root.iterdir():
            if not ns_dir.is_dir():
                continue
            ns_target = target_root / ns_dir.name
            ns_target.mkdir(exist_ok=True)
            for pack_dir in ns_dir.iterdir():
                if not pack_dir.is_dir():
                    continue
                dest = ns_target / pack_dir.name
                if dest.exists():
                    continue
                import shutil as _sh

                _sh.copytree(pack_dir, dest)
                print(f"  [OK] Bundled pack installed: {ns_dir.name}/{pack_dir.name}")
    except Exception as exc:
        print(f"  [WARN] Bundled pack copy failed: {exc}")
```

- [ ] **Step 3: Call it after config.yaml creation**

In the bootstrap main flow, after the existing `# Sprache bestimmen` block, add:

```python
    # Bundled packs (HN, Discord, RSS — free, apache-2.0)
    _copy_bundled_packs(JARVIS_HOME)
```

- [ ] **Step 4: Smoke test — the function must not crash on an absent `_bundled_packs` dir**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "
import sys
sys.path.insert(0, 'scripts')
from pathlib import Path
import importlib.util
spec = importlib.util.spec_from_file_location('bw', 'scripts/bootstrap_windows.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mod._copy_bundled_packs(Path('/tmp/fake-jarvis-home'))
print('ok')
"
```

Expected: `ok` (no error, no output from `print` calls because `_bundled_packs/` doesn't exist yet).

- [ ] **Step 5: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add scripts/bootstrap_windows.py && git commit -m "feat(packs): copy bundled default packs to ~/.cognithor/packs on first run"
```

---

### Task 2.9 — Phase 2 integration test

End-to-end: build a zip in a temp dir, install it via the CLI, load it via the loader, verify `register()` runs.

**Files:**
- Create: `tests/test_packs/test_integration.py`

- [ ] **Step 1: Write the integration test**

```python
"""End-to-end integration test for the pack system.

Exercises: CLI install → on-disk layout → PackLoader discovery → PackLoader load → register() runs.
"""

from __future__ import annotations

import hashlib
import json
import zipfile
from pathlib import Path

import pytest

from cognithor.packs.cli import main as pack_main
from cognithor.packs.interface import PackContext
from cognithor.packs.loader import PackLoader


def _build_pack_zip(tmp_path: Path, pack_id: str = "e2e") -> Path:
    eula = "EULA e2e"
    eula_hash = hashlib.sha256(eula.encode("utf-8")).hexdigest()
    manifest = {
        "schema_version": 1,
        "namespace": "cognithor-official",
        "pack_id": pack_id,
        "version": "1.0.0",
        "display_name": "E2E",
        "description": "integration test",
        "license": "apache-2.0",
        "min_cognithor_version": ">=0.1.0",
        "eula_sha256": eula_hash,
        "publisher": {"id": "cognithor-official", "display_name": "Cognithor"},
    }
    body = """
from cognithor.packs.interface import AgentPack

class Pack(AgentPack):
    def register(self, ctx):
        ctx.gateway = "touched"  # sentinel so we can tell it ran
"""
    zip_path = tmp_path / f"{pack_id}.zip"
    with zipfile.ZipFile(zip_path, "w") as zf:
        zf.writestr("pack_manifest.json", json.dumps(manifest))
        zf.writestr("eula.md", eula)
        zf.writestr("pack.py", body)
    return zip_path


def test_install_and_load_end_to_end(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    packs_root = tmp_path / "packs"
    zip_path = _build_pack_zip(tmp_path)

    monkeypatch.setenv("COGNITHOR_PACKS_DIR", str(packs_root))
    monkeypatch.setattr("builtins.input", lambda _prompt="": "y")

    rc = pack_main(["install", str(zip_path)])
    assert rc == 0

    loader = PackLoader(packs_dir=packs_root, cognithor_version="0.83.0")
    ctx = PackContext()
    loader.load_all(ctx)

    pack = loader.get("cognithor-official/e2e")
    assert pack is not None
    assert pack.manifest.version == "1.0.0"
    assert ctx.gateway == "touched"  # register() ran
```

- [ ] **Step 2: Run it**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/test_packs/test_integration.py -x -q
```

Expected: 1 test passes.

- [ ] **Step 3: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add tests/test_packs/test_integration.py && git commit -m "test(packs): add end-to-end install+load integration test"
```

---

### Task 2.10 — Phase 2 end-of-phase validation

Same gate as Phase 1: lint, format, full suite.

- [ ] **Step 1: Ruff check**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m ruff check src/cognithor/packs/ tests/test_packs/ src/cognithor/__main__.py src/cognithor/gateway/gateway.py scripts/bootstrap_windows.py
```

Expected: `All checks passed!`

- [ ] **Step 2: Ruff format check**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m ruff format --check src/cognithor/packs/ tests/test_packs/
```

Expected: `N files already formatted`. Format any that need it.

- [ ] **Step 3: Full project test suite**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/ -q
```

Expected: previous count + 35 new pack tests ≈ 2305 passed.

- [ ] **Step 4: Tag Phase 2 complete**

```bash
cd "D:/Jarvis/jarvis complete v20" && git tag -a phase2-pack-infra -m "Phase 2 complete: cognithor.packs plugin infrastructure"
```

---

## Phase 3 — Private `cognithor-packs` Repo + CI

**Goal:** Create the private GitHub repo `Alex8791-cyber/cognithor-packs`, bootstrap its layout (README, LICENSE, `_template/`, `scripts/`, `.github/workflows/`), write the index builder and validator scripts, and wire the CI workflow so every push to `main` regenerates `index.json` and triggers a Vercel deploy hook.

**Outcome:** At the end of Phase 3, the private repo exists on GitHub with the full scaffold, pushed, and the first CI run has executed successfully (generating an empty-but-valid `index.json` because no real packs are committed yet — those come in Phase 4).

**Working directory for this phase:** `D:\Jarvis\cognithor-packs\` (new local clone). `D:\Jarvis\jarvis complete v20\` stays untouched.

### Task 3.1 — Create the private repo via GitHub API

Uses the token we already have in the credential manager (from earlier sessions).

- [ ] **Step 1: Verify the token is still in git credential store**

```bash
printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>&1 | grep password
```

Expected: `password=ghp_...` or `password=gho_...`.

- [ ] **Step 2: Extract the token into a shell variable (only for this phase)**

```bash
GH_TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>&1 | awk -F= '/^password=/ {print $2}')
echo "token starts with: ${GH_TOKEN:0:4}..."
```

Expected: `token starts with: ghp_` or `gho_`.

- [ ] **Step 3: Create the repo**

```bash
curl -sfSL -X POST \
  -H "Authorization: Bearer ${GH_TOKEN}" \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/user/repos \
  -d '{
    "name": "cognithor-packs",
    "description": "Private source of truth for Cognithor agent packs (first-party + community)",
    "private": true,
    "has_issues": true,
    "has_projects": false,
    "has_wiki": false,
    "auto_init": false
  }' | python -c "import json,sys;d=json.load(sys.stdin);print(d.get('html_url', d.get('message', 'ERR')))"
```

Expected: `https://github.com/Alex8791-cyber/cognithor-packs`. If the response is an error about the repo already existing, skip to Step 4.

- [ ] **Step 4: Clone the empty repo locally**

```bash
cd "D:/Jarvis" && git clone "https://github.com/Alex8791-cyber/cognithor-packs.git"
```

Expected: a warning about cloning an empty repo, then `D:\Jarvis\cognithor-packs\` exists.

- [ ] **Step 5: Set the default branch to `main`**

```bash
cd "D:/Jarvis/cognithor-packs" && git checkout -b main
```

---

### Task 3.2 — Write `README.md` and `LICENSE`

**Files:**
- Create: `D:/Jarvis/cognithor-packs/README.md`
- Create: `D:/Jarvis/cognithor-packs/LICENSE`

- [ ] **Step 1: Write the README**

```bash
cat > "D:/Jarvis/cognithor-packs/README.md" <<'EOF'
# Cognithor Agent Packs

Private source of truth for Cognithor agent packs — both first-party
(`cognithor-official/...`) and community (future Q4 2026 marketplace).

## Layout

```
<namespace>/<pack_id>/
├── pack.py                # Pack class extending cognithor.packs.AgentPack
├── pack_manifest.json     # validated by cognithor.packs.interface.PackManifest
├── eula.md                # SHA-256 pinned in manifest
├── src/                   # pack source code (whatever the pack needs)
├── tests/                 # per-pack tests
└── catalog/               # public marketing content (site fetches this)
    ├── catalog.mdx
    └── og-image.png
```

## Adding a new pack

1. Copy `_template/` to `<namespace>/<new-pack-id>/`
2. Fill in `pack_manifest.json` (namespace, pack_id, version, description,
   license, pricing for paid packs, publisher)
3. Write your pack in `src/` and `pack.py`
4. Write `catalog/catalog.mdx` — this is what the site shows
5. Update `eula.md` and re-hash it (see `scripts/validate_pack.py`)
6. Commit + push. CI regenerates `index.json` and triggers a site deploy.

## Index

`index.json` at the repo root is CI-generated on every push. It contains
a minimal metadata record for each pack, consumed by the site's build-time
fetcher. Do not edit it by hand.

## Licensing

The repo as a whole is proprietary. Individual packs may be
`apache-2.0`-licensed (see their manifest `license` field) — those are
distributed as bundled free packs with Cognithor Core.
EOF
```

- [ ] **Step 2: Write the LICENSE**

```bash
cat > "D:/Jarvis/cognithor-packs/LICENSE" <<'EOF'
Copyright (c) 2026 Alexander Söllner / Cognithor

All rights reserved. This repository and its contents are proprietary.

Individual packs may carry their own license, indicated in each pack's
`pack_manifest.json` `license` field and in that pack's `LICENSE.txt`
file if present. Packs licensed `apache-2.0` are distributed as bundled
free packs with Cognithor Core and are governed by the Apache License 2.0
as found in the Cognithor Core repository.

No warranty is provided. Use at your own risk.
EOF
```

- [ ] **Step 3: Verify files exist**

```bash
ls -la "D:/Jarvis/cognithor-packs/"
```

Expected: `README.md` and `LICENSE` present.

---

### Task 3.3 — Write `_template/` — copy-paste starter for new packs

**Files:**
- Create: `D:/Jarvis/cognithor-packs/_template/pack.py`
- Create: `D:/Jarvis/cognithor-packs/_template/pack_manifest.json`
- Create: `D:/Jarvis/cognithor-packs/_template/eula.md`
- Create: `D:/Jarvis/cognithor-packs/_template/catalog/catalog.mdx`
- Create: `D:/Jarvis/cognithor-packs/_template/src/__init__.py`

- [ ] **Step 1: Create directory structure**

```bash
mkdir -p "D:/Jarvis/cognithor-packs/_template/src" "D:/Jarvis/cognithor-packs/_template/catalog" "D:/Jarvis/cognithor-packs/_template/tests"
```

- [ ] **Step 2: Write `_template/pack.py`**

```bash
cat > "D:/Jarvis/cognithor-packs/_template/pack.py" <<'EOF'
"""Template pack entrypoint.

Every pack must expose a top-level ``Pack`` class that extends
``cognithor.packs.interface.AgentPack`` and implements ``register()``.
"""

from __future__ import annotations

from cognithor.packs.interface import AgentPack, PackContext


class Pack(AgentPack):
    def register(self, context: PackContext) -> None:
        # Typical pack wiring:
        #
        #   from .src.my_source import MyLeadSource
        #   context.leads.register_source(MyLeadSource())
        #
        #   from .src.my_tools import register_tools
        #   register_tools(context.mcp_client)
        pass

    def unregister(self, context: PackContext) -> None:
        # Reverse what register() did. Default: no-op.
        pass
EOF
```

- [ ] **Step 3: Write `_template/pack_manifest.json`**

```bash
cat > "D:/Jarvis/cognithor-packs/_template/pack_manifest.json" <<'EOF'
{
  "schema_version": 1,
  "namespace": "cognithor-official",
  "pack_id": "TEMPLATE-PLEASE-CHANGE",
  "version": "0.1.0",
  "display_name": "Template Pack",
  "description": "One-sentence description of what this pack does.",
  "license": "proprietary",
  "min_cognithor_version": ">=0.83.0",
  "eula_sha256": "REPLACE-WITH-sha256-OF-eula.md",
  "publisher": {
    "id": "cognithor-official",
    "display_name": "Cognithor",
    "website": "https://cognithor.com",
    "contact_email": "support@cognithor.com",
    "payout_provider": "lemonsqueezy"
  },
  "revenue_share": {"creator": 70, "platform": 30},
  "lead_sources": [],
  "tools": [],
  "checkout_url": null,
  "commercial_checkout_url": null,
  "pricing": {}
}
EOF
```

- [ ] **Step 4: Write `_template/eula.md`**

```bash
cat > "D:/Jarvis/cognithor-packs/_template/eula.md" <<'EOF'
# End User License Agreement

**Pack:** (see `pack_manifest.json`)
**Version:** (see `pack_manifest.json`)
**Publisher:** (see `pack_manifest.json`)

## 1. License scope

You are granted a non-exclusive, non-transferable license to use this
pack on machines you own or control, for your own personal or a single
commercial entity's use.

## 2. Allowed use

- Install the pack on any number of your own machines.
- Use the pack's tools for your own work product.
- Modify the pack locally for your own use.

## 3. Restrictions

- No redistribution of pack source or binaries.
- No resale of the pack or repackaging as your own product.
- No use of the pack to offer "Cognithor as a service" to third parties
  without a separate commercial agreement.

## 4. Telemetry

This pack sends no telemetry to any server. Your data stays on your
machine. Network calls the pack makes are strictly to the third-party
services the pack integrates with (documented in `catalog/catalog.mdx`).

## 5. Refunds

14 days from purchase, no questions asked, via the Lemon Squeezy
customer portal or by emailing the publisher contact address.

## 6. No warranty

This pack is provided "as is" with no warranty, express or implied.
The publisher is not liable for any damages arising from use.

## 7. EULA versioning

This EULA version is embedded in the pack manifest via `eula_sha256`.
Future pack updates may ship a new EULA version, in which case you will
be prompted to accept the new terms on upgrade. Continued use of the
pack after an upgrade constitutes acceptance of the new terms.
EOF
```

- [ ] **Step 5: Write `_template/catalog/catalog.mdx`**

```bash
cat > "D:/Jarvis/cognithor-packs/_template/catalog/catalog.mdx" <<'EOF'
---
slug: template
title: Template Pack
tagline: One-sentence pitch that tells the buyer what they get.
status: coming-soon
tier: A
category:
  - category-1
  - category-2
price: 0
currency: USD
bundleSize: '0 MB'
fileCount: 0
version: 0.1.0
releasedAt: '2026-04-15T00:00:00Z'
lastUpdated: '2026-04-15T00:00:00Z'
requires:
  cognithor: '>=0.83.0'
  llm: 'qwen3:7b or qwen3:27b'
  ram: '16 GB'
  disk: '10 GB'
tools: []
seo:
  title: 'Template Pack · Cognithor Agent Pack'
  description: 'Template pack description for SEO.'
---

## Template Pack

(Replace this body with a real pitch. See `reddit-lead-hunter-pro/catalog/catalog.mdx` for a reference.)
EOF
```

- [ ] **Step 6: Write `_template/src/__init__.py`**

```bash
echo "" > "D:/Jarvis/cognithor-packs/_template/src/__init__.py"
```

---

### Task 3.4 — Write `scripts/build_index.py`

The CI-invoked script that generates `index.json` from all pack manifests.

**Files:**
- Create: `D:/Jarvis/cognithor-packs/scripts/build_index.py`

- [ ] **Step 1: Create the scripts directory**

```bash
mkdir -p "D:/Jarvis/cognithor-packs/scripts"
```

- [ ] **Step 2: Write the script**

```bash
cat > "D:/Jarvis/cognithor-packs/scripts/build_index.py" <<'PYEOF'
#!/usr/bin/env python3
"""Regenerate ``index.json`` from all pack manifests in the repo.

Walks every ``<namespace>/<pack_id>/pack_manifest.json`` and extracts a
minimal metadata record suited for the site's build-time fetcher. The
``catalog/catalog.mdx`` file is NOT inlined; the site fetches it
on-demand per pack.

Invoked by ``.github/workflows/build-index-and-deploy.yml`` on every push
to main that touches a pack manifest or catalog.
"""

from __future__ import annotations

import json
import re
import sys
from datetime import UTC, datetime
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent

# Directories to skip when walking the repo.
_SKIP = {".git", ".github", "_template", "scripts", "node_modules"}

_REQUIRED_FIELDS = {
    "schema_version",
    "namespace",
    "pack_id",
    "version",
    "display_name",
    "description",
    "license",
    "min_cognithor_version",
    "eula_sha256",
    "publisher",
}


def _load_manifest(path: Path) -> dict | None:
    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        print(f"[error] {path}: cannot parse JSON: {exc}", file=sys.stderr)
        return None
    missing = _REQUIRED_FIELDS - set(raw.keys())
    if missing:
        print(f"[error] {path}: missing fields: {sorted(missing)}", file=sys.stderr)
        return None
    return raw


def _read_catalog_frontmatter(pack_dir: Path) -> dict:
    """Read minimal frontmatter from catalog/catalog.mdx if present.

    Returns a dict with ``status``, ``tier``, ``tagline``. These are
    site-display fields that live in the mdx rather than the manifest to
    give creators editorial freedom.
    """
    mdx = pack_dir / "catalog" / "catalog.mdx"
    if not mdx.exists():
        return {}
    text = mdx.read_text(encoding="utf-8")
    if not text.startswith("---\n"):
        return {}
    end = text.find("\n---\n", 4)
    if end == -1:
        return {}
    fm = text[4:end]
    out: dict = {}
    for line in fm.splitlines():
        m = re.match(r"^(\w+):\s*(.+)$", line)
        if m:
            key, val = m.group(1), m.group(2).strip().strip("'\"")
            out[key] = val
    return out


def build_entry(manifest: dict, pack_dir: Path) -> dict:
    fm = _read_catalog_frontmatter(pack_dir)
    entry = {
        "qualified_id": f"{manifest['namespace']}/{manifest['pack_id']}",
        "namespace": manifest["namespace"],
        "pack_id": manifest["pack_id"],
        "version": manifest["version"],
        "display_name": manifest["display_name"],
        "description": manifest["description"],
        "tagline": fm.get("tagline", manifest["description"]),
        "license": manifest["license"],
        "status": fm.get("status", "coming-soon"),
        "tier": fm.get("tier", "A"),
        "publisher": manifest["publisher"],
        "checkout_url": manifest.get("checkout_url"),
        "commercial_checkout_url": manifest.get("commercial_checkout_url"),
        "pricing": manifest.get("pricing", {}),
        "launch_seats_remaining": None,  # populated by a separate LS-API step later
        "lead_sources": manifest.get("lead_sources", []),
        "tools": manifest.get("tools", []),
        "catalog_path": f"{manifest['namespace']}/{manifest['pack_id']}/catalog/catalog.mdx"
        if (pack_dir / "catalog" / "catalog.mdx").exists()
        else None,
        "og_image_path": f"{manifest['namespace']}/{manifest['pack_id']}/catalog/og-image.png"
        if (pack_dir / "catalog" / "og-image.png").exists()
        else None,
    }
    return entry


def main() -> int:
    packs: list[dict] = []
    errors = 0

    # Walk <namespace>/<pack_id>/pack_manifest.json
    for ns_dir in sorted(REPO_ROOT.iterdir()):
        if not ns_dir.is_dir() or ns_dir.name.startswith(".") or ns_dir.name in _SKIP:
            continue
        # A namespace directory has pack subdirectories (not a manifest of its own).
        # We detect it by the presence of at least one child with a pack_manifest.json.
        has_child_pack = any(
            (p / "pack_manifest.json").exists()
            for p in ns_dir.iterdir()
            if p.is_dir()
        )
        if not has_child_pack:
            continue
        for pack_dir in sorted(ns_dir.iterdir()):
            if not pack_dir.is_dir():
                continue
            manifest_path = pack_dir / "pack_manifest.json"
            if not manifest_path.exists():
                continue
            manifest = _load_manifest(manifest_path)
            if manifest is None:
                errors += 1
                continue
            packs.append(build_entry(manifest, pack_dir))

    index = {
        "generated_at": datetime.now(UTC).isoformat(),
        "schema_version": 1,
        "packs": packs,
    }
    output_path = REPO_ROOT / "index.json"
    output_path.write_text(
        json.dumps(index, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )
    print(f"[OK] wrote {output_path} with {len(packs)} packs")
    if errors:
        print(f"[WARN] {errors} packs had errors and were skipped")
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PYEOF
```

- [ ] **Step 3: Smoke-run the script against the empty repo**

```bash
cd "D:/Jarvis/cognithor-packs" && python scripts/build_index.py
```

Expected:
```
[OK] wrote D:/Jarvis/cognithor-packs/index.json with 0 packs
```

And `index.json` now exists with `"packs": []`.

- [ ] **Step 4: Verify index.json content**

```bash
cat "D:/Jarvis/cognithor-packs/index.json"
```

Expected: JSON with `generated_at`, `schema_version: 1`, `packs: []`.

---

### Task 3.5 — Write `scripts/validate_pack.py`

Pre-commit / CI validation that checks a single pack's manifest, EULA hash, entrypoint.

**Files:**
- Create: `D:/Jarvis/cognithor-packs/scripts/validate_pack.py`

- [ ] **Step 1: Write the script**

```bash
cat > "D:/Jarvis/cognithor-packs/scripts/validate_pack.py" <<'PYEOF'
#!/usr/bin/env python3
"""Validate a single pack directory.

Usage: python scripts/validate_pack.py <pack-dir>

Checks:
  1. pack_manifest.json exists and parses as JSON
  2. All required manifest fields are present
  3. eula.md exists
  4. eula.md SHA-256 matches manifest.eula_sha256
  5. pack.py exists (or custom entrypoint)
  6. proprietary packs have a pricing field
  7. catalog/catalog.mdx exists (warning, not error)

Exit code 0 on success, non-zero on any validation failure.
"""

from __future__ import annotations

import hashlib
import json
import re
import sys
from pathlib import Path

_REQUIRED = {
    "schema_version",
    "namespace",
    "pack_id",
    "version",
    "display_name",
    "description",
    "license",
    "min_cognithor_version",
    "eula_sha256",
    "publisher",
}
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?$")
_NS_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
_SHA_RE = re.compile(r"^[a-f0-9]{64}$")


def validate(pack_dir: Path) -> list[str]:
    errors: list[str] = []

    manifest_path = pack_dir / "pack_manifest.json"
    if not manifest_path.exists():
        return [f"missing pack_manifest.json in {pack_dir}"]

    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        return [f"invalid JSON in {manifest_path}: {exc}"]

    missing = _REQUIRED - set(manifest.keys())
    if missing:
        errors.append(f"missing fields: {sorted(missing)}")

    if "namespace" in manifest and not _NS_RE.match(manifest["namespace"]):
        errors.append(f"invalid namespace: {manifest['namespace']!r}")
    if "pack_id" in manifest and not _NS_RE.match(manifest["pack_id"]):
        errors.append(f"invalid pack_id: {manifest['pack_id']!r}")
    if "version" in manifest and not _SEMVER_RE.match(manifest["version"]):
        errors.append(f"version not semver: {manifest['version']!r}")
    if "eula_sha256" in manifest and not _SHA_RE.match(manifest["eula_sha256"]):
        errors.append("eula_sha256 must be 64 hex chars")

    eula_path = pack_dir / "eula.md"
    if not eula_path.exists():
        errors.append("missing eula.md")
    elif "eula_sha256" in manifest:
        actual = hashlib.sha256(eula_path.read_bytes()).hexdigest()
        if actual != manifest["eula_sha256"]:
            errors.append(
                f"eula_sha256 mismatch: manifest says {manifest['eula_sha256'][:12]}..., "
                f"eula.md hashes to {actual[:12]}..."
            )

    entrypoint = manifest.get("entrypoint", "pack.py")
    if not (pack_dir / entrypoint).exists():
        errors.append(f"missing entrypoint {entrypoint!r}")

    if manifest.get("license") == "proprietary" and not manifest.get("pricing"):
        errors.append("proprietary license requires a pricing field")

    if not (pack_dir / "catalog" / "catalog.mdx").exists():
        # Warning, not error — print via stdout
        print(f"[warn] {pack_dir}: no catalog/catalog.mdx")

    return errors


def main() -> int:
    if len(sys.argv) != 2:
        print("Usage: validate_pack.py <pack-dir>", file=sys.stderr)
        return 2
    pack_dir = Path(sys.argv[1]).resolve()
    if not pack_dir.is_dir():
        print(f"[error] not a directory: {pack_dir}", file=sys.stderr)
        return 2
    errors = validate(pack_dir)
    if errors:
        for e in errors:
            print(f"[error] {pack_dir.name}: {e}", file=sys.stderr)
        return 1
    print(f"[OK] {pack_dir.name}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PYEOF
```

- [ ] **Step 2: Validate the template as a smoke test**

```bash
cd "D:/Jarvis/cognithor-packs" && python scripts/validate_pack.py _template
```

Expected: one error — the template's `eula_sha256` placeholder doesn't match the real EULA hash. That's exactly what we want the validator to catch.

---

### Task 3.6 — Write `.github/workflows/build-index-and-deploy.yml`

- [ ] **Step 1: Create workflow directory**

```bash
mkdir -p "D:/Jarvis/cognithor-packs/.github/workflows"
```

- [ ] **Step 2: Write the workflow**

```bash
cat > "D:/Jarvis/cognithor-packs/.github/workflows/build-index-and-deploy.yml" <<'YMLEOF'
name: Build pack index and trigger site deploy

on:
  push:
    branches: [main]
    paths:
      - '*/pack_manifest.json'
      - '*/catalog/**'
      - '_template/**'
      - 'scripts/build_index.py'
  workflow_dispatch: {}

permissions:
  contents: write

jobs:
  build-index:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.13'

      - name: Regenerate index.json
        run: python scripts/build_index.py

      - name: Commit index.json if changed
        run: |
          git config user.name "cognithor-bot"
          git config user.email "bot@cognithor.dev"
          git add index.json
          git diff --cached --quiet && echo "no changes" || (git commit -m "chore: regenerate index.json" && git push)

      - name: Trigger Vercel deploy hook
        if: env.VERCEL_DEPLOY_HOOK_URL != ''
        env:
          VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_URL }}
        run: |
          echo "Calling Vercel deploy hook..."
          curl -fsSL -X POST "$VERCEL_DEPLOY_HOOK_URL" -o /tmp/deploy-response.json
          cat /tmp/deploy-response.json
YMLEOF
```

---

### Task 3.7 — Write `.gitignore`

- [ ] **Step 1: Create `.gitignore`**

```bash
cat > "D:/Jarvis/cognithor-packs/.gitignore" <<'EOF'
__pycache__/
*.pyc
.pytest_cache/
.venv/
.env
*.egg-info/
dist/
build/
.DS_Store
EOF
```

---

### Task 3.8 — Initial commit and push

- [ ] **Step 1: Stage everything**

```bash
cd "D:/Jarvis/cognithor-packs" && git add -A && git status
```

Expected: `README.md`, `LICENSE`, `.gitignore`, `_template/`, `scripts/build_index.py`, `scripts/validate_pack.py`, `.github/workflows/build-index-and-deploy.yml`, `index.json` all staged as new files.

- [ ] **Step 2: Initial commit**

```bash
cd "D:/Jarvis/cognithor-packs" && git commit -m "chore: initial scaffold for cognithor-packs private repo

- README + LICENSE (proprietary with apache-2.0 carve-out for bundled packs)
- _template/ starter with pack.py, manifest, EULA, catalog.mdx
- scripts/build_index.py — regenerates index.json from manifests
- scripts/validate_pack.py — per-pack validation
- .github/workflows/build-index-and-deploy.yml — CI + Vercel deploy hook
- empty index.json (regenerated on every push)"
```

- [ ] **Step 3: Push**

```bash
cd "D:/Jarvis/cognithor-packs" && git push -u origin main
```

Expected: push succeeds. GitHub Action starts automatically because the workflow file landed; it may fail the Vercel-hook step (secret not set yet) but the index regeneration should succeed.

- [ ] **Step 4: Verify the CI run**

```bash
sleep 15 && GH_TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>&1 | awk -F= '/^password=/ {print $2}') && curl -sfSL -H "Authorization: Bearer ${GH_TOKEN}" "https://api.github.com/repos/Alex8791-cyber/cognithor-packs/actions/runs?per_page=1" | python -c "import json,sys;d=json.load(sys.stdin);r=d['workflow_runs'][0];print(r['status'], r['conclusion'], r['html_url'])"
```

Expected: `completed success https://github.com/...` or `in_progress None ...`. If failed, open the URL and read the logs — most likely the Vercel hook step complained about a missing secret, which is expected at this point.

---

### Task 3.9 — Configure the Vercel Deploy Hook secret

Manual (one-time) step. Not executed by the plan but documented here so the user runs it.

- [ ] **Step 1: Create the Vercel Deploy Hook**

In the Vercel dashboard for the `cognithor-site` project:
1. Settings → Git → Deploy Hooks → Create Hook
2. Name: `cognithor-packs`
3. Branch: `main`
4. Copy the generated URL (looks like `https://api.vercel.com/v1/integrations/deploy/prj_.../...`)

- [ ] **Step 2: Add the secret to the GitHub repo**

```bash
# Interactive — you'll paste the URL when prompted
GH_TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>&1 | awk -F= '/^password=/ {print $2}')
read -p "Paste Vercel Deploy Hook URL: " DEPLOY_URL

# GitHub secrets require a pubkey-encryption dance. Simpler: use the gh CLI if present.
# If `gh` is not installed, set the secret manually via the GitHub UI:
#   Settings → Secrets and variables → Actions → New repository secret
#   Name: VERCEL_DEPLOY_HOOK_URL
#   Value: <paste URL>
echo "Manual step: visit https://github.com/Alex8791-cyber/cognithor-packs/settings/secrets/actions/new"
echo "and create a secret named VERCEL_DEPLOY_HOOK_URL with value: $DEPLOY_URL"
```

- [ ] **Step 3: Re-trigger the workflow to verify the hook fires**

```bash
cd "D:/Jarvis/cognithor-packs" && git commit --allow-empty -m "chore: re-run CI to verify deploy hook" && git push
```

Then check the Actions tab — the final step should print the Vercel response.

---

### Task 3.10 — Phase 3 sanity check

- [ ] **Step 1: Verify repo is published and layout is correct**

```bash
GH_TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>&1 | awk -F= '/^password=/ {print $2}')
curl -sfSL -H "Authorization: Bearer ${GH_TOKEN}" "https://api.github.com/repos/Alex8791-cyber/cognithor-packs" | python -c "import json,sys;d=json.load(sys.stdin);print('private:',d['private']);print('default_branch:',d['default_branch']);print('url:',d['html_url'])"
```

Expected:
```
private: True
default_branch: main
url: https://github.com/Alex8791-cyber/cognithor-packs
```

- [ ] **Step 2: Verify `index.json` exists on the remote**

```bash
GH_TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>&1 | awk -F= '/^password=/ {print $2}')
curl -sfSL -H "Authorization: Bearer ${GH_TOKEN}" -H "Accept: application/vnd.github.raw" "https://api.github.com/repos/Alex8791-cyber/cognithor-packs/contents/index.json?ref=main" | python -m json.tool
```

Expected: the empty-but-valid `index.json` with `"packs": []`.

- [ ] **Step 3: Tag Phase 3 locally**

```bash
cd "D:/Jarvis/cognithor-packs" && git tag -a phase3-bootstrap -m "Phase 3 complete: private packs repo bootstrapped" && git push --tags
```

---

## Phase 4 — Pack Extraction

**Goal:** Move the four packs' code out of `src/cognithor/social/` into `D:\Jarvis\cognithor-packs\`, write each pack's `pack.py`, `pack_manifest.json`, `eula.md`, and `src/<source>_source.py` adapter, migrate their tests, and delete the originals from Core. Populate `src/cognithor/_bundled_packs/` with the three free packs so first-run bootstrap copies them.

**Outcome:** Core contains zero Reddit-specific code. `src/cognithor/social/` no longer exists. Four packs live in the private repo. CI regenerates `index.json` with 4 entries. Core test suite is green with an updated tool-count assertion.

**Working directories:** Both `D:\Jarvis\jarvis complete v20\` (Core) and `D:\Jarvis\cognithor-packs\` (packs repo). Commits land in whichever repo each task touches.

### Task 4.1 — Create Reddit Lead Hunter Pro directory skeleton

**Target:** `D:\Jarvis\cognithor-packs\reddit-lead-hunter-pro\`

- [ ] **Step 1: Scaffold directories**

```bash
cd "D:/Jarvis/cognithor-packs" && mkdir -p reddit-lead-hunter-pro/src reddit-lead-hunter-pro/tests reddit-lead-hunter-pro/catalog
```

- [ ] **Step 2: Create `src/__init__.py` and `tests/__init__.py`**

```bash
touch "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/src/__init__.py"
touch "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/tests/__init__.py"
```

---

### Task 4.2 — Copy Reddit source files from Core to the pack

Files to copy (source locations in Core, target paths in pack):

| Core source | Pack destination |
|---|---|
| `src/cognithor/social/scanner.py` | `reddit-lead-hunter-pro/src/scanner.py` |
| `src/cognithor/social/reply.py` | `reddit-lead-hunter-pro/src/reply.py` |
| `src/cognithor/social/refiner.py` | `reddit-lead-hunter-pro/src/refiner.py` |
| `src/cognithor/social/templates.py` | `reddit-lead-hunter-pro/src/templates.py` |
| `src/cognithor/social/discovery.py` | `reddit-lead-hunter-pro/src/discovery.py` |
| `src/cognithor/social/learner.py` | `reddit-lead-hunter-pro/src/learner.py` |
| `src/cognithor/social/tracker.py` | `reddit-lead-hunter-pro/src/tracker.py` |
| `src/cognithor/mcp/reddit_tools.py` | `reddit-lead-hunter-pro/src/tools.py` |

- [ ] **Step 1: Run the copies**

```bash
CORE="D:/Jarvis/jarvis complete v20/src/cognithor"
PACK="D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/src"

cp "$CORE/social/scanner.py"   "$PACK/scanner.py"
cp "$CORE/social/reply.py"     "$PACK/reply.py"
cp "$CORE/social/refiner.py"   "$PACK/refiner.py"
cp "$CORE/social/templates.py" "$PACK/templates.py"
cp "$CORE/social/discovery.py" "$PACK/discovery.py"
cp "$CORE/social/learner.py"   "$PACK/learner.py"
cp "$CORE/social/tracker.py"   "$PACK/tracker.py"
cp "$CORE/mcp/reddit_tools.py" "$PACK/tools.py"
```

- [ ] **Step 2: Rewrite imports inside the copied files**

Every copied file has imports like `from cognithor.social.models import Lead`. In the pack, these should become `from cognithor.leads.models import Lead` (because the pack consumes the public SDK in Core, not the legacy `social/` tree that we're about to delete).

```bash
cd "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/src" && python -c "
import re
from pathlib import Path

replacements = [
    (r'from cognithor\.social\.models\b', 'from cognithor.leads.models'),
    (r'from cognithor\.social\.store\b', 'from cognithor.leads.store'),
    (r'from cognithor\.social\.scanner\b', 'from .scanner'),
    (r'from cognithor\.social\.reply\b', 'from .reply'),
    (r'from cognithor\.social\.refiner\b', 'from .refiner'),
    (r'from cognithor\.social\.templates\b', 'from .templates'),
    (r'from cognithor\.social\.discovery\b', 'from .discovery'),
    (r'from cognithor\.social\.learner\b', 'from .learner'),
    (r'from cognithor\.social\.tracker\b', 'from .tracker'),
]

for path in Path('.').glob('*.py'):
    if path.name == '__init__.py':
        continue
    text = path.read_text(encoding='utf-8')
    orig = text
    for pattern, replacement in replacements:
        text = re.sub(pattern, replacement, text)
    if text != orig:
        path.write_text(text, encoding='utf-8')
        print(f'rewrote: {path}')
"
```

Expected: a handful of files report `rewrote: ...`.

- [ ] **Step 3: Smoke-check the imports**

```bash
cd "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/src" && python -c "
import ast
for f in ['scanner.py', 'reply.py', 'refiner.py', 'templates.py', 'discovery.py', 'learner.py', 'tracker.py', 'tools.py']:
    try:
        ast.parse(open(f, encoding='utf-8').read())
        print(f'{f}: ast ok')
    except SyntaxError as e:
        print(f'{f}: SYNTAX ERROR {e}')
"
```

Expected: `ast ok` for every file.

---

### Task 4.3 — Write `reddit_source.py` — the LeadSource adapter

This is the bridge between the pack's Reddit scanner and Core's generic `LeadService`. Similar to the `LegacyRedditSource` in `cognithor/social/_legacy_adapters.py`, but the pack version uses its own local scanner.py, reply.py, refiner.py via relative imports.

**Files:**
- Create: `D:\Jarvis\cognithor-packs\reddit-lead-hunter-pro\src\reddit_source.py`

- [ ] **Step 1: Write the adapter**

```python
"""Reddit LeadSource adapter — bridges the pack's Reddit scanner into
Cognithor Core's generic LeadService.

Registered with ``PackContext.leads.register_source(...)`` in ``pack.py``.
"""

from __future__ import annotations

from typing import Any

from cognithor.leads.models import Lead, LeadStatus
from cognithor.leads.source import LeadSource

from .refiner import ReplyRefiner
from .reply import ReplyPoster
from .scanner import RedditScanner, ScanConfig
from .templates import TemplateManager


class RedditLeadSource(LeadSource):
    source_id = "reddit"
    display_name = "Reddit"
    icon = "forum"
    color = "#FF4500"
    capabilities = frozenset({"scan", "draft_reply", "refine_reply", "auto_post", "discover_targets"})

    def __init__(
        self,
        llm_fn: Any = None,
        browser_agent: Any = None,
        template_manager: TemplateManager | None = None,
    ) -> None:
        self._scanner = RedditScanner(llm_fn=llm_fn)
        self._refiner = ReplyRefiner(llm_fn=llm_fn)
        self._poster = ReplyPoster(browser_agent=browser_agent)
        self._templates = template_manager

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        subreddits: list[str] = list(config.get("subreddits") or [])
        if not subreddits:
            return []

        scan_cfg = ScanConfig(
            product_name=product,
            product_description=product_description,
            min_score=min_score,
            reply_tone=config.get("reply_tone", "helpful, technically credible, no sales pitch"),
        )

        leads: list[Lead] = []
        for sub in subreddits:
            posts = self._scanner.fetch_posts(sub, limit=100)
            for post in posts:
                if len(post.get("title", "")) < 15:
                    continue
                score, reasoning = await self._scanner.score_post(post, scan_cfg)
                if score < min_score:
                    continue
                draft = await self._scanner.draft_reply(post, scan_cfg, intent_score=score)
                if not draft.strip():
                    continue
                leads.append(
                    Lead(
                        post_id=post.get("id", ""),
                        source_id="reddit",
                        subreddit=sub,
                        title=post.get("title", ""),
                        body=(post.get("selftext") or "")[:500],
                        url=f"https://reddit.com{post.get('permalink', '')}",
                        author=post.get("author", "[deleted]"),
                        created_utc=post.get("created_utc", 0),
                        upvotes=post.get("score", 0),
                        num_comments=post.get("num_comments", 0),
                        intent_score=score,
                        score_reason=reasoning,
                        reply_draft=draft,
                        status=LeadStatus.NEW,
                    )
                )
        return leads

    async def draft_reply(self, lead: Lead, *, tone: str) -> str:
        fake_post = {
            "id": lead.post_id,
            "title": lead.title,
            "selftext": lead.body,
            "author": lead.author,
            "permalink": lead.url.removeprefix("https://reddit.com"),
        }
        cfg = ScanConfig(product_name="", reply_tone=tone)
        return await self._scanner.draft_reply(fake_post, cfg, intent_score=lead.intent_score)

    async def refine_reply(self, lead: Lead, draft: str) -> str:
        return await self._refiner.refine(draft, lead=lead)

    async def post_reply(self, lead: Lead, text: str) -> None:
        await self._poster.post(lead=lead, text=text)
```

Write to `D:\Jarvis\cognithor-packs\reddit-lead-hunter-pro\src\reddit_source.py`.

---

### Task 4.4 — Write the pack entrypoint `pack.py`

**Files:**
- Create: `D:\Jarvis\cognithor-packs\reddit-lead-hunter-pro\pack.py`

- [ ] **Step 1: Write the entrypoint**

```python
"""Reddit Lead Hunter Pro — pack entrypoint.

Registers a RedditLeadSource with the generic LeadService, plus the
Reddit-specific MCP tools.
"""

from __future__ import annotations

from cognithor.packs.interface import AgentPack, PackContext
from cognithor.utils.logging import get_logger

from .src.reddit_source import RedditLeadSource
from .src.templates import TemplateManager

log = get_logger(__name__)


class Pack(AgentPack):
    def __init__(self, manifest) -> None:  # type: ignore[no-untyped-def]
        super().__init__(manifest)
        self._source: RedditLeadSource | None = None

    def register(self, context: PackContext) -> None:
        leads_service = context.leads
        if leads_service is None:
            log.warning("reddit_pack_no_leads_service")
            return

        # Pull llm_fn from the gateway's ollama adapter if available.
        llm_fn = None
        gateway = context.gateway
        if gateway is not None and hasattr(gateway, "_ollama") and gateway._ollama is not None:
            _model = getattr(gateway._config.models.planner, "name", "qwen3:27b") if gateway._config else "qwen3:27b"

            async def _llm_fn(**kwargs):  # type: ignore[no-untyped-def]
                return await gateway._ollama.chat(model=_model, **kwargs)

            llm_fn = _llm_fn

        browser_agent = getattr(gateway, "_browser_agent", None) if gateway is not None else None

        # Lazy template manager — needs a LeadStore instance.
        store = getattr(leads_service, "_store", None)
        template_manager = TemplateManager(store) if store is not None else None

        self._source = RedditLeadSource(
            llm_fn=llm_fn,
            browser_agent=browser_agent,
            template_manager=template_manager,
        )
        leads_service.register_source(self._source)
        log.info("reddit_lead_hunter_pro_registered")

        # Register the pack's MCP tools, if the mcp client is available.
        mcp_client = context.mcp_client
        if mcp_client is not None:
            try:
                from .src.tools import register_reddit_tools

                register_reddit_tools(mcp_client, leads_service)
                log.info("reddit_mcp_tools_registered")
            except Exception as exc:
                log.warning("reddit_mcp_tools_registration_failed", error=str(exc))

    def unregister(self, context: PackContext) -> None:
        if context.leads is not None and self._source is not None:
            context.leads.unregister_source("reddit")
            self._source = None
```

Note: the pack's `tools.py` (originally `mcp/reddit_tools.py`) may need a rename of its top-level function from whatever it was to `register_reddit_tools`. Check and rename if needed in the next step.

- [ ] **Step 2: Find and align the tools registration function name**

```bash
cd "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/src" && grep -n "^def register" tools.py
```

If the existing function is named differently (e.g., `register_reddit_lead_tools`), either:
- Rename it in `tools.py`, or
- Update the import in `pack.py` to match.

Pick consistency with the existing name; don't rename just for the plan.

---

### Task 4.5 — Write the Reddit Lead Hunter Pro `pack_manifest.json`

**Files:**
- Create: `D:\Jarvis\cognithor-packs\reddit-lead-hunter-pro\pack_manifest.json`

- [ ] **Step 1: Write the manifest**

The `eula_sha256` will be filled in after writing `eula.md`, so we use a placeholder now and update it in the next task.

```bash
cat > "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/pack_manifest.json" <<'EOF'
{
  "schema_version": 1,
  "namespace": "cognithor-official",
  "pack_id": "reddit-lead-hunter-pro",
  "version": "1.2.0",
  "display_name": "Reddit Lead Hunter Pro",
  "description": "Find high-intent leads on Reddit, score them with your local LLM, draft replies from 50 curated templates, and drop them into an encrypted CRM. Auto-post with Playwright, real-time Telegram + Slack alerts, and a Flutter command-center tab.",
  "license": "proprietary",
  "min_cognithor_version": ">=0.83.0",
  "entrypoint": "pack.py",
  "eula_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
  "publisher": {
    "id": "cognithor-official",
    "display_name": "Cognithor",
    "website": "https://cognithor.com",
    "contact_email": "support@cognithor.com",
    "payout_provider": "lemonsqueezy"
  },
  "revenue_share": {"creator": 70, "platform": 30},
  "lead_sources": ["reddit"],
  "tools": [
    "reddit_scan",
    "reddit_score_leads",
    "reddit_reply",
    "reddit_crm_upsert",
    "reddit_discover_subreddits"
  ],
  "checkout_url": "https://cognithor.lemonsqueezy.com/buy/REPLACE-WITH-INDIE-VARIANT-ID",
  "commercial_checkout_url": "https://cognithor.lemonsqueezy.com/buy/REPLACE-WITH-COMMERCIAL-VARIANT-ID",
  "pricing": {
    "indie": {
      "list_price": 149,
      "launch_price": 79,
      "post_launch_price": 99,
      "launch_cap": 100,
      "currency": "USD"
    },
    "commercial": {
      "list_price": 399,
      "launch_price": 199,
      "post_launch_price": 249,
      "launch_cap": 25,
      "currency": "USD"
    }
  }
}
EOF
```

---

### Task 4.6 — Write the RLH Pro EULA

**Files:**
- Create: `D:\Jarvis\cognithor-packs\reddit-lead-hunter-pro\eula.md`

- [ ] **Step 1: Copy from the template, then customize**

```bash
cp "D:/Jarvis/cognithor-packs/_template/eula.md" "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/eula.md"
```

- [ ] **Step 2: Customize the EULA for RLH Pro**

Edit the file to replace the first three lines:

```markdown
**Pack:** Reddit Lead Hunter Pro
**Version:** 1.2.0
**Publisher:** Cognithor (cognithor-official) · support@cognithor.com
```

Leave the rest of the EULA body unchanged (the terms are generic enough to reuse).

- [ ] **Step 3: Compute the EULA SHA-256 and update the manifest**

```bash
cd "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro" && python -c "
import hashlib, json, pathlib
eula = pathlib.Path('eula.md').read_bytes()
sha = hashlib.sha256(eula).hexdigest()
print('sha:', sha)
manifest_path = pathlib.Path('pack_manifest.json')
data = json.loads(manifest_path.read_text(encoding='utf-8'))
data['eula_sha256'] = sha
manifest_path.write_text(json.dumps(data, indent=2) + '\n', encoding='utf-8')
print('updated pack_manifest.json')
"
```

Expected: prints the hash and `updated pack_manifest.json`.

- [ ] **Step 4: Validate the pack**

```bash
cd "D:/Jarvis/cognithor-packs" && python scripts/validate_pack.py reddit-lead-hunter-pro
```

Expected: `[OK] reddit-lead-hunter-pro`.

---

### Task 4.7 — Migrate the RLH Pro catalog from the site

**Files:**
- Move: `D:\Jarvis\cognithor-site\content\packs\reddit-lead-hunter.mdx` → `D:\Jarvis\cognithor-packs\reddit-lead-hunter-pro\catalog\catalog.mdx`

- [ ] **Step 1: Copy the MDX**

```bash
cp "D:/Jarvis/cognithor-site/content/packs/reddit-lead-hunter.mdx" "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/catalog/catalog.mdx"
```

- [ ] **Step 2: Update the frontmatter to match the new pricing model**

Open `D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/catalog/catalog.mdx` and replace the `price: 79` / `currency: USD` block with a structured `pricing` object that mirrors the manifest. Also update `version`, `lastUpdated`, and add `tier` / `tagline` if missing.

Replace lines 10-12 (`price`, `currency`, `bundleSize`) with:

```yaml
pricing:
  indie:
    listPrice: 149
    launchPrice: 79
    postLaunchPrice: 99
    launchCap: 100
    currency: USD
  commercial:
    listPrice: 399
    launchPrice: 199
    postLaunchPrice: 249
    launchCap: 25
    currency: USD
bundleSize: '6.2 MB'
```

- [ ] **Step 3: Do NOT delete the MDX from the site yet**

Phase 5 deletes it. Leaving it in place now means both the old and new locations exist briefly, but the site still builds.

---

### Task 4.8 — Migrate RLH Pro tests

Move the Reddit-specific test files from Core's `tests/test_social/` into the pack's `tests/` directory.

**Files:**
- Move: `tests/test_social/test_scanner.py` → `reddit-lead-hunter-pro/tests/test_scanner.py`
- Move: `tests/test_social/test_reply.py` → `reddit-lead-hunter-pro/tests/test_reply.py`
- Move: `tests/test_social/test_refiner.py` → `reddit-lead-hunter-pro/tests/test_refiner.py`
- Move: `tests/test_social/test_templates.py` → `reddit-lead-hunter-pro/tests/test_templates.py`
- Move: `tests/test_social/test_discovery.py` → `reddit-lead-hunter-pro/tests/test_discovery.py`
- Move: `tests/test_social/test_learner.py` → `reddit-lead-hunter-pro/tests/test_learner.py`
- Move: `tests/test_social/test_tracker.py` → `reddit-lead-hunter-pro/tests/test_tracker.py`

- [ ] **Step 1: Copy the test files**

```bash
CORE_TESTS="D:/Jarvis/jarvis complete v20/tests/test_social"
PACK_TESTS="D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/tests"

for f in test_scanner test_reply test_refiner test_templates test_discovery test_learner test_tracker; do
    cp "$CORE_TESTS/$f.py" "$PACK_TESTS/$f.py"
done
```

- [ ] **Step 2: Rewrite imports**

```bash
cd "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/tests" && python -c "
import re
from pathlib import Path

replacements = [
    (r'from cognithor\.social\.scanner\b', 'from reddit_lead_hunter_pro.src.scanner'),
    (r'from cognithor\.social\.reply\b', 'from reddit_lead_hunter_pro.src.reply'),
    (r'from cognithor\.social\.refiner\b', 'from reddit_lead_hunter_pro.src.refiner'),
    (r'from cognithor\.social\.templates\b', 'from reddit_lead_hunter_pro.src.templates'),
    (r'from cognithor\.social\.discovery\b', 'from reddit_lead_hunter_pro.src.discovery'),
    (r'from cognithor\.social\.learner\b', 'from reddit_lead_hunter_pro.src.learner'),
    (r'from cognithor\.social\.tracker\b', 'from reddit_lead_hunter_pro.src.tracker'),
    (r'from cognithor\.social\.models\b', 'from cognithor.leads.models'),
]

for path in Path('.').glob('test_*.py'):
    text = path.read_text(encoding='utf-8')
    orig = text
    for pattern, replacement in replacements:
        text = re.sub(pattern, replacement, text)
    if text != orig:
        path.write_text(text, encoding='utf-8')
        print(f'rewrote: {path}')
"
```

- [ ] **Step 3: Write a `conftest.py` for the pack tests**

The pack isn't a pip package yet, so pytest needs help finding the `reddit_lead_hunter_pro` module. Create `D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/tests/conftest.py`:

```python
"""Make the pack importable as ``reddit_lead_hunter_pro`` during pytest."""

from __future__ import annotations

import sys
from pathlib import Path

_PACK_ROOT = Path(__file__).resolve().parent.parent
_PACKS_ROOT = _PACK_ROOT.parent

# Add the packs root (cognithor-packs/) so `import reddit_lead_hunter_pro` works.
if str(_PACKS_ROOT) not in sys.path:
    sys.path.insert(0, str(_PACKS_ROOT))

# The pack dir name has hyphens but Python packages use underscores.
# Create an alias by registering a module in sys.modules under the hyphenated name.
```

Actually the hyphen-to-underscore problem is real. The pack directory is `reddit-lead-hunter-pro` but Python can't import that name. Two options:

Option A: Rename the directory to `reddit_lead_hunter_pro`. Cleaner, but the qualified_id in the manifest is `cognithor-official/reddit-lead-hunter-pro` (with hyphens) — that's the user-facing id. We'd need to sanitize the disk path.

Option B: Load the pack tests by file path, not by importable name — pytest supports this via `--rootdir` and `--import-mode=importlib`.

Option C: Leave the tests in Core's `tests/test_reddit_pack/` but import from the pack directory via a sys.path hack.

**Decision: Option B.** In the pack repo, run pytest with `--import-mode=importlib`. This lets the tests import files by path without making the pack directory a conventional Python package. Document the run command in the pack's README.

Replace the `conftest.py` content above with:

```python
"""Add the pack src directory to sys.path so tests can import the modules directly.

Run these tests from the pack directory:
    cd reddit-lead-hunter-pro
    python -m pytest tests/ --import-mode=importlib
"""

from __future__ import annotations

import sys
from pathlib import Path

_PACK_SRC = Path(__file__).resolve().parent.parent / "src"
if str(_PACK_SRC) not in sys.path:
    sys.path.insert(0, str(_PACK_SRC))
```

And update the import-rewrite step 2 above to use `from scanner import`, `from reply import`, etc. (flat imports, no package prefix), since the `src/` directory is on `sys.path`.

Re-run Step 2 with the adjusted regex:

```bash
cd "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro/tests" && python -c "
import re
from pathlib import Path

replacements = [
    (r'from cognithor\.social\.scanner\b',   'from scanner'),
    (r'from cognithor\.social\.reply\b',     'from reply'),
    (r'from cognithor\.social\.refiner\b',   'from refiner'),
    (r'from cognithor\.social\.templates\b', 'from templates'),
    (r'from cognithor\.social\.discovery\b', 'from discovery'),
    (r'from cognithor\.social\.learner\b',   'from learner'),
    (r'from cognithor\.social\.tracker\b',   'from tracker'),
    (r'from cognithor\.social\.models\b',    'from cognithor.leads.models'),
]
for path in Path('.').glob('test_*.py'):
    text = path.read_text(encoding='utf-8')
    orig = text
    for pattern, replacement in replacements:
        text = re.sub(pattern, replacement, text)
    if text != orig:
        path.write_text(text, encoding='utf-8')
        print(f'rewrote: {path}')
"
```

- [ ] **Step 4: Run pack tests**

```bash
cd "D:/Jarvis/cognithor-packs/reddit-lead-hunter-pro" && python -m pytest tests/ --import-mode=importlib -q
```

Expected: all ported tests pass. Some may need small adjustments if they mocked Reddit-specific internals that changed during the import rewrite.

---

### Task 4.9 — HN Lead Hunter pack extraction

Same shape as RLH Pro, smaller scope (HN is a single scanner file + tests).

**Files:**
- Create: `D:/Jarvis/cognithor-packs/hn-lead-hunter/` directory tree
- Move: `src/cognithor/social/hn_scanner.py` → `hn-lead-hunter/src/hn_scanner.py`
- Create: `hn-lead-hunter/src/hn_source.py`
- Create: `hn-lead-hunter/pack.py`
- Create: `hn-lead-hunter/pack_manifest.json`
- Create: `hn-lead-hunter/eula.md`
- Create: `hn-lead-hunter/catalog/catalog.mdx`
- Move: `tests/test_social/test_hn_scanner.py` → `hn-lead-hunter/tests/test_hn_scanner.py`

- [ ] **Step 1: Scaffold**

```bash
cd "D:/Jarvis/cognithor-packs" && mkdir -p hn-lead-hunter/src hn-lead-hunter/tests hn-lead-hunter/catalog
touch hn-lead-hunter/src/__init__.py hn-lead-hunter/tests/__init__.py
```

- [ ] **Step 2: Copy source and rewrite imports**

```bash
cp "D:/Jarvis/jarvis complete v20/src/cognithor/social/hn_scanner.py" "D:/Jarvis/cognithor-packs/hn-lead-hunter/src/hn_scanner.py"

python -c "
from pathlib import Path
import re
p = Path('D:/Jarvis/cognithor-packs/hn-lead-hunter/src/hn_scanner.py')
t = p.read_text(encoding='utf-8')
t = re.sub(r'from cognithor\.social\.', 'from cognithor.leads.', t)
p.write_text(t, encoding='utf-8')
print('ok')
"
```

- [ ] **Step 3: Write `hn-lead-hunter/src/hn_source.py`**

```python
"""Hacker News LeadSource adapter."""

from __future__ import annotations

from typing import Any

from cognithor.leads.models import Lead
from cognithor.leads.source import LeadSource

from .hn_scanner import HackerNewsScanner


class HnLeadSource(LeadSource):
    source_id = "hn"
    display_name = "Hacker News"
    icon = "article"
    color = "#FF6600"
    capabilities = frozenset({"scan"})

    def __init__(self, llm_fn: Any = None) -> None:
        self._scanner = HackerNewsScanner(llm_fn=llm_fn)

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        categories = list(config.get("categories") or ["top", "new"])
        raw = await self._scanner.scan(
            product_name=product,
            product_description=product_description,
            categories=categories,
            min_score=min_score,
        )
        return [
            Lead(
                post_id=f"hn-{entry.get('id', '')}",
                source_id="hn",
                title=entry.get("title", ""),
                url=entry.get("url", "") or f"https://news.ycombinator.com/item?id={entry.get('id', '')}",
                intent_score=entry.get("intent_score", 0),
                score_reason=entry.get("score_reason", ""),
            )
            for entry in raw.get("leads", [])
        ]
```

- [ ] **Step 4: Write `hn-lead-hunter/pack.py`**

```python
"""Hacker News Lead Hunter — pack entrypoint."""

from __future__ import annotations

from cognithor.packs.interface import AgentPack, PackContext
from cognithor.utils.logging import get_logger

from .src.hn_source import HnLeadSource

log = get_logger(__name__)


class Pack(AgentPack):
    def register(self, context: PackContext) -> None:
        if context.leads is None:
            return
        llm_fn = None
        if context.gateway is not None and hasattr(context.gateway, "_ollama"):
            _model = getattr(context.gateway._config.models.planner, "name", "qwen3:27b") if context.gateway._config else "qwen3:27b"
            async def _llm_fn(**kwargs):  # type: ignore[no-untyped-def]
                return await context.gateway._ollama.chat(model=_model, **kwargs)
            llm_fn = _llm_fn
        context.leads.register_source(HnLeadSource(llm_fn=llm_fn))
        log.info("hn_lead_hunter_registered")

    def unregister(self, context: PackContext) -> None:
        if context.leads is not None:
            context.leads.unregister_source("hn")
```

- [ ] **Step 5: Write `hn-lead-hunter/pack_manifest.json`**

```bash
cat > "D:/Jarvis/cognithor-packs/hn-lead-hunter/pack_manifest.json" <<'EOF'
{
  "schema_version": 1,
  "namespace": "cognithor-official",
  "pack_id": "hn-lead-hunter",
  "version": "1.0.0",
  "display_name": "Hacker News Lead Hunter",
  "description": "Monitor Hacker News top, new, and best stories for posts matching your product pitch. Scored by your local LLM.",
  "license": "apache-2.0",
  "min_cognithor_version": ">=0.83.0",
  "entrypoint": "pack.py",
  "eula_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
  "publisher": {
    "id": "cognithor-official",
    "display_name": "Cognithor",
    "website": "https://cognithor.com"
  },
  "revenue_share": {"creator": 70, "platform": 30},
  "lead_sources": ["hn"],
  "tools": ["hn_scan"],
  "checkout_url": null,
  "pricing": {}
}
EOF
```

- [ ] **Step 6: Write EULA, update hash, validate**

```bash
cd "D:/Jarvis/cognithor-packs/hn-lead-hunter" && cp "../_template/eula.md" eula.md

# Customize header
python -c "
from pathlib import Path
p = Path('eula.md')
t = p.read_text(encoding='utf-8')
t = t.replace(
    '**Pack:** (see \`pack_manifest.json\`)\n**Version:** (see \`pack_manifest.json\`)\n**Publisher:** (see \`pack_manifest.json\`)',
    '**Pack:** Hacker News Lead Hunter\n**Version:** 1.0.0\n**Publisher:** Cognithor (cognithor-official)\n**License:** Apache-2.0 (free, bundled with Core)'
)
p.write_text(t, encoding='utf-8')

import hashlib, json
sha = hashlib.sha256(Path('eula.md').read_bytes()).hexdigest()
m = json.loads(Path('pack_manifest.json').read_text())
m['eula_sha256'] = sha
Path('pack_manifest.json').write_text(json.dumps(m, indent=2) + '\n', encoding='utf-8')
print('sha:', sha)
"

cd "D:/Jarvis/cognithor-packs" && python scripts/validate_pack.py hn-lead-hunter
```

Expected: `[OK] hn-lead-hunter`.

- [ ] **Step 7: Migrate HN test**

```bash
cp "D:/Jarvis/jarvis complete v20/tests/test_social/test_hn_scanner.py" "D:/Jarvis/cognithor-packs/hn-lead-hunter/tests/test_hn_scanner.py"

python -c "
from pathlib import Path
import re
p = Path('D:/Jarvis/cognithor-packs/hn-lead-hunter/tests/test_hn_scanner.py')
t = p.read_text(encoding='utf-8')
t = re.sub(r'from cognithor\.social\.hn_scanner\b', 'from hn_scanner', t)
p.write_text(t, encoding='utf-8')
"

# Write conftest
cat > "D:/Jarvis/cognithor-packs/hn-lead-hunter/tests/conftest.py" <<'EOF'
from __future__ import annotations
import sys
from pathlib import Path
_src = Path(__file__).resolve().parent.parent / "src"
if str(_src) not in sys.path:
    sys.path.insert(0, str(_src))
EOF

cd "D:/Jarvis/cognithor-packs/hn-lead-hunter" && python -m pytest tests/ --import-mode=importlib -q
```

Expected: HN scanner tests pass.

- [ ] **Step 8: Write the catalog mdx**

```bash
cat > "D:/Jarvis/cognithor-packs/hn-lead-hunter/catalog/catalog.mdx" <<'EOF'
---
slug: hn-lead-hunter
title: Hacker News Lead Hunter
tagline: Monitor HN top, new, and best for prospects. Free, bundled, Apache 2.0.
status: live
tier: B
category:
  - lead-generation
license: apache-2.0
bundleSize: '0.2 MB'
fileCount: 5
version: 1.0.0
releasedAt: '2026-04-15T00:00:00Z'
lastUpdated: '2026-04-15T00:00:00Z'
requires:
  cognithor: '>=0.83.0'
  llm: 'qwen3:7b'
  ram: '8 GB'
tools:
  - hn_scan
seo:
  title: 'Hacker News Lead Hunter · Cognithor Agent Pack'
  description: 'Free Hacker News monitoring pack for Cognithor. Scores top, new, and best stories with your local LLM.'
---

## Hacker News Lead Hunter

A small, free, bundled pack that fetches the top / new / best Hacker News
story feeds and scores each one with your local LLM against your product
pitch. Ships with Cognithor Core — no purchase, no install step.

Use it alongside a paid **Reddit Lead Hunter Pro** subscription to cover
both of the big technical discussion hubs in one place.
EOF
```

---

### Task 4.10 — Discord Lead Hunter pack extraction

Same shape as HN, with Discord scanner.

- [ ] **Step 1: Scaffold**

```bash
cd "D:/Jarvis/cognithor-packs" && mkdir -p discord-lead-hunter/src discord-lead-hunter/tests discord-lead-hunter/catalog
touch discord-lead-hunter/src/__init__.py discord-lead-hunter/tests/__init__.py
```

- [ ] **Step 2: Copy source + rewrite imports**

```bash
cp "D:/Jarvis/jarvis complete v20/src/cognithor/social/discord_scanner.py" "D:/Jarvis/cognithor-packs/discord-lead-hunter/src/discord_scanner.py"

python -c "
from pathlib import Path
import re
p = Path('D:/Jarvis/cognithor-packs/discord-lead-hunter/src/discord_scanner.py')
t = p.read_text(encoding='utf-8')
t = re.sub(r'from cognithor\.social\.', 'from cognithor.leads.', t)
p.write_text(t, encoding='utf-8')
"
```

- [ ] **Step 3: Write `discord-lead-hunter/src/discord_source.py`**

Same shape as `hn_source.py`, adapted for Discord. Use `LegacyDiscordSource` from `_legacy_adapters.py` as the reference.

```python
"""Discord LeadSource adapter."""

from __future__ import annotations

import os
from typing import Any

from cognithor.leads.models import Lead
from cognithor.leads.source import LeadSource

from .discord_scanner import DiscordScanner


class DiscordLeadSource(LeadSource):
    source_id = "discord"
    display_name = "Discord"
    icon = "tag"
    color = "#5865F2"
    capabilities = frozenset({"scan"})

    def __init__(self, llm_fn: Any = None) -> None:
        token = os.environ.get("COGNITHOR_DISCORD_TOKEN", "")
        self._scanner: DiscordScanner | None = (
            DiscordScanner(bot_token=token, llm_fn=llm_fn) if token else None
        )

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        if self._scanner is None:
            return []
        channel_ids = list(config.get("channel_ids") or [])
        raw = await self._scanner.scan(
            channel_ids=channel_ids,
            product_name=product,
            product_description=product_description,
            min_score=min_score,
        )
        return [
            Lead(
                post_id=f"discord-{entry.get('id', '')}",
                source_id="discord",
                title=entry.get("title", "")[:200],
                url=entry.get("url", ""),
                intent_score=entry.get("intent_score", 0),
                score_reason=entry.get("score_reason", ""),
                body=entry.get("content", "")[:500],
                author=entry.get("author", ""),
            )
            for entry in raw.get("leads", [])
        ]
```

- [ ] **Step 4: Write `pack.py`**

Copy `hn-lead-hunter/pack.py` and replace `HnLeadSource` with `DiscordLeadSource`, `hn` with `discord`, `hn_lead_hunter_registered` with `discord_lead_hunter_registered`.

- [ ] **Step 5: Write `pack_manifest.json`**

```bash
cat > "D:/Jarvis/cognithor-packs/discord-lead-hunter/pack_manifest.json" <<'EOF'
{
  "schema_version": 1,
  "namespace": "cognithor-official",
  "pack_id": "discord-lead-hunter",
  "version": "1.0.0",
  "display_name": "Discord Lead Hunter",
  "description": "Scan Discord channels your bot is in for high-intent messages, scored by your local LLM. Requires your own bot token.",
  "license": "apache-2.0",
  "min_cognithor_version": ">=0.83.0",
  "entrypoint": "pack.py",
  "eula_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
  "publisher": {"id": "cognithor-official", "display_name": "Cognithor"},
  "revenue_share": {"creator": 70, "platform": 30},
  "lead_sources": ["discord"],
  "tools": ["discord_scan"],
  "checkout_url": null,
  "pricing": {}
}
EOF
```

- [ ] **Step 6: EULA, hash, validate, migrate test, catalog mdx**

Repeat the pattern from Task 4.9 Steps 6-8, substituting `discord` for `hn`:

```bash
cd "D:/Jarvis/cognithor-packs/discord-lead-hunter" && cp "../_template/eula.md" eula.md

python -c "
from pathlib import Path
p = Path('eula.md')
t = p.read_text(encoding='utf-8')
t = t.replace(
    '**Pack:** (see \`pack_manifest.json\`)\n**Version:** (see \`pack_manifest.json\`)\n**Publisher:** (see \`pack_manifest.json\`)',
    '**Pack:** Discord Lead Hunter\n**Version:** 1.0.0\n**Publisher:** Cognithor (cognithor-official)\n**License:** Apache-2.0 (free, bundled with Core)'
)
p.write_text(t, encoding='utf-8')
import hashlib, json
sha = hashlib.sha256(Path('eula.md').read_bytes()).hexdigest()
m = json.loads(Path('pack_manifest.json').read_text())
m['eula_sha256'] = sha
Path('pack_manifest.json').write_text(json.dumps(m, indent=2) + '\n', encoding='utf-8')
"

cd "D:/Jarvis/cognithor-packs" && python scripts/validate_pack.py discord-lead-hunter
```

Then migrate `tests/test_social/test_discord_scanner.py` (if it exists) and write a short `catalog.mdx`.

```bash
cat > "D:/Jarvis/cognithor-packs/discord-lead-hunter/catalog/catalog.mdx" <<'EOF'
---
slug: discord-lead-hunter
title: Discord Lead Hunter
tagline: Score messages in your Discord channels with local LLM. Free, bundled.
status: live
tier: B
category:
  - lead-generation
license: apache-2.0
version: 1.0.0
releasedAt: '2026-04-15T00:00:00Z'
lastUpdated: '2026-04-15T00:00:00Z'
requires:
  cognithor: '>=0.83.0'
  llm: 'qwen3:7b'
  ram: '8 GB'
  discordBotToken: 'yes'
tools:
  - discord_scan
seo:
  title: 'Discord Lead Hunter · Cognithor Agent Pack'
  description: 'Free Discord message scanner for Cognithor. Scores high-intent conversations with your local LLM.'
---

## Discord Lead Hunter

A free, bundled Cognithor pack that lets you monitor Discord channels
your own bot has access to. Each new message is scored by your local LLM
against your product pitch, and high-intent ones land in the unified
Leads tab.

You supply the bot token via `COGNITHOR_DISCORD_TOKEN`; the pack never
sends your token or messages to any cloud service.
EOF
```

---

### Task 4.11 — RSS Lead Hunter pack extraction

Same pattern as HN and Discord.

- [ ] **Step 1: Scaffold + copy**

```bash
cd "D:/Jarvis/cognithor-packs" && mkdir -p rss-lead-hunter/src rss-lead-hunter/tests rss-lead-hunter/catalog
touch rss-lead-hunter/src/__init__.py rss-lead-hunter/tests/__init__.py

cp "D:/Jarvis/jarvis complete v20/src/cognithor/social/rss_scanner.py" "D:/Jarvis/cognithor-packs/rss-lead-hunter/src/rss_scanner.py"

python -c "
from pathlib import Path
import re
p = Path('D:/Jarvis/cognithor-packs/rss-lead-hunter/src/rss_scanner.py')
t = p.read_text(encoding='utf-8')
t = re.sub(r'from cognithor\.social\.', 'from cognithor.leads.', t)
p.write_text(t, encoding='utf-8')
"
```

- [ ] **Step 2: Write `rss-lead-hunter/src/rss_source.py`**

```python
"""RSS / Atom feed LeadSource adapter."""

from __future__ import annotations

from typing import Any

from cognithor.leads.models import Lead
from cognithor.leads.source import LeadSource

from .rss_scanner import RssFeedScanner


class RssLeadSource(LeadSource):
    source_id = "rss"
    display_name = "RSS Feeds"
    icon = "rss_feed"
    color = "#FFA500"
    capabilities = frozenset({"scan"})

    def __init__(self, llm_fn: Any = None) -> None:
        self._scanner = RssFeedScanner(llm_fn=llm_fn)

    async def scan(
        self,
        *,
        config: dict[str, Any],
        product: str,
        product_description: str,
        min_score: int,
    ) -> list[Lead]:
        feeds = list(config.get("feeds") or [])
        if not feeds:
            return []
        raw = await self._scanner.scan(
            feeds=feeds,
            product_name=product,
            product_description=product_description,
            min_score=min_score,
        )
        return [
            Lead(
                post_id=entry.get("entry_hash") or entry.get("id") or entry.get("url", ""),
                source_id="rss",
                title=entry.get("title", ""),
                url=entry.get("url", ""),
                intent_score=entry.get("intent_score", 0),
                score_reason=entry.get("score_reason", ""),
                body=(entry.get("summary") or "")[:500],
            )
            for entry in raw.get("leads", [])
        ]
```

- [ ] **Step 3: Write `pack.py`, `pack_manifest.json`, `eula.md`, `catalog/catalog.mdx`**

Use the HN/Discord patterns, substituting `rss` / `RssLeadSource` / `RSS Lead Hunter`.

Manifest:

```json
{
  "schema_version": 1,
  "namespace": "cognithor-official",
  "pack_id": "rss-lead-hunter",
  "version": "1.0.0",
  "display_name": "RSS Lead Hunter",
  "description": "Score any RSS or Atom feed (news sites, blogs, forums) with your local LLM for leads. Free, bundled.",
  "license": "apache-2.0",
  "min_cognithor_version": ">=0.83.0",
  "entrypoint": "pack.py",
  "eula_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
  "publisher": {"id": "cognithor-official", "display_name": "Cognithor"},
  "revenue_share": {"creator": 70, "platform": 30},
  "lead_sources": ["rss"],
  "tools": ["rss_scan"],
  "checkout_url": null,
  "pricing": {}
}
```

Migrate `tests/test_social/test_rss_scanner.py`.

- [ ] **Step 4: Validate**

```bash
cd "D:/Jarvis/cognithor-packs" && python scripts/validate_pack.py rss-lead-hunter
```

---

### Task 4.12 — Regenerate index.json and commit all four packs

- [ ] **Step 1: Regenerate**

```bash
cd "D:/Jarvis/cognithor-packs" && python scripts/build_index.py
```

Expected: `[OK] wrote index.json with 4 packs`.

- [ ] **Step 2: Stage, commit, push**

```bash
cd "D:/Jarvis/cognithor-packs" && git add -A && git status
```

Expected: four new pack directories + updated `index.json`.

```bash
cd "D:/Jarvis/cognithor-packs" && git commit -m "feat: extract 4 initial packs from cognithor core

- reddit-lead-hunter-pro: paid (\$79 indie / \$199 commercial), proprietary
- hn-lead-hunter:       free, apache-2.0, bundled
- discord-lead-hunter:  free, apache-2.0, bundled
- rss-lead-hunter:      free, apache-2.0, bundled

Each pack ships its own source, tests, EULA, manifest, and catalog.mdx.
index.json regenerated (4 entries)." && git push
```

Expected: push succeeds. The CI action re-generates `index.json` and tries to call the Vercel deploy hook (which won't have anything new to display until Phase 5 is done, but the hook will trigger a site rebuild that safely no-ops on the missing `fetch-packs.ts`).

---

### Task 4.13 — Populate `src/cognithor/_bundled_packs/` in Core

Core must ship the three free packs so first-run bootstrap can copy them to `~/.cognithor/packs/`. Copy (not symlink, to keep the Python wheel self-contained).

**Files:**
- Create: `D:/Jarvis/jarvis complete v20/src/cognithor/_bundled_packs/cognithor-official/{hn,discord,rss}-lead-hunter/`

- [ ] **Step 1: Scaffold**

```bash
cd "D:/Jarvis/jarvis complete v20/src/cognithor" && mkdir -p _bundled_packs/cognithor-official
```

- [ ] **Step 2: Copy the three free packs**

```bash
PACKS="D:/Jarvis/cognithor-packs"
BUNDLED="D:/Jarvis/jarvis complete v20/src/cognithor/_bundled_packs/cognithor-official"

for pack in hn-lead-hunter discord-lead-hunter rss-lead-hunter; do
    rm -rf "$BUNDLED/$pack"
    cp -r "$PACKS/$pack" "$BUNDLED/$pack"
done
```

- [ ] **Step 3: Remove `tests/` from the bundled copies (not needed in Core wheel)**

```bash
for pack in hn-lead-hunter discord-lead-hunter rss-lead-hunter; do
    rm -rf "D:/Jarvis/jarvis complete v20/src/cognithor/_bundled_packs/cognithor-official/$pack/tests"
done
```

- [ ] **Step 4: Add `_bundled_packs` to pyproject.toml package data**

Find the `[tool.setuptools.package-data]` or `[tool.poetry.include]` section in `pyproject.toml` and ensure `_bundled_packs/**/*` is included. If the project uses `setuptools` with `include-package-data = true` and an `__init__.py` is present in every directory, this works automatically — but `_bundled_packs` is a data directory with no Python imports, so it needs explicit inclusion.

```bash
grep -n "package-data\|include-package-data\|packages" "D:/Jarvis/jarvis complete v20/pyproject.toml"
```

If `include-package-data = true` is set, add a `MANIFEST.in` entry:

```bash
cat >> "D:/Jarvis/jarvis complete v20/MANIFEST.in" <<'EOF'
recursive-include src/cognithor/_bundled_packs *
EOF
```

- [ ] **Step 5: Smoke test — `bootstrap_windows._copy_bundled_packs` now works**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -c "
import sys
sys.path.insert(0, 'scripts')
from pathlib import Path
import importlib.util
spec = importlib.util.spec_from_file_location('bw', 'scripts/bootstrap_windows.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)

import tempfile
with tempfile.TemporaryDirectory() as tmp:
    mod._copy_bundled_packs(Path(tmp))
    installed = list((Path(tmp) / 'packs').rglob('pack_manifest.json'))
    print(f'bundled packs installed: {len(installed)}')
    for p in installed:
        print(f'  {p.relative_to(tmp)}')
"
```

Expected: 3 pack_manifest.json files listed under `packs/cognithor-official/{hn,discord,rss}-lead-hunter/`.

- [ ] **Step 6: Commit Core**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add src/cognithor/_bundled_packs MANIFEST.in && git commit -m "feat(packs): ship HN/Discord/RSS as bundled default free packs"
```

---

### Task 4.14 — Delete the old `src/cognithor/social/` directory from Core

This is the destructive step. Only run after Phase 1 Task 1.11 confirmed the test suite passes with the `_legacy_adapters` shim, AND Tasks 4.1-4.13 have the four pack directories fully built + tested.

**Files:**
- Delete: `src/cognithor/social/`
- Delete: `src/cognithor/mcp/reddit_tools.py`
- Delete: `tests/test_social/`

- [ ] **Step 1: Stop referencing `social` from `gateway.py` and bootstrap**

The Gateway's Phase F block currently imports `HackerNewsScanner`, `DiscordScanner`, `RedditScanner` from `cognithor.social.*` and sets them on `self._reddit_lead_service`. That code is now obsolete because the bundled packs register themselves via the loader.

Find and remove the obsolete wiring:

```bash
cd "D:/Jarvis/jarvis complete v20" && grep -n "HackerNewsScanner\|DiscordScanner\|from cognithor.social" src/cognithor/gateway/gateway.py
```

Delete those blocks (they're in the Phase F init, roughly lines 380-460 — the Reddit Lead Service creation, HN scanner wiring, Discord scanner wiring, RSS scanner wiring, and `register_social_tools` call). The pack loader takes over all of this.

Keep: `self._reddit_lead_service` variable as an alias for the `LeadService` inner that the shim created, OR replace all references to `self._reddit_lead_service` with `self._leads_service` (cleaner). For MVP, alias:

```python
# After the pack loader runs:
self._reddit_lead_service = getattr(self, "_pack_loader", None) and pack_context.leads
self._leads_service = pack_context.leads
```

- [ ] **Step 2: Delete the legacy adapters shim + social/ directory**

```bash
cd "D:/Jarvis/jarvis complete v20" && rm -rf src/cognithor/social tests/test_social
rm -f src/cognithor/mcp/reddit_tools.py
```

- [ ] **Step 3: Verify nothing in Core still imports from `cognithor.social`**

```bash
cd "D:/Jarvis/jarvis complete v20" && grep -rn "from cognithor.social\|import cognithor.social" src/ tests/ scripts/ 2>&1 | head -20
```

Expected: zero matches. If there are any, fix them now.

- [ ] **Step 4: Update `test_skill_tools_coverage.py` for the new tool count**

The test asserts a specific number of registered MCP tools. With Reddit-specific tools moving to the pack, the count drops.

```bash
cd "D:/Jarvis/jarvis complete v20" && grep -n "call_count\|register_builtin_handler" tests/test_skill_tools_coverage.py
```

Lower the assertion by however many Reddit tools existed (check `reddit_tools.py` in the pack to count).

- [ ] **Step 5: Run full test suite**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/ -q
```

Expected: mostly passing. Any remaining failures are likely in integration tests that directly instantiated `RedditLeadService` with positional args — update them to use `LeadService` or skip them as "now covered by pack tests."

- [ ] **Step 6: Commit the deletion**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add -A && git commit -m "refactor(core): delete src/cognithor/social (extracted to packs)

All Reddit-specific code now lives in the cognithor-packs private repo
as reddit-lead-hunter-pro (paid) and the three free bundled packs
(hn-lead-hunter, discord-lead-hunter, rss-lead-hunter). Core now only
contains the generic cognithor.leads.sdk.

The legacy RedditLeadService class is gone; code that needs a LeadService
uses cognithor.leads.LeadService directly.

Closes out the Core side of issue #113."
```

---

### Task 4.15 — Phase 4 end-of-phase gate

- [ ] **Step 1: Core ruff + format**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m ruff check src/cognithor tests/ && python -m ruff format --check src/cognithor tests/
```

Expected: all checks passed (with exceptions for pre-existing warnings tracked separately).

- [ ] **Step 2: Core full test suite**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m pytest tests/ -q
```

Expected: all passing. Test count is now lower than before (~2249 → ~2100) because ~150 Reddit-specific tests moved to the pack.

- [ ] **Step 3: Pack tests (all four packs)**

```bash
for pack in reddit-lead-hunter-pro hn-lead-hunter discord-lead-hunter rss-lead-hunter; do
    echo "=== $pack ==="
    cd "D:/Jarvis/cognithor-packs/$pack" && python -m pytest tests/ --import-mode=importlib -q
done
```

Expected: each pack's tests pass independently.

- [ ] **Step 4: Tag Phase 4**

```bash
cd "D:/Jarvis/jarvis complete v20" && git tag -a phase4-packs-extracted -m "Phase 4 complete: 4 packs extracted, Core social/ deleted"
cd "D:/Jarvis/cognithor-packs" && git tag -a phase4-packs-extracted -m "Phase 4 complete: initial packs committed" && git push --tags
```

---

## Phase 5 — Site Migration to Build-Time Pack Fetcher

**Goal:** Replace the hand-maintained `lib/data/home-packs.ts` + `content/packs/*.mdx` on the site with a build-time fetcher that pulls pack metadata and catalog MDX from the private `cognithor-packs` repo via GitHub REST API. Add the post-purchase `/packs/[slug]/installed` thank-you route.

**Outcome:** `pnpm build` in `cognithor-site` fetches live pack data from GitHub at build time, generates `HOME_PACKS` dynamically, and statically renders `/packs` and `/packs/[slug]` pages with up-to-date pricing, version, and marketing copy. The obsolete MDX and hand-maintained teaser list are deleted.

**Working directory for this phase:** `D:\Jarvis\cognithor-site\`.

### Task 5.1 — Install `@octokit/rest`

- [ ] **Step 1: Add the dependency**

```bash
cd "D:/Jarvis/cognithor-site" && pnpm add @octokit/rest
```

Expected: dependency added, `pnpm-lock.yaml` updated.

- [ ] **Step 2: Verify**

```bash
cd "D:/Jarvis/cognithor-site" && grep -A1 '"@octokit/rest"' package.json
```

Expected: the dependency appears in `dependencies`.

---

### Task 5.2 — Create the GitHub read-only token for the site build

Manual step — the token needs to be issued from the GitHub UI and pasted into Vercel + a local `.env.local`. Document in the plan so the engineer has the exact clicks.

- [ ] **Step 1: Create a fine-grained PAT**

In the GitHub web UI: Settings → Developer Settings → Personal access tokens → Fine-grained tokens → Generate new token.

- Token name: `cognithor-site build-time read`
- Expiration: 1 year
- Resource owner: Alex8791-cyber
- Repository access: Only select repositories → `cognithor-packs`
- Permissions: Repository permissions → Contents → **Read-only**
- Click Generate token. Copy the `github_pat_...` value — it's shown only once.

- [ ] **Step 2: Add to `cognithor-site/.env.local`**

```bash
cd "D:/Jarvis/cognithor-site" && cat >> .env.local <<EOF
COGNITHOR_PACKS_READ_TOKEN=github_pat_PASTE_HERE
EOF
```

Verify `.env.local` is in `.gitignore` (it should be, Next.js adds it by default):

```bash
grep -n ".env.local" "D:/Jarvis/cognithor-site/.gitignore"
```

Expected: one match.

- [ ] **Step 3: Add to Vercel project settings**

Via the Vercel dashboard: cognithor-site → Settings → Environment Variables:
- Name: `COGNITHOR_PACKS_READ_TOKEN`
- Value: paste the PAT
- Environments: Production + Preview
- Mark **"Available at build time"** (NOT "Available at runtime")

---

### Task 5.3 — Write `lib/data/fetch-packs.ts`

**Files:**
- Create: `D:/Jarvis/cognithor-site/lib/data/fetch-packs.ts`

- [ ] **Step 1: Write the fetcher**

```typescript
/**
 * Build-time pack catalog fetcher.
 *
 * Reads `index.json` and per-pack `catalog/catalog.mdx` from the private
 * `Alex8791-cyber/cognithor-packs` repository via the GitHub REST API.
 * Called from Next.js Server Components / generateStaticParams at build
 * time. Never exposed to the browser.
 */

import { Octokit } from '@octokit/rest';
import fs from 'node:fs/promises';
import path from 'node:path';

const OWNER = 'Alex8791-cyber';
const REPO = 'cognithor-packs';
const BRANCH = 'main';

export interface PricingTier {
  list_price: number;
  launch_price: number;
  post_launch_price: number;
  launch_cap: number;
  currency: string;
}

export interface PackPublisher {
  id: string;
  display_name: string;
  website?: string | null;
  contact_email?: string | null;
}

export interface PackIndexEntry {
  qualified_id: string;
  namespace: string;
  pack_id: string;
  version: string;
  display_name: string;
  description: string;
  tagline: string;
  license: string;
  status: 'live' | 'coming-soon' | 'beta';
  tier: 'S' | 'A' | 'B';
  publisher: PackPublisher;
  checkout_url: string | null;
  commercial_checkout_url?: string | null;
  pricing: Record<string, PricingTier>;
  launch_seats_remaining: number | null;
  lead_sources: string[];
  tools: string[];
  catalog_path: string | null;
  og_image_path: string | null;
}

export interface PackCatalog {
  generated_at: string;
  schema_version: number;
  packs: PackIndexEntry[];
}

export interface PackDetail extends PackIndexEntry {
  catalog_mdx: string;
}

const CACHE_PATH = path.resolve(process.cwd(), 'lib/data/_packs_cache.json');

function createClient(): Octokit {
  const token = process.env.COGNITHOR_PACKS_READ_TOKEN;
  if (!token) {
    throw new Error(
      'COGNITHOR_PACKS_READ_TOKEN is not set. Set it in .env.local for local ' +
        'dev or in Vercel env vars for build. Alternative for local dev: run ' +
        '`pnpm run packs:fetch` to refresh lib/data/_packs_cache.json from a ' +
        'local cognithor-packs checkout.',
    );
  }
  return new Octokit({ auth: token });
}

async function readLocalCache(): Promise<PackCatalog | null> {
  try {
    const raw = await fs.readFile(CACHE_PATH, 'utf-8');
    return JSON.parse(raw) as PackCatalog;
  } catch {
    return null;
  }
}

async function writeLocalCache(catalog: PackCatalog): Promise<void> {
  await fs.writeFile(CACHE_PATH, JSON.stringify(catalog, null, 2) + '\n', 'utf-8');
}

async function fetchIndexFromGithub(): Promise<PackCatalog> {
  const kit = createClient();
  const { data } = await kit.repos.getContent({
    owner: OWNER,
    repo: REPO,
    path: 'index.json',
    ref: BRANCH,
  });
  if (Array.isArray(data) || data.type !== 'file') {
    throw new Error('index.json is not a file in the packs repo');
  }
  if (!('content' in data) || typeof data.content !== 'string') {
    throw new Error('index.json content missing from GitHub response');
  }
  const raw = Buffer.from(data.content, 'base64').toString('utf-8');
  return JSON.parse(raw) as PackCatalog;
}

export async function fetchPackIndex(): Promise<PackIndexEntry[]> {
  // If token is present, always prefer the live fetch and refresh the cache.
  if (process.env.COGNITHOR_PACKS_READ_TOKEN) {
    try {
      const catalog = await fetchIndexFromGithub();
      await writeLocalCache(catalog);
      return catalog.packs;
    } catch (err) {
      console.warn(
        '[fetch-packs] live fetch failed, falling back to local cache:',
        (err as Error).message,
      );
    }
  }
  const cached = await readLocalCache();
  if (cached) return cached.packs;
  throw new Error(
    'No pack catalog available: set COGNITHOR_PACKS_READ_TOKEN or run `pnpm run packs:fetch` to populate lib/data/_packs_cache.json',
  );
}

export async function fetchPackDetail(slug: string): Promise<PackDetail | null> {
  const packs = await fetchPackIndex();
  const entry = packs.find((p) => p.pack_id === slug);
  if (!entry) return null;

  if (!entry.catalog_path) {
    return { ...entry, catalog_mdx: '' };
  }

  // Fetch the mdx file content.
  if (process.env.COGNITHOR_PACKS_READ_TOKEN) {
    try {
      const kit = createClient();
      const { data } = await kit.repos.getContent({
        owner: OWNER,
        repo: REPO,
        path: entry.catalog_path,
        ref: BRANCH,
      });
      if (!Array.isArray(data) && data.type === 'file' && 'content' in data) {
        const mdx = Buffer.from(data.content, 'base64').toString('utf-8');
        return { ...entry, catalog_mdx: mdx };
      }
    } catch (err) {
      console.warn(
        `[fetch-packs] catalog mdx fetch failed for ${slug}:`,
        (err as Error).message,
      );
    }
  }
  return { ...entry, catalog_mdx: '' };
}
```

- [ ] **Step 2: Write a minimal smoke test**

Create `lib/data/fetch-packs.test.ts`:

```typescript
import { describe, expect, it, vi } from 'vitest';

describe('fetchPackIndex', () => {
  it('throws when no token and no cache', async () => {
    vi.stubEnv('COGNITHOR_PACKS_READ_TOKEN', '');
    const mod = await import('./fetch-packs');
    await expect(mod.fetchPackIndex()).rejects.toThrow();
    vi.unstubAllEnvs();
  });
});
```

- [ ] **Step 3: Run tests**

```bash
cd "D:/Jarvis/cognithor-site" && pnpm vitest run lib/data/fetch-packs.test.ts
```

Expected: 1 test passes (provided the `_packs_cache.json` doesn't exist yet).

---

### Task 5.4 — Local-dev helper: `pnpm run packs:fetch`

For contributors without a GitHub token, let them point at a local `cognithor-packs` checkout to populate the cache.

**Files:**
- Create: `D:/Jarvis/cognithor-site/scripts/fetch-packs-local.ts`
- Modify: `D:/Jarvis/cognithor-site/package.json` (add script)

- [ ] **Step 1: Write the helper script**

```typescript
#!/usr/bin/env tsx
/**
 * Local-dev helper: read a local `cognithor-packs` checkout and regenerate
 * `lib/data/_packs_cache.json` so the site can build without a GitHub token.
 *
 * Usage: pnpm run packs:fetch [--repo <path>]
 *
 * Default repo path: ../cognithor-packs (relative to the site root)
 */

import fs from 'node:fs/promises';
import path from 'node:path';

const args = process.argv.slice(2);
let repoPath = path.resolve(process.cwd(), '../cognithor-packs');
for (let i = 0; i < args.length; i++) {
  if (args[i] === '--repo' && args[i + 1]) {
    repoPath = path.resolve(args[i + 1]);
    i++;
  }
}

async function main() {
  const indexPath = path.join(repoPath, 'index.json');
  try {
    await fs.access(indexPath);
  } catch {
    console.error(`[packs:fetch] index.json not found at ${indexPath}`);
    console.error('Pass --repo <path-to-cognithor-packs> to override.');
    process.exit(1);
  }
  const raw = await fs.readFile(indexPath, 'utf-8');
  const parsed = JSON.parse(raw);

  const cachePath = path.resolve(process.cwd(), 'lib/data/_packs_cache.json');
  await fs.writeFile(cachePath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
  console.log(`[packs:fetch] wrote ${cachePath} with ${parsed.packs.length} packs`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

- [ ] **Step 2: Add the script to `package.json`**

Find the `"scripts":` block and add:

```json
"packs:fetch": "tsx scripts/fetch-packs-local.ts"
```

- [ ] **Step 3: Run it once**

```bash
cd "D:/Jarvis/cognithor-site" && pnpm run packs:fetch
```

Expected: `[packs:fetch] wrote ...lib/data/_packs_cache.json with 4 packs`.

- [ ] **Step 4: Commit the cache as a fallback**

```bash
cd "D:/Jarvis/cognithor-site" && git add lib/data/_packs_cache.json scripts/fetch-packs-local.ts package.json pnpm-lock.yaml
```

The cache file is committed so CI + new contributors have a starting point even without the token.

---

### Task 5.5 — Rewire `PackStoreGrid.tsx` to use the fetcher

**Files:**
- Modify: `D:/Jarvis/cognithor-site/components/sections/packs/PackStoreGrid.tsx`

- [ ] **Step 1: Rewrite the component**

The existing component imports `HOME_PACKS` from `@/lib/data/home-packs` synchronously. The new version is a Server Component that awaits `fetchPackIndex()`.

```typescript
import { PackCard } from '@/components/primitives/PackCard';
import { NeonButton } from '@/components/primitives/NeonButton';
import { fetchPackIndex } from '@/lib/data/fetch-packs';

function formatPricing(pack: Awaited<ReturnType<typeof fetchPackIndex>>[number]) {
  const indie = pack.pricing.indie;
  if (!indie) return { price: 0, currency: 'USD', listPrice: null };
  return {
    price: indie.launch_price,
    currency: indie.currency,
    listPrice: indie.list_price > indie.launch_price ? indie.list_price : null,
  };
}

export async function PackStoreGrid() {
  const packs = await fetchPackIndex();

  return (
    <section className="bg-void relative overflow-hidden pb-16 md:pb-24">
      <div className="mx-auto max-w-[1280px] px-4 md:px-6 lg:px-12">
        <div className="mb-10 flex flex-col gap-3 md:mb-14">
          <div className="text-cy-electric/60 font-mono text-xs tracking-widest uppercase">
            ▸ LIBRARY
          </div>
          <h2 className="text-text-primary font-mono text-3xl font-extrabold md:text-5xl">
            THE FULL LIBRARY.
          </h2>
        </div>

        <div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
          {packs.map((pack) => {
            const { price, currency, listPrice } = formatPricing(pack);
            return (
              <PackCard
                key={pack.pack_id}
                slug={pack.pack_id}
                title={pack.display_name}
                tagline={pack.tagline}
                status={pack.status}
                tier={pack.tier}
                price={price}
                listPrice={listPrice}
                currency={currency}
                features={[]}
                tools={pack.tools}
              />
            );
          })}
        </div>

        <aside
          aria-label="Creator waitlist"
          className="border-cy-electric/20 bg-panel mt-12 flex flex-col items-start gap-4 rounded-[2px] border p-8 md:flex-row md:items-center md:justify-between md:p-10"
        >
          <div>
            <h3 className="text-text-primary mb-2 font-mono text-2xl font-extrabold tracking-tight md:text-3xl">
              BUILD YOUR OWN PACK.{' '}
              <span className="text-am-highlight">KEEP 70%.</span>
            </h3>
            <p className="text-text-secondary max-w-xl text-sm md:text-base">
              Creator marketplace opens Q4 2026. Publish once, earn forever.
              70/30 revenue share, transparent payouts, no venture capital
              in the middle.
            </p>
          </div>
          <NeonButton size="lg" href="/publish">
            ▶ Join creator waitlist
          </NeonButton>
        </aside>
      </div>
    </section>
  );
}
```

- [ ] **Step 2: Update `PackCard` to support the strikethrough list price**

Find `components/primitives/PackCard.tsx` and add an optional `listPrice?: number | null` prop. Render it as `<span class="line-through">{currency} {listPrice}</span>` next to the launch price. If `listPrice` is null, render nothing.

- [ ] **Step 3: Update the existing test for `PackStoreGrid`**

The test asserts specific hardcoded pack names from the old `HOME_PACKS`. Change it to mock `fetchPackIndex` and assert the mocked data renders.

```typescript
// components/sections/packs/PackStoreGrid.test.tsx

import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

vi.mock('@/lib/data/fetch-packs', () => ({
  fetchPackIndex: vi.fn().mockResolvedValue([
    {
      qualified_id: 'cognithor-official/reddit-lead-hunter-pro',
      pack_id: 'reddit-lead-hunter-pro',
      display_name: 'Reddit Lead Hunter Pro',
      tagline: 'Find leads on Reddit',
      status: 'live',
      tier: 'S',
      pricing: { indie: { list_price: 149, launch_price: 79, post_launch_price: 99, launch_cap: 100, currency: 'USD' } },
      tools: ['reddit_scan'],
      version: '1.2.0',
    },
  ]),
}));

describe('PackStoreGrid', () => {
  it('renders the pack catalog from fetch-packs', async () => {
    const { PackStoreGrid } = await import('./PackStoreGrid');
    const tree = await PackStoreGrid();
    render(tree);
    expect(screen.getByText(/Reddit Lead Hunter Pro/)).toBeInTheDocument();
  });
});
```

---

### Task 5.6 — Rewire `PackSpotlightSection.tsx`

Similar pattern: pick the first live pack from `fetchPackIndex()`, render its card, point the BuyButton at `checkout_url` (not a hardcoded Gumroad URL).

- [ ] **Step 1: Rewrite**

```typescript
import { BuyButton } from '@/components/primitives/BuyButton';
import { TerminalWindow } from '@/components/primitives/TerminalWindow';
import { AudioProvider } from '@/lib/hooks/useAudio';
import { fetchPackIndex } from '@/lib/data/fetch-packs';
import { packRedditLeadHunterScript } from '@/lib/data/demo-scripts/pack-reddit-lead-hunter';

export async function PackSpotlightSection() {
  const packs = await fetchPackIndex();
  const featured = packs.find((p) => p.status === 'live' && p.license === 'proprietary') ?? packs[0];
  if (!featured) return null;

  const indie = featured.pricing?.indie;
  const priceLabel = indie ? `$${indie.launch_price}` : 'Free';
  const listLabel = indie && indie.list_price > indie.launch_price ? `$${indie.list_price}` : null;

  // Prefer the on-site pack detail page for discoverability;
  // the thank-you page handles the LS checkout flow separately.
  const detailHref = `/packs/${featured.pack_id}`;

  return (
    <AudioProvider>
      <section className="bg-void relative overflow-hidden py-12 md:py-24">
        {/* ... existing decorative gradient + grid markup ... */}
        <div className="relative mx-auto max-w-[1280px] px-4 md:px-6 lg:px-12">
          <div className="text-cy-electric/60 mb-6 font-mono text-xs tracking-widest uppercase">
            ▸ FEATURED PACK
          </div>
          <div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
            <div>
              <h2 className="text-text-primary mb-4 font-mono text-3xl font-extrabold md:text-6xl">
                {featured.display_name}
              </h2>
              <p className="text-text-secondary mb-8 text-lg">{featured.tagline}</p>
              <div className="mb-6 flex items-baseline gap-3">
                {listLabel && (
                  <span className="text-text-secondary line-through">{listLabel}</span>
                )}
                <span className="text-cy-electric font-mono text-3xl font-bold">
                  {priceLabel}
                </span>
                <span className="text-text-secondary text-xs">one-time · launch price</span>
              </div>
              <div className="flex items-center gap-4">
                <BuyButton href={detailHref}>Install Pack</BuyButton>
              </div>
            </div>
            <div>
              <TerminalWindow
                title={`${featured.pack_id}.run`}
                scripts={[packRedditLeadHunterScript]}
                variant="embedded"
              />
            </div>
          </div>
        </div>
      </section>
    </AudioProvider>
  );
}
```

- [ ] **Step 2: Update the existing `PackSpotlightSection.test.tsx`**

Replace the hardcoded-href assertion (still points at the Gumroad URL after a previous edit) with a mock-based assertion that verifies the link goes to `/packs/reddit-lead-hunter-pro`.

---

### Task 5.7 — Rewire `/packs/[slug]/page.tsx`

**Files:**
- Modify: `D:/Jarvis/cognithor-site/app/[locale]/(marketing)/packs/[slug]/page.tsx`

- [ ] **Step 1: Generate static params from the fetcher**

```typescript
import { fetchPackDetail, fetchPackIndex } from '@/lib/data/fetch-packs';
import { notFound } from 'next/navigation';
// ... existing imports for the detail rendering components ...

export async function generateStaticParams() {
  const packs = await fetchPackIndex();
  return packs.map((p) => ({ slug: p.pack_id }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const pack = await fetchPackDetail(slug);
  if (!pack) return { title: 'Pack not found' };
  return {
    title: `${pack.display_name} · Cognithor Agent Pack`,
    description: pack.tagline,
  };
}

export default async function PackDetailPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const pack = await fetchPackDetail(slug);
  if (!pack) notFound();

  // Render existing detail sections with `pack` fields.
  // The MDX body is in `pack.catalog_mdx` — compile it with next-mdx-remote
  // (already used elsewhere on the site) and render inline.
  // For MVP, keep existing sections (hero, features, FAQ, etc.) but feed them
  // from `pack` instead of a synchronous MDX import.
  return (
    // ... existing detail markup, with fields sourced from `pack` ...
    <div>{/* hero, features, FAQ, etc. */}</div>
  );
}
```

- [ ] **Step 2: Adapt the existing detail sections**

The existing `PackDetailHero`, `PackFinalCtaSection`, `PackFeatureGrid` components currently take props from the MDX frontmatter. Keep the same props, but pass `pack.display_name`, `pack.tagline`, `pack.pricing.indie`, etc.

---

### Task 5.8 — Create the `/packs/[slug]/installed` thank-you route

**Files:**
- Create: `D:/Jarvis/cognithor-site/app/[locale]/(marketing)/packs/[slug]/installed/page.tsx`

- [ ] **Step 1: Write the route**

```typescript
import { fetchPackDetail } from '@/lib/data/fetch-packs';
import { notFound } from 'next/navigation';
import { CopyableCodeBlock } from '@/components/primitives/CopyableCodeBlock';

interface Props {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ order_id?: string; token?: string }>;
}

export const dynamic = 'force-dynamic';  // reads searchParams

export default async function PackInstalledPage({ params, searchParams }: Props) {
  const { slug } = await params;
  const { order_id, token } = await searchParams;
  const pack = await fetchPackDetail(slug);
  if (!pack) notFound();

  const hasValidQuery = Boolean(order_id && token);
  const zipUrl = token ? `https://assets.lemonsqueezy.com/${token}` : null;
  const installCmd = zipUrl ? `cognithor pack install ${zipUrl}` : '';

  return (
    <main className="bg-void min-h-screen px-4 py-16 md:px-6 lg:px-12">
      <div className="mx-auto max-w-[720px] space-y-10">
        <header>
          <div className="text-cy-electric/60 mb-2 font-mono text-xs tracking-widest uppercase">
            ▸ purchase complete
          </div>
          <h1 className="text-text-primary font-mono text-4xl font-extrabold md:text-5xl">
            Thanks for buying {pack.display_name}
          </h1>
          {order_id && (
            <p className="text-text-secondary mt-4 font-mono text-sm">
              Order: <code className="text-cy-electric">{order_id}</code>
            </p>
          )}
        </header>

        {hasValidQuery ? (
          <>
            <section className="space-y-4">
              <h2 className="text-text-primary font-mono text-2xl font-bold">
                Option 1 — One-line install
              </h2>
              <p className="text-text-secondary">
                Paste this into your Cognithor CLI:
              </p>
              <CopyableCodeBlock value={installCmd} />
            </section>

            <section className="space-y-4">
              <h2 className="text-text-primary font-mono text-2xl font-bold">
                Option 2 — Download the zip manually
              </h2>
              <a
                href={zipUrl!}
                className="bg-cy-electric text-void inline-block rounded px-6 py-3 font-mono font-bold hover:opacity-90"
              >
                ▼ Download {pack.pack_id}.zip
              </a>
              <p className="text-text-secondary text-sm">
                Then run: <code>cognithor pack install ~/Downloads/{pack.pack_id}.zip</code>
              </p>
            </section>

            <section className="border-cy-electric/20 bg-panel rounded-[2px] border p-6">
              <h2 className="text-text-primary mb-2 font-mono text-xl font-bold">
                Save this email
              </h2>
              <p className="text-text-secondary text-sm">
                Lemon Squeezy has sent you the same links by email. Keep that
                mail — you can re-download the pack anytime from the LS
                customer portal.
              </p>
            </section>
          </>
        ) : (
          <section className="bg-panel border-am-highlight/20 rounded-[2px] border p-6">
            <h2 className="text-am-highlight mb-2 font-mono text-xl font-bold">
              Missing or invalid download link
            </h2>
            <p className="text-text-secondary text-sm">
              This page expects an order_id and token in the URL. If you got
              here from a Lemon Squeezy receipt, check that the link wasn&apos;t
              broken by your mail client. Otherwise, email{' '}
              <a href="mailto:support@cognithor.com" className="text-cy-electric">
                support@cognithor.com
              </a>{' '}
              with your order id and we&apos;ll get you a fresh link.
            </p>
          </section>
        )}
      </div>
    </main>
  );
}
```

- [ ] **Step 2: Ensure `CopyableCodeBlock` exists**

If it's not already a primitive, create it at `components/primitives/CopyableCodeBlock.tsx`:

```typescript
'use client';

import { useState } from 'react';

export function CopyableCodeBlock({ value }: { value: string }) {
  const [copied, setCopied] = useState(false);
  const onCopy = async () => {
    await navigator.clipboard.writeText(value);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };
  return (
    <div className="group relative">
      <pre className="bg-panel border-cy-electric/30 text-cy-electric overflow-x-auto rounded-[2px] border p-4 font-mono text-sm">
        {value}
      </pre>
      <button
        onClick={onCopy}
        className="border-cy-electric/40 bg-void text-text-secondary hover:text-cy-electric absolute top-2 right-2 rounded border px-3 py-1 font-mono text-xs"
      >
        {copied ? '✓ copied' : 'copy'}
      </button>
    </div>
  );
}
```

---

### Task 5.9 — Delete the obsolete site files

- [ ] **Step 1: Delete**

```bash
cd "D:/Jarvis/cognithor-site" && rm -f content/packs/reddit-lead-hunter.mdx lib/data/home-packs.ts
```

- [ ] **Step 2: Grep for stragglers**

```bash
cd "D:/Jarvis/cognithor-site" && grep -rn "home-packs\|content/packs/reddit" --include="*.ts" --include="*.tsx" 2>&1 | head
```

Expected: zero matches. Any that remain need updating.

---

### Task 5.10 — Update tests and run the full site suite

- [ ] **Step 1: Run vitest**

```bash
cd "D:/Jarvis/cognithor-site" && pnpm vitest run 2>&1 | tail -30
```

Expected: all tests pass. Fix any that reference the deleted files.

- [ ] **Step 2: Type-check**

```bash
cd "D:/Jarvis/cognithor-site" && pnpm tsc --noEmit 2>&1 | tail -20
```

Expected: no errors.

- [ ] **Step 3: Next build**

```bash
cd "D:/Jarvis/cognithor-site" && pnpm build 2>&1 | tail -30
```

Expected: build succeeds. The build output should list all 4 packs as statically generated routes under `/packs/[slug]`.

---

### Task 5.11 — Commit the site migration

- [ ] **Step 1: Commit**

```bash
cd "D:/Jarvis/cognithor-site" && git add -A && git commit -m "feat(site): migrate pack catalog to build-time fetch from private repo

- lib/data/fetch-packs.ts: Octokit-based build-time fetcher with local cache fallback
- scripts/fetch-packs-local.ts + 'pnpm run packs:fetch' for contributors without token
- PackStoreGrid + PackSpotlightSection now async Server Components pulling from fetchPackIndex
- /packs/[slug] uses generateStaticParams from the fetcher
- /packs/[slug]/installed: new Lemon Squeezy thank-you route with one-line install command
- PackCard gains optional listPrice prop for strikethrough pricing
- delete content/packs/reddit-lead-hunter.mdx and lib/data/home-packs.ts
- lib/data/_packs_cache.json committed as fallback"
```

Note: this commit is NOT pushed (user asked earlier to wait on pushing the site) — only local.

---

### Task 5.12 — Verify CI-driven rebuild works end-to-end

After Phase 5 Task 5.11, the site's local state is the post-migration one. The CI pipeline (`cognithor-packs` push → `index.json` regenerated → Vercel deploy hook → site rebuild) is live but hasn't been tested end-to-end against a real publish yet.

- [ ] **Step 1: Make a trivial content change in the packs repo**

```bash
cd "D:/Jarvis/cognithor-packs" && python -c "
import json, pathlib
p = pathlib.Path('rss-lead-hunter/pack_manifest.json')
d = json.loads(p.read_text(encoding='utf-8'))
d['description'] = d['description'].rstrip('.') + ' (deploy-hook smoke test).'
p.write_text(json.dumps(d, indent=2) + '\n', encoding='utf-8')
"
git add rss-lead-hunter/pack_manifest.json && git commit -m "chore: smoke-test deploy-hook" && git push
```

- [ ] **Step 2: Watch the CI run**

```bash
sleep 20
GH_TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>&1 | awk -F= '/^password=/ {print $2}')
curl -sfSL -H "Authorization: Bearer ${GH_TOKEN}" "https://api.github.com/repos/Alex8791-cyber/cognithor-packs/actions/runs?per_page=1" | python -c "import json,sys;r=json.load(sys.stdin)['workflow_runs'][0];print(r['status'],r['conclusion'],r['html_url'])"
```

Expected: `completed success ...`. Open the URL and verify the deploy hook step ran.

- [ ] **Step 3: Revert the smoke-test change**

```bash
cd "D:/Jarvis/cognithor-packs" && git revert --no-edit HEAD && git push
```

---

### Task 5.13 — Phase 5 end-of-phase gate

- [ ] **Step 1: Site tests**

```bash
cd "D:/Jarvis/cognithor-site" && pnpm vitest run && pnpm tsc --noEmit && pnpm build 2>&1 | tail -10
```

Expected: green across all three.

- [ ] **Step 2: Tag**

```bash
cd "D:/Jarvis/cognithor-site" && git tag -a phase5-site-migration -m "Phase 5 complete: site fetches pack catalog from private repo"
```

---

## Phase 6 — Flutter Upsell UX

**Goal:** Rewrite the Reddit-specific `RedditLeadsScreen` as a source-agnostic `LeadsScreen`, add a hardcoded `known_packs.dart` catalog for upsell rendering, build a `LockedPackCard` widget, and wire the whole thing into `main_shell.dart` + `config/social_page.dart`.

**Outcome:** Users who have not installed RLH Pro see a locked upsell card where Reddit config would be. Clicking the card opens `https://cognithor.com/packs/reddit-lead-hunter-pro` in the system browser. Users who have installed RLH Pro see the full Reddit config section. The sidebar tab is gated on having at least one lead source registered.

**Working directory for this phase:** `D:\Jarvis\jarvis complete v20\flutter_app\`.

### Task 6.1 — Write `lib/data/known_packs.dart`

**Files:**
- Create: `D:/Jarvis/jarvis complete v20/flutter_app/lib/data/known_packs.dart`

- [ ] **Step 1: Write the catalog**

```dart
/// Hardcoded pack catalog that Flutter ships with.
///
/// Used by the Leads screen and social config page to render locked upsell
/// cards for packs the user hasn't installed yet. The live pricing / version
/// is always on the pack detail page at cognithor.com — this local catalog
/// is stale-tolerant because the "Get this pack" button always opens the
/// browser to the authoritative URL.
///
/// Update this file on Core releases. Phase 2 will replace it with a
/// once-a-day fetch from a signed cognithor.com/api/packs/catalog endpoint.

library;

import 'package:flutter/material.dart';

class KnownPack {
  final String qualifiedId;
  final String packId;
  final String displayName;
  final String tagline;
  final List<String> featureBullets;
  final String priceBadge;
  final String? listPriceBadge;
  final String packDetailUrl;
  final IconData icon;
  final Color accentColor;
  final String sourceId;

  const KnownPack({
    required this.qualifiedId,
    required this.packId,
    required this.displayName,
    required this.tagline,
    required this.featureBullets,
    required this.priceBadge,
    this.listPriceBadge,
    required this.packDetailUrl,
    required this.icon,
    required this.accentColor,
    required this.sourceId,
  });
}

const List<KnownPack> kKnownPacks = [
  KnownPack(
    qualifiedId: 'cognithor-official/reddit-lead-hunter-pro',
    packId: 'reddit-lead-hunter-pro',
    displayName: 'Reddit Lead Hunter Pro',
    tagline:
        'Find high-intent leads on Reddit, score them with your local LLM, '
        'draft replies from 50 curated templates, and drop them into an '
        'encrypted CRM.',
    featureBullets: [
      'OAuth Reddit API (rate-safe, never banned)',
      '50 curated reply templates + stil learner',
      'Playwright auto-poster + Telegram/Slack alerts',
    ],
    priceBadge: 'from \$79',
    listPriceBadge: '\$149',
    packDetailUrl: 'https://cognithor.com/packs/reddit-lead-hunter-pro',
    icon: Icons.forum,
    accentColor: Color(0xFFFF4500),
    sourceId: 'reddit',
  ),
  KnownPack(
    qualifiedId: 'cognithor-official/hn-lead-hunter',
    packId: 'hn-lead-hunter',
    displayName: 'Hacker News Lead Hunter',
    tagline: 'Monitor HN top/new/best with local LLM scoring. Free, bundled.',
    featureBullets: [
      'Official HN Firebase + Algolia APIs',
      'Local LLM scoring with your own model',
      'Ships with Cognithor Core',
    ],
    priceBadge: 'free · bundled',
    packDetailUrl: 'https://cognithor.com/packs/hn-lead-hunter',
    icon: Icons.article,
    accentColor: Color(0xFFFF6600),
    sourceId: 'hn',
  ),
  KnownPack(
    qualifiedId: 'cognithor-official/discord-lead-hunter',
    packId: 'discord-lead-hunter',
    displayName: 'Discord Lead Hunter',
    tagline: 'Score messages in your Discord channels. Requires your own bot token.',
    featureBullets: [
      'Bring your own Discord bot token',
      'Local LLM scoring — no cloud roundtrips',
      'Ships with Cognithor Core',
    ],
    priceBadge: 'free · bundled',
    packDetailUrl: 'https://cognithor.com/packs/discord-lead-hunter',
    icon: Icons.tag,
    accentColor: Color(0xFF5865F2),
    sourceId: 'discord',
  ),
  KnownPack(
    qualifiedId: 'cognithor-official/rss-lead-hunter',
    packId: 'rss-lead-hunter',
    displayName: 'RSS Lead Hunter',
    tagline: 'Score any RSS/Atom feed (news sites, blogs, forums) with your local LLM.',
    featureBullets: [
      'Stdlib XML parser — no extra dependencies',
      'RSS 2.0 + Atom support',
      'Ships with Cognithor Core',
    ],
    priceBadge: 'free · bundled',
    packDetailUrl: 'https://cognithor.com/packs/rss-lead-hunter',
    icon: Icons.rss_feed,
    accentColor: Color(0xFFFFA500),
    sourceId: 'rss',
  ),
];

KnownPack? findKnownPackBySourceId(String sourceId) {
  for (final p in kKnownPacks) {
    if (p.sourceId == sourceId) return p;
  }
  return null;
}
```

---

### Task 6.2 — Write `SourcesProvider`

Fetches `/api/v1/leads/sources` from the backend, exposes registered sources to widgets.

**Files:**
- Create: `D:/Jarvis/jarvis complete v20/flutter_app/lib/providers/sources_provider.dart`

- [ ] **Step 1: Write the provider**

```dart
import 'package:flutter/foundation.dart';
import 'package:cognithor_ui/services/api_client.dart';

class LeadSource {
  final String sourceId;
  final String displayName;
  final String icon;
  final String color;
  final Set<String> capabilities;

  const LeadSource({
    required this.sourceId,
    required this.displayName,
    required this.icon,
    required this.color,
    required this.capabilities,
  });

  factory LeadSource.fromJson(Map<String, dynamic> json) {
    return LeadSource(
      sourceId: json['source_id'] as String,
      displayName: json['display_name'] as String,
      icon: json['icon'] as String,
      color: json['color'] as String,
      capabilities: Set<String>.from(json['capabilities'] as List),
    );
  }
}

class SourcesProvider extends ChangeNotifier {
  ApiClient? _api;
  List<LeadSource> _sources = [];
  bool _loading = false;
  String? _error;

  List<LeadSource> get sources => _sources;
  bool get loading => _loading;
  String? get error => _error;
  bool get isEmpty => _sources.isEmpty;
  bool hasSource(String sourceId) => _sources.any((s) => s.sourceId == sourceId);

  void setApi(ApiClient api) {
    _api = api;
  }

  Future<void> refresh() async {
    if (_api == null) return;
    _loading = true;
    _error = null;
    notifyListeners();
    try {
      final resp = await _api!.get('/api/v1/leads/sources');
      final raw = resp['sources'] as List? ?? [];
      _sources = raw.map((e) => LeadSource.fromJson(e as Map<String, dynamic>)).toList();
    } catch (e) {
      _error = e.toString();
    } finally {
      _loading = false;
      notifyListeners();
    }
  }
}
```

- [ ] **Step 2: Wire the provider into `main.dart`**

Find the `MultiProvider` block in `lib/main.dart` and add `ChangeNotifierProvider(create: (_) => SourcesProvider())`. Then in `MainShell.initState`, call `refresh()` after the API client is set.

---

### Task 6.3 — Write `LockedPackCard` widget

**Files:**
- Create: `D:/Jarvis/jarvis complete v20/flutter_app/lib/widgets/packs/locked_pack_card.dart`

- [ ] **Step 1: Write the widget**

```dart
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:cognithor_ui/data/known_packs.dart';

class LockedPackCard extends StatelessWidget {
  final KnownPack pack;

  const LockedPackCard({super.key, required this.pack});

  Future<void> _openDetail() async {
    final uri = Uri.parse(pack.packDetailUrl);
    if (await canLaunchUrl(uri)) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    }
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Card(
      elevation: 0,
      shape: RoundedRectangleBorder(
        side: BorderSide(color: pack.accentColor.withValues(alpha: 0.3)),
        borderRadius: BorderRadius.circular(4),
      ),
      color: theme.colorScheme.surface,
      child: InkWell(
        onTap: _openDetail,
        borderRadius: BorderRadius.circular(4),
        child: Padding(
          padding: const EdgeInsets.all(20),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Row(
                children: [
                  Container(
                    padding: const EdgeInsets.all(8),
                    decoration: BoxDecoration(
                      color: pack.accentColor.withValues(alpha: 0.1),
                      borderRadius: BorderRadius.circular(4),
                    ),
                    child: Icon(pack.icon, color: pack.accentColor, size: 24),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: Text(
                      pack.displayName,
                      style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
                    ),
                  ),
                  Icon(Icons.lock_outline, size: 18, color: theme.colorScheme.outline),
                ],
              ),
              const SizedBox(height: 12),
              Text(
                pack.tagline,
                style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
              ),
              const SizedBox(height: 16),
              ...pack.featureBullets.map(
                (b) => Padding(
                  padding: const EdgeInsets.only(bottom: 6),
                  child: Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text('▸ ', style: TextStyle(color: pack.accentColor, fontFamily: 'monospace')),
                      Expanded(
                        child: Text(
                          b,
                          style: theme.textTheme.bodySmall?.copyWith(
                            color: theme.colorScheme.onSurfaceVariant,
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 16),
              Row(
                children: [
                  if (pack.listPriceBadge != null) ...[
                    Text(
                      pack.listPriceBadge!,
                      style: theme.textTheme.bodySmall?.copyWith(
                        decoration: TextDecoration.lineThrough,
                        color: theme.colorScheme.outline,
                      ),
                    ),
                    const SizedBox(width: 8),
                  ],
                  Text(
                    pack.priceBadge,
                    style: theme.textTheme.titleMedium?.copyWith(
                      color: pack.accentColor,
                      fontWeight: FontWeight.bold,
                      fontFamily: 'monospace',
                    ),
                  ),
                  const Spacer(),
                  ElevatedButton.icon(
                    onPressed: _openDetail,
                    icon: const Icon(Icons.open_in_new, size: 16),
                    label: Text('Get ${pack.displayName.split(' ').first}'),
                    style: ElevatedButton.styleFrom(
                      backgroundColor: pack.accentColor,
                      foregroundColor: Colors.white,
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

- [ ] **Step 2: Ensure `url_launcher` is a dependency**

```bash
cd "D:/Jarvis/jarvis complete v20/flutter_app" && grep -n "url_launcher" pubspec.yaml
```

Expected: already present. If not, add it and run `flutter pub get`.

---

### Task 6.4 — Rename `reddit_leads_screen.dart` → `leads_screen.dart` and rewrite

**Files:**
- Rename: `lib/screens/reddit_leads_screen.dart` → `lib/screens/leads_screen.dart`
- Modify: `lib/screens/main_shell.dart`

- [ ] **Step 1: Rename**

```bash
cd "D:/Jarvis/jarvis complete v20/flutter_app" && git mv lib/screens/reddit_leads_screen.dart lib/screens/leads_screen.dart
```

- [ ] **Step 2: Rewrite the body**

Open `lib/screens/leads_screen.dart` and replace the class body with a source-agnostic version:

```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cognithor_ui/data/known_packs.dart';
import 'package:cognithor_ui/providers/sources_provider.dart';
import 'package:cognithor_ui/providers/reddit_leads_provider.dart';  // still used for the lead list
import 'package:cognithor_ui/widgets/leads/lead_card.dart';
import 'package:cognithor_ui/widgets/leads/lead_detail_sheet.dart';
import 'package:cognithor_ui/widgets/packs/locked_pack_card.dart';

class LeadsScreen extends StatefulWidget {
  const LeadsScreen({super.key});

  @override
  State<LeadsScreen> createState() => _LeadsScreenState();
}

class _LeadsScreenState extends State<LeadsScreen> {
  String? _activeSourceFilter;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<SourcesProvider>().refresh();
    });
  }

  @override
  Widget build(BuildContext context) {
    final sources = context.watch<SourcesProvider>();
    return Scaffold(
      appBar: AppBar(title: const Text('Leads'), elevation: 0),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _buildSourceChips(sources),
            const SizedBox(height: 16),
            _buildUpsellSection(sources),
            const SizedBox(height: 24),
            _buildLeadList(),
          ],
        ),
      ),
    );
  }

  Widget _buildSourceChips(SourcesProvider sources) {
    return Wrap(
      spacing: 8,
      children: [
        ChoiceChip(
          label: const Text('All'),
          selected: _activeSourceFilter == null,
          onSelected: (_) => setState(() => _activeSourceFilter = null),
        ),
        for (final s in sources.sources)
          ChoiceChip(
            label: Text(s.displayName),
            selected: _activeSourceFilter == s.sourceId,
            onSelected: (_) => setState(() => _activeSourceFilter = s.sourceId),
          ),
      ],
    );
  }

  Widget _buildUpsellSection(SourcesProvider sources) {
    final installed = sources.sources.map((s) => s.sourceId).toSet();
    final lockedPacks = kKnownPacks.where((p) => !installed.contains(p.sourceId)).toList();
    if (lockedPacks.isEmpty) return const SizedBox.shrink();
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.only(bottom: 12),
          child: Text(
            'More sources available',
            style: Theme.of(context).textTheme.titleMedium,
          ),
        ),
        ...lockedPacks.map((p) => Padding(
              padding: const EdgeInsets.only(bottom: 12),
              child: LockedPackCard(pack: p),
            )),
      ],
    );
  }

  Widget _buildLeadList() {
    return Consumer<RedditLeadsProvider>(
      builder: (context, provider, _) {
        final leads = _activeSourceFilter == null
            ? provider.leads
            : provider.leads.where((l) => l.sourceId == _activeSourceFilter).toList();
        if (leads.isEmpty) {
          return const Padding(
            padding: EdgeInsets.all(24),
            child: Center(child: Text('No leads yet — run a scan from the Settings → Social page.')),
          );
        }
        return Column(
          children: leads
              .map((l) => LeadCard(
                    lead: l,
                    onTap: () => _openDetail(context, l),
                  ))
              .toList(),
        );
      },
    );
  }

  void _openDetail(BuildContext context, dynamic lead) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      builder: (_) => LeadDetailSheet(lead: lead),
    );
  }
}
```

**Note:** `RedditLeadsProvider` is renamed to `LeadsProvider` in a follow-up refactor later in this phase. For now, update the import and keep it working.

- [ ] **Step 3: Update `main_shell.dart`**

Replace `import 'package:cognithor_ui/screens/reddit_leads_screen.dart'` with `import 'package:cognithor_ui/screens/leads_screen.dart'`, and `RedditLeadsScreen()` with `LeadsScreen()`.

---

### Task 6.5 — Wire upsell into `config/social_page.dart`

Instead of "grandfathered UI" — when the Reddit source is not registered, show a `LockedPackCard` in place of the Reddit config section. HN/Discord/RSS sections continue to show their own config (free packs are expected to be installed).

- [ ] **Step 1: Modify the page**

At the top of `config/social_page.dart`, add:

```dart
import 'package:cognithor_ui/data/known_packs.dart';
import 'package:cognithor_ui/providers/sources_provider.dart';
import 'package:cognithor_ui/widgets/packs/locked_pack_card.dart';
```

Inside the `Consumer<ConfigProvider>` builder, wrap the Reddit section with:

```dart
Consumer<SourcesProvider>(
  builder: (context, sources, _) {
    final hasReddit = sources.hasSource('reddit');
    if (!hasReddit) {
      final pack = findKnownPackBySourceId('reddit');
      return pack != null ? LockedPackCard(pack: pack) : const SizedBox.shrink();
    }
    // ... existing Reddit config widgets (JarvisToggleField, etc.) ...
    return Column(children: [/* original Reddit fields */]);
  },
),
```

Leave the HN, Discord, and RSS sections unchanged — those are bundled free packs, so if they're not registered there's a real problem, not a monetization moment.

---

### Task 6.6 — Widget test for `LockedPackCard`

**Files:**
- Create: `D:/Jarvis/jarvis complete v20/flutter_app/test/widgets/locked_pack_card_test.dart`

- [ ] **Step 1: Write the test**

```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:cognithor_ui/data/known_packs.dart';
import 'package:cognithor_ui/widgets/packs/locked_pack_card.dart';

void main() {
  testWidgets('LockedPackCard renders pack metadata', (tester) async {
    const pack = KnownPack(
      qualifiedId: 'test/test-pack',
      packId: 'test-pack',
      displayName: 'Test Pack',
      tagline: 'test tagline',
      featureBullets: ['f1', 'f2'],
      priceBadge: 'from \$79',
      listPriceBadge: '\$149',
      packDetailUrl: 'https://example.com/packs/test',
      icon: Icons.science,
      accentColor: Color(0xFFFF00FF),
      sourceId: 'test',
    );

    await tester.pumpWidget(const MaterialApp(
      home: Scaffold(body: LockedPackCard(pack: pack)),
    ));

    expect(find.text('Test Pack'), findsOneWidget);
    expect(find.text('test tagline'), findsOneWidget);
    expect(find.text('f1'), findsOneWidget);
    expect(find.text('f2'), findsOneWidget);
    expect(find.text('from \$79'), findsOneWidget);
    expect(find.text('\$149'), findsOneWidget);
    expect(find.byIcon(Icons.lock_outline), findsOneWidget);
    expect(find.byIcon(Icons.science), findsOneWidget);
  });
}
```

- [ ] **Step 2: Run**

```bash
cd "D:/Jarvis/jarvis complete v20/flutter_app" && flutter test test/widgets/locked_pack_card_test.dart
```

Expected: 1 test passes.

---

### Task 6.7 — Update sidebar gating to use `SourcesProvider`

The current `main_shell.dart` (from Issue #113) checks `ConfigProvider.leadsEngineEnabled`. Replace with a check against `SourcesProvider.sources.isNotEmpty` so the tab shows whenever the backend has at least one source registered.

- [ ] **Step 1: Update the watcher**

In `main_shell.dart`, replace:

```dart
final leadsEngineEnabled = context.watch<ConfigProvider>().leadsEngineEnabled;
```

with:

```dart
final leadsEngineEnabled = context.watch<SourcesProvider>().sources.isNotEmpty;
```

- [ ] **Step 2: Flutter analyze + test**

```bash
cd "D:/Jarvis/jarvis complete v20/flutter_app" && flutter analyze lib/ test/ 2>&1 | tail -20
```

Expected: no issues in modified files.

```bash
cd "D:/Jarvis/jarvis complete v20/flutter_app" && flutter test 2>&1 | tail -10
```

Expected: all tests pass.

---

### Task 6.8 — Commit Flutter Phase 6

- [ ] **Step 1: Commit**

```bash
cd "D:/Jarvis/jarvis complete v20" && git add flutter_app/ && git commit -m "feat(flutter): pack-aware LeadsScreen with locked upsell cards

- lib/data/known_packs.dart: hardcoded catalog of all known packs
- lib/providers/sources_provider.dart: polls /api/v1/leads/sources
- lib/widgets/packs/locked_pack_card.dart: upsell card that opens the pack
  detail page in the system browser
- lib/screens/leads_screen.dart: renamed from reddit_leads_screen.dart,
  now source-agnostic with multi-source filter chips and an upsell section
  for packs the user hasn't installed yet
- config/social_page.dart: conditional rendering — Reddit config section
  is replaced by a LockedPackCard when the Reddit pack is not loaded
- main_shell.dart: sidebar gating now uses SourcesProvider instead of
  ConfigProvider.leadsEngineEnabled
- test/widgets/locked_pack_card_test.dart: widget test for the card"
```

---

## Phase 7 — Final Validation + Release Prep

**Goal:** Run the full cross-repo validation (Core + packs + site + Flutter), print the git hygiene commands for the user to execute manually, print the Lemon Squeezy setup checklist, print the manual smoke-test checklist, tag the release.

### Task 7.1 — Core full-suite gate

- [ ] **Step 1: Ruff + pytest**

```bash
cd "D:/Jarvis/jarvis complete v20" && python -m ruff check src/cognithor tests/ && python -m ruff format --check src/cognithor tests/ && python -m pytest tests/ -q
```

Expected: all green.

---

### Task 7.2 — All four pack test suites

- [ ] **Step 1: Run**

```bash
for pack in reddit-lead-hunter-pro hn-lead-hunter discord-lead-hunter rss-lead-hunter; do
    echo "=== $pack ==="
    cd "D:/Jarvis/cognithor-packs/$pack" && python -m pytest tests/ --import-mode=importlib -q
done
```

Expected: all four pass.

---

### Task 7.3 — Site gate

- [ ] **Step 1: Run**

```bash
cd "D:/Jarvis/cognithor-site" && pnpm vitest run && pnpm tsc --noEmit && pnpm build 2>&1 | tail -15
```

Expected: vitest green, tsc clean, next build succeeds with all 4 packs listed as statically generated routes.

---

### Task 7.4 — Flutter gate

- [ ] **Step 1: Run**

```bash
cd "D:/Jarvis/jarvis complete v20/flutter_app" && flutter analyze lib/ test/ && flutter test 2>&1 | tail -10
```

Expected: no analyze issues, all tests pass.

---

### Task 7.5 — Print git-hygiene commands for the user

These are printed for the user, NOT executed by the plan. The user runs them manually when they're ready to publish the Core cleanup.

- [ ] **Step 1: Print the commands**

```
# Push Core cleanup
cd "D:/Jarvis/jarvis complete v20"
git log phase1-leads-sdk..phase4-packs-extracted --oneline
git push origin main
git push --tags

# Verify no Reddit code escaped to public
git grep -i "reddit" -- 'src/cognithor/**'
#   Expected: only comments, docstrings, and possibly the _bundled_packs/
#   directory (which is fine — those are free packs, apache-2.0).

# Push the private packs repo
cd "D:/Jarvis/cognithor-packs"
git push origin main --follow-tags

# Site is NOT pushed yet per earlier decision (user wants to wait until hosting is set up).
cd "D:/Jarvis/cognithor-site"
git log phase5-site-migration^..HEAD --oneline
# When ready: git push origin master
```

No history rewriting is needed. The `cognithor.social` directory was Apache-2.0 code when it existed; deleting it in a forward commit is sufficient. Old git commits still show it — that's fine, old commits stay Apache-2.0.

---

### Task 7.6 — Print Lemon Squeezy setup checklist

- [ ] **Step 1: Print the checklist**

The user runs this manually in the LS dashboard. Required before Reddit Lead Hunter Pro can actually be sold.

```
Lemon Squeezy setup checklist for Reddit Lead Hunter Pro:

1. Create a Lemon Squeezy store at cognithor.lemonsqueezy.com (or your chosen subdomain).
2. Settings → Store → Business type → Digital products.
3. Verify your payout method (Stripe Connect or direct bank).
4. Products → New product:
   - Name: Reddit Lead Hunter Pro
   - Description: (copy from catalog.mdx)
   - Type: Digital download (Single payment)
   - Status: Draft until smoke-test passes
5. Variants → Add two variants:
   a) Indie License
      - Price: $79 USD
      - Max sales: 100 (to enforce launch cap)
      - Post-launch price variant: $99 (create as separate variant, activate
        manually after cap reached)
   b) Commercial License
      - Price: $199 USD
      - Max sales: 25
      - Post-launch: $249 variant
6. Upload file: reddit-lead-hunter-pro-1.2.0.zip
   - Build with: cd cognithor-packs && zip -r reddit-lead-hunter-pro-1.2.0.zip \
     reddit-lead-hunter-pro/ -x '*/tests/*' -x '*/__pycache__/*'
   - Attach to BOTH variants.
7. Checkout → Thank-you URL:
   https://cognithor.com/packs/reddit-lead-hunter-pro/installed?order_id={order_id}&token={download_token}
8. Webhooks: (leave empty for MVP — Phase 2 adds license-key validation)
9. Copy each variant's checkout URL into:
   cognithor-packs/reddit-lead-hunter-pro/pack_manifest.json
   - `checkout_url` = indie variant URL
   - `commercial_checkout_url` = commercial variant URL
   - Commit + push (CI rebuilds index.json + site)
10. Set store to Live when ready.
```

---

### Task 7.7 — Print manual smoke-test checklist

- [ ] **Step 1: Print the checklist**

```
Manual smoke test before announcing RLH Pro:

Core + pack install:
[ ] Fresh Cognithor install on a clean machine (VM or reset $HOME).
[ ] First start: bootstrap copies the 3 bundled free packs to ~/.cognithor/packs/.
[ ] `cognithor pack list` shows HN, Discord, RSS.
[ ] `cognithor pack install <LS-test-link>` downloads the zip, prompts EULA,
    installs to ~/.cognithor/packs/cognithor-official/reddit-lead-hunter-pro/.
[ ] Restart Cognithor. Pack loader log shows reddit_lead_hunter_pro_registered.
[ ] `cognithor pack list` now shows all 4 packs.

Flutter UI:
[ ] Launch Command Center.
[ ] Sidebar shows Leads tab (at least 1 source registered).
[ ] Leads tab loads, shows source filter chips for all 4 sources.
[ ] Settings → Social shows Reddit section (now that RLH Pro is installed).
[ ] Uninstall RLH Pro: `cognithor pack remove cognithor-official/reddit-lead-hunter-pro`.
[ ] Restart. Settings → Social now shows LockedPackCard in place of Reddit config.
[ ] Click "Get Reddit Lead Hunter Pro" — opens browser to cognithor.com/packs/reddit-lead-hunter-pro.

Site:
[ ] /packs page shows all 4 packs.
[ ] RLH Pro shows strikethrough $149 next to $79 and a "launch price" badge.
[ ] /packs/reddit-lead-hunter-pro detail page renders from the fetched MDX.
[ ] Click "Install Pack" → routes to LS checkout.
[ ] Complete a test purchase (LS has a sandbox mode).
[ ] LS redirects to /packs/reddit-lead-hunter-pro/installed?order_id=X&token=Y.
[ ] Thank-you page renders with the one-line install command.
[ ] Copy command, paste into Cognithor CLI, verify install succeeds.
[ ] Refund the test purchase in LS sandbox (verifies refund flow works).

End-to-end:
[ ] Push an empty commit to cognithor-packs. CI run green.
[ ] Vercel deploy hook fires. Site rebuilds. New packs (if any) visible.
```

---

### Task 7.8 — Tag final release and commit summary

- [ ] **Step 1: Tag across all repos**

```bash
cd "D:/Jarvis/jarvis complete v20" && git tag -a v0.83.0 -m "v0.83.0 — Agent Pack Architecture

- cognithor.packs plugin system (interface, loader, installer, CLI)
- cognithor.leads.sdk source-agnostic lead engine
- 4 agent packs extracted to private cognithor-packs repo:
  - reddit-lead-hunter-pro (\$79 paid)
  - hn-lead-hunter, discord-lead-hunter, rss-lead-hunter (free bundled)
- Flutter LeadsScreen rewritten source-agnostic with locked-card upsells
- /api/v1/leads/sources REST endpoint for source registry
- Bundled pack auto-copy on first-run bootstrap
- EULA click-through with manifest-pinned SHA-256
- Forward-compatible publisher + revenue_share (70/30) for Q4 community marketplace"

cd "D:/Jarvis/cognithor-packs" && git tag -a v0.1.0 -m "v0.1.0 — initial pack catalog with 4 packs"

cd "D:/Jarvis/cognithor-site" && git tag -a v2026-04-packs -m "Site migrated to build-time pack fetcher"
```

- [ ] **Step 2: Final summary commit (Core)**

```bash
cd "D:/Jarvis/jarvis complete v20" && git status
```

Expected: clean working tree.

---

## Plan complete

- **Total tasks:** 75
- **Estimated effort:** 3-4 focused sessions
- **Main risks:**
  - **Hyphenated directory names** — the pack directories use hyphens (`reddit-lead-hunter-pro`) but Python packages use underscores. Addressed by pytest `--import-mode=importlib` and sys.path manipulation in `conftest.py`.
  - **Gateway wiring ordering** — the pack loader runs in Phase F after the legacy social service init is torn down. Task 4.14 deletes the legacy wiring; if the order is wrong, Phase 2's integration test misses a bug. Covered by the full test suite at Phase 4 Task 4.15.
  - **Vercel build-time secret handling** — if `COGNITHOR_PACKS_READ_TOKEN` is accidentally marked as runtime-available, it could leak via a misconfigured Next.js page. Mitigation: document clearly in Task 5.2, verify in Task 5.13.
  - **Lemon Squeezy thank-you URL** — the URL pattern depends on LS's template variables (`{order_id}`, `{download_token}`). If LS changes the format, the route breaks. Covered manually by Task 7.7 smoke test.

