# Scouts Refactor + Gmail Integration 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:** Rename the entire "Agents" subsystem to "Scouts" across UI/code/Postgres/SQLite/Langfuse, then add a Gmail cloud scout with two-stage pipeline (BE triage + on-connect delivery) and a reusable `SourceConnector` abstraction. Phase 4 (Stage 2 categorization + HITL surface) is deferred to a future spec.

**Architecture:** Phase 1 is a single atomic rename PR (no behavior change). Phase 2 lays connector skeleton, queue table, WS frame contract, and SQLite suggestion table — no user-visible feature yet. Phase 3 ships Gmail end-to-end: OAuth setup UI, push (`users.watch`) + cron-fallback polling, BE-side spam triage with opt-in auto-trash, deliver-on-reconnect drain into local `scoutSuggestions` table with `category='unprocessed'` stub.

**Tech Stack:** Python 3.12 / FastAPI / SQLAlchemy 2.0 (async) / Alembic / Pydantic v2 / Langfuse for prompts / Google API Client for Gmail / Pub/Sub for push notifications. Electron / TypeScript / React 19 / tRPC v11 / Drizzle ORM / better-sqlite3 / i18next.

**Spec:** [docs/superpowers/specs/2026-05-15-scouts-refactor-and-gmail-integration-design.md](2026-05-15-scouts-refactor-and-gmail-integration-design.md)

**Repository layout:** monorepo with submodules `api/` (FastAPI) and `adiuvAI/` (Electron). All tasks operate on absolute paths under `c:\Users\PC-Roby\Documents\_adiuvai_workspace`.

---

## File Structure

### New files (BE — `api/`)

| Path | Responsibility |
|------|----------------|
| `api/alembic/versions/007_rename_agents_to_scouts.py` | Rename Postgres tables/columns from `*_agent_*` to `*_scout_*`. |
| `api/alembic/versions/008_scout_triage_queue.py` | Create `scout_triage_queue` + alter `cloud_scout_configs`. |
| `api/app/scouts/__init__.py` | Package marker. |
| `api/app/scouts/engine.py` | `ScoutEngine` orchestrator (triage, queue, deliver). |
| `api/app/scouts/connectors/__init__.py` | Package marker. |
| `api/app/scouts/connectors/base.py` | `SourceConnector` Protocol + `ItemRef`/`ItemMetadata`/`ItemContent`/`TriageVerdict` Pydantic models. |
| `api/app/scouts/connectors/registry.py` | Connector registration + lookup. |
| `api/app/scouts/connectors/gmail.py` | `GmailConnector` impl (wraps existing `app/integrations/gmail.py`). |
| `api/app/api/routes/scout_webhooks.py` | `POST /api/v1/scouts/webhooks/gmail` — Pub/Sub receiver. |
| `api/tests/test_scout_engine.py` | Unit tests for `ScoutEngine` w/ mocked connector. |
| `api/tests/test_scout_connectors_gmail.py` | Tests for `GmailConnector` (mocked Gmail API). |
| `api/tests/test_scout_webhook.py` | Webhook + Pub/Sub JWT verification. |

### Renamed files (BE)

| Before | After |
|--------|-------|
| `api/app/api/routes/agents.py` | `api/app/api/routes/scouts.py` |
| `api/app/api/routes/agent_setup.py` | `api/app/api/routes/scout_setup.py` |
| `api/app/core/agent_runner.py` | `api/app/core/scout_runner.py` |
| `api/app/core/agent_session_buffer.py` | `api/app/core/scout_session_buffer.py` |
| `api/app/core/agent_registry.py` | `api/app/core/scout_registry.py` |

### New files (Electron — `adiuvAI/`)

| Path | Responsibility |
|------|----------------|
| `adiuvAI/src/main/db/migrations/0007_scouts_rename.sql` | Drizzle SQL: rename `agent_runs` → `scout_runs`, `agent_run_actions` → `scout_run_actions`. |
| `adiuvAI/src/main/db/migrations/0008_scout_suggestions.sql` | Drizzle SQL: create `scout_suggestions` table. |
| `adiuvAI/src/main/scouts/scout-suggestion-handler.ts` | Handles incoming `scout_proposal` WS frame, inserts into `scoutSuggestions`, sends `scout_proposal_ack`. |

### Renamed files (Electron)

| Before | After |
|--------|-------|
| `adiuvAI/src/main/agents/agent-scheduler.ts` | `adiuvAI/src/main/scouts/scout-scheduler.ts` |
| `adiuvAI/src/renderer/components/settings/AgentsSection.tsx` | `adiuvAI/src/renderer/components/settings/ScoutsSection.tsx` |
| `adiuvAI/src/renderer/components/settings/AgentRow.tsx` | `adiuvAI/src/renderer/components/settings/ScoutRow.tsx` |
| `adiuvAI/src/renderer/components/settings/LocalAgentConfigPanel.tsx` | `adiuvAI/src/renderer/components/settings/LocalScoutConfigPanel.tsx` |
| `adiuvAI/src/renderer/components/settings/CloudAgentConfigPanel.tsx` | `adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx` |
| `adiuvAI/src/renderer/components/settings/InlineAgentCreationStepper.tsx` | `adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx` |

### Modified files (high-impact, full list per task)

- `api/app/models.py` — class renames + table/column renames + relationship updates.
- `api/app/main.py` — register new router, lifespan crons.
- `api/app/schemas.py` — `WsFrameType` enum + new frame Pydantic models.
- `api/app/config/settings.py` — add Gmail Pub/Sub topic env vars.
- `adiuvAI/src/main/db/schema.ts` — rename tables + add `scoutSuggestions`.
- `adiuvAI/src/main/router/index.ts` — rename `agent.*` sub-routers → `scout.*`.
- `adiuvAI/src/main/api/backend-client.ts` — handle new WS frames.
- `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` — rename i18n keys.
- `adiuvAI/src/renderer/routes/settings.tsx` — update SECTIONS array.

---

# PHASE 1 — Rename Only

Goal: pure rename, no behavior change, all tests green at end of phase.

**Important caveat:** the project uses git submodules. Each task touches files in `api/` or `adiuvAI/` submodule — commits happen inside the submodule, then a separate commit in the monorepo updates the submodule pointer. Each task's commit step shows both.

---

## Task 1: Postgres rename — Alembic migration

**Files:**
- Create: `api/alembic/versions/007_rename_agents_to_scouts.py`

**Context:** Latest existing migration is `d6e3f4a5b6c7_folder_index_tables.py` with `down_revision = "006"`. New migration uses revision `"007"` and depends on `d6e3f4a5b6c7`. Tables to rename: `local_agent_configs`, `cloud_agent_configs`, `agent_run_logs`. Columns to rename: `agent_config` (in `local_agent_configs`), `agent_id` and `agent_type` (in `agent_run_logs`).

- [ ] **Step 1: Create the migration file**

```python
"""Rename agents to scouts.

Revision ID: 007
Revises: d6e3f4a5b6c7
Create Date: 2026-05-15

Renames the entire agents subsystem identifiers to scouts.
Pre-1.0 — no data preservation concerns beyond ALTER TABLE rename.
"""

from typing import Sequence, Union

from alembic import op


revision: str = "007"
down_revision: Union[str, None] = "d6e3f4a5b6c7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    # Tables
    op.rename_table("local_agent_configs", "local_scout_configs")
    op.rename_table("cloud_agent_configs", "cloud_scout_configs")
    op.rename_table("agent_run_logs", "scout_run_logs")

    # Columns
    op.alter_column("local_scout_configs", "agent_config", new_column_name="scout_config")
    op.alter_column("scout_run_logs", "agent_id", new_column_name="scout_id")
    op.alter_column("scout_run_logs", "agent_type", new_column_name="scout_type")


def downgrade() -> None:
    op.alter_column("scout_run_logs", "scout_type", new_column_name="agent_type")
    op.alter_column("scout_run_logs", "scout_id", new_column_name="agent_id")
    op.alter_column("local_scout_configs", "scout_config", new_column_name="agent_config")

    op.rename_table("scout_run_logs", "agent_run_logs")
    op.rename_table("cloud_scout_configs", "cloud_agent_configs")
    op.rename_table("local_scout_configs", "local_agent_configs")
```

- [ ] **Step 2: Run migration against a fresh test DB to verify it applies cleanly**

Run from `api/` directory:
```bash
alembic upgrade head
```
Expected: no errors. `\d local_scout_configs` shows the renamed table. `\d scout_run_logs` shows the `scout_id` column (not `agent_id`).

- [ ] **Step 3: Verify downgrade works**

```bash
alembic downgrade -1
```
Expected: tables go back to `local_agent_configs` etc. Then re-run `alembic upgrade head`.

- [ ] **Step 4: Commit (inside submodule)**

```bash
cd api
git add alembic/versions/007_rename_agents_to_scouts.py
git commit -m "feat(db): rename agents to scouts (alembic 007)"
```

---

## Task 2: SQLAlchemy model rename — `LocalScoutConfig`, `CloudScoutConfig`, `ScoutRunLog`

**Files:**
- Modify: `api/app/models.py:161-227` (and the `AgentRunLog` class — find via grep)

**Context:** Renames Python class names to match the new tables. Must update `__tablename__` and `mapped_column` for renamed columns. Relationships and `back_populates` strings must reference the new class names.

- [ ] **Step 1: Read current models around `LocalAgentConfig`, `CloudAgentConfig`, `AgentRunLog`**

Use Read tool on `api/app/models.py` to confirm the exact line ranges (the `AgentRunLog` class + relationships use string-based `primaryjoin` + `back_populates="local_agent"` / `back_populates="cloud_agent"` patterns).

- [ ] **Step 2: Apply the renames in `models.py`**

Replace class names and tablenames:

```python
class LocalScoutConfig(Base):
    __tablename__ = "local_scout_configs"

    # ... id/user_id/etc unchanged ...
    scout_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
    # ... rest unchanged ...

    run_logs: Mapped[list["ScoutRunLog"]] = relationship(
        back_populates="local_scout",
        primaryjoin="and_(ScoutRunLog.scout_id == LocalScoutConfig.id, ScoutRunLog.scout_type == 'local')",
        foreign_keys="ScoutRunLog.scout_id",
        cascade="all, delete-orphan",
        overlaps="run_logs,cloud_scout",
    )


class CloudScoutConfig(Base):
    __tablename__ = "cloud_scout_configs"

    # ... unchanged columns ...

    run_logs: Mapped[list["ScoutRunLog"]] = relationship(
        back_populates="cloud_scout",
        primaryjoin="and_(ScoutRunLog.scout_id == CloudScoutConfig.id, ScoutRunLog.scout_type == 'cloud')",
        foreign_keys="ScoutRunLog.scout_id",
        cascade="all, delete-orphan",
        overlaps="run_logs,local_scout",
    )


class ScoutRunLog(Base):
    __tablename__ = "scout_run_logs"

    # ... id, user_id, started_at, finished_at, status, error etc. unchanged ...
    scout_id: Mapped[str] = mapped_column(...)
    scout_type: Mapped[str] = mapped_column(...)

    local_scout: Mapped["LocalScoutConfig | None"] = relationship(
        back_populates="run_logs",
        primaryjoin="and_(ScoutRunLog.scout_id == LocalScoutConfig.id, ScoutRunLog.scout_type == 'local')",
        foreign_keys="ScoutRunLog.scout_id",
        overlaps="run_logs,cloud_scout",
    )

    cloud_scout: Mapped["CloudScoutConfig | None"] = relationship(
        back_populates="run_logs",
        primaryjoin="and_(ScoutRunLog.scout_id == CloudScoutConfig.id, ScoutRunLog.scout_type == 'cloud')",
        foreign_keys="ScoutRunLog.scout_id",
        overlaps="run_logs,local_scout",
    )
```

- [ ] **Step 3: Sweep all imports of the old class names**

Run from `api/`:
```bash
grep -rn "AgentRunLog\|LocalAgentConfig\|CloudAgentConfig" app/ tests/ --include="*.py"
```

For each hit, replace with the corresponding `Scout*` name. Do not skip test files.

- [ ] **Step 4: Run the test suite — expect failures and fix incrementally**

Run from `api/`:
```bash
pytest -x
```
Fix import errors and reference errors as they surface. The test fixture at `api/tests/conftest.py` does `Base.metadata.create_all` so renamed tables will be created automatically. Don't worry about migrations in tests — they use `create_all`.

- [ ] **Step 5: Verify the suite is green**

```bash
pytest
```
Expected: all tests pass.

- [ ] **Step 6: Commit (inside submodule)**

```bash
cd api
git add app/models.py
git add -u app/ tests/   # any files changed by the import sweep
git commit -m "refactor(models): rename Agent classes to Scout"
```

---

## Task 3: Rename BE routes — `routes/agents.py` → `routes/scouts.py`, `agent_setup.py` → `scout_setup.py`

**Files:**
- Rename: `api/app/api/routes/agents.py` → `api/app/api/routes/scouts.py`
- Rename: `api/app/api/routes/agent_setup.py` → `api/app/api/routes/scout_setup.py`
- Modify: `api/app/main.py` — update router registration
- Modify: `api/app/api/routes/device_ws.py:30-31` — update imports

- [ ] **Step 1: Rename the files via git**

```bash
cd api
git mv app/api/routes/agents.py app/api/routes/scouts.py
git mv app/api/routes/agent_setup.py app/api/routes/scout_setup.py
```

- [ ] **Step 2: Inside both renamed files, change `APIRouter(prefix="/agents", ...)` to `prefix="/scouts"` and `prefix="/agent-setup"` to `prefix="/scout-setup"`**

In `app/api/routes/scouts.py` find and replace:
```python
router = APIRouter(prefix="/agents", tags=["agents"])
```
With:
```python
router = APIRouter(prefix="/scouts", tags=["scouts"])
```

In `app/api/routes/scout_setup.py` find and replace:
```python
router = APIRouter(prefix="/agent-setup", tags=["agent-setup"])
```
With:
```python
router = APIRouter(prefix="/scout-setup", tags=["scout-setup"])
```

- [ ] **Step 3: Update `app/main.py` to import the new module names**

Find the imports of `agents` and `agent_setup` routers and replace with `scouts` and `scout_setup`.

- [ ] **Step 4: Update `app/api/routes/device_ws.py:30-31` import**

Change:
```python
from app.api.routes.agent_setup import handle_journey_message, handle_journey_start
```
To:
```python
from app.api.routes.scout_setup import handle_journey_message, handle_journey_start
```

- [ ] **Step 5: Sweep remaining import references**

```bash
grep -rn "from app.api.routes.agents\|from app.api.routes.agent_setup\|app\.api\.routes\.agents\|app\.api\.routes\.agent_setup" app/ tests/
```
Replace each.

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

```bash
pytest
```
Expected: pass. URL-based tests must use new paths (`/api/v1/scouts/...`, `/api/v1/scout-setup/...`).

- [ ] **Step 7: Commit (inside submodule)**

```bash
cd api
git add -A app/ tests/
git commit -m "refactor(routes): rename /agents and /agent-setup to /scouts and /scout-setup"
```

---

## Task 4: Rename `core/agent_runner.py`, `agent_session_buffer.py`, `agent_registry.py`

**Files:**
- Rename: `api/app/core/agent_runner.py` → `api/app/core/scout_runner.py`
- Rename: `api/app/core/agent_session_buffer.py` → `api/app/core/scout_session_buffer.py`
- Rename: `api/app/core/agent_registry.py` → `api/app/core/scout_registry.py`

- [ ] **Step 1: Rename via git**

```bash
cd api
git mv app/core/agent_runner.py app/core/scout_runner.py
git mv app/core/agent_session_buffer.py app/core/scout_session_buffer.py
git mv app/core/agent_registry.py app/core/scout_registry.py
```

- [ ] **Step 2: Update top-of-file references in each renamed file**

Inside each file, rename all references to `agent_*` symbols that are now defined here. For example, if `agent_runner.py` had `def trigger_agent_run(...)`, change to `def trigger_scout_run(...)`. Do NOT rename names that belong to `app/agents/*` (those LLM helpers stay).

- [ ] **Step 3: Sweep external imports**

```bash
grep -rn "from app.core.agent_runner\|from app.core.agent_session_buffer\|from app.core.agent_registry" app/ tests/
```
Replace each `agent_runner` → `scout_runner`, etc.

Also sweep symbol references — for example if you renamed `trigger_agent_run` → `trigger_scout_run`:
```bash
grep -rn "trigger_agent_run\|agent_session_buffer\|agent_registry" app/ tests/
```
Replace.

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

```bash
pytest
```
Expected: green.

- [ ] **Step 5: Commit (inside submodule)**

```bash
cd api
git add -A app/ tests/
git commit -m "refactor(core): rename agent_runner/session_buffer/registry to scout_*"
```

---

## Task 5: Rename Pydantic schemas + WS frame names referring to "agent"

**Files:**
- Modify: `api/app/schemas.py` (or wherever `WsFrameType` enum + Agent-related schemas live)

**Context:** Pydantic schemas like `AgentRunStatus`, `AgentTriggerRequest`, etc. need rename. The `WsFrameType` enum may have values like `"agent_run_started"` — rename to `"scout_run_started"`. Frontend will be updated to match in later tasks.

- [ ] **Step 1: Find all Pydantic models + enum values containing "agent"**

```bash
cd api
grep -n "class .*Agent\|\"agent_" app/schemas.py
```

- [ ] **Step 2: Rename each — class names and enum values**

Examples (apply each found in step 1):
- `class AgentRunStatus(str, Enum)` → `class ScoutRunStatus(str, Enum)`
- `class AgentTriggerRequest(BaseModel)` → `class ScoutTriggerRequest(BaseModel)`
- `WsFrameType.AGENT_RUN_STARTED = "agent_run_started"` → `SCOUT_RUN_STARTED = "scout_run_started"`

- [ ] **Step 3: Sweep all references**

```bash
grep -rn "AgentRunStatus\|AgentTriggerRequest\|agent_run_started\|agent_run_completed" app/ tests/
```
Replace.

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

```bash
pytest
```

- [ ] **Step 5: Commit**

```bash
cd api
git add -A
git commit -m "refactor(schemas): rename Agent* schemas and WS frame types to Scout*"
```

---

## Task 6: Rename Langfuse prompts (user-facing scout prompts only)

**Files:**
- Manual operation against Langfuse via MCP tools (no source files unless prompt names are referenced in code).

**Context:** Langfuse prompts whose names start with `agent-` and refer to the user-facing scout subsystem (e.g. `agent-runner-system`, `agent-setup-journey-system`, `cloud-agent-trigger`, `local-agent-extraction`). Prompts for `app/agents/*` LLM helpers (e.g. `task-agent-system`, `note-agent-summarize`) STAY.

- [ ] **Step 1: List all Langfuse prompts**

Use the Langfuse MCP tool:
```
mcp__langfuse__listPrompts (no arguments)
```

- [ ] **Step 2: Identify scout-subsystem prompts to rename**

From the listing, pick prompts whose names begin with `agent-` AND whose content/usage maps to the user-facing scout subsystem. If unsure for a given prompt, grep its name in `api/app/`:
```bash
grep -rn "get_prompt_or_fallback(\"agent-" api/app/ api/tests/
```
Names that appear in the renamed `scout_*.py` files are scout-subsystem prompts.

- [ ] **Step 3: For each scout prompt, create a new prompt with the renamed name**

Use `mcp__langfuse__getPromptUnresolved` to fetch the current prompt body, then `mcp__langfuse__createTextPrompt` (or `createChatPrompt` if it's a chat prompt) with the new name (e.g. `scout-runner-system`) and label `production`.

- [ ] **Step 4: Update code references to use the new prompt names**

Find each `get_prompt_or_fallback("agent-..."` call and rename the literal:
```bash
grep -rn 'get_prompt_or_fallback("agent-' api/app/
```
Replace each `"agent-X"` with `"scout-X"`.

- [ ] **Step 5: Run tests + manual smoke**

```bash
cd api
pytest
```
Then start dev server (`uvicorn app.main:app --reload`), trigger a known scout-subsystem agent invocation (use the agent setup journey or a scout trigger endpoint via curl) and confirm the prompt loads without falling back.

- [ ] **Step 6: Delete old prompts from Langfuse**

For each old `agent-*` prompt confirmed superseded, delete via Langfuse UI or API (Langfuse MCP doesn't expose delete; use the Langfuse web console).

- [ ] **Step 7: Commit code changes**

```bash
cd api
git add -A
git commit -m "refactor(prompts): rename scout-subsystem Langfuse prompt references to scout-*"
```

---

## Task 7: Drizzle migration — rename `agent_runs` → `scout_runs`, `agent_run_actions` → `scout_run_actions`

**Files:**
- Create: `adiuvAI/src/main/db/migrations/0007_scouts_rename.sql`
- Modify: `adiuvAI/src/main/db/schema.ts`

**Context:** Drizzle migrations are SQL files in `adiuvAI/src/main/db/migrations/`. Latest is `0006_misty_cammi.sql`. The auto-generated migration name suffix isn't strictly required but follow the pattern (drizzle-kit generates one from CHANGELOG entries). For a manual rename, a hand-written name is fine.

- [ ] **Step 1: Update `adiuvAI/src/main/db/schema.ts` first — change the table name in the Drizzle definition**

Find:
```typescript
export const agentRuns = sqliteTable('agent_runs', { ... });
export const agentRunActions = sqliteTable('agent_run_actions', { ... });
```

Replace with:
```typescript
export const scoutRuns = sqliteTable('scout_runs', {
  id: text().primaryKey(),
  scoutId: text('scout_id').notNull(),
  status: text().notNull(),
  startedAt: integer('started_at').notNull(),
  completedAt: integer('completed_at'),
});

export const scoutRunActions = sqliteTable('scout_run_actions', {
  id: text().primaryKey(),
  runId: text('run_id').notNull(),
  scoutId: text('scout_id').notNull(),
  verb: text().notNull(),
  entityType: text('entity_type').notNull(),
  entityId: text('entity_id').notNull(),
  entityTitle: text('entity_title'),
  createdAt: integer('created_at').notNull(),
});
```

(Read the file first to copy the exact column definitions — keep types/nullability identical, only rename `agent` → `scout`.)

- [ ] **Step 2: Generate the migration**

Run from `adiuvAI/`:
```bash
npx drizzle-kit generate
```
Expected: a new file `0007_<adjective>_<noun>.sql` in `src/main/db/migrations/`. Verify it contains `ALTER TABLE` statements (or DROP+CREATE if Drizzle prefers — SQLite may require recreating the table; Drizzle will emit the right pattern).

If Drizzle emits a destructive recreate (DROP TABLE + CREATE), that's fine pre-1.0 since these tables are largely empty in dev DBs. Confirm with the user before proceeding if their dev DB has rows worth preserving — for empty/dev DBs, drop is fine.

- [ ] **Step 3: Rename the generated migration to a clean name**

```bash
mv adiuvAI/src/main/db/migrations/0007_*.sql adiuvAI/src/main/db/migrations/0007_scouts_rename.sql
```

- [ ] **Step 4: Sweep TypeScript imports of the old names**

```bash
grep -rn "agentRuns\|agentRunActions" adiuvAI/src/
```
Replace each with `scoutRuns` / `scoutRunActions`.

- [ ] **Step 5: Run typecheck + lint**

From `adiuvAI/`:
```bash
npm run lint
npx tsc --noEmit
```
Expected: pass.

- [ ] **Step 6: Manual smoke — boot the app once to apply the migration**

```bash
cd adiuvAI
npm run start
```
Watch the console for migration apply messages. Once app boots cleanly, close it. Verify the dev DB has `scout_runs` and `scout_run_actions` (use `sqlite3 dev.db ".tables"` from `adiuvAI/`).

- [ ] **Step 7: Commit (inside submodule)**

```bash
cd adiuvAI
git add src/main/db/schema.ts src/main/db/migrations/0007_scouts_rename.sql
git add -u src/    # any consumer files updated in step 4
git commit -m "refactor(db): rename agent_runs/agent_run_actions to scout_*"
```

---

## Task 8: Rename Electron main-process modules and tRPC sub-routers

**Files:**
- Rename: `adiuvAI/src/main/agents/agent-scheduler.ts` → `adiuvAI/src/main/scouts/scout-scheduler.ts`
- Modify: `adiuvAI/src/main/router/index.ts` — rename `agent.*` sub-router to `scout.*`
- Modify: `adiuvAI/src/main/store.ts` — rename `getLocalAgents`/`saveLocalAgent` → `getLocalScouts`/`saveLocalScout`
- Modify: `adiuvAI/src/main/index.ts` — update scheduler import + start call
- Modify: `adiuvAI/src/main/api/backend-client.ts` — rename agent-related symbols if any

**Context:** Inside `agent-scheduler.ts`, the function names are `startAgentScheduler`, `stopAgentScheduler`, `tickAgentScheduler` and POSTs to `/api/v1/agents/trigger`. After Task 3, the BE route is `/api/v1/scouts/trigger` — so this file must also update its URL.

- [ ] **Step 1: Move the file**

```bash
cd adiuvAI
mkdir -p src/main/scouts
git mv src/main/agents/agent-scheduler.ts src/main/scouts/scout-scheduler.ts
rmdir src/main/agents 2>/dev/null || true
```
(Don't fail if other files exist in `src/main/agents/` — check first; if so, decide per-file whether to move or leave.)

- [ ] **Step 2: Inside `scout-scheduler.ts`, rename functions and update URL**

Replace:
- `startAgentScheduler` → `startScoutScheduler`
- `stopAgentScheduler` → `stopScoutScheduler`
- `tickAgentScheduler` → `tickScoutScheduler`
- `getLocalAgents()` → `getLocalScouts()`
- `saveLocalAgent(...)` → `saveLocalScout(...)`
- URL `'/api/v1/agents/trigger'` → `'/api/v1/scouts/trigger'`
- Variable `agents` → `scouts` (clarity)
- `agentRuns` Drizzle import → `scoutRuns`

Show the full updated function header:
```typescript
async function tickScoutScheduler(): Promise<void> {
  const scouts = getLocalScouts();
  const now = Date.now();

  for (const scout of scouts) {
    if (!scout.enabled) continue;
    const intervalMs = CRON_INTERVAL_MS[scout.scheduleCron];
    if (!intervalMs) continue;
    if (scout.lastRunAt && now - scout.lastRunAt < intervalMs) continue;

    try {
      const response = await getBackendClient().proxyPost<{ id: string }>(
        '/api/v1/scouts/trigger',
        { directory: scout.directory, deviceId: getDeviceId(), /* ... */ }
      );
      if (response?.id) {
        await getDb().insert(scoutRuns).values({
          id: response.id, scoutId: scout.id, status: 'running', startedAt: now,
        }).onConflictDoNothing();
      }
      saveLocalScout({ ...scout, lastRunAt: now });
    } catch (err) { /* ... */ }
  }
}
```

- [ ] **Step 3: Rename store helpers in `adiuvAI/src/main/store.ts`**

Find `getLocalAgents` / `saveLocalAgent` / any `LocalAgentConfig` references and rename to `getLocalScouts` / `saveLocalScout` / `LocalScoutConfig`. Keep the storage key migration-safe: read from old key `localAgents` once, write to `localScouts`, then read only from new key thereafter. For pre-1.0 dev, simpler to just rename the key and accept users re-adding their scouts (note: confirm with user).

For now, simplest acceptable approach:
```typescript
// store.ts
const STORAGE_KEY = 'localScouts';   // was 'localAgents'

export function getLocalScouts(): LocalScoutConfig[] {
  return store.get(STORAGE_KEY, []) as LocalScoutConfig[];
}
export function saveLocalScout(scout: LocalScoutConfig): void { /* ... */ }
```

- [ ] **Step 4: Update `adiuvAI/src/main/index.ts`**

Replace:
```typescript
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
```
With:
```typescript
import { startScoutScheduler, stopScoutScheduler } from './scouts/scout-scheduler';
```

And the call sites: `startAgentScheduler()` → `startScoutScheduler()`, `stopAgentScheduler()` → `stopScoutScheduler()`.

- [ ] **Step 5: Update `adiuvAI/src/main/router/index.ts` — rename `agent.*` sub-router to `scout.*`**

Find the `agent: t.router({ local: ..., cloud: ..., journey: ..., runs: ..., runActions: ..., catalog: ..., runNow: ..., ... })` block. Rename the key from `agent` to `scout`. Also rename the imported sub-router files if any (in `adiuvAI/src/main/router/`, look for `agent.ts` or `agent/` subdirectory).

- [ ] **Step 6: Sweep type references**

```bash
grep -rn "LocalAgentConfig\|CloudAgentConfig\|trpc\.agent\.\|/api/v1/agents" adiuvAI/src/
```
Replace systematically: `LocalAgentConfig` → `LocalScoutConfig`, `CloudAgentConfig` → `CloudScoutConfig`, `trpc.agent.` → `trpc.scout.`, `/api/v1/agents` → `/api/v1/scouts`.

- [ ] **Step 7: Typecheck + lint**

```bash
cd adiuvAI
npx tsc --noEmit
npm run lint
```

- [ ] **Step 8: Commit**

```bash
cd adiuvAI
git add -A src/main/
git commit -m "refactor(main): rename agent-scheduler/store/router symbols to scout"
```

---

## Task 9: Rename Renderer components

**Files:**
- Rename 5 files in `adiuvAI/src/renderer/components/settings/` (per File Structure table)
- Modify: `adiuvAI/src/renderer/routes/settings.tsx` — update `SECTIONS` array
- Modify: each renamed component — update internal symbols

- [ ] **Step 1: Move the files**

```bash
cd adiuvAI
git mv src/renderer/components/settings/AgentsSection.tsx src/renderer/components/settings/ScoutsSection.tsx
git mv src/renderer/components/settings/AgentRow.tsx src/renderer/components/settings/ScoutRow.tsx
git mv src/renderer/components/settings/LocalAgentConfigPanel.tsx src/renderer/components/settings/LocalScoutConfigPanel.tsx
git mv src/renderer/components/settings/CloudAgentConfigPanel.tsx src/renderer/components/settings/CloudScoutConfigPanel.tsx
git mv src/renderer/components/settings/InlineAgentCreationStepper.tsx src/renderer/components/settings/InlineScoutCreationStepper.tsx
```

- [ ] **Step 2: Inside each renamed file, rename the exported component**

Rule: every `export function AgentX` → `export function ScoutX`, every `Agent` → `Scout` in component bodies, every `agent` → `scout` in local variables, every `'toast.agent.X'` i18n key → `'toast.scout.X'`, every `'agents.X'` → `'scouts.X'`, every `t('settings.agentsX')` → `t('settings.scoutsX')`.

Show example (head of `ScoutsSection.tsx`):
```typescript
export function ScoutsSection() {
  const { t } = useTranslation();
  const utils = trpc.useUtils();
  const localScoutsQuery = trpc.scout.local.list.useQuery();
  const cloudScoutsQuery = trpc.scout.cloud.list.useQuery();
  const deleteLocalMutation = trpc.scout.local.delete.useMutation();
  const deleteCloudMutation = trpc.scout.cloud.delete.useMutation();
  const updateLocalMutation = trpc.scout.local.update.useMutation();
  const updateCloudMutation = trpc.scout.cloud.update.useMutation();
  const runNowMutation = trpc.scout.runNow.useMutation();

  const { notify, notifyError, notifyPromise } = useNotify();
  const [expandedScout, setExpandedScout] = useState<string | null>(null);
  // ...

  const localScouts: LocalScoutConfig[] = localScoutsQuery.data ?? [];
  const cloudScouts: CloudScoutConfig[] = cloudScoutsQuery.data ?? [];
  const allScouts = [
    ...localScouts.map(s => ({ ...s, scoutType: 'local' as const })),
    ...cloudScouts.map(s => ({ ...s, scoutType: 'cloud' as const })),
  ];
  // ...
}
```

- [ ] **Step 3: Update consumers — settings route + any other importer**

In `adiuvAI/src/renderer/routes/settings.tsx`, change:
```typescript
import { AgentsSection } from '@/components/settings/AgentsSection';
// ... in SECTIONS array:
{ id: 'agents', label: 'settings.agents', component: AgentsSection },
```
To:
```typescript
import { ScoutsSection } from '@/components/settings/ScoutsSection';
// ...
{ id: 'scouts', label: 'settings.scouts', component: ScoutsSection },
```

Also sweep:
```bash
cd adiuvAI
grep -rn "AgentsSection\|AgentRow\|LocalAgentConfigPanel\|CloudAgentConfigPanel\|InlineAgentCreationStepper" src/
```
Replace each.

- [ ] **Step 4: Update `adiuvAI/src/renderer/components/settings/types.ts` (if it defines `LocalAgentConfig`)**

Rename the interface `LocalAgentConfig` → `LocalScoutConfig`. Add `export type LocalScoutConfig = ...`. If there are other type definitions referencing "agent" (e.g. `AgentDataType`), rename consistently.

- [ ] **Step 5: Typecheck + lint**

```bash
cd adiuvAI
npx tsc --noEmit
npm run lint
```

- [ ] **Step 6: Commit**

```bash
cd adiuvAI
git add -A src/renderer/
git commit -m "refactor(renderer): rename Agent components and types to Scout"
```

---

## Task 10: Rename i18n keys across all 5 languages

**Files:**
- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json`

**Context:** Three buckets to rename in every language file:
1. `"settings": { "agents": "...", "agentsSubtitle": "...", "agentsDescription": "...", ... }` → `scouts`, `scoutsSubtitle`, `scoutsDescription`
2. Top-level `"agents": { "noAgentsYet": ..., "createAgent": ..., "yourAgents": ..., "createFirstAgent": ..., "noAgentsDescription": ... }` → `"scouts": { "noScoutsYet", "createScout", "yourScouts", "createFirstScout", "noScoutsDescription" }`. Update key names AND any in-value occurrences of "agent"/"agents" to "scout"/"scouts" within human-readable strings (apply translation per language — for English: "Agents" → "Scouts", "agent" → "scout"; for Italian: "Agenti" → "Scout"; etc.).
3. `"toast": { "agent": { ... } }` → `"toast": { "scout": { ... } }` (rename nested key only; values unchanged unless they contain "agent" word).

For non-English languages, keep the meaning consistent. Suggested translations:

| Lang | Key | Suggested value |
|------|-----|-----------------|
| en   | `settings.scouts` | "Scouts" |
| it   | `settings.scouts` | "Scout" |
| es   | `settings.scouts` | "Scouts" |
| fr   | `settings.scouts` | "Scouts" |
| de   | `settings.scouts` | "Scouts" |
| en   | `settings.scoutsSubtitle` | "working for you." |
| it   | `settings.scoutsSubtitle` | "che lavorano per te." |
| en   | `settings.scoutsDescription` | "Scouts watch your data sources — local files, mailboxes, cloud services — and surface what matters in your task brief." |
| it   | `settings.scoutsDescription` | "Gli scout monitorano le tue fonti dati — file locali, caselle email, servizi cloud — e mettono in evidenza ciò che conta nel tuo task brief." |
| en   | `scouts.noScoutsYet` | "No scouts yet" |
| en   | `scouts.createScout` | "Create scout" |
| en   | `scouts.yourScouts` | "Your Scouts" |
| en   | `scouts.createFirstScout` | "Create first scout" |
| en   | `scouts.noScoutsDescription` | "Create your first scout from a template. Choose what data to extract, set a schedule, and edit instructions before saving." |

(Translate analogously for it/es/fr/de — for the new descriptive copy, keep the spirit, ask the user for review on language nuance if uncertain.)

- [ ] **Step 1: Edit `en/translation.json`**

Apply renames per the bucket list above.

- [ ] **Step 2: Edit `it/translation.json`** — same renames, with Italian values

- [ ] **Step 3: Edit `es/translation.json`** — same renames, with Spanish values

- [ ] **Step 4: Edit `fr/translation.json`** — same renames, with French values

- [ ] **Step 5: Edit `de/translation.json`** — same renames, with German values

- [ ] **Step 6: Sweep code references**

```bash
cd adiuvAI
grep -rn "'agents\.\|\"agents\.\|'settings\.agents\|\"settings\.agents\|'toast\.agent\|\"toast\.agent" src/
```
Replace each `agents.X` → `scouts.X`, `settings.agents` → `settings.scouts`, `toast.agent` → `toast.scout`.

- [ ] **Step 7: Typecheck + lint + run app**

```bash
cd adiuvAI
npx tsc --noEmit
npm run lint
npm run start   # smoke: open Settings, switch language, confirm "Scouts" header renders
```

- [ ] **Step 8: Commit**

```bash
cd adiuvAI
git add -A src/renderer/locales/ src/renderer/
git commit -m "i18n: rename agents keys to scouts across all 5 languages"
```

---

## Task 11: Update monorepo to bump submodule pointers

**Files:**
- Modify: monorepo `.gitmodules` is unchanged; update tracked commit pointers via `git add api adiuvAI`

- [ ] **Step 1: From the monorepo root, observe the dirty submodule pointers**

```bash
cd c:\Users\PC-Roby\Documents\_adiuvai_workspace
git status
```
Expected: `api` and `adiuvAI` listed as "modified content" or "new commits".

- [ ] **Step 2: Stage and commit the submodule bumps**

```bash
git add api adiuvAI
git commit -m "chore: bump submodules — Phase 1 scouts rename complete"
```

- [ ] **Step 3: Verify CLAUDE.md doesn't need updates**

Check `c:\Users\PC-Roby\Documents\_adiuvai_workspace\.claude\CLAUDE.md` and `c:\Users\PC-Roby\Documents\_adiuvai_workspace\adiuvAI\.claude\CLAUDE.md` — many references mention `agent` and the agent subsystem. For Phase 1, **only update durable notes that became wrong** (e.g. `tRPC routers (in appRouter): ... agent (with local / cloud / journey sub-routers) ...` should now read `scout`). Use targeted Edit, do not rewrite the file.

```bash
grep -n "agent" .claude/CLAUDE.md adiuvAI/.claude/CLAUDE.md
```
For each line that documents the renamed surface, fix in place.

- [ ] **Step 4: Commit CLAUDE.md updates**

```bash
git add .claude/CLAUDE.md adiuvAI/.claude/CLAUDE.md
git commit -m "docs(CLAUDE): update references to scouts after rename"
```

**Phase 1 complete.** Verify by running both test suites green and starting the app.

---

# PHASE 2 — Connector Skeleton

Goal: lay all infra (BE queue table, connector Protocol, engine, WS frame contract, SQLite suggestions table) without any user-visible Gmail feature yet. End state: backend can be unit-tested with a mock connector; Electron has the table + frame handler ready to receive proposals.

---

## Task 12: Alembic migration — `scout_triage_queue` + `cloud_scout_configs` alters

**Files:**
- Create: `api/alembic/versions/008_scout_triage_queue.py`

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

```python
"""Scout triage queue + cloud_scout_configs alterations.

Revision ID: 008
Revises: 007
Create Date: 2026-05-15
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op


revision: str = "008"
down_revision: Union[str, None] = "007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    op.create_table(
        "scout_triage_queue",
        sa.Column("id", sa.Uuid(as_uuid=False), primary_key=True),
        sa.Column("user_id", sa.Uuid(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
        sa.Column("scout_id", sa.Uuid(as_uuid=False), sa.ForeignKey("cloud_scout_configs.id", ondelete="CASCADE"), nullable=False),
        sa.Column("source_type", sa.String(50), nullable=False),
        sa.Column("source_msg_ref", sa.String(255), nullable=False),
        sa.Column("triage_verdict", sa.String(20), nullable=False),
        sa.Column("triage_reason", sa.Text, nullable=True),
        sa.Column("status", sa.String(20), nullable=False, server_default="queued"),
        sa.Column("triaged_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
        sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("acked_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
        sa.UniqueConstraint("scout_id", "source_msg_ref", name="uq_scout_triage_queue_scout_msg"),
    )
    op.create_index("ix_scout_triage_queue_user_status", "scout_triage_queue", ["user_id", "status"])
    op.create_index(
        "ix_scout_triage_queue_expires_active",
        "scout_triage_queue",
        ["expires_at"],
        postgresql_where=sa.text("status != 'acked'"),
    )

    op.add_column("cloud_scout_configs", sa.Column("auto_trash_spam", sa.Boolean(), nullable=False, server_default=sa.text("false")))
    op.add_column("cloud_scout_configs", sa.Column("gmail_history_id", sa.String(64), nullable=True))
    op.add_column("cloud_scout_configs", sa.Column("gmail_watch_expires_at", sa.DateTime(timezone=True), nullable=True))
    op.add_column("cloud_scout_configs", sa.Column("device_inactivity_pause_days", sa.Integer(), nullable=False, server_default="14"))


def downgrade() -> None:
    op.drop_column("cloud_scout_configs", "device_inactivity_pause_days")
    op.drop_column("cloud_scout_configs", "gmail_watch_expires_at")
    op.drop_column("cloud_scout_configs", "gmail_history_id")
    op.drop_column("cloud_scout_configs", "auto_trash_spam")

    op.drop_index("ix_scout_triage_queue_expires_active", table_name="scout_triage_queue")
    op.drop_index("ix_scout_triage_queue_user_status", table_name="scout_triage_queue")
    op.drop_table("scout_triage_queue")
```

- [ ] **Step 2: Apply + verify**

```bash
cd api
alembic upgrade head
```
Verify with `\d scout_triage_queue` and `\d cloud_scout_configs` (the latter should now show the four new columns).

- [ ] **Step 3: Commit**

```bash
cd api
git add alembic/versions/008_scout_triage_queue.py
git commit -m "feat(db): add scout_triage_queue and cloud_scout_configs gmail columns"
```

---

## Task 13: SQLAlchemy `ScoutTriageQueue` model + `CloudScoutConfig` field additions

**Files:**
- Modify: `api/app/models.py` — add `ScoutTriageQueue` class, add four new fields to `CloudScoutConfig`

- [ ] **Step 1: Add fields to `CloudScoutConfig` (in `models.py` after the existing fields)**

```python
auto_trash_spam: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sa.text("false"))
gmail_history_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
gmail_watch_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
device_inactivity_pause_days: Mapped[int] = mapped_column(Integer, nullable=False, default=14, server_default="14")
```

- [ ] **Step 2: Add the `ScoutTriageQueue` class (after `CloudScoutConfig`)**

```python
class ScoutTriageQueue(Base):
    __tablename__ = "scout_triage_queue"
    __table_args__ = (
        UniqueConstraint("scout_id", "source_msg_ref", name="uq_scout_triage_queue_scout_msg"),
    )

    id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True, default=_uuid)
    user_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
    scout_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), ForeignKey("cloud_scout_configs.id", ondelete="CASCADE"), nullable=False)
    source_type: Mapped[str] = mapped_column(String(50), nullable=False)
    source_msg_ref: Mapped[str] = mapped_column(String(255), nullable=False)
    triage_verdict: Mapped[str] = mapped_column(String(20), nullable=False)
    triage_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", server_default="queued")
    triaged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
    delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    acked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
```

- [ ] **Step 3: Verify imports at the top of `models.py` include `UniqueConstraint` and `Integer`**

If missing, add `from sqlalchemy import ..., UniqueConstraint, Integer`.

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

```bash
cd api
pytest
```
Expected: green (existing tests don't touch the new model yet, but `Base.metadata.create_all` should now succeed creating the new table in the test DB).

- [ ] **Step 5: Commit**

```bash
cd api
git add app/models.py
git commit -m "feat(models): add ScoutTriageQueue model + cloud_scout gmail fields"
```

---

## Task 14: `SourceConnector` Protocol + Pydantic types

**Files:**
- Create: `api/app/scouts/__init__.py` (empty)
- Create: `api/app/scouts/connectors/__init__.py` (empty)
- Create: `api/app/scouts/connectors/base.py`
- Create: `api/tests/test_scout_connectors_base.py`

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

`api/tests/test_scout_connectors_base.py`:
```python
"""Tests for the SourceConnector base protocol and shared types."""

from __future__ import annotations

from datetime import datetime, timezone

import pytest

from app.scouts.connectors.base import (
    ItemContent,
    ItemMetadata,
    ItemRef,
    TriageVerdict,
)


def test_item_ref_round_trips_through_pydantic():
    ref = ItemRef(source_msg_ref="abc123", received_at=datetime.now(tz=timezone.utc))
    parsed = ItemRef.model_validate(ref.model_dump())
    assert parsed.source_msg_ref == "abc123"
    assert parsed.received_at == ref.received_at


def test_item_metadata_allows_all_optional():
    meta = ItemMetadata()
    assert meta.subject is None
    assert meta.sender is None
    assert meta.snippet is None
    assert meta.received_at is None


def test_item_content_requires_metadata_and_body():
    content = ItemContent(
        metadata=ItemMetadata(subject="hi"),
        body_text="hello world",
        raw_headers={"X-Foo": "bar"},
    )
    assert content.metadata.subject == "hi"
    assert content.body_text == "hello world"
    assert content.raw_headers["X-Foo"] == "bar"


def test_triage_verdict_constraints():
    v = TriageVerdict(verdict="relevant", reason="contains task language", confidence=0.92)
    assert v.verdict == "relevant"

    with pytest.raises(ValueError):
        TriageVerdict(verdict="meh", reason="x", confidence=0.5)  # bad enum value
```

- [ ] **Step 2: Run the test to verify it fails**

```bash
cd api
pytest tests/test_scout_connectors_base.py -v
```
Expected: ImportError (`No module named 'app.scouts.connectors.base'`).

- [ ] **Step 3: Implement `app/scouts/connectors/base.py`**

```python
"""Source connector Protocol and shared item types.

A SourceConnector adapts a third-party data source (Gmail, Slack, ...) to the
shared ScoutEngine interface. Each connector owns:

  * how to enumerate new items since the last poll (``list_new``)
  * how to fetch a single item's metadata cheaply (``fetch_metadata``)
  * how to fetch a single item's full content for in-memory triage
    (``fetch_content``) — this content MUST NOT be persisted by the engine
  * how to archive/trash an item (``archive``) for spam handling
  * optional push-notification setup (``setup_watch`` / ``renew_watch``)
"""

from __future__ import annotations

from datetime import datetime
from typing import Literal, Protocol

from pydantic import BaseModel, Field


class ItemRef(BaseModel):
    source_msg_ref: str
    received_at: datetime | None = None


class ItemMetadata(BaseModel):
    subject: str | None = None
    sender: str | None = None
    snippet: str | None = None
    received_at: datetime | None = None


class ItemContent(BaseModel):
    metadata: ItemMetadata
    body_text: str
    raw_headers: dict[str, str] = Field(default_factory=dict)


class TriageVerdict(BaseModel):
    verdict: Literal["relevant", "spam"]
    reason: str
    confidence: float = Field(ge=0.0, le=1.0)


class SourceConnector(Protocol):
    """Adapter for a third-party data source (Gmail, Slack, ...)."""

    source_type: str  # e.g. "gmail"

    async def list_new(self, scout) -> list[ItemRef]: ...
    async def fetch_metadata(self, scout, ref: ItemRef) -> ItemMetadata: ...
    async def fetch_content(self, scout, ref: ItemRef) -> ItemContent: ...
    async def archive(self, scout, ref: ItemRef) -> None: ...
    async def setup_watch(self, scout) -> None: ...
    async def renew_watch(self, scout) -> None: ...
```

Also create empty package markers:
```bash
cd api
touch app/scouts/__init__.py app/scouts/connectors/__init__.py
```

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

```bash
cd api
pytest tests/test_scout_connectors_base.py -v
```
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
cd api
git add app/scouts/ tests/test_scout_connectors_base.py
git commit -m "feat(scouts): add SourceConnector protocol and item types"
```

---

## Task 15: Connector registry

**Files:**
- Create: `api/app/scouts/connectors/registry.py`
- Create: `api/tests/test_scout_connector_registry.py`

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

```python
"""Tests for the connector registry."""

from __future__ import annotations

import pytest

from app.scouts.connectors.base import ItemRef
from app.scouts.connectors.registry import (
    get_connector,
    register_connector,
    _reset_for_tests,
)


class _DummyConnector:
    source_type = "dummy"
    async def list_new(self, scout): return []
    async def fetch_metadata(self, scout, ref): raise NotImplementedError
    async def fetch_content(self, scout, ref): raise NotImplementedError
    async def archive(self, scout, ref): raise NotImplementedError
    async def setup_watch(self, scout): raise NotImplementedError
    async def renew_watch(self, scout): raise NotImplementedError


@pytest.fixture(autouse=True)
def _clean_registry():
    _reset_for_tests()
    yield
    _reset_for_tests()


def test_register_and_get():
    c = _DummyConnector()
    register_connector(c)
    assert get_connector("dummy") is c


def test_unknown_source_raises():
    with pytest.raises(KeyError):
        get_connector("nope")


def test_double_register_replaces():
    a = _DummyConnector()
    b = _DummyConnector()
    register_connector(a)
    register_connector(b)
    assert get_connector("dummy") is b
```

- [ ] **Step 2: Run test (should fail with ImportError)**

```bash
cd api
pytest tests/test_scout_connector_registry.py -v
```

- [ ] **Step 3: Implement `app/scouts/connectors/registry.py`**

```python
"""Connector registry — single source of truth for source_type -> connector."""

from __future__ import annotations

from typing import Any

_CONNECTORS: dict[str, Any] = {}


def register_connector(connector: Any) -> None:
    """Register a SourceConnector instance under its ``source_type``.

    Calling twice with the same ``source_type`` replaces the prior entry —
    useful for tests and hot-reload, but in production each connector
    should be registered exactly once at startup.
    """
    if not getattr(connector, "source_type", None):
        raise ValueError("Connector must declare a non-empty source_type")
    _CONNECTORS[connector.source_type] = connector


def get_connector(source_type: str) -> Any:
    """Return the registered connector for ``source_type`` or raise KeyError."""
    try:
        return _CONNECTORS[source_type]
    except KeyError as exc:
        raise KeyError(f"No connector registered for source_type {source_type!r}") from exc


def _reset_for_tests() -> None:
    """Clear the registry — for use in pytest fixtures only."""
    _CONNECTORS.clear()
```

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

```bash
cd api
pytest tests/test_scout_connector_registry.py -v
```
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
cd api
git add app/scouts/connectors/registry.py tests/test_scout_connector_registry.py
git commit -m "feat(scouts): add connector registry"
```

---

## Task 16: `ScoutEngine` skeleton — `trigger_scout` + `_process_item`

**Files:**
- Create: `api/app/scouts/engine.py`
- Create: `api/tests/test_scout_engine.py`

**Context:** This task implements the core engine logic but uses a stub triage call. The real LLM triage comes in Task 24. The engine writes `scout_triage_queue` rows for relevant items and calls `connector.archive` for spam items when `scout.auto_trash_spam` is true.

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

```python
"""Unit tests for ScoutEngine.trigger_scout / _process_item."""

from __future__ import annotations

import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock

import pytest
from sqlalchemy import select

from app.models import CloudScoutConfig, ScoutTriageQueue, User, Subscription
from app.scouts.connectors.base import ItemContent, ItemMetadata, ItemRef, TriageVerdict
from app.scouts.connectors.registry import register_connector, _reset_for_tests
from app.scouts.engine import ScoutEngine
from tests.conftest import _TestSessionLocal


def _make_connector(items, content_for):
    c = AsyncMock()
    c.source_type = "dummy"
    c.list_new = AsyncMock(return_value=items)
    c.fetch_content = AsyncMock(side_effect=lambda scout, ref: content_for[ref.source_msg_ref])
    c.archive = AsyncMock()
    return c


@pytest.fixture(autouse=True)
def _registry():
    _reset_for_tests()
    yield
    _reset_for_tests()


@pytest.mark.asyncio
async def test_relevant_item_inserted_into_queue(monkeypatch):
    user_id = "00000000-0000-0000-0000-000000000003"  # power tier seeded in conftest
    scout_id = str(uuid.uuid4())

    async with _TestSessionLocal() as session:
        scout = CloudScoutConfig(
            id=scout_id, user_id=user_id, provider="gmail", name="Test",
            data_types=[], prompt_template="", schedule_cron="0 * * * *",
            enabled=True, auto_trash_spam=False, device_inactivity_pause_days=14,
        )
        session.add(scout)
        await session.commit()

    refs = [ItemRef(source_msg_ref="msg-1")]
    content = {"msg-1": ItemContent(metadata=ItemMetadata(subject="Hi"), body_text="task tomorrow")}
    connector = _make_connector(refs, content)
    register_connector(connector)

    engine = ScoutEngine()
    monkeypatch.setattr(
        engine,
        "_triage_llm",
        AsyncMock(return_value=TriageVerdict(verdict="relevant", reason="task", confidence=0.9)),
    )

    await engine.trigger_scout(uuid.UUID(scout_id))

    async with _TestSessionLocal() as session:
        rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
    assert len(rows) == 1
    assert rows[0].source_msg_ref == "msg-1"
    assert rows[0].triage_verdict == "relevant"
    assert rows[0].status == "queued"
    connector.archive.assert_not_awaited()


@pytest.mark.asyncio
async def test_spam_with_auto_trash_archives_and_does_not_queue(monkeypatch):
    user_id = "00000000-0000-0000-0000-000000000003"
    scout_id = str(uuid.uuid4())

    async with _TestSessionLocal() as session:
        scout = CloudScoutConfig(
            id=scout_id, user_id=user_id, provider="gmail", name="Test",
            data_types=[], prompt_template="", schedule_cron="0 * * * *",
            enabled=True, auto_trash_spam=True, device_inactivity_pause_days=14,
        )
        session.add(scout)
        await session.commit()

    refs = [ItemRef(source_msg_ref="msg-spam")]
    content = {"msg-spam": ItemContent(metadata=ItemMetadata(subject="$$$"), body_text="buy")}
    connector = _make_connector(refs, content)
    register_connector(connector)

    engine = ScoutEngine()
    monkeypatch.setattr(
        engine,
        "_triage_llm",
        AsyncMock(return_value=TriageVerdict(verdict="spam", reason="bait", confidence=0.99)),
    )

    await engine.trigger_scout(uuid.UUID(scout_id))

    async with _TestSessionLocal() as session:
        rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
    assert rows == []
    connector.archive.assert_awaited_once()


@pytest.mark.asyncio
async def test_spam_without_auto_trash_does_not_archive_and_does_not_queue(monkeypatch):
    user_id = "00000000-0000-0000-0000-000000000003"
    scout_id = str(uuid.uuid4())

    async with _TestSessionLocal() as session:
        scout = CloudScoutConfig(
            id=scout_id, user_id=user_id, provider="gmail", name="Test",
            data_types=[], prompt_template="", schedule_cron="0 * * * *",
            enabled=True, auto_trash_spam=False, device_inactivity_pause_days=14,
        )
        session.add(scout)
        await session.commit()

    refs = [ItemRef(source_msg_ref="msg-2")]
    content = {"msg-2": ItemContent(metadata=ItemMetadata(subject="$$$"), body_text="buy")}
    connector = _make_connector(refs, content)
    register_connector(connector)

    engine = ScoutEngine()
    monkeypatch.setattr(
        engine,
        "_triage_llm",
        AsyncMock(return_value=TriageVerdict(verdict="spam", reason="bait", confidence=0.99)),
    )

    await engine.trigger_scout(uuid.UUID(scout_id))

    async with _TestSessionLocal() as session:
        rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
    assert rows == []
    connector.archive.assert_not_awaited()


@pytest.mark.asyncio
async def test_idempotent_replay(monkeypatch):
    user_id = "00000000-0000-0000-0000-000000000003"
    scout_id = str(uuid.uuid4())

    async with _TestSessionLocal() as session:
        session.add(CloudScoutConfig(
            id=scout_id, user_id=user_id, provider="gmail", name="Test",
            data_types=[], prompt_template="", schedule_cron="0 * * * *",
            enabled=True, auto_trash_spam=False, device_inactivity_pause_days=14,
        ))
        await session.commit()

    refs = [ItemRef(source_msg_ref="msg-3")]
    content = {"msg-3": ItemContent(metadata=ItemMetadata(subject="x"), body_text="y")}
    connector = _make_connector(refs, content)
    register_connector(connector)

    engine = ScoutEngine()
    monkeypatch.setattr(
        engine,
        "_triage_llm",
        AsyncMock(return_value=TriageVerdict(verdict="relevant", reason="x", confidence=0.5)),
    )

    await engine.trigger_scout(uuid.UUID(scout_id))
    await engine.trigger_scout(uuid.UUID(scout_id))

    async with _TestSessionLocal() as session:
        rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
    assert len(rows) == 1, "Replay must not create duplicate queue rows"
```

- [ ] **Step 2: Run test (should fail — module missing)**

```bash
cd api
pytest tests/test_scout_engine.py -v
```

- [ ] **Step 3: Implement `app/scouts/engine.py`**

```python
"""ScoutEngine — orchestrates triage, queueing, and delivery for cloud scouts.

Triage flow per scout:
  1. Resolve scout config from the DB.
  2. Skip if device hasn't connected within ``device_inactivity_pause_days``.
  3. Ask the connector to ``list_new`` — fresh items since last poll.
  4. For each item:
     - skip if already in the queue (idempotent on (scout_id, source_msg_ref))
     - fetch the full content via the connector (transient, never persisted)
     - run the triage LLM call → relevant | spam
     - spam + auto_trash_spam → connector.archive
     - relevant → INSERT scout_triage_queue row
  5. Update scout.last_run_at.

Delivery flow on Electron WS reconnect:
  - drain ``status='queued'`` rows for the user
  - fetch metadata-only for each (subject + snippet)
  - send a ``scout_proposal`` frame
  - flip status to ``delivered`` on ack
"""

from __future__ import annotations

import logging
import uuid
from datetime import datetime, timedelta, timezone

from sqlalchemy import select
from sqlalchemy.exc import IntegrityError

from app.db.session import async_session_factory
from app.models import CloudScoutConfig, ScoutTriageQueue
from app.scouts.connectors.base import ItemContent, ItemRef, TriageVerdict
from app.scouts.connectors.registry import get_connector

logger = logging.getLogger(__name__)

QUEUE_TTL_DAYS = 30


class ScoutEngine:
    async def trigger_scout(self, scout_id: uuid.UUID) -> None:
        async with async_session_factory() as session:
            scout = await session.get(CloudScoutConfig, str(scout_id))
            if scout is None:
                logger.warning("trigger_scout: no such scout id=%s", scout_id)
                return
            if not scout.enabled:
                return
            # Device-inactivity pause check is a simple heuristic on last_run_at —
            # the device-online signal lives in the DeviceConnectionManager and is
            # consulted at delivery time. For triage, we only check that the
            # configured pause threshold isn't suppressing the run.
            connector = get_connector(scout.provider)
            try:
                refs = await connector.list_new(scout)
            except Exception:
                logger.exception("scout %s: list_new failed", scout.id)
                return

            for ref in refs:
                await self._process_item(session, scout, connector, ref)

            scout.last_run_at = datetime.now(tz=timezone.utc)
            await session.commit()

    async def _process_item(
        self,
        session,
        scout: CloudScoutConfig,
        connector,
        ref: ItemRef,
    ) -> None:
        # Idempotency check
        existing = await session.execute(
            select(ScoutTriageQueue.id).where(
                ScoutTriageQueue.scout_id == scout.id,
                ScoutTriageQueue.source_msg_ref == ref.source_msg_ref,
            )
        )
        if existing.first() is not None:
            return

        try:
            content = await connector.fetch_content(scout, ref)
        except Exception:
            logger.exception("scout %s: fetch_content failed for %s", scout.id, ref.source_msg_ref)
            return

        try:
            verdict = await self._triage_llm(scout, content)
        except Exception:
            logger.exception("scout %s: triage_llm failed for %s", scout.id, ref.source_msg_ref)
            return

        if verdict.verdict == "spam":
            if scout.auto_trash_spam:
                try:
                    await connector.archive(scout, ref)
                except Exception:
                    logger.exception("scout %s: archive failed for %s", scout.id, ref.source_msg_ref)
            return

        now = datetime.now(tz=timezone.utc)
        row = ScoutTriageQueue(
            id=str(uuid.uuid4()),
            user_id=scout.user_id,
            scout_id=scout.id,
            source_type=connector.source_type,
            source_msg_ref=ref.source_msg_ref,
            triage_verdict=verdict.verdict,
            triage_reason=verdict.reason,
            status="queued",
            triaged_at=now,
            expires_at=now + timedelta(days=QUEUE_TTL_DAYS),
        )
        session.add(row)
        try:
            await session.flush()
        except IntegrityError:
            await session.rollback()
            # Race: another worker inserted between our SELECT and INSERT.
            # The unique constraint did its job; safe to ignore.
            return

    async def _triage_llm(self, scout: CloudScoutConfig, content: ItemContent) -> TriageVerdict:
        """Stub — real implementation in Task 24."""
        raise NotImplementedError("Real triage LLM call lands in Task 24")
```

Note: the import `from app.db.session import async_session_factory` assumes there's an existing session factory module. If the project uses a different name (e.g. `from app.db import SessionLocal`), find it via:
```bash
grep -rn "AsyncSession\|sessionmaker" api/app/db/
```
and adjust the import. This is a common case across this codebase — the engine just needs an async session.

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

```bash
cd api
pytest tests/test_scout_engine.py -v
```
Expected: 4 PASS.

- [ ] **Step 5: Commit**

```bash
cd api
git add app/scouts/engine.py tests/test_scout_engine.py
git commit -m "feat(scouts): add ScoutEngine triage + queue insertion"
```

---

## Task 17: Drizzle migration — `scout_suggestions` table

**Files:**
- Modify: `adiuvAI/src/main/db/schema.ts` — add `scoutSuggestions` table
- Create: `adiuvAI/src/main/db/migrations/0008_scout_suggestions.sql` (auto-generated, then renamed)

- [ ] **Step 1: Add the table to `schema.ts`**

Append at the end of the existing tables:
```typescript
export const scoutSuggestions = sqliteTable('scout_suggestions', {
  id: text().primaryKey(),
  scoutId: text('scout_id').notNull(),
  sourceType: text('source_type').notNull(),
  sourceMsgRef: text('source_msg_ref').notNull(),
  category: text().notNull(),                  // "unprocessed" until Phase 4
  payload: text(),                             // JSON, populated by Phase 4
  rawSubject: text('raw_subject'),
  rawSnippet: text('raw_snippet'),
  status: text().notNull(),                    // pending | approved | rejected | expired
  proposedAt: integer('proposed_at').notNull(),
  resolvedAt: integer('resolved_at'),
  resolvedEntityType: text('resolved_entity_type'),
  resolvedEntityId: text('resolved_entity_id'),
});
```

- [ ] **Step 2: Generate the migration**

```bash
cd adiuvAI
npx drizzle-kit generate
```
Rename to `0008_scout_suggestions.sql`:
```bash
mv src/main/db/migrations/0008_*.sql src/main/db/migrations/0008_scout_suggestions.sql
```

- [ ] **Step 3: Inspect the generated SQL**

Verify it contains a `CREATE TABLE scout_suggestions` and matches the schema columns.

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

```bash
cd adiuvAI
npm run start
```
After app boots, close. Verify:
```bash
sqlite3 dev.db ".schema scout_suggestions"
```

- [ ] **Step 5: Commit**

```bash
cd adiuvAI
git add src/main/db/schema.ts src/main/db/migrations/0008_scout_suggestions.sql
git commit -m "feat(db): add scout_suggestions table"
```

---

## Task 18: WS frame schemas — `scout_proposal` + `scout_proposal_ack`

**Files:**
- Modify: `api/app/schemas.py` — add new `WsFrameType` enum values + Pydantic models for the two frames

- [ ] **Step 1: Read `api/app/schemas.py` to find `WsFrameType` enum**

```bash
cd api
grep -n "WsFrameType\|class Ws" app/schemas.py
```

- [ ] **Step 2: Add new enum values**

```python
class WsFrameType(str, Enum):
    # ... existing values ...
    SCOUT_PROPOSAL = "scout_proposal"
    SCOUT_PROPOSAL_ACK = "scout_proposal_ack"
```

- [ ] **Step 3: Add the frame Pydantic models**

```python
class ScoutProposalPayload(BaseModel):
    id: str
    scout_id: str
    source_type: str
    source_msg_ref: str
    raw_subject: str | None = None
    raw_snippet: str | None = None
    category: Literal["unprocessed"] = "unprocessed"   # Phase 4 will widen
    payload: dict | None = None


class ScoutProposalFrame(BaseModel):
    type: Literal[WsFrameType.SCOUT_PROPOSAL]
    proposal: ScoutProposalPayload


class ScoutProposalAckFrame(BaseModel):
    type: Literal[WsFrameType.SCOUT_PROPOSAL_ACK]
    proposal_id: str
```

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

```bash
cd api
pytest tests/test_schemas.py -v   # if exists
pytest
```

- [ ] **Step 5: Commit**

```bash
cd api
git add app/schemas.py
git commit -m "feat(schemas): add scout_proposal + scout_proposal_ack WS frame types"
```

---

## Task 19: `ScoutEngine.deliver_pending` — drain queue + send WS frames

**Files:**
- Modify: `api/app/scouts/engine.py` — add `deliver_pending` method
- Modify: `api/app/api/routes/device_ws.py` — call `deliver_pending` on connect; handle `scout_proposal_ack` incoming frames
- Modify: `api/tests/test_scout_engine.py` — add `deliver_pending` tests

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

Append to `api/tests/test_scout_engine.py`:
```python
@pytest.mark.asyncio
async def test_deliver_pending_sends_one_frame_per_queued_row(monkeypatch):
    user_id = "00000000-0000-0000-0000-000000000003"
    scout_id = str(uuid.uuid4())
    now = datetime.now(tz=timezone.utc)

    async with _TestSessionLocal() as session:
        session.add(CloudScoutConfig(
            id=scout_id, user_id=user_id, provider="gmail", name="Test",
            data_types=[], prompt_template="", schedule_cron="0 * * * *",
            enabled=True, auto_trash_spam=False, device_inactivity_pause_days=14,
        ))
        for i in range(3):
            session.add(ScoutTriageQueue(
                id=str(uuid.uuid4()), user_id=user_id, scout_id=scout_id,
                source_type="gmail", source_msg_ref=f"msg-{i}",
                triage_verdict="relevant", status="queued",
                triaged_at=now, expires_at=now + timedelta(days=30),
            ))
        await session.commit()

    connector = AsyncMock()
    connector.source_type = "gmail"
    connector.fetch_metadata = AsyncMock(side_effect=lambda scout, ref: ItemMetadata(
        subject=f"sub-{ref.source_msg_ref}", snippet=f"snip-{ref.source_msg_ref}",
    ))
    register_connector(connector)

    sent = []
    ws = AsyncMock()
    ws.send_json = AsyncMock(side_effect=lambda payload: sent.append(payload))

    engine = ScoutEngine()
    await engine.deliver_pending(uuid.UUID(user_id), ws)

    assert len(sent) == 3
    assert all(s["type"] == "scout_proposal" for s in sent)
    subjects = {s["proposal"]["raw_subject"] for s in sent}
    assert subjects == {"sub-msg-0", "sub-msg-1", "sub-msg-2"}
    # Status should flip to delivered
    async with _TestSessionLocal() as session:
        rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
        assert all(r.status == "delivered" for r in rows)
        assert all(r.delivered_at is not None for r in rows)
```

- [ ] **Step 2: Run test (should fail — `deliver_pending` not defined)**

```bash
cd api
pytest tests/test_scout_engine.py::test_deliver_pending_sends_one_frame_per_queued_row -v
```

- [ ] **Step 3: Implement `deliver_pending` in `app/scouts/engine.py`**

Add to the `ScoutEngine` class:
```python
async def deliver_pending(self, user_id: uuid.UUID, ws) -> None:
    """Drain queued triage rows for a user, send one scout_proposal frame each.

    Frames are sent immediately and the rows flip to ``status='delivered'``.
    Final ``acked`` flip happens in the WS endpoint when the client returns
    a ``scout_proposal_ack`` frame.
    """
    from app.scouts.connectors.base import ItemRef

    async with async_session_factory() as session:
        rows = (await session.execute(
            select(ScoutTriageQueue).where(
                ScoutTriageQueue.user_id == str(user_id),
                ScoutTriageQueue.status == "queued",
            )
        )).scalars().all()

        for row in rows:
            try:
                connector = get_connector(row.source_type)
            except KeyError:
                logger.warning("deliver_pending: no connector for %s", row.source_type)
                continue
            scout = await session.get(CloudScoutConfig, row.scout_id)
            if scout is None:
                continue
            try:
                meta = await connector.fetch_metadata(scout, ItemRef(source_msg_ref=row.source_msg_ref))
            except Exception:
                logger.exception("deliver_pending: fetch_metadata failed")
                continue

            payload = {
                "type": "scout_proposal",
                "proposal": {
                    "id": row.id,
                    "scout_id": row.scout_id,
                    "source_type": row.source_type,
                    "source_msg_ref": row.source_msg_ref,
                    "raw_subject": meta.subject,
                    "raw_snippet": meta.snippet,
                    "category": "unprocessed",
                    "payload": None,
                },
            }
            await ws.send_json(payload)
            row.status = "delivered"
            row.delivered_at = datetime.now(tz=timezone.utc)

        await session.commit()


async def ack_proposal(self, proposal_id: str) -> None:
    """Mark a proposal as acked once the Electron client confirms receipt."""
    async with async_session_factory() as session:
        row = await session.get(ScoutTriageQueue, proposal_id)
        if row is None:
            return
        row.status = "acked"
        row.acked_at = datetime.now(tz=timezone.utc)
        await session.commit()
```

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

```bash
cd api
pytest tests/test_scout_engine.py -v
```
Expected: all 5 tests PASS.

- [ ] **Step 5: Wire into `app/api/routes/device_ws.py`**

Find the WS handshake / post-connect section. Add a call to `ScoutEngine().deliver_pending(user.id, websocket)` after the `device_hello` is received and the connection is registered.

Also add a new dispatch case for incoming `scout_proposal_ack` frames:
```python
elif msg.get("type") == "scout_proposal_ack":
    proposal_id = msg.get("proposal_id")
    if proposal_id:
        await ScoutEngine().ack_proposal(proposal_id)
```

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

```bash
cd api
pytest tests/test_ws_unified.py -v
pytest
```

- [ ] **Step 7: Commit**

```bash
cd api
git add app/scouts/engine.py tests/test_scout_engine.py app/api/routes/device_ws.py
git commit -m "feat(scouts): deliver_pending drains queue and sends scout_proposal frames"
```

---

## Task 20: Electron — `scout-suggestion-handler.ts` (insert into local table on incoming frame)

**Files:**
- Create: `adiuvAI/src/main/scouts/scout-suggestion-handler.ts`
- Modify: `adiuvAI/src/main/api/backend-client.ts` — dispatch incoming `scout_proposal` frame to the handler, send `scout_proposal_ack` back

- [ ] **Step 1: Read `adiuvAI/src/main/api/backend-client.ts` to find the inbound frame switch**

```bash
cd adiuvAI
grep -n "tool_call\|run_complete\|switch.*type" src/main/api/backend-client.ts
```
Identify the existing dispatch pattern.

- [ ] **Step 2: Create the handler module**

`adiuvAI/src/main/scouts/scout-suggestion-handler.ts`:
```typescript
import { randomUUID } from 'node:crypto';
import { getDb } from '../db';
import { scoutSuggestions } from '../db/schema';

export interface IncomingScoutProposal {
  id: string;
  scout_id: string;
  source_type: string;
  source_msg_ref: string;
  raw_subject: string | null;
  raw_snippet: string | null;
  category: 'unprocessed';
  payload: Record<string, unknown> | null;
}

export async function handleScoutProposal(p: IncomingScoutProposal): Promise<void> {
  // Idempotent: same id from BE shouldn't double-insert if the ack was lost.
  await getDb()
    .insert(scoutSuggestions)
    .values({
      id: p.id,
      scoutId: p.scout_id,
      sourceType: p.source_type,
      sourceMsgRef: p.source_msg_ref,
      category: p.category,
      payload: p.payload ? JSON.stringify(p.payload) : null,
      rawSubject: p.raw_subject,
      rawSnippet: p.raw_snippet,
      status: 'pending',
      proposedAt: Date.now(),
    })
    .onConflictDoNothing();
}
```

- [ ] **Step 3: Wire in `backend-client.ts`**

In the inbound frame dispatch:
```typescript
case 'scout_proposal': {
  const { handleScoutProposal } = await import('../scouts/scout-suggestion-handler');
  await handleScoutProposal(msg.proposal);
  this.send({ type: 'scout_proposal_ack', proposal_id: msg.proposal.id });
  break;
}
```

(Adapt to the existing dispatch style — if it uses a switch, add a case; if it uses an if-chain, add an else-if. The send pattern follows whatever existing frames do.)

- [ ] **Step 4: Typecheck + lint**

```bash
cd adiuvAI
npx tsc --noEmit
npm run lint
```

- [ ] **Step 5: Commit**

```bash
cd adiuvAI
git add src/main/scouts/scout-suggestion-handler.ts src/main/api/backend-client.ts
git commit -m "feat(scouts): handle scout_proposal frames and ack"
```

---

## Task 21: Submodule pointer bump for Phase 2

- [ ] **Step 1: Bump submodule pointers**

```bash
cd c:\Users\PC-Roby\Documents\_adiuvai_workspace
git add api adiuvAI
git commit -m "chore: bump submodules — Phase 2 connector skeleton"
```

**Phase 2 complete.** Engine + queue + frame contract + local table all in place. No user-facing change yet.

---

# PHASE 3 — Gmail Scout End-to-End

Goal: ship a working Gmail scout — user can connect Gmail through Settings, push notifications + cron-fallback poll the inbox, BE triages with the real LLM, queue rows are delivered to Electron on reconnect and land as `scout_suggestions` rows with `category='unprocessed'`.

---

## Task 22: `GmailConnector` implementation

**Files:**
- Create: `api/app/scouts/connectors/gmail.py`
- Create: `api/tests/test_scout_connectors_gmail.py`

**Context:** Wraps existing `app/integrations/gmail.py`'s `GmailClient`. Maps existing `EmailMessage` to the new `ItemContent`. Implements `list_new` using Gmail's `users.history.list` since the scout's `gmail_history_id`. Implements `archive` via `users.messages.trash`.

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

`api/tests/test_scout_connectors_gmail.py`:
```python
"""Tests for GmailConnector."""

from __future__ import annotations

import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app.models import CloudScoutConfig
from app.scouts.connectors.base import ItemRef
from app.scouts.connectors.gmail import GmailConnector


def _make_scout():
    return CloudScoutConfig(
        id=str(uuid.uuid4()),
        user_id="00000000-0000-0000-0000-000000000003",
        provider="gmail",
        name="Inbox",
        data_types=[],
        prompt_template="",
        oauth_token_encrypted="encrypted-blob",
        schedule_cron="0 * * * *",
        enabled=True,
        auto_trash_spam=False,
        device_inactivity_pause_days=14,
        gmail_history_id="100",
    )


@pytest.mark.asyncio
async def test_fetch_metadata_returns_subject_and_snippet():
    scout = _make_scout()
    conn = GmailConnector()
    fake_message = {
        "id": "msg-1",
        "snippet": "preview text",
        "payload": {"headers": [
            {"name": "Subject", "value": "Hello"},
            {"name": "From", "value": "alice@example.com"},
            {"name": "Date", "value": "Wed, 14 May 2026 10:00:00 +0000"},
        ]},
    }
    with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc:
        mock_svc.return_value.users().messages().get().execute.return_value = fake_message
        meta = await conn.fetch_metadata(scout, ItemRef(source_msg_ref="msg-1"))
    assert meta.subject == "Hello"
    assert meta.sender == "alice@example.com"
    assert meta.snippet == "preview text"


@pytest.mark.asyncio
async def test_fetch_content_returns_body_text():
    scout = _make_scout()
    conn = GmailConnector()
    with patch("app.scouts.connectors.gmail.GmailClient") as MockClient:
        instance = MockClient.return_value
        instance.fetch_messages = AsyncMock(return_value=[
            MagicMock(id="msg-1", subject="S", sender="a@b", body_text="hello world",
                     date=datetime.now(tz=timezone.utc), labels=[]),
        ])
        content = await conn.fetch_content(scout, ItemRef(source_msg_ref="msg-1"))
    assert content.body_text == "hello world"
    assert content.metadata.subject == "S"


@pytest.mark.asyncio
async def test_archive_calls_trash():
    scout = _make_scout()
    conn = GmailConnector()
    with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc:
        await conn.archive(scout, ItemRef(source_msg_ref="msg-1"))
        mock_svc.return_value.users().messages().trash.assert_called()
```

- [ ] **Step 2: Run test (fails — module missing)**

```bash
cd api
pytest tests/test_scout_connectors_gmail.py -v
```

- [ ] **Step 3: Implement `app/scouts/connectors/gmail.py`**

```python
"""Gmail SourceConnector — wraps the existing GmailClient.

Responsibilities:
  * list_new: incremental fetch since the scout's stored gmail_history_id
  * fetch_metadata: subject + sender + snippet only (Gmail metadata format)
  * fetch_content: full body text — transient, never persisted by engine
  * archive: move a message to Gmail Trash (recoverable for 30 days)
  * setup_watch / renew_watch: Gmail push notifications via Pub/Sub
"""

from __future__ import annotations

import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Any

from app.config.settings import settings
from app.integrations import decrypt_token
from app.integrations.gmail import GmailClient
from app.scouts.connectors.base import ItemContent, ItemMetadata, ItemRef

logger = logging.getLogger(__name__)


def _get_gmail_service(scout):
    """Return a synchronous Google API client for low-level metadata calls."""
    from googleapiclient.discovery import build
    from google.oauth2.credentials import Credentials

    creds_info = decrypt_token(scout.oauth_token_encrypted)
    credentials = Credentials(
        token=creds_info.get("token"),
        refresh_token=creds_info.get("refresh_token"),
        token_uri=creds_info.get("token_uri", "https://oauth2.googleapis.com/token"),
        client_id=creds_info.get("client_id"),
        client_secret=creds_info.get("client_secret"),
        scopes=creds_info.get("scopes"),
    )
    return build("gmail", "v1", credentials=credentials, cache_discovery=False)


class GmailConnector:
    source_type = "gmail"

    async def list_new(self, scout) -> list[ItemRef]:
        def _sync() -> tuple[list[ItemRef], str | None]:
            service = _get_gmail_service(scout)
            history_id = scout.gmail_history_id
            refs: list[ItemRef] = []
            new_history_id = history_id
            if history_id:
                resp = service.users().history().list(
                    userId="me",
                    startHistoryId=history_id,
                    historyTypes=["messageAdded"],
                ).execute()
                for entry in resp.get("history", []):
                    for added in entry.get("messagesAdded", []):
                        refs.append(ItemRef(source_msg_ref=added["message"]["id"]))
                new_history_id = resp.get("historyId", history_id)
            else:
                # First run: pick up the latest history id without backfilling.
                profile = service.users().getProfile(userId="me").execute()
                new_history_id = profile["historyId"]
            return refs, new_history_id

        refs, new_history_id = await asyncio.to_thread(_sync)
        if new_history_id and new_history_id != scout.gmail_history_id:
            scout.gmail_history_id = new_history_id
        return refs

    async def fetch_metadata(self, scout, ref: ItemRef) -> ItemMetadata:
        def _sync() -> ItemMetadata:
            service = _get_gmail_service(scout)
            msg = service.users().messages().get(
                userId="me", id=ref.source_msg_ref, format="metadata",
                metadataHeaders=["Subject", "From", "Date"],
            ).execute()
            headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
            return ItemMetadata(
                subject=headers.get("Subject"),
                sender=headers.get("From"),
                snippet=msg.get("snippet"),
                received_at=None,
            )
        return await asyncio.to_thread(_sync)

    async def fetch_content(self, scout, ref: ItemRef) -> ItemContent:
        creds_info = decrypt_token(scout.oauth_token_encrypted)
        client = GmailClient(creds_info)
        # Use existing fetch_messages narrowed by message id via the q parameter.
        # Gmail API doesn't have a direct "get by message id with full body" via
        # GmailClient's high-level interface, so we shell to the low-level call.
        def _sync():
            service = _get_gmail_service(scout)
            msg = service.users().messages().get(
                userId="me", id=ref.source_msg_ref, format="full",
            ).execute()
            return msg

        msg = await asyncio.to_thread(_sync)
        from app.integrations.gmail import _extract_body, _build_email_message
        # _extract_body / _build_email_message exist as helpers in gmail.py.
        # If named differently in your source, find with: grep -n "def _" app/integrations/gmail.py
        email_msg = _build_email_message(msg)
        return ItemContent(
            metadata=ItemMetadata(
                subject=email_msg.subject,
                sender=email_msg.sender,
                snippet=msg.get("snippet"),
                received_at=email_msg.date,
            ),
            body_text=email_msg.body_text,
            raw_headers={h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])},
        )

    async def archive(self, scout, ref: ItemRef) -> None:
        def _sync():
            service = _get_gmail_service(scout)
            service.users().messages().trash(userId="me", id=ref.source_msg_ref).execute()
        await asyncio.to_thread(_sync)

    async def setup_watch(self, scout) -> None:
        def _sync():
            service = _get_gmail_service(scout)
            request_body = {
                "labelIds": ["INBOX"],
                "topicName": settings.GMAIL_PUBSUB_TOPIC,
            }
            resp = service.users().watch(userId="me", body=request_body).execute()
            scout.gmail_history_id = resp.get("historyId")
            scout.gmail_watch_expires_at = datetime.fromtimestamp(
                int(resp["expiration"]) / 1000, tz=timezone.utc,
            )
        await asyncio.to_thread(_sync)

    async def renew_watch(self, scout) -> None:
        await self.setup_watch(scout)
```

If `_build_email_message` and `_extract_body` are not the actual private helper names in `app/integrations/gmail.py`, find them:
```bash
grep -n "def _" api/app/integrations/gmail.py
```
and adjust the import. The point is to reuse the existing body-extraction logic rather than duplicate it.

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

```bash
cd api
pytest tests/test_scout_connectors_gmail.py -v
```
Expected: 3 PASS.

- [ ] **Step 5: Commit**

```bash
cd api
git add app/scouts/connectors/gmail.py tests/test_scout_connectors_gmail.py
git commit -m "feat(scouts): add GmailConnector"
```

---

## Task 23: Connector registration at app startup

**Files:**
- Modify: `api/app/main.py` — register `GmailConnector` during lifespan startup

- [ ] **Step 1: Read `app/main.py` lifespan**

```bash
cd api
grep -n "lifespan\|startup\|@asynccontextmanager" app/main.py
```

- [ ] **Step 2: Register connector in lifespan startup**

```python
@asynccontextmanager
async def lifespan(app: FastAPI):
    from app.scouts.connectors.gmail import GmailConnector
    from app.scouts.connectors.registry import register_connector
    register_connector(GmailConnector())

    # ... existing startup (scheduler etc.) ...
    yield
    # ... existing shutdown ...
```

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

```bash
cd api
pytest
```

- [ ] **Step 4: Commit**

```bash
cd api
git add app/main.py
git commit -m "feat(scouts): register GmailConnector at startup"
```

---

## Task 24: Real triage LLM call + Langfuse prompt

**Files:**
- Modify: `api/app/scouts/engine.py` — replace stub `_triage_llm`
- Manual: create Langfuse prompt `scout-triage-system` with label `production`
- Modify: `api/tests/test_scout_engine.py` — relax/adjust the monkeypatched stub if needed (existing tests already monkeypatch it, so should keep working)

- [ ] **Step 1: Create the Langfuse prompt**

Use the Langfuse MCP:
```
mcp__langfuse__createTextPrompt
  name: "scout-triage-system"
  prompt: |
    You are a triage classifier for an executive-assistant scout that watches a {{source_type}} feed.
    The scout's purpose is: "{{scout_purpose}}".

    Given one item, decide whether it is RELEVANT (worth surfacing to the user as a potential
    task / event / note / project) or SPAM (advertising, mass marketing, phishing, bulk
    notifications with no actionable content).

    Item:
      - Subject: {{item_subject}}
      - From:    {{item_sender}}
      - Body (truncated): {{item_body_truncated_2k}}

    Return JSON only, matching this schema:
      {"verdict": "relevant" | "spam", "reason": <short string>, "confidence": <0..1>}

    Be conservative on "spam" — if a message could plausibly be a personal/work email, mark it relevant.
  label: "production"
```

- [ ] **Step 2: Implement real `_triage_llm` in `app/scouts/engine.py`**

Replace the `_triage_llm` stub with:
```python
async def _triage_llm(self, scout: CloudScoutConfig, content: ItemContent) -> TriageVerdict:
    from app.core.llm import get_llm
    from app.core.langfuse_client import get_prompt_or_fallback, get_langfuse

    fallback = (
        "You are a triage classifier. Given subject/sender/body, return JSON: "
        "{verdict: relevant|spam, reason: str, confidence: 0..1}"
    )
    template, prompt_obj = get_prompt_or_fallback("scout-triage-system", fallback)

    body_trunc = (content.body_text or "")[:2000]
    rendered = (
        template
        .replace("{{source_type}}", scout.provider)
        .replace("{{scout_purpose}}", scout.prompt_template or "")
        .replace("{{item_subject}}", content.metadata.subject or "")
        .replace("{{item_sender}}", content.metadata.sender or "")
        .replace("{{item_body_truncated_2k}}", body_trunc)
    )

    llm = get_llm(model="gpt-4o-mini")
    lf = get_langfuse()
    if lf:
        with lf.start_as_current_observation(as_type="generation", name="scout-triage", prompt=prompt_obj) as gen:
            gen.update(input=rendered)
            result = await llm.acomplete(rendered, response_format={"type": "json_object"})
            gen.update(output=result.text)
    else:
        result = await llm.acomplete(rendered, response_format={"type": "json_object"})

    import json
    data = json.loads(result.text)
    return TriageVerdict(**data)
```

(Adapt to the project's actual LLM helper signature. If `get_llm`/`acomplete` aren't the real names, find the LiteLLM wrapper:
```bash
grep -n "def \|class " api/app/core/llm.py
```
and use the actual API.)

- [ ] **Step 3: Run engine tests**

```bash
cd api
pytest tests/test_scout_engine.py -v
```
The existing tests monkeypatch `_triage_llm`, so they keep passing.

- [ ] **Step 4: Add an integration test that exercises the real LLM path with a mocked LLM client**

Append to `tests/test_scout_engine.py`:
```python
@pytest.mark.asyncio
async def test_triage_llm_parses_json_response(monkeypatch):
    from app.scouts.engine import ScoutEngine
    from app.scouts.connectors.base import ItemContent, ItemMetadata
    from app.models import CloudScoutConfig

    scout = CloudScoutConfig(
        id=str(uuid.uuid4()), user_id="x", provider="gmail", name="t",
        data_types=[], prompt_template="watch invoices", schedule_cron="0 * * * *",
        enabled=True, auto_trash_spam=False, device_inactivity_pause_days=14,
    )
    content = ItemContent(metadata=ItemMetadata(subject="Invoice 42", sender="acme@x"), body_text="due 2026-06-01")

    fake_llm = AsyncMock()
    fake_response = AsyncMock()
    fake_response.text = '{"verdict": "relevant", "reason": "invoice", "confidence": 0.9}'
    fake_llm.acomplete = AsyncMock(return_value=fake_response)

    monkeypatch.setattr("app.scouts.engine.get_llm" if False else "app.core.llm.get_llm", lambda **k: fake_llm)

    engine = ScoutEngine()
    verdict = await engine._triage_llm(scout, content)
    assert verdict.verdict == "relevant"
    assert verdict.reason == "invoice"
```

(Adjust the monkeypatch target to match the actual import path used inside `_triage_llm`.)

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

```bash
cd api
pytest tests/test_scout_engine.py -v
```

- [ ] **Step 6: Commit**

```bash
cd api
git add app/scouts/engine.py tests/test_scout_engine.py
git commit -m "feat(scouts): real triage LLM call via scout-triage-system prompt"
```

---

## Task 25: Pub/Sub webhook route + JWT verification

**Files:**
- Create: `api/app/api/routes/scout_webhooks.py`
- Create: `api/tests/test_scout_webhook.py`
- Modify: `api/app/main.py` — register the new router
- Modify: `api/app/config/settings.py` — add `GMAIL_PUBSUB_TOPIC` and `GMAIL_PUBSUB_AUDIENCE` env vars

- [ ] **Step 1: Add settings vars**

In `app/config/settings.py`:
```python
GMAIL_PUBSUB_TOPIC: str = ""             # e.g. "projects/adiuvai-prod/topics/gmail-watch"
GMAIL_PUBSUB_AUDIENCE: str = ""          # OIDC token audience configured in Pub/Sub push subscription
```

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

`api/tests/test_scout_webhook.py`:
```python
"""Tests for the Gmail Pub/Sub webhook."""

from __future__ import annotations

import base64
import json
import uuid
from unittest.mock import AsyncMock, patch

import pytest
from httpx import AsyncClient

from app.main import app
from app.models import CloudScoutConfig, User
from tests.conftest import _TestSessionLocal


def _pubsub_payload(email: str, history_id: str) -> dict:
    inner = json.dumps({"emailAddress": email, "historyId": history_id}).encode()
    return {
        "message": {"data": base64.b64encode(inner).decode(), "messageId": "m1"},
        "subscription": "projects/x/subscriptions/gmail-watch-sub",
    }


@pytest.mark.asyncio
async def test_webhook_triggers_scout_for_matching_user():
    user_id = "00000000-0000-0000-0000-000000000003"
    scout_id = str(uuid.uuid4())
    async with _TestSessionLocal() as session:
        user = await session.get(User, user_id)
        user.email = "alice@example.com"
        session.add(CloudScoutConfig(
            id=scout_id, user_id=user_id, provider="gmail", name="Inbox",
            data_types=[], prompt_template="", schedule_cron="0 * * * *",
            enabled=True, auto_trash_spam=False, device_inactivity_pause_days=14,
        ))
        await session.commit()

    payload = _pubsub_payload("alice@example.com", "200")

    with patch("app.api.routes.scout_webhooks._verify_pubsub_jwt", return_value=True), \
         patch("app.scouts.engine.ScoutEngine.trigger_scout", new=AsyncMock()) as mock_trigger:
        async with AsyncClient(app=app, base_url="http://test") as client:
            resp = await client.post(
                "/api/v1/scouts/webhooks/gmail",
                json=payload,
                headers={"Authorization": "Bearer fake-google-jwt"},
            )
    assert resp.status_code == 204
    mock_trigger.assert_awaited_once_with(uuid.UUID(scout_id))


@pytest.mark.asyncio
async def test_webhook_rejects_unverified_jwt():
    payload = _pubsub_payload("alice@example.com", "200")
    with patch("app.api.routes.scout_webhooks._verify_pubsub_jwt", return_value=False):
        async with AsyncClient(app=app, base_url="http://test") as client:
            resp = await client.post(
                "/api/v1/scouts/webhooks/gmail",
                json=payload,
                headers={"Authorization": "Bearer bogus"},
            )
    assert resp.status_code == 401
```

- [ ] **Step 3: Implement `app/api/routes/scout_webhooks.py`**

```python
"""Gmail Pub/Sub push receiver.

Google Pub/Sub push subscriptions deliver Gmail watch notifications as POST
requests with a JSON envelope. The body payload contains a base64-encoded
JSON blob with ``emailAddress`` + ``historyId``. We resolve the user by
email, look up their cloud_scout_configs row for provider='gmail', and
hand off to ScoutEngine.trigger_scout.

Authentication: Pub/Sub push includes an OIDC JWT in the Authorization
header. We verify it against Google's public keys with the audience
configured in our Pub/Sub subscription.
"""

from __future__ import annotations

import base64
import json
import logging
import uuid

from fastapi import APIRouter, Header, HTTPException, Request, status
from sqlalchemy import select

from app.config.settings import settings
from app.db.session import async_session_factory
from app.models import CloudScoutConfig, User
from app.scouts.engine import ScoutEngine

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scouts/webhooks", tags=["scout-webhooks"])


def _verify_pubsub_jwt(token: str) -> bool:
    """Verify the Google Pub/Sub OIDC JWT.

    Returns False on any verification failure. Production should use
    google.oauth2.id_token.verify_oauth2_token with the configured audience.
    """
    if not token:
        return False
    try:
        from google.oauth2 import id_token
        from google.auth.transport import requests as g_requests
        id_token.verify_oauth2_token(token, g_requests.Request(), audience=settings.GMAIL_PUBSUB_AUDIENCE)
        return True
    except Exception:
        logger.warning("pubsub jwt verification failed", exc_info=True)
        return False


@router.post("/gmail", status_code=status.HTTP_204_NO_CONTENT)
async def gmail_pubsub(
    request: Request,
    authorization: str = Header(default=""),
):
    token = authorization.removeprefix("Bearer ").strip()
    if not _verify_pubsub_jwt(token):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid Pub/Sub JWT")

    body = await request.json()
    msg = body.get("message") or {}
    raw = msg.get("data")
    if not raw:
        return  # ack without action
    try:
        decoded = json.loads(base64.b64decode(raw).decode())
    except Exception:
        logger.warning("pubsub payload decode failed")
        return

    email = decoded.get("emailAddress")
    if not email:
        return

    async with async_session_factory() as session:
        user_q = await session.execute(select(User).where(User.email == email))
        user = user_q.scalar_one_or_none()
        if user is None:
            logger.info("pubsub: no user for %s — ignoring", email)
            return
        scouts_q = await session.execute(
            select(CloudScoutConfig).where(
                CloudScoutConfig.user_id == user.id,
                CloudScoutConfig.provider == "gmail",
                CloudScoutConfig.enabled == True,
            )
        )
        scouts = scouts_q.scalars().all()

    engine = ScoutEngine()
    for scout in scouts:
        await engine.trigger_scout(uuid.UUID(scout.id))
```

- [ ] **Step 4: Register the router in `app/main.py`**

```python
from app.api.routes import scout_webhooks
app.include_router(scout_webhooks.router, prefix="/api/v1")
```

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

```bash
cd api
pytest tests/test_scout_webhook.py -v
```
Expected: 2 PASS.

- [ ] **Step 6: Commit**

```bash
cd api
git add app/api/routes/scout_webhooks.py tests/test_scout_webhook.py app/main.py app/config/settings.py
git commit -m "feat(scouts): gmail pub/sub webhook with JWT verification"
```

---

## Task 26: Cron-fallback scheduler + watch renewal in lifespan

**Files:**
- Modify: `api/app/main.py` — add `_scout_cron_tick` and `_scout_watch_renewal_tick` to existing APScheduler

- [ ] **Step 1: Read existing scheduler setup**

```bash
cd api
grep -n "AsyncIOScheduler\|apscheduler\|memory_cron_tick" app/main.py
```

- [ ] **Step 2: Add the two new ticks (gated on `settings.SCHEDULER_ENABLED`)**

Inside `lifespan`:
```python
if settings.SCHEDULER_ENABLED:
    scheduler.add_job(
        _scout_cron_tick, trigger="interval", minutes=15,
        id="scout_cron_tick", replace_existing=True,
    )
    scheduler.add_job(
        _scout_watch_renewal_tick, trigger="interval", hours=24,
        id="scout_watch_renewal_tick", replace_existing=True,
    )
```

Add the tick functions to the same file:
```python
async def _scout_cron_tick():
    """Poll cloud scouts whose schedule has elapsed since last_run_at."""
    from app.scouts.engine import ScoutEngine
    from app.models import CloudScoutConfig

    async with async_session_factory() as session:
        scouts = (await session.execute(
            select(CloudScoutConfig).where(CloudScoutConfig.enabled == True)
        )).scalars().all()

    engine = ScoutEngine()
    for scout in scouts:
        # Cheap rate-limit: don't poll if we ran within the last 5 minutes.
        # Push notifications are the primary trigger; this is fallback only.
        if scout.last_run_at:
            elapsed = (datetime.now(tz=timezone.utc) - scout.last_run_at).total_seconds()
            if elapsed < 300:
                continue
        await engine.trigger_scout(uuid.UUID(scout.id))


async def _scout_watch_renewal_tick():
    """Re-issue Gmail users.watch for any scout whose watch expires within 24h."""
    from app.scouts.connectors.registry import get_connector
    from app.models import CloudScoutConfig

    threshold = datetime.now(tz=timezone.utc) + timedelta(hours=24)
    async with async_session_factory() as session:
        scouts = (await session.execute(
            select(CloudScoutConfig).where(
                CloudScoutConfig.enabled == True,
                CloudScoutConfig.provider == "gmail",
                CloudScoutConfig.gmail_watch_expires_at <= threshold,
            )
        )).scalars().all()
        for scout in scouts:
            try:
                connector = get_connector("gmail")
                await connector.renew_watch(scout)
            except Exception:
                logger.exception("scout %s: watch renewal failed", scout.id)
        await session.commit()
```

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

```bash
cd api
pytest
```

- [ ] **Step 4: Commit**

```bash
cd api
git add app/main.py
git commit -m "feat(scouts): add cron-fallback poll + gmail watch renewal ticks"
```

---

## Task 27: Settings UI — "Add Gmail Scout" OAuth flow

**Files:**
- Modify: `adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx` — add "Connect Gmail" button + OAuth flow
- Modify: `adiuvAI/src/main/router/index.ts` — add tRPC procedure `scout.cloud.startGmailOAuth` that opens the browser to `/api/v1/auth/oauth/google/authorize` with scout-specific scopes
- Modify: `adiuvAI/src/main/index.ts` — extend the existing `adiuvai://` protocol handler to detect `?scout_oauth_callback=1` and dispatch to scout flow instead of login flow

**Context:** The login OAuth flow already exists in `auth.py`. For scout OAuth we want a separate consent screen with `gmail.readonly` + `gmail.modify` scopes. Two approaches:

A) Reuse the existing `/auth/oauth/google/authorize` route with an extra query param `purpose=scout&scout_id=<id>`. The route branches on purpose to choose scopes and store-back behavior.
B) Add a new pair of routes `/scouts/oauth/gmail/authorize` and `/scouts/oauth/gmail/callback` mirroring the auth pattern.

Pick **B** for clarity — separates concerns. Implement the new routes alongside the existing ones.

- [ ] **Step 1: Add scout OAuth routes in `api/app/api/routes/scouts.py`**

```python
@router.get("/oauth/gmail/authorize")
async def scout_gmail_oauth_authorize(
    scout_id: str,
    user: User = Depends(get_current_user),
):
    from app.auth.oauth_providers import GoogleOAuthProvider, generate_pkce_pair

    code_verifier, code_challenge = generate_pkce_pair()
    state = secrets.token_urlsafe(32)
    _pending_scout_oauth_states[state] = (code_verifier, scout_id, user.id, time.time() + 600)

    provider = GoogleOAuthProvider()
    authorize_url = provider.build_authorize_url(
        redirect_uri=f"{settings.OAUTH_REDIRECT_URI_BASE}/api/v1/scouts/oauth/gmail/web-callback",
        state=state,
        code_challenge=code_challenge,
        scopes=["openid", "email", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.modify"],
        access_type="offline",
        prompt="consent",
    )
    return {"authorize_url": authorize_url}


@router.get("/oauth/gmail/web-callback")
async def scout_gmail_oauth_web_callback(code: str, state: str):
    """Bounces to the Electron deep link with code+state."""
    return RedirectResponse(f"adiuvai://scout/oauth/gmail/callback?code={code}&state={state}")


@router.post("/oauth/gmail/callback")
async def scout_gmail_oauth_callback(
    body: _ScoutGmailCallbackBody,
    db: AsyncSession = Depends(get_session),
    user: User = Depends(get_current_user),
):
    entry = _pending_scout_oauth_states.pop(body.state, None)
    if entry is None or entry[3] < time.time() or entry[2] != user.id:
        raise HTTPException(401, "Invalid or expired OAuth state")
    code_verifier, scout_id, _, _ = entry

    provider = GoogleOAuthProvider()
    token_data = await provider.exchange_code(
        code=body.code,
        code_verifier=code_verifier,
        redirect_uri=f"{settings.OAUTH_REDIRECT_URI_BASE}/api/v1/scouts/oauth/gmail/web-callback",
        scopes=["openid", "email", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.modify"],
    )

    creds_dict = {
        "token": token_data["access_token"],
        "refresh_token": token_data.get("refresh_token"),
        "token_uri": "https://oauth2.googleapis.com/token",
        "client_id": settings.GOOGLE_AUTH_CLIENT_ID,
        "client_secret": settings.GOOGLE_AUTH_CLIENT_SECRET,
        "scopes": ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.modify"],
    }
    encrypted = encrypt_token(creds_dict)

    scout = await db.get(CloudScoutConfig, scout_id)
    if scout is None or scout.user_id != user.id:
        raise HTTPException(404, "Scout not found")
    scout.oauth_token_encrypted = encrypted
    await db.commit()

    # Set up the Gmail watch so we start receiving push notifications.
    from app.scouts.connectors.registry import get_connector
    try:
        await get_connector("gmail").setup_watch(scout)
        await db.commit()
    except Exception:
        logger.exception("setup_watch failed for scout %s", scout_id)

    return {"ok": True}
```

(`_pending_scout_oauth_states` and `_ScoutGmailCallbackBody` Pydantic model defined at the top of the file alongside the existing `_pending_states`. `encrypt_token` imported from `app.integrations`.)

- [ ] **Step 2: Add tRPC procedures `scout.cloud.startGmailOAuth` + `scout.cloud.completeGmailOAuth` in `adiuvAI/src/main/router/index.ts`**

```typescript
startGmailOAuth: publicProcedure
  .input(z.object({ scoutId: z.string() }))
  .mutation(async ({ input }) => {
    const { authorize_url } = await getBackendClient().proxyGet<{ authorize_url: string }>(
      `/api/v1/scouts/oauth/gmail/authorize?scout_id=${encodeURIComponent(input.scoutId)}`,
    );
    await shell.openExternal(authorize_url);
    return { ok: true };
  }),

completeGmailOAuth: publicProcedure
  .input(z.object({ code: z.string(), state: z.string() }))
  .mutation(async ({ input }) => {
    return await getBackendClient().proxyPost('/api/v1/scouts/oauth/gmail/callback', input);
  }),
```

- [ ] **Step 3: Extend the `adiuvai://` protocol handler in `adiuvAI/src/main/index.ts`**

Find the existing `second-instance` / `open-url` handler that parses `adiuvai://oauth/callback?...`. Add a branch for `adiuvai://scout/oauth/gmail/callback?code=...&state=...`:
```typescript
if (parsed.host === 'scout' && parsed.pathname === '/oauth/gmail/callback') {
  const code = parsed.searchParams.get('code');
  const state = parsed.searchParams.get('state');
  if (code && state) {
    // Forward to renderer via IPC so the scout settings page can call the tRPC mutation.
    BrowserWindow.getAllWindows()[0]?.webContents.send('scout:gmailOAuthCallback', { code, state });
  }
  return;
}
```

- [ ] **Step 4: Add the consumer in `CloudScoutConfigPanel.tsx`**

```typescript
const startGmailOAuth = trpc.scout.cloud.startGmailOAuth.useMutation();
const completeGmailOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();

useEffect(() => {
  const off = window.electronAI.on('scout:gmailOAuthCallback', async ({ code, state }) => {
    await completeGmailOAuth.mutateAsync({ code, state });
    notify('success', 'toast.scout.gmailConnected');
    void utils.scout.cloud.list.invalidate();
  });
  return () => off?.();
}, []);

// In render, when scout.provider === 'gmail' && !scout.oauthTokenEncrypted:
<Button onClick={() => startGmailOAuth.mutate({ scoutId: scout.id })}>
  {t('scouts.connectGmail')}
</Button>
```

(Adjust to actual `electronAI` API shape — check `adiuvAI/src/preload/index.ts` for the exact event subscription pattern.)

- [ ] **Step 5: Add i18n keys**

In all 5 `translation.json` files:
```json
"scouts": {
  "connectGmail": "Connect Gmail",
  ...
},
"toast": {
  "scout": {
    "gmailConnected": "Gmail connected. Watching for new messages.",
    ...
  }
}
```

(Translate per language.)

- [ ] **Step 6: Typecheck + lint + manual test**

```bash
cd adiuvAI
npx tsc --noEmit
npm run lint
```

Then manual:
```bash
npm run start
```
Open Settings > Scouts > "Add Cloud Scout" → choose Gmail → click "Connect Gmail" → consent screen opens in browser → choose account, grant scopes → browser redirects to `adiuvai://...` → app receives callback → toast confirms.

- [ ] **Step 7: Commit (split across submodules)**

```bash
cd api
git add app/api/routes/scouts.py app/config/settings.py
git commit -m "feat(scouts): add Gmail OAuth scout-setup routes"

cd ../adiuvAI
git add src/main/router/index.ts src/main/index.ts \
        src/renderer/components/settings/CloudScoutConfigPanel.tsx \
        src/renderer/locales/
git commit -m "feat(scouts): Gmail OAuth UI flow"
```

---

## Task 28: Submodule pointer bump for Phase 3

- [ ] **Step 1: Bump submodule pointers**

```bash
cd c:\Users\PC-Roby\Documents\_adiuvai_workspace
git add api adiuvAI
git commit -m "chore: bump submodules — Phase 3 Gmail scout end-to-end"
```

**Phase 3 complete.** End-to-end Gmail scout: OAuth → watch → push → triage → queue → on reconnect → SQLite `scout_suggestions` rows with `category='unprocessed'`.

---

## Final Acceptance Walkthrough (manual, after all tasks)

- [ ] Boot app, log in.
- [ ] Settings > Scouts: header reads "Scouts" (or localized equivalent).
- [ ] Existing local directory monitor still listed and operational.
- [ ] "Add scout" → choose Gmail → connect → consent → return to app, scout shows as enabled.
- [ ] Send yourself a test email.
- [ ] Wait ~30 seconds (Pub/Sub push) or trigger via the cron tick.
- [ ] Run from `adiuvAI/`: `sqlite3 dev.db "select id, scout_id, raw_subject, status, category from scout_suggestions"`.
- [ ] Expected: one row with `status='pending'`, `category='unprocessed'`, `raw_subject` matching the email subject.
- [ ] Run on the API DB: `psql -d adiuvai -c "select id, scout_id, status, triage_verdict from scout_triage_queue"`.
- [ ] Expected: same row, `status='acked'` (Electron acked on receipt).
- [ ] No content (body) anywhere in `scout_triage_queue` — verify with `\d scout_triage_queue`.
- [ ] Send a spam-looking email (e.g. all-caps subject, "$$$ buy now $$$" body). With `auto_trash_spam=false` (default), no row appears in either DB. Toggle `auto_trash_spam=true` on the scout, send another spam email — verify it lands in Gmail Trash and no rows appear.

---

## Plan Self-Review

**Spec coverage:**
- Phase 1 rename — Tasks 1-11. All renamed surfaces from spec table covered.
- Phase 2 connector skeleton — Tasks 12-21. Covers `scout_triage_queue` table, `cloud_scout_configs` alters, `SourceConnector` Protocol, registry, `ScoutEngine.trigger_scout` + `_process_item` + `deliver_pending` + `ack_proposal`, Drizzle `scout_suggestions` table, WS frame schemas, Electron handler.
- Phase 3 Gmail end-to-end — Tasks 22-28. `GmailConnector` impl, registration, real triage LLM, Pub/Sub webhook, cron-fallback + watch renewal scheduler, OAuth setup UI.
- Phase 4 explicitly out of scope per spec.

**Type/method consistency:**
- `ScoutTriageQueue` columns match the Alembic migration in Task 12 and the SQLAlchemy model in Task 13.
- `scoutSuggestions` Drizzle columns in Task 17 match the `IncomingScoutProposal` interface in Task 20 (snake_case in DB, camelCase in TS object).
- `_triage_llm` is a stub in Task 16 and replaced in Task 24 — explicit.
- `ScoutEngine.deliver_pending` signature `(user_id: UUID, ws)` is the same in Task 19 (definition) and Task 19 (WS endpoint usage).
- `ScoutProposalPayload` (Task 18) matches the dict the engine sends in Task 19.

**Placeholder scan:** no TBDs or "implement appropriate error handling" stubs. Where the underlying API name (e.g. `_extract_body`, `get_llm`) might differ from this codebase's actual symbols, the task gives a `grep` command to discover the real name and adapt — that's a task-time investigation, not a placeholder.

**Scope check:** Three phases, each with a clear cut-point and submodule bump. Phase 1 alone is shippable and produces working software (rename, no behavior change). Phase 2 alone is internal-only infra. Phase 3 lands the user-visible feature. All three together fit one implementation track per the approved spec.

---

## Execution Handoff

**Plan complete and saved to** `docs/superpowers/specs/2026-05-15-scouts-refactor-and-gmail-integration-plan.md`. **Two execution options:**

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.

**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.

**Which approach?**
