# circuitforge-core Extraction 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 the shared scaffold from Peregrine into a standalone `circuitforge-core` Python package, update Peregrine to depend on it, and leave Peregrine's behaviour unchanged. **Architecture:** New private repo `circuitforge-core` at `/Library/Development/CircuitForge/circuitforge-core/`. Contains: db base connection, LLM router, tier system, and config loader — the minimum core needed for both Peregrine and Snipe. Vision module, wizard framework, and pipeline module are stubbed (vision and wizard are net-new; pipeline is extracted but task-runner is deferred). Peregrine's `requirements.txt` gets a `-e ../circuitforge-core` entry; all internal imports updated. **Tech Stack:** Python 3.11+, SQLite (stdlib), pytest, pyproject.toml (core only — Peregrine stays on requirements.txt) --- ## File Map ### New files (circuitforge-core repo) | File | Responsibility | |---|---| | `circuitforge_core/__init__.py` | Package version export | | `circuitforge_core/db/__init__.py` | Re-exports `get_connection` | | `circuitforge_core/db/base.py` | `get_connection()` — SQLite/SQLCipher connection factory (extracted from `peregrine/scripts/db.py`) | | `circuitforge_core/db/migrations.py` | `run_migrations(conn, migrations_dir)` — simple sequential migration runner | | `circuitforge_core/llm/__init__.py` | Re-exports `LLMRouter` | | `circuitforge_core/llm/router.py` | `LLMRouter` class (extracted from `peregrine/scripts/llm_router.py`) | | `circuitforge_core/vision/__init__.py` | Re-exports `VisionRouter` | | `circuitforge_core/vision/router.py` | `VisionRouter` stub — raises `NotImplementedError` until v0.2 | | `circuitforge_core/wizard/__init__.py` | Re-exports `BaseWizard` | | `circuitforge_core/wizard/base.py` | `BaseWizard` stub — raises `NotImplementedError` until first product wires it | | `circuitforge_core/pipeline/__init__.py` | Re-exports `StagingDB` | | `circuitforge_core/pipeline/staging.py` | `StagingDB` stub — interface for SQLite staging queue; raises `NotImplementedError` | | `circuitforge_core/tiers/__init__.py` | Re-exports `can_use`, `TIERS`, `BYOK_UNLOCKABLE`, `LOCAL_VISION_UNLOCKABLE` | | `circuitforge_core/tiers/tiers.py` | Generalised tier system (extracted from `peregrine/app/wizard/tiers.py`, product-specific feature keys removed) | | `circuitforge_core/config/__init__.py` | Re-exports `require_env`, `load_env` | | `circuitforge_core/config/settings.py` | `require_env(key)`, `load_env(path)` — env validation helpers | | `pyproject.toml` | Package metadata, no dependencies beyond stdlib + pyyaml + requests + openai | | `tests/test_db.py` | Tests for `get_connection` and migration runner | | `tests/test_tiers.py` | Tests for `can_use`, BYOK unlock, LOCAL_VISION unlock | | `tests/test_llm_router.py` | Tests for LLMRouter fallback chain (mock backends) | | `tests/test_config.py` | Tests for `require_env` missing/present | ### Modified files (Peregrine repo) | File | Change | |---|---| | `requirements.txt` | Add `-e ../circuitforge-core` | | `scripts/db.py` | Replace `get_connection` body with `from circuitforge_core.db import get_connection; ...` re-export | | `scripts/llm_router.py` | Replace `LLMRouter` class body with import from `circuitforge_core.llm` | | `app/wizard/tiers.py` | Replace tier/BYOK logic with imports from `circuitforge_core.tiers`; keep Peregrine-specific `FEATURES` dict | --- ## Task 1: Scaffold circuitforge-core repo **Files:** - Create: `circuitforge-core/pyproject.toml` - Create: `circuitforge-core/circuitforge_core/__init__.py` - Create: `circuitforge-core/.gitignore` - Create: `circuitforge-core/README.md` - [ ] **Step 1: Create repo directory and init git** ```bash mkdir -p /Library/Development/CircuitForge/circuitforge-core cd /Library/Development/CircuitForge/circuitforge-core git init ``` - [ ] **Step 2: Write pyproject.toml** ```toml # /Library/Development/CircuitForge/circuitforge-core/pyproject.toml [build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] name = "circuitforge-core" version = "0.1.0" description = "Shared scaffold for CircuitForge products" requires-python = ">=3.11" dependencies = [ "pyyaml>=6.0", "requests>=2.31", "openai>=1.0", ] [tool.setuptools.packages.find] where = ["."] include = ["circuitforge_core*"] [tool.pytest.ini_options] testpaths = ["tests"] ``` - [ ] **Step 3: Write package __init__.py** ```python # circuitforge-core/circuitforge_core/__init__.py __version__ = "0.1.0" ``` - [ ] **Step 4: Write .gitignore** ``` __pycache__/ *.pyc .env *.egg-info/ dist/ .pytest_cache/ ``` - [ ] **Step 5: Install editable and verify import** ```bash cd /Library/Development/CircuitForge/circuitforge-core conda run -n job-seeker pip install -e . conda run -n job-seeker python -c "import circuitforge_core; print(circuitforge_core.__version__)" ``` Expected: `0.1.0` - [ ] **Step 6: Create tests/__init__.py** ```bash mkdir -p /Library/Development/CircuitForge/circuitforge-core/tests touch /Library/Development/CircuitForge/circuitforge-core/tests/__init__.py ``` - [ ] **Step 7: Commit** ```bash git add . git commit -m "feat: scaffold circuitforge-core package" ``` --- ## Task 2: Extract db base connection **Files:** - Create: `circuitforge-core/circuitforge_core/db/__init__.py` - Create: `circuitforge-core/circuitforge_core/db/base.py` - Create: `circuitforge-core/circuitforge_core/db/migrations.py` - Create: `circuitforge-core/tests/__init__.py` - Create: `circuitforge-core/tests/test_db.py` - [ ] **Step 1: Write failing tests** ```python # circuitforge-core/tests/test_db.py import sqlite3 import tempfile from pathlib import Path import pytest from circuitforge_core.db import get_connection, run_migrations def test_get_connection_returns_sqlite_connection(tmp_path): db = tmp_path / "test.db" conn = get_connection(db) assert isinstance(conn, sqlite3.Connection) conn.close() def test_get_connection_creates_file(tmp_path): db = tmp_path / "test.db" assert not db.exists() conn = get_connection(db) conn.close() assert db.exists() def test_run_migrations_applies_sql_files(tmp_path): db = tmp_path / "test.db" migrations_dir = tmp_path / "migrations" migrations_dir.mkdir() (migrations_dir / "001_create_foo.sql").write_text( "CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT);" ) conn = get_connection(db) run_migrations(conn, migrations_dir) cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='foo'") assert cursor.fetchone() is not None conn.close() def test_run_migrations_is_idempotent(tmp_path): db = tmp_path / "test.db" migrations_dir = tmp_path / "migrations" migrations_dir.mkdir() (migrations_dir / "001_create_foo.sql").write_text( "CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT);" ) conn = get_connection(db) run_migrations(conn, migrations_dir) run_migrations(conn, migrations_dir) # second run must not raise conn.close() def test_run_migrations_applies_in_order(tmp_path): db = tmp_path / "test.db" migrations_dir = tmp_path / "migrations" migrations_dir.mkdir() (migrations_dir / "001_create_foo.sql").write_text( "CREATE TABLE foo (id INTEGER PRIMARY KEY);" ) (migrations_dir / "002_add_name.sql").write_text( "ALTER TABLE foo ADD COLUMN name TEXT;" ) conn = get_connection(db) run_migrations(conn, migrations_dir) conn.execute("INSERT INTO foo (name) VALUES ('bar')") conn.close() ``` - [ ] **Step 2: Run tests to verify they fail** ```bash cd /Library/Development/CircuitForge/circuitforge-core conda run -n job-seeker pytest tests/test_db.py -v ``` Expected: ImportError or AttributeError (module not yet defined) - [ ] **Step 3: Write db/base.py** (extracted from `peregrine/scripts/db.py` lines 1–40) ```python # circuitforge-core/circuitforge_core/db/base.py """ SQLite connection factory for CircuitForge products. Supports plain SQLite and SQLCipher (AES-256) when CLOUD_MODE is active. """ from __future__ import annotations import os import sqlite3 from pathlib import Path def get_connection(db_path: Path, key: str = "") -> sqlite3.Connection: """ Open a SQLite database connection. In cloud mode with a key: uses SQLCipher (API-identical to sqlite3). Otherwise: plain sqlite3. Args: db_path: Path to the database file. Created if absent. key: SQLCipher encryption key. Empty = unencrypted. """ cloud_mode = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes") if cloud_mode and key: from pysqlcipher3 import dbapi2 as _sqlcipher # type: ignore conn = _sqlcipher.connect(str(db_path)) conn.execute(f"PRAGMA key='{key}'") return conn return sqlite3.connect(str(db_path)) ``` - [ ] **Step 4: Write db/migrations.py** ```python # circuitforge-core/circuitforge_core/db/migrations.py """ Sequential SQL migration runner. Applies *.sql files from migrations_dir in filename order. Tracks applied migrations in a _migrations table — safe to call multiple times. """ from __future__ import annotations import sqlite3 from pathlib import Path def run_migrations(conn: sqlite3.Connection, migrations_dir: Path) -> None: """Apply any unapplied *.sql migrations from migrations_dir.""" conn.execute( "CREATE TABLE IF NOT EXISTS _migrations " "(name TEXT PRIMARY KEY, applied_at TEXT DEFAULT CURRENT_TIMESTAMP)" ) conn.commit() applied = {row[0] for row in conn.execute("SELECT name FROM _migrations")} sql_files = sorted(migrations_dir.glob("*.sql")) for sql_file in sql_files: if sql_file.name in applied: continue conn.executescript(sql_file.read_text()) conn.execute("INSERT INTO _migrations (name) VALUES (?)", (sql_file.name,)) conn.commit() ``` - [ ] **Step 5: Write db/__init__.py** ```python # circuitforge-core/circuitforge_core/db/__init__.py from .base import get_connection from .migrations import run_migrations __all__ = ["get_connection", "run_migrations"] ``` - [ ] **Step 6: Run tests to verify they pass** ```bash conda run -n job-seeker pytest tests/test_db.py -v ``` Expected: 5 PASSED - [ ] **Step 7: Commit** ```bash git add circuitforge_core/db/ tests/ git commit -m "feat: add db base connection and migration runner" ``` --- ## Task 3: Extract tier system **Files:** - Create: `circuitforge-core/circuitforge_core/tiers/__init__.py` - Create: `circuitforge-core/circuitforge_core/tiers/tiers.py` - Create: `circuitforge-core/tests/test_tiers.py` Source: `peregrine/app/wizard/tiers.py` - [ ] **Step 1: Write failing tests** ```python # circuitforge-core/tests/test_tiers.py import pytest from circuitforge_core.tiers import can_use, TIERS, BYOK_UNLOCKABLE, LOCAL_VISION_UNLOCKABLE def test_tiers_order(): assert TIERS == ["free", "paid", "premium", "ultra"] def test_free_feature_always_accessible(): # Features not in FEATURES dict are free for everyone assert can_use("nonexistent_feature", tier="free") is True def test_paid_feature_blocked_for_free_tier(): # Caller must register features — test via can_use with explicit min_tier assert can_use("test_paid", tier="free", _features={"test_paid": "paid"}) is False def test_paid_feature_accessible_for_paid_tier(): assert can_use("test_paid", tier="paid", _features={"test_paid": "paid"}) is True def test_byok_unlocks_byok_feature(): byok_feature = next(iter(BYOK_UNLOCKABLE)) if BYOK_UNLOCKABLE else None if byok_feature: assert can_use(byok_feature, tier="free", has_byok=True) is True def test_byok_does_not_unlock_non_byok_feature(): assert can_use("test_paid", tier="free", has_byok=True, _features={"test_paid": "paid"}) is False def test_local_vision_unlocks_vision_feature(): vision_feature = next(iter(LOCAL_VISION_UNLOCKABLE)) if LOCAL_VISION_UNLOCKABLE else None if vision_feature: assert can_use(vision_feature, tier="free", has_local_vision=True) is True def test_local_vision_does_not_unlock_non_vision_feature(): assert can_use("test_paid", tier="free", has_local_vision=True, _features={"test_paid": "paid"}) is False ``` - [ ] **Step 2: Run tests to verify they fail** ```bash conda run -n job-seeker pytest tests/test_tiers.py -v ``` Expected: ImportError - [ ] **Step 3: Write tiers/tiers.py** (generalised from `peregrine/app/wizard/tiers.py` — remove all Peregrine-specific `FEATURES` entries; keep the `can_use` logic and unlock mechanism) ```python # circuitforge-core/circuitforge_core/tiers/tiers.py """ Tier system for CircuitForge products. Tiers: free < paid < premium < ultra Products register their own FEATURES dict and pass it to can_use(). BYOK_UNLOCKABLE: features that unlock when the user has any configured LLM backend (local or API key). These are gated only because CF would otherwise provide the compute. LOCAL_VISION_UNLOCKABLE: features that unlock when the user has a local vision model configured (e.g. moondream2). Distinct from BYOK — a text LLM key does NOT unlock vision features. """ from __future__ import annotations TIERS: list[str] = ["free", "paid", "premium", "ultra"] # Features that unlock when the user has any LLM backend configured. # Each product extends this frozenset with its own BYOK-unlockable features. BYOK_UNLOCKABLE: frozenset[str] = frozenset() # Features that unlock when the user has a local vision model configured. LOCAL_VISION_UNLOCKABLE: frozenset[str] = frozenset() def can_use( feature: str, tier: str, has_byok: bool = False, has_local_vision: bool = False, _features: dict[str, str] | None = None, ) -> bool: """ Return True if the given tier (and optional unlocks) can access feature. Args: feature: Feature key string. tier: User's current tier ("free", "paid", "premium", "ultra"). has_byok: True if user has a configured LLM backend. has_local_vision: True if user has a local vision model configured. _features: Feature→min_tier map. Products pass their own dict here. If None, all features are free. """ features = _features or {} if feature not in features: return True if has_byok and feature in BYOK_UNLOCKABLE: return True if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE: return True min_tier = features[feature] try: return TIERS.index(tier) >= TIERS.index(min_tier) except ValueError: return False def tier_label( feature: str, has_byok: bool = False, has_local_vision: bool = False, _features: dict[str, str] | None = None, ) -> str: """Return a human-readable label for the minimum tier needed for feature.""" features = _features or {} if feature not in features: return "free" if has_byok and feature in BYOK_UNLOCKABLE: return "free (BYOK)" if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE: return "free (local vision)" return features[feature] ``` - [ ] **Step 4: Write tiers/__init__.py** ```python # circuitforge-core/circuitforge_core/tiers/__init__.py from .tiers import can_use, tier_label, TIERS, BYOK_UNLOCKABLE, LOCAL_VISION_UNLOCKABLE __all__ = ["can_use", "tier_label", "TIERS", "BYOK_UNLOCKABLE", "LOCAL_VISION_UNLOCKABLE"] ``` - [ ] **Step 5: Run tests** ```bash conda run -n job-seeker pytest tests/test_tiers.py -v ``` Expected: 8 PASSED - [ ] **Step 6: Commit** ```bash git add circuitforge_core/tiers/ tests/test_tiers.py git commit -m "feat: add generalised tier system with BYOK and local vision unlocks" ``` --- ## Task 4: Extract LLM router **Files:** - Create: `circuitforge-core/circuitforge_core/llm/__init__.py` - Create: `circuitforge-core/circuitforge_core/llm/router.py` - Create: `circuitforge-core/tests/test_llm_router.py` Source: `peregrine/scripts/llm_router.py` - [ ] **Step 1: Write failing tests** ```python # circuitforge-core/tests/test_llm_router.py from unittest.mock import MagicMock, patch import pytest from circuitforge_core.llm import LLMRouter def _make_router(config: dict) -> LLMRouter: """Build a router from an in-memory config dict (bypass file loading).""" router = object.__new__(LLMRouter) router.config = config return router def test_complete_uses_first_reachable_backend(): router = _make_router({ "fallback_order": ["local"], "backends": { "local": { "type": "openai_compat", "base_url": "http://localhost:11434/v1", "model": "llama3", "supports_images": False, } } }) mock_client = MagicMock() mock_client.chat.completions.create.return_value = MagicMock( choices=[MagicMock(message=MagicMock(content="hello"))] ) with patch.object(router, "_is_reachable", return_value=True), \ patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client): result = router.complete("say hello") assert result == "hello" def test_complete_falls_back_on_unreachable_backend(): router = _make_router({ "fallback_order": ["unreachable", "working"], "backends": { "unreachable": { "type": "openai_compat", "base_url": "http://nowhere:1/v1", "model": "x", "supports_images": False, }, "working": { "type": "openai_compat", "base_url": "http://localhost:11434/v1", "model": "llama3", "supports_images": False, } } }) mock_client = MagicMock() mock_client.chat.completions.create.return_value = MagicMock( choices=[MagicMock(message=MagicMock(content="fallback"))] ) def reachable(url): return "nowhere" not in url with patch.object(router, "_is_reachable", side_effect=reachable), \ patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client): result = router.complete("test") assert result == "fallback" def test_complete_raises_when_all_backends_exhausted(): router = _make_router({ "fallback_order": ["dead"], "backends": { "dead": { "type": "openai_compat", "base_url": "http://nowhere:1/v1", "model": "x", "supports_images": False, } } }) with patch.object(router, "_is_reachable", return_value=False): with pytest.raises(RuntimeError, match="exhausted"): router.complete("test") ``` - [ ] **Step 2: Run tests to verify they fail** ```bash conda run -n job-seeker pytest tests/test_llm_router.py -v ``` Expected: ImportError - [ ] **Step 3: Copy LLM router from Peregrine** Copy the full contents of `/Library/Development/CircuitForge/peregrine/scripts/llm_router.py` to `circuitforge-core/circuitforge_core/llm/router.py`, then update the one internal import: ```python # Change at top of file: # OLD: CONFIG_PATH = Path(__file__).parent.parent / "config" / "llm.yaml" # NEW: CONFIG_PATH = Path.home() / ".config" / "circuitforge" / "llm.yaml" ``` - [ ] **Step 4: Write llm/__init__.py** ```python # circuitforge-core/circuitforge_core/llm/__init__.py from .router import LLMRouter __all__ = ["LLMRouter"] ``` - [ ] **Step 5: Run tests** ```bash conda run -n job-seeker pytest tests/test_llm_router.py -v ``` Expected: 3 PASSED - [ ] **Step 6: Commit** ```bash git add circuitforge_core/llm/ tests/test_llm_router.py git commit -m "feat: add LLM router (extracted from Peregrine)" ``` --- ## Task 5: Add vision stub and config module **Files:** - Create: `circuitforge-core/circuitforge_core/vision/__init__.py` - Create: `circuitforge-core/circuitforge_core/vision/router.py` - Create: `circuitforge-core/circuitforge_core/config/__init__.py` - Create: `circuitforge-core/circuitforge_core/config/settings.py` - Create: `circuitforge-core/tests/test_config.py` - [ ] **Step 1: Write failing config tests** ```python # circuitforge-core/tests/test_config.py import os import pytest from circuitforge_core.config import require_env, load_env def test_require_env_returns_value_when_set(monkeypatch): monkeypatch.setenv("TEST_KEY", "hello") assert require_env("TEST_KEY") == "hello" def test_require_env_raises_when_missing(monkeypatch): monkeypatch.delenv("TEST_KEY", raising=False) with pytest.raises(EnvironmentError, match="TEST_KEY"): require_env("TEST_KEY") def test_load_env_sets_variables(tmp_path, monkeypatch): env_file = tmp_path / ".env" env_file.write_text("FOO=bar\nBAZ=qux\n") monkeypatch.delenv("FOO", raising=False) load_env(env_file) assert os.environ.get("FOO") == "bar" assert os.environ.get("BAZ") == "qux" def test_load_env_skips_missing_file(tmp_path): load_env(tmp_path / "nonexistent.env") # must not raise ``` - [ ] **Step 2: Run to verify failure** ```bash conda run -n job-seeker pytest tests/test_config.py -v ``` Expected: ImportError - [ ] **Step 3: Write config/settings.py** ```python # circuitforge-core/circuitforge_core/config/settings.py """Env validation and .env loader for CircuitForge products.""" from __future__ import annotations import os from pathlib import Path def require_env(key: str) -> str: """Return env var value or raise EnvironmentError with clear message.""" value = os.environ.get(key) if not value: raise EnvironmentError( f"Required environment variable {key!r} is not set. " f"Check your .env file." ) return value def load_env(path: Path) -> None: """Load key=value pairs from a .env file into os.environ. Skips missing files.""" if not path.exists(): return for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") os.environ.setdefault(key.strip(), value.strip()) ``` - [ ] **Step 4: Write config/__init__.py** ```python # circuitforge-core/circuitforge_core/config/__init__.py from .settings import require_env, load_env __all__ = ["require_env", "load_env"] ``` - [ ] **Step 5: Write vision stub** ```python # circuitforge-core/circuitforge_core/vision/router.py """ Vision model router — stub until v0.2. Supports: moondream2 (local) and Claude vision API (cloud). """ from __future__ import annotations class VisionRouter: """Routes image analysis requests to local or cloud vision models.""" def analyze(self, image_bytes: bytes, prompt: str) -> str: """ Analyze image_bytes with the given prompt. Raises NotImplementedError until vision backends are wired up. """ raise NotImplementedError( "VisionRouter is not yet implemented. " "Photo analysis requires a Paid tier or local vision model (v0.2+)." ) ``` ```python # circuitforge-core/circuitforge_core/vision/__init__.py from .router import VisionRouter __all__ = ["VisionRouter"] ``` - [ ] **Step 6: Run all tests** ```bash conda run -n job-seeker pytest tests/ -v ``` Expected: All PASSED (config tests pass; vision stub has no tests — it's a placeholder) - [ ] **Step 7: Commit** ```bash git add circuitforge_core/vision/ circuitforge_core/config/ tests/test_config.py git commit -m "feat: add config module and vision router stub" ``` --- ## Task 5b: Add wizard and pipeline stubs **Files:** - Create: `circuitforge-core/circuitforge_core/wizard/__init__.py` - Create: `circuitforge-core/circuitforge_core/wizard/base.py` - Create: `circuitforge-core/circuitforge_core/pipeline/__init__.py` - Create: `circuitforge-core/circuitforge_core/pipeline/staging.py` - Create: `circuitforge-core/tests/test_stubs.py` These modules are required by the spec (section 2.2) but are net-new (wizard) or partially net-new (pipeline). They are stubbed here so downstream products can import them; full implementation happens when each product needs them. - [ ] **Step 1: Write failing stub import tests** ```python # circuitforge-core/tests/test_stubs.py import pytest from circuitforge_core.wizard import BaseWizard from circuitforge_core.pipeline import StagingDB def test_wizard_raises_not_implemented(): wizard = BaseWizard() with pytest.raises(NotImplementedError): wizard.run() def test_pipeline_raises_not_implemented(): staging = StagingDB() with pytest.raises(NotImplementedError): staging.enqueue("job", {}) ``` - [ ] **Step 2: Run to verify failure** ```bash conda run -n job-seeker pytest tests/test_stubs.py -v ``` Expected: ImportError - [ ] **Step 3: Write wizard stub** ```python # circuitforge-core/circuitforge_core/wizard/base.py """ First-run onboarding wizard base class. Full implementation is net-new per product (v0.1+ for snipe, etc.) """ from __future__ import annotations class BaseWizard: """ Base class for CircuitForge first-run wizards. Subclass and implement run() in each product. """ def run(self) -> None: """Execute the onboarding wizard flow. Must be overridden by subclass.""" raise NotImplementedError( "BaseWizard.run() must be implemented by a product-specific subclass." ) ``` ```python # circuitforge-core/circuitforge_core/wizard/__init__.py from .base import BaseWizard __all__ = ["BaseWizard"] ``` - [ ] **Step 4: Write pipeline stub** ```python # circuitforge-core/circuitforge_core/pipeline/staging.py """ SQLite-backed staging queue for CircuitForge pipeline tasks. Full implementation deferred — stub raises NotImplementedError. """ from __future__ import annotations from typing import Any class StagingDB: """ Staging queue for background pipeline tasks (search polling, score updates, etc.) Stub: raises NotImplementedError until wired up in a product. """ def enqueue(self, task_type: str, payload: dict[str, Any]) -> None: """Add a task to the staging queue.""" raise NotImplementedError( "StagingDB.enqueue() is not yet implemented. " "Background task pipeline is a v0.2+ feature." ) def dequeue(self) -> tuple[str, dict[str, Any]] | None: """Fetch the next pending task. Returns (task_type, payload) or None.""" raise NotImplementedError( "StagingDB.dequeue() is not yet implemented." ) ``` ```python # circuitforge-core/circuitforge_core/pipeline/__init__.py from .staging import StagingDB __all__ = ["StagingDB"] ``` - [ ] **Step 5: Run tests** ```bash conda run -n job-seeker pytest tests/test_stubs.py -v ``` Expected: 2 PASSED - [ ] **Step 6: Verify all tests still pass** ```bash conda run -n job-seeker pytest tests/ -v ``` Expected: All PASSED - [ ] **Step 7: Commit** ```bash git add circuitforge_core/wizard/ circuitforge_core/pipeline/ tests/test_stubs.py git commit -m "feat: add wizard and pipeline stubs" ``` --- ## Task 6: Update Peregrine to use circuitforge-core **Files:** - Modify: `peregrine/requirements.txt` - Modify: `peregrine/scripts/db.py` - Modify: `peregrine/scripts/llm_router.py` - Modify: `peregrine/app/wizard/tiers.py` - [ ] **Step 1: Add circuitforge-core to Peregrine requirements** Add to `/Library/Development/CircuitForge/peregrine/requirements.txt` (top of file, before other deps): ``` -e ../circuitforge-core ``` - [ ] **Step 2: Verify install** ```bash cd /Library/Development/CircuitForge/peregrine conda run -n job-seeker pip install -r requirements.txt conda run -n job-seeker python -c "from circuitforge_core.db import get_connection; print('ok')" ``` Expected: `ok` - [ ] **Step 3: Update peregrine/scripts/db.py — replace get_connection** Add at top of file (after existing docstring and imports): ```python from circuitforge_core.db import get_connection as _cf_get_connection def get_connection(db_path=DEFAULT_DB, key=""): """Thin shim — delegates to circuitforge_core.db.get_connection.""" return _cf_get_connection(db_path, key) ``` Remove the old `get_connection` function body (lines ~15–35). Keep all Peregrine-specific schema (`CREATE_JOBS`, `CREATE_COVER_LETTERS`, etc.) and functions unchanged. - [ ] **Step 4: Run Peregrine tests to verify nothing broke** ```bash cd /Library/Development/CircuitForge/peregrine conda run -n job-seeker pytest tests/ -v -x ``` Expected: All existing tests pass - [ ] **Step 5: Update peregrine/scripts/llm_router.py — replace LLMRouter** Replace the `LLMRouter` class definition with: ```python from circuitforge_core.llm import LLMRouter # noqa: F401 — re-export for existing callers ``` Keep the module-level `CONFIG_PATH` constant so any code that references `llm_router.CONFIG_PATH` still works. - [ ] **Step 6: Run Peregrine tests again** ```bash conda run -n job-seeker pytest tests/ -v -x ``` Expected: All passing - [ ] **Step 7: Update peregrine/app/wizard/tiers.py — import from core** At the top of the file, add: ```python from circuitforge_core.tiers import can_use as _core_can_use, TIERS, tier_label ``` Update the `can_use` function to delegate to core, passing Peregrine's `FEATURES` dict: ```python def can_use(feature: str, tier: str, has_byok: bool = False, has_local_vision: bool = False) -> bool: return _core_can_use(feature, tier, has_byok=has_byok, has_local_vision=has_local_vision, _features=FEATURES) ``` Keep the Peregrine-specific `FEATURES` dict and `BYOK_UNLOCKABLE` frozenset in place — they are still defined here, just used via the core function. - [ ] **Step 8: Run full Peregrine tests** ```bash conda run -n job-seeker pytest tests/ -v ``` Expected: All passing - [ ] **Step 9: Smoke-test Peregrine startup** ```bash cd /Library/Development/CircuitForge/peregrine ./manage.sh status # verify it's running, or start it ``` Manually open http://localhost:8502 and verify the UI loads without errors. - [ ] **Step 10: Commit Peregrine changes** ```bash cd /Library/Development/CircuitForge/peregrine git add requirements.txt scripts/db.py scripts/llm_router.py app/wizard/tiers.py git commit -m "feat: migrate to circuitforge-core for db, llm router, and tiers" ``` - [ ] **Step 11: Commit circuitforge-core README and push both repos** ```bash cd /Library/Development/CircuitForge/circuitforge-core # Write a minimal README then: git add README.md git commit -m "docs: add README" git remote add origin https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git git push -u origin main ``` --- ## Task 7: Final verification - [ ] **Step 1: Run circuitforge-core full test suite** ```bash cd /Library/Development/CircuitForge/circuitforge-core conda run -n job-seeker pytest tests/ -v --tb=short ``` Expected: All PASSED - [ ] **Step 2: Run Peregrine full test suite** ```bash cd /Library/Development/CircuitForge/peregrine conda run -n job-seeker pytest tests/ -v --tb=short ``` Expected: All PASSED - [ ] **Step 3: Verify editable install works from a fresh shell** ```bash conda run -n job-seeker python -c " from circuitforge_core.db import get_connection, run_migrations from circuitforge_core.llm import LLMRouter from circuitforge_core.tiers import can_use, TIERS from circuitforge_core.config import require_env, load_env from circuitforge_core.vision import VisionRouter from circuitforge_core.wizard import BaseWizard from circuitforge_core.pipeline import StagingDB print('All imports OK') " ``` Expected: `All imports OK`