# Cloud Scout Creation Flow (Gmail) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Give Gmail cloud scouts a slim creation flow (name + focus + auto-trash + label/sender filter, OAuth during creation) fitting the two-stage HITL pipeline, with full edit parity in the config panel — and build the foundational cloud-scout CRUD backend routes that were never implemented. **Architecture:** Backend gains the missing `CloudScoutConfig` CRUD routes (`POST/GET/PUT/DELETE /api/v1/scouts/cloud`) plus a `gmail_address` column, Gmail label-listing and disconnect endpoints. The Electron stepper is split into a thin router that delegates to `LocalScoutCreationFlow` (unchanged) or a new `CloudScoutCreationFlow`. The cloud config panel is rewritten for edit parity. **Tech Stack:** Python 3.12 / FastAPI / SQLAlchemy 2.0 async / Alembic / Pydantic v2 / Google API client. Electron / TypeScript / React 19 / tRPC v11 / zod / i18next. **Spec:** [docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-design.md](2026-05-16-cloud-scout-creation-flow-design.md) **CRITICAL CONTEXT — discovered during planning:** The backend cloud-scout CRUD routes (`/api/v1/scouts/cloud`) **do not exist**. The Electron tRPC `scout.cloud.{list,create,update,delete}` procedures call them but they 404. Cloud scouts are currently non-functional end-to-end. Phase A below builds these foundational routes; the slim-flow work (Phases C–D) depends on them. This is a larger scope than the spec literally implied ("verify/extend") because there is nothing to extend yet. **Repository layout:** monorepo with submodules `api/` (FastAPI) and `adiuvAI/` (Electron), both on branch `develop`. Monorepo on `main`. Don't bump submodule pointers until the final task of each submodule's work. **Working dir:** `c:\Users\PC-Roby\Documents\_adiuvai_workspace`. Windows; use the Bash tool for git/pytest. Pre-existing flaky tests to ignore throughout: `test_eval_runner`, `test_eval_journey`, `test_home_request_calls_memory_middleware` (hangs), `test_stream_end_serializes`. --- ## File Structure ### Backend (`api/`) | Path | Responsibility | New/Modified | |------|----------------|--------------| | `api/alembic/versions/009_cloud_scout_gmail_address.py` | Add `gmail_address` column | New | | `api/app/models.py` | `CloudScoutConfig.gmail_address` field | Modified | | `api/app/schemas/__init__.py` | Cloud create/update/response Pydantic models | Modified | | `api/app/api/routes/scouts.py` | Cloud CRUD routes + serializer + label-list + disconnect + gmail_address persistence | Modified | | `api/app/scouts/connectors/gmail.py` | `list_labels` + `stop_watch` | Modified | | `api/tests/test_scout_cloud_crud.py` | Cloud CRUD route tests | New | | `api/tests/test_scout_connectors_gmail.py` | `list_labels` + `stop_watch` tests | Modified | ### Electron (`adiuvAI/`) | Path | Responsibility | New/Modified | |------|----------------|--------------| | `adiuvAI/src/shared/api-types.ts` | `CloudScoutConfigSchema` new fields | Modified | | `adiuvAI/src/main/router/index.ts` | tRPC cloud input changes + `gmailLabels` + `disconnectGmail` | Modified | | `adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx` | Thin router: shared template step → delegate | Modified | | `adiuvAI/src/renderer/components/settings/LocalScoutCreationFlow.tsx` | Extracted current local flow | New | | `adiuvAI/src/renderer/components/settings/CloudScoutCreationFlow.tsx` | New Gmail slim flow | New | | `adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx` | Rewrite for parity | Modified | | `adiuvAI/src/renderer/components/settings/TemplateSelectCard.tsx` | Disable Teams/Outlook | Modified | | `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` | New scout keys | Modified | --- # PHASE A — Backend Cloud CRUD Routes (foundational) ## Task 1: Cloud scout Pydantic schemas **Files:** - Modify: `api/app/schemas/__init__.py` **Context:** No request/response schemas exist for cloud-scout CRUD. Add them near the other scout schemas (`ScoutCatalogItem`, `ScoutCreationCheckResponse`, etc). The response mirrors the `CloudScoutConfig` ORM columns, camelCased by the existing serialization layer (the route returns a plain dict; the Electron `proxyGet` camelCases keys, so we return snake_case keys and let the client convert — verify by checking how `ScoutRunLogResponse` is returned). - [ ] **Step 1: Read the existing scout schemas to match style** Read `api/app/schemas/__init__.py` around the `ScoutCatalogItem` / `ScoutCreationCheckResponse` / `ScoutRunLogResponse` definitions. Note whether response models use `alias_generator`/camelCase or snake_case + client conversion. (The Electron `proxyGet`/`proxyPost` camelCases responses and snake_cases requests, so backend speaks snake_case.) - [ ] **Step 2: Add the cloud schemas** Add to `api/app/schemas/__init__.py`: ```python class CloudScoutCreateRequest(BaseModel): name: str provider: Literal["gmail", "teams", "outlook"] data_types: list[str] = Field(default_factory=list) prompt_template: str = "" schedule_cron: str | None = None # None → server default filter_config: dict | None = None auto_trash_spam: bool = False class CloudScoutUpdateRequest(BaseModel): name: str | None = None data_types: list[str] | None = None prompt_template: str | None = None schedule_cron: str | None = None filter_config: dict | None = None auto_trash_spam: bool | None = None enabled: bool | None = None class CloudScoutResponse(BaseModel): id: str user_id: str provider: str name: str data_types: list[str] prompt_template: str schedule_cron: str filter_config: dict | None auto_trash_spam: bool enabled: bool last_run_at: int | None gmail_address: str | None oauth_connected: bool created_at: int updated_at: int ``` If `Literal` / `Field` aren't imported in this file, add them: `from typing import Literal` and `from pydantic import BaseModel, Field`. - [ ] **Step 3: Verify import** ```bash cd api python -c "from app.schemas import CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse; print('ok')" ``` Expected: `ok`. - [ ] **Step 4: Commit** ```bash cd api git add app/schemas/__init__.py git commit -m "feat(scouts): add cloud scout CRUD pydantic schemas" ``` --- ## Task 2: Cloud scout serializer + CRUD routes **Files:** - Modify: `api/app/api/routes/scouts.py` - Test: `api/tests/test_scout_cloud_crud.py` **Context:** `scouts.py` already has `_dt_ms` (line 60), `_dt_ms_opt` (line 64), `_to_data_types` (line 68), `_enforce_agent_limit` (line 99), and uses `Depends(get_session)` + `Depends(get_current_user)` returning `UserProfile`. Mirror the existing route style. The serializer computes `oauth_connected` from `oauth_token_encrypted is not None`. - [ ] **Step 1: Write the failing tests** Create `api/tests/test_scout_cloud_crud.py`: ```python """Tests for cloud scout CRUD routes.""" from __future__ import annotations import uuid from unittest.mock import patch import pytest from httpx import ASGITransport, AsyncClient from app.main import app from app.models import CloudScoutConfig from tests.conftest import _TestSessionLocal, make_jwt def _auth_headers(tier: str = "power") -> dict: return {"Authorization": f"Bearer {make_jwt(tier)}"} @pytest.fixture(autouse=True) def _patch_session(): # Route handlers use app.db.get_session; point it at the test session. with patch("app.api.routes.scouts.get_session", new=_test_get_session): yield async def _test_get_session(): async with _TestSessionLocal() as session: yield session @pytest.mark.asyncio async def test_create_cloud_scout_defaults_schedule(): payload = { "name": "Inbox", "provider": "gmail", "data_types": [], "prompt_template": "client requests", "auto_trash_spam": True, # schedule_cron omitted → server default } async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post("/api/v1/scouts/cloud", json=payload, headers=_auth_headers()) assert resp.status_code == 201, resp.text body = resp.json() assert body["name"] == "Inbox" assert body["provider"] == "gmail" assert body["auto_trash_spam"] is True assert body["prompt_template"] == "client requests" assert body["schedule_cron"] # non-empty default applied assert body["oauth_connected"] is False assert body["gmail_address"] is None @pytest.mark.asyncio async def test_list_cloud_scouts_returns_only_own(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: await client.post( "/api/v1/scouts/cloud", json={"name": "A", "provider": "gmail"}, headers=_auth_headers(), ) resp = await client.get("/api/v1/scouts/cloud", headers=_auth_headers()) assert resp.status_code == 200 rows = resp.json() assert all(r["provider"] == "gmail" for r in rows) assert any(r["name"] == "A" for r in rows) @pytest.mark.asyncio async def test_update_cloud_scout_applies_filter_and_autotrash(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: created = (await client.post( "/api/v1/scouts/cloud", json={"name": "B", "provider": "gmail"}, headers=_auth_headers(), )).json() sid = created["id"] resp = await client.put( f"/api/v1/scouts/cloud/{sid}", json={"filter_config": {"labels": ["INBOX"], "senders": ["@client.co"]}, "auto_trash_spam": True, "prompt_template": "invoices"}, headers=_auth_headers(), ) assert resp.status_code == 200, resp.text body = resp.json() assert body["filter_config"] == {"labels": ["INBOX"], "senders": ["@client.co"]} assert body["auto_trash_spam"] is True assert body["prompt_template"] == "invoices" @pytest.mark.asyncio async def test_delete_cloud_scout(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: created = (await client.post( "/api/v1/scouts/cloud", json={"name": "C", "provider": "gmail"}, headers=_auth_headers(), )).json() sid = created["id"] resp = await client.delete(f"/api/v1/scouts/cloud/{sid}", headers=_auth_headers()) assert resp.status_code == 200 listing = (await client.get("/api/v1/scouts/cloud", headers=_auth_headers())).json() assert all(r["id"] != sid for r in listing) ``` Note: the `_patch_session` fixture assumes the route reads `get_session` as a module-level name in `scouts.py`. If `get_session` is imported differently, adjust the patch target. Confirm via `grep -n "get_session" api/app/api/routes/scouts.py`. - [ ] **Step 2: Run tests to confirm they fail** ```bash cd api pytest tests/test_scout_cloud_crud.py -v ``` Expected: 404s (routes don't exist) → assertion failures. - [ ] **Step 3: Add the serializer + routes to `scouts.py`** Add near the other helpers in `api/app/api/routes/scouts.py`: ```python def _to_cloud_response(scout: CloudScoutConfig) -> dict: return { "id": scout.id, "user_id": scout.user_id, "provider": scout.provider, "name": scout.name, "data_types": scout.data_types or [], "prompt_template": scout.prompt_template or "", "schedule_cron": scout.schedule_cron, "filter_config": scout.filter_config, "auto_trash_spam": scout.auto_trash_spam, "enabled": scout.enabled, "last_run_at": _dt_ms_opt(scout.last_run_at), "gmail_address": scout.gmail_address, "oauth_connected": scout.oauth_token_encrypted is not None, "created_at": _dt_ms(scout.created_at), "updated_at": _dt_ms(scout.updated_at), } ``` Then add the routes (place after the existing scout routes, before the OAuth section): ```python _DEFAULT_CLOUD_SCHEDULE = "0 */6 * * *" @router.get("/cloud", response_model=list[CloudScoutResponse]) async def list_cloud_scouts( db: AsyncSession = Depends(get_session), current_user: UserProfile = Depends(get_current_user), ): rows = (await db.execute( select(CloudScoutConfig).where(CloudScoutConfig.user_id == current_user.id) )).scalars().all() return [_to_cloud_response(s) for s in rows] @router.post("/cloud", response_model=CloudScoutResponse, status_code=status.HTTP_201_CREATED) async def create_cloud_scout( body: CloudScoutCreateRequest, db: AsyncSession = Depends(get_session), current_user: UserProfile = Depends(get_current_user), ): scout = CloudScoutConfig( id=str(uuid.uuid4()), user_id=current_user.id, provider=body.provider, name=body.name, data_types=body.data_types, prompt_template=body.prompt_template, filter_config=body.filter_config, schedule_cron=body.schedule_cron or _DEFAULT_CLOUD_SCHEDULE, auto_trash_spam=body.auto_trash_spam, enabled=True, ) db.add(scout) await db.commit() await db.refresh(scout) return _to_cloud_response(scout) @router.put("/cloud/{scout_id}", response_model=CloudScoutResponse) async def update_cloud_scout( scout_id: str, body: CloudScoutUpdateRequest, db: AsyncSession = Depends(get_session), current_user: UserProfile = Depends(get_current_user), ): scout = await db.get(CloudScoutConfig, scout_id) if scout is None or scout.user_id != current_user.id: raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") if body.name is not None: scout.name = body.name if body.data_types is not None: scout.data_types = body.data_types if body.prompt_template is not None: scout.prompt_template = body.prompt_template if body.schedule_cron is not None: scout.schedule_cron = body.schedule_cron if body.filter_config is not None: scout.filter_config = body.filter_config if body.auto_trash_spam is not None: scout.auto_trash_spam = body.auto_trash_spam if body.enabled is not None: scout.enabled = body.enabled await db.commit() await db.refresh(scout) return _to_cloud_response(scout) @router.delete("/cloud/{scout_id}") async def delete_cloud_scout( scout_id: str, db: AsyncSession = Depends(get_session), current_user: UserProfile = Depends(get_current_user), ): scout = await db.get(CloudScoutConfig, scout_id) if scout is None or scout.user_id != current_user.id: raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") await db.delete(scout) await db.commit() return {"ok": True} ``` Ensure imports at the top of `scouts.py` include: `uuid`, `select` (from sqlalchemy), and the three new schemas `CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse`. Add to the existing `from app.schemas import (...)` block and `from sqlalchemy import select` if missing. - [ ] **Step 4: Run the tests** ```bash cd api pytest tests/test_scout_cloud_crud.py -v ``` Expected: 4 PASS. If the `get_session` patch target is wrong, fix it and re-run. - [ ] **Step 5: Commit** ```bash cd api git add app/api/routes/scouts.py tests/test_scout_cloud_crud.py git commit -m "feat(scouts): add cloud scout CRUD routes + serializer" ``` --- # PHASE B — gmail_address + connector additions ## Task 3: Alembic 009 — gmail_address column + model field **Files:** - Create: `api/alembic/versions/009_cloud_scout_gmail_address.py` - Modify: `api/app/models.py` - [ ] **Step 1: Create the migration** ```python """Add gmail_address to cloud_scout_configs. Revision ID: 009 Revises: 008 Create Date: 2026-05-16 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "009" down_revision: Union[str, None] = "008" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.add_column("cloud_scout_configs", sa.Column("gmail_address", sa.String(320), nullable=True)) def downgrade() -> None: op.drop_column("cloud_scout_configs", "gmail_address") ``` - [ ] **Step 2: Verify revision graph** ```bash cd api python -c "from alembic.script import ScriptDirectory; from alembic.config import Config; sd = ScriptDirectory.from_config(Config('alembic.ini')); print([s.revision for s in sd.walk_revisions()][:3])" ``` Expected: `['009', '008', '007']`. - [ ] **Step 3: Add the model field** In `api/app/models.py`, inside `CloudScoutConfig` (after `device_inactivity_pause_days`): ```python gmail_address: Mapped[str | None] = mapped_column(String(320), nullable=True) ``` - [ ] **Step 4: Run scout tests to confirm create_all still works** ```bash cd api pytest tests/test_scout_cloud_crud.py tests/test_scout_engine.py -v ``` Expected: green (the test DB uses `create_all`, picks up the new column). - [ ] **Step 5: Commit** ```bash cd api git add alembic/versions/009_cloud_scout_gmail_address.py app/models.py git commit -m "feat(scouts): add gmail_address column to cloud_scout_configs" ``` --- ## Task 4: Persist gmail_address on OAuth callback **Files:** - Modify: `api/app/api/routes/scouts.py` (the `scout_gmail_oauth_callback` handler, lines 370-440) **Context:** After token exchange, fetch the Gmail profile to get `emailAddress` and store it. The connector's `_get_gmail_service` can build a service, but at callback time the token is freshly minted — simplest is to call Gmail's `getProfile` via a one-off service built from `creds_dict`. - [ ] **Step 1: Add gmail_address persistence after token storage** In `scout_gmail_oauth_callback`, after `scout.oauth_token_encrypted = encrypted` and before the first `await db.commit()`, add: ```python # Fetch the connected Gmail address for display. try: import asyncio from googleapiclient.discovery import build from google.oauth2.credentials import Credentials def _fetch_email() -> str | None: creds = Credentials( token=creds_dict["token"], refresh_token=creds_dict.get("refresh_token"), token_uri=creds_dict["token_uri"], client_id=creds_dict["client_id"], client_secret=creds_dict["client_secret"], scopes=creds_dict["scopes"], ) service = build("gmail", "v1", credentials=creds, cache_discovery=False) profile = service.users().getProfile(userId="me").execute() return profile.get("emailAddress") scout.gmail_address = await asyncio.to_thread(_fetch_email) except Exception: logger.exception("failed to fetch gmail address for scout %s", scout_id) ``` - [ ] **Step 2: Verify the handler still imports/compiles** ```bash cd api python -c "import app.api.routes.scouts; print('ok')" ``` Expected: `ok`. - [ ] **Step 3: Commit** ```bash cd api git add app/api/routes/scouts.py git commit -m "feat(scouts): persist connected gmail_address on oauth callback" ``` --- ## Task 5: GmailConnector.list_labels + stop_watch **Files:** - Modify: `api/app/scouts/connectors/gmail.py` - Modify: `api/tests/test_scout_connectors_gmail.py` **Context:** `_get_gmail_service(scout)` (line 48) returns a sync Google API service. Mirror the existing `archive` / `fetch_metadata` `asyncio.to_thread(_sync)` pattern. - [ ] **Step 1: Write the failing tests** Append to `api/tests/test_scout_connectors_gmail.py`: ```python @pytest.mark.asyncio async def test_list_labels_returns_id_and_name(): scout = _make_scout() conn = GmailConnector() fake = {"labels": [ {"id": "INBOX", "name": "INBOX", "type": "system"}, {"id": "Label_1", "name": "Work", "type": "user"}, ]} with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc: mock_svc.return_value.users().labels().list().execute.return_value = fake labels = await conn.list_labels(scout) assert {"id": "INBOX", "name": "INBOX"} in labels assert {"id": "Label_1", "name": "Work"} in labels @pytest.mark.asyncio async def test_stop_watch_calls_stop(): scout = _make_scout() conn = GmailConnector() with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc: await conn.stop_watch(scout) mock_svc.return_value.users().stop.assert_called() ``` (`_make_scout` already exists in this test file from Phase 3.) - [ ] **Step 2: Run to confirm fail** ```bash cd api pytest tests/test_scout_connectors_gmail.py -k "list_labels or stop_watch" -v ``` Expected: AttributeError (methods missing). - [ ] **Step 3: Implement the two methods** Add to the `GmailConnector` class in `api/app/scouts/connectors/gmail.py`: ```python async def list_labels(self, scout) -> list[dict]: """Return the account's Gmail labels as [{id, name}]. Empty if no token.""" if not scout.oauth_token_encrypted: return [] def _sync() -> list[dict]: service = _get_gmail_service(scout) resp = service.users().labels().list(userId="me").execute() return [{"id": lbl["id"], "name": lbl["name"]} for lbl in resp.get("labels", [])] return await asyncio.to_thread(_sync) async def stop_watch(self, scout) -> None: """Stop Gmail push notifications. Swallows errors (watch may be gone).""" if not scout.oauth_token_encrypted: return def _sync() -> None: service = _get_gmail_service(scout) service.users().stop(userId="me").execute() try: await asyncio.to_thread(_sync) except Exception: logger.exception("stop_watch failed for scout %s", scout.id) ``` (`asyncio` and `logger` are already imported in this file.) - [ ] **Step 4: Run the tests** ```bash cd api pytest tests/test_scout_connectors_gmail.py -v ``` Expected: all PASS (3 existing + 2 new). - [ ] **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 list_labels + stop_watch" ``` --- ## Task 6: Gmail label-list + disconnect routes **Files:** - Modify: `api/app/api/routes/scouts.py` - Test: `api/tests/test_scout_cloud_crud.py` (append) - [ ] **Step 1: Write the failing tests** Append to `api/tests/test_scout_cloud_crud.py`: ```python @pytest.mark.asyncio async def test_gmail_labels_route_returns_labels(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: created = (await client.post( "/api/v1/scouts/cloud", json={"name": "L", "provider": "gmail"}, headers=_auth_headers(), )).json() sid = created["id"] with patch("app.api.routes.scouts.get_connector") as mock_get: from unittest.mock import AsyncMock mock_get.return_value.list_labels = AsyncMock(return_value=[{"id": "INBOX", "name": "INBOX"}]) resp = await client.get(f"/api/v1/scouts/cloud/{sid}/gmail-labels", headers=_auth_headers()) assert resp.status_code == 200 assert resp.json() == [{"id": "INBOX", "name": "INBOX"}] @pytest.mark.asyncio async def test_gmail_disconnect_clears_token(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: created = (await client.post( "/api/v1/scouts/cloud", json={"name": "D", "provider": "gmail"}, headers=_auth_headers(), )).json() sid = created["id"] # mark it connected directly in the DB async with _TestSessionLocal() as session: row = await session.get(CloudScoutConfig, sid) row.oauth_token_encrypted = "blob" row.gmail_address = "a@b.com" await session.commit() with patch("app.api.routes.scouts.get_connector") as mock_get: from unittest.mock import AsyncMock mock_get.return_value.stop_watch = AsyncMock() resp = await client.post(f"/api/v1/scouts/cloud/{sid}/gmail-disconnect", headers=_auth_headers()) assert resp.status_code == 200 body = resp.json() assert body["oauth_connected"] is False assert body["gmail_address"] is None assert body["enabled"] is False ``` - [ ] **Step 2: Run to confirm fail** ```bash cd api pytest tests/test_scout_cloud_crud.py -k "labels or disconnect" -v ``` Expected: 404. - [ ] **Step 3: Add the routes** Add to `api/app/api/routes/scouts.py` (after the cloud CRUD routes). Ensure `from app.scouts.connectors.registry import get_connector` is imported at module top (or import inside the handler): ```python @router.get("/cloud/{scout_id}/gmail-labels") async def list_gmail_labels( scout_id: str, db: AsyncSession = Depends(get_session), current_user: UserProfile = Depends(get_current_user), ): scout = await db.get(CloudScoutConfig, scout_id) if scout is None or scout.user_id != current_user.id: raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") try: connector = get_connector("gmail") except KeyError: return [] return await connector.list_labels(scout) @router.post("/cloud/{scout_id}/gmail-disconnect", response_model=CloudScoutResponse) async def disconnect_gmail( scout_id: str, db: AsyncSession = Depends(get_session), current_user: UserProfile = Depends(get_current_user), ): scout = await db.get(CloudScoutConfig, scout_id) if scout is None or scout.user_id != current_user.id: raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") try: connector = get_connector("gmail") await connector.stop_watch(scout) except KeyError: pass scout.oauth_token_encrypted = None scout.gmail_history_id = None scout.gmail_watch_expires_at = None scout.gmail_address = None scout.enabled = False await db.commit() await db.refresh(scout) return _to_cloud_response(scout) ``` Add `from app.scouts.connectors.registry import get_connector` to the imports if not present. - [ ] **Step 4: Run the tests** ```bash cd api pytest tests/test_scout_cloud_crud.py -v ``` Expected: all PASS (6 total). - [ ] **Step 5: Commit** ```bash cd api git add app/api/routes/scouts.py tests/test_scout_cloud_crud.py git commit -m "feat(scouts): add gmail label-list + disconnect routes" ``` --- # PHASE C — tRPC + shared types ## Task 7: Shared CloudScoutConfig type fields **Files:** - Modify: `adiuvAI/src/shared/api-types.ts` - [ ] **Step 1: Extend `CloudScoutConfigSchema`** In `adiuvAI/src/shared/api-types.ts`, update the schema (currently ends with `oauthConnected: z.boolean().optional()`): ```typescript export const CloudScoutConfigSchema = z.object({ id: z.string(), userId: z.string(), provider: z.enum(['gmail', 'teams', 'outlook']), name: z.string(), dataTypes: z.array(z.string()), promptTemplate: z.string(), scheduleCron: z.string(), filterConfig: z.object({ labels: z.array(z.string()).optional(), senders: z.array(z.string()).optional(), }).optional(), autoTrashSpam: z.boolean().optional(), enabled: z.boolean(), lastRunAt: z.number().int().nullable().optional(), gmailAddress: z.string().nullable().optional(), oauthConnected: z.boolean().optional(), createdAt: z.number().int(), updatedAt: z.number().int(), }); export type CloudScoutConfig = z.infer; ``` - [ ] **Step 2: Typecheck** ```bash cd adiuvAI npx tsc --noEmit 2>&1 | grep "api-types" || echo "no api-types errors" ``` Expected: `no api-types errors`. - [ ] **Step 3: Commit** ```bash cd adiuvAI git add src/shared/api-types.ts git commit -m "feat(scouts): add cloud scout config fields to shared type" ``` --- ## Task 8: tRPC cloud router — input changes + new procedures **Files:** - Modify: `adiuvAI/src/main/router/index.ts` (the `scoutCloudRouter`, lines 1155-1239) - [ ] **Step 1: Update `create` input — schedule optional, add autoTrashSpam** Replace the `create` procedure's input schema: ```typescript create: publicProcedure .input(z.object({ name: z.string(), provider: z.enum(['gmail', 'teams', 'outlook']), dataTypes: z.array(z.string()).default([]), promptTemplate: z.string().default(''), scheduleCron: z.string().optional(), filterConfig: z.object({ labels: z.array(z.string()).optional(), senders: z.array(z.string()).optional(), }).optional(), autoTrashSpam: z.boolean().optional(), })) .mutation(async ({ input }) => { try { const result = await getBackendClient().proxyPost( '/api/v1/scouts/cloud', input as Record, ); return { data: result, error: null }; } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to create cloud scout'; return { data: null, error: msg }; } }), ``` - [ ] **Step 2: Update `update` input — add autoTrashSpam, type filterConfig** ```typescript update: publicProcedure .input(z.object({ id: z.string(), name: z.string().optional(), dataTypes: z.array(z.string()).optional(), promptTemplate: z.string().optional(), scheduleCron: z.string().optional(), filterConfig: z.object({ labels: z.array(z.string()).optional(), senders: z.array(z.string()).optional(), }).optional(), autoTrashSpam: z.boolean().optional(), enabled: z.boolean().optional(), })) .mutation(async ({ input }) => { const { id, ...updates } = input; try { const result = await getBackendClient().proxyPut( `/api/v1/scouts/cloud/${id}`, updates as Record, ); return { data: result, error: null }; } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to update cloud scout'; return { data: null, error: msg }; } }), ``` - [ ] **Step 3: Add `gmailLabels` + `disconnectGmail` procedures** Add inside `scoutCloudRouter`, after `completeGmailOAuth`: ```typescript gmailLabels: publicProcedure .input(z.object({ scoutId: z.string() })) .query(async ({ input }) => { try { return await getBackendClient().proxyGet<{ id: string; name: string }[]>( `/api/v1/scouts/cloud/${input.scoutId}/gmail-labels`, ); } catch (err) { console.error('[Scout] gmailLabels error:', err instanceof Error ? err.message : err); return []; } }), disconnectGmail: publicProcedure .input(z.object({ scoutId: z.string() })) .mutation(async ({ input }) => { try { const result = await getBackendClient().proxyPost( `/api/v1/scouts/cloud/${input.scoutId}/gmail-disconnect`, {}, ); return { data: result, error: null }; } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to disconnect Gmail'; return { data: null, error: msg }; } }), ``` - [ ] **Step 4: Typecheck** ```bash cd adiuvAI npx tsc --noEmit 2>&1 | grep "router/index" || echo "no router errors" ``` Expected: `no router errors`. - [ ] **Step 5: Commit** ```bash cd adiuvAI git add src/main/router/index.ts git commit -m "feat(scouts): tRPC cloud input changes + gmailLabels + disconnectGmail" ``` --- # PHASE D — Electron UI ## Task 9: Extract LocalScoutCreationFlow from the stepper **Files:** - Create: `adiuvAI/src/renderer/components/settings/LocalScoutCreationFlow.tsx` - Modify: `adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx` **Context:** This is a pure refactor — move the current local 3-step body (config + review for `local_directory`) into its own component without behavior change. The stepper keeps Step 1 (template pick) and delegates. This isolates the local flow before the cloud flow is added, keeping each file focused. - [ ] **Step 1: Create `LocalScoutCreationFlow.tsx`** Move the local-specific logic (directory picker, data-types, schedule, prompt builder, review, create-local mutation) out of `InlineScoutCreationStepper.tsx` into a new component: ```typescript import { useState } from 'react'; import { FolderOpen, X, Sparkles } from 'lucide-react'; import { trpc } from '@/lib/trpc'; import { useNotify } from '@/hooks/useNotify'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { cn } from '@/lib/utils'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import type { AgentCatalogItem } from '../../../shared/api-types'; import { DATA_TYPE_CONFIG, SCHEDULE_OPTIONS } from './types'; import { PromptBuilderChat } from './PromptBuilderChat'; export function LocalScoutCreationFlow({ template, onCancel, onCreated, }: { template: AgentCatalogItem; onCancel: () => void; onCreated: () => void; }) { const createLocalMutation = trpc.scout.local.create.useMutation(); const { notify, notifyError } = useNotify(); const [step, setStep] = useState<2 | 3>(2); const [name, setName] = useState(template.name); const [directory, setDirectory] = useState(''); const [promptDialogOpen, setPromptDialogOpen] = useState(false); const [dataTypes, setDataTypes] = useState((template.supportedDataTypes ?? []).slice(0, 2)); const [schedule, setSchedule] = useState('0 * * * *'); const [promptTemplate, setPromptTemplate] = useState(''); const [scoutConfig, setScoutConfig] = useState | null>(null); const [error, setError] = useState(''); const isSubmitting = createLocalMutation.isPending; async function pickDirectory() { try { const result = await window.electronDialog.showOpenDialog({ properties: ['openDirectory'], title: 'Select directory for scout to watch', }); if (!result.canceled && result.filePaths.length > 0) setDirectory(result.filePaths[0]!); } catch { /* noop */ } } function toggleDataType(type: string) { setDataTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]); } function nextFromConfig() { if (!name.trim()) { setError('Scout name is required.'); return; } if (!directory) { setError('Select a directory.'); return; } if (dataTypes.length === 0) { setError('Select at least one data type.'); return; } setError(''); setStep(3); } function handleCreate() { setError(''); createLocalMutation.mutate( { name, directory, dataTypes, scheduleCron: schedule, agentConfig: scoutConfig ?? null }, { onSuccess: () => { notify('success', 'toast.scout.created'); onCreated(); }, onError: (err) => { notifyError('toast.scout.createError', err); setError(err.message); }, }, ); } const unlocked = !!directory && dataTypes.length > 0; return (
{step === 2 && ( <>

Step 2 of 3

Configure
setName(e.target.value)} className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary" placeholder="scout name." spellCheck={false} />

Point it at your files, pick what to extract, and set the run schedule.

{directory && (
{directory}
)}
{DATA_TYPE_CONFIG.map(({ value, label, Icon }) => { const active = dataTypes.includes(value); return ( ); })}
setPromptTemplate(p)} onConfigUpdate={(c) => setScoutConfig(c)} />
)} {step === 3 && (

Step 3 of 3

Review and
create your scout.

Everything looks good? Hit create and your scout will start running on schedule.

Template

{template.name}

Name: {name}

Data types: {dataTypes.join(', ') || 'None'}

Schedule: {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}

Directory: {directory}

{scoutConfig &&

Extraction config: Added

}
)} {error &&

{error}

}
{step === 2 && } {step === 3 && }
); } ``` - [ ] **Step 2: Slim down `InlineScoutCreationStepper.tsx` to a router** Replace the whole file body with the shared template-pick step that delegates: ```typescript import { useState } from 'react'; import { Bot } from 'lucide-react'; import type { AgentCatalogItem } from '../../../shared/api-types'; import { TemplateSelectCard } from './TemplateSelectCard'; import { LocalScoutCreationFlow } from './LocalScoutCreationFlow'; import { CloudScoutCreationFlow } from './CloudScoutCreationFlow'; export function InlineScoutCreationStepper({ catalog, isLoadingCatalog, onCancel, onCreated, }: { catalog: AgentCatalogItem[]; isLoadingCatalog: boolean; onCancel: () => void; onCreated: () => void; }) { const [selectedTemplate, setSelectedTemplate] = useState(null); if (selectedTemplate) { return selectedTemplate.type === 'local_directory' ? setSelectedTemplate(null)} onCreated={onCreated} /> : setSelectedTemplate(null)} onCreated={onCreated} />; } return (

Step 1 of 3

Choose your
starting template.

Pick a starting point — you can customize everything before the scout goes live.

{isLoadingCatalog && (
Loading templates...
)} {!isLoadingCatalog && catalog.length === 0 && (

No templates available yet. Add your server URL in Account settings, then try again.

)}
{catalog.map((item) => ( setSelectedTemplate(item)} /> ))}
); } ``` Add the missing `Button` import: `import { Button } from '@/components/ui/button';`. - [ ] **Step 3: Typecheck (CloudScoutCreationFlow not yet created — expect that one import error only)** ```bash cd adiuvAI npx tsc --noEmit 2>&1 | grep -iE "InlineScoutCreationStepper|LocalScoutCreationFlow" | grep -v "CloudScoutCreationFlow" || echo "only the expected missing-CloudFlow import remains" ``` Expected: only the `CloudScoutCreationFlow` missing-module error (resolved in Task 10). - [ ] **Step 4: Commit** ```bash cd adiuvAI git add src/renderer/components/settings/LocalScoutCreationFlow.tsx src/renderer/components/settings/InlineScoutCreationStepper.tsx git commit -m "refactor(scouts): extract LocalScoutCreationFlow, stepper becomes router" ``` --- ## Task 10: CloudScoutCreationFlow (Gmail slim flow) **Files:** - Create: `adiuvAI/src/renderer/components/settings/CloudScoutCreationFlow.tsx` **Context:** Two steps. Step A: name + focus + auto-trash + Connect Gmail (creates dormant scout, runs OAuth). Step B (after connect): label multi-select + sender chips + Finish. The OAuth deep-link callback is delivered via `window.electronAI.onScoutGmailOAuthCallback` (already wired in Phase 3, used by `CloudScoutConfigPanel`). - [ ] **Step 1: Create the component** ```typescript import { useEffect, useRef, useState } from 'react'; import { Mail, X, Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { trpc } from '@/lib/trpc'; import { useNotify } from '@/hooks/useNotify'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { Switch } from '@/components/ui/switch'; import type { AgentCatalogItem } from '../../../shared/api-types'; export function CloudScoutCreationFlow({ template, onCancel, onCreated, }: { template: AgentCatalogItem; onCancel: () => void; onCreated: () => void; }) { const { t } = useTranslation(); const { notify, notifyError } = useNotify(); const createMutation = trpc.scout.cloud.create.useMutation(); const updateMutation = trpc.scout.cloud.update.useMutation(); const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation(); const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation(); const [step, setStep] = useState<'basics' | 'filter'>('basics'); const [name, setName] = useState(template.name); const [focus, setFocus] = useState(''); const [autoTrash, setAutoTrash] = useState(false); const [scoutId, setScoutId] = useState(null); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(''); const [labels, setLabels] = useState([]); const [senderInput, setSenderInput] = useState(''); const [senders, setSenders] = useState([]); const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery( { scoutId: scoutId ?? '' }, { enabled: step === 'filter' && !!scoutId }, ); const scoutIdRef = useRef(null); scoutIdRef.current = scoutId; // OAuth deep-link callback → complete, then advance to filter step. useEffect(() => { const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI; if (!electronAI?.onScoutGmailOAuthCallback) return; const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => { try { await completeOAuth.mutateAsync({ code, state }); notify('success', 'toast.scout.gmailConnected'); setConnecting(false); setStep('filter'); } catch (err) { setConnecting(false); notifyError('toast.scout.createError', err instanceof Error ? err : new Error(String(err))); } }); return () => { off?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function handleConnect() { if (!name.trim()) { setError(t('scouts.nameRequired')); return; } setError(''); setConnecting(true); try { const res = await createMutation.mutateAsync({ name, provider: 'gmail', dataTypes: [], promptTemplate: focus, autoTrashSpam: autoTrash, filterConfig: {}, }); if (res.error || !res.data) throw new Error(res.error ?? 'create failed'); setScoutId(res.data.id); await startOAuth.mutateAsync({ scoutId: res.data.id }); // Wait for the deep-link callback (handled in the effect). } catch (err) { setConnecting(false); setError(err instanceof Error ? err.message : 'Failed to connect'); } } function toggleLabel(id: string) { setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]); } function addSender() { const v = senderInput.trim(); if (v && !senders.includes(v)) setSenders(prev => [...prev, v]); setSenderInput(''); } async function handleFinish() { if (!scoutId) return; try { await updateMutation.mutateAsync({ id: scoutId, filterConfig: { labels, senders }, }); notify('success', 'toast.scout.created'); onCreated(); } catch (err) { notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err))); } } if (step === 'basics') { return (

Step 2 of 3

Configure
setName(e.target.value)} className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary" placeholder={t('scouts.namePlaceholder')} spellCheck={false} />

{t('scouts.gmailBasicsHint')}

setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />

{t('scouts.autoTrashSpam')}

{t('scouts.autoTrashHint')}

{error &&

{error}

}
); } // step === 'filter' return (

Step 3 of 3

Narrow the
emails it watches.

{t('scouts.filterHint')}

{labelsQuery.isPending &&

Loading labels…

}
{(labelsQuery.data ?? []).map(lbl => ( ))}
setSenderInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }} placeholder={t('scouts.filterSendersPlaceholder')} />
{senders.length > 0 && (
{senders.map(s => ( {s} ))}
)}

{t('scouts.watchAllInbox')}

); } ``` If `Switch` isn't an existing shadcn component, add it: `cd adiuvAI && npx shadcn@latest add switch`. Verify first with `ls src/renderer/components/ui/switch.tsx`. - [ ] **Step 2: Typecheck** ```bash cd adiuvAI npx tsc --noEmit 2>&1 | grep -iE "CloudScoutCreationFlow|InlineScoutCreationStepper" || echo "no creation-flow errors" ``` Expected: `no creation-flow errors` (i18n keys are runtime, not typechecked). - [ ] **Step 3: Commit** ```bash cd adiuvAI git add src/renderer/components/settings/CloudScoutCreationFlow.tsx src/renderer/components/ui/switch.tsx git commit -m "feat(scouts): add CloudScoutCreationFlow Gmail slim flow" ``` --- ## Task 11: Rewrite CloudScoutConfigPanel for parity **Files:** - Modify: `adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx` - [ ] **Step 1: Replace the panel body** ```typescript import { useEffect, useState } from 'react'; import { Mail, X, Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { trpc } from '@/lib/trpc'; import { useNotify } from '@/hooks/useNotify'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { Switch } from '@/components/ui/switch'; import type { CloudScoutConfig } from '../../../shared/api-types'; export function CloudScoutConfigPanel({ scout, }: { scout: CloudScoutConfig & { scoutType: 'cloud' }; onOpenJourney: () => void; }) { const { t } = useTranslation(); const utils = trpc.useUtils(); const updateMutation = trpc.scout.cloud.update.useMutation(); const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation(); const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation(); const disconnect = trpc.scout.cloud.disconnectGmail.useMutation(); const { notify, notifyError } = useNotify(); const [focus, setFocus] = useState(scout.promptTemplate ?? ''); const [autoTrash, setAutoTrash] = useState(scout.autoTrashSpam ?? false); const [labels, setLabels] = useState(scout.filterConfig?.labels ?? []); const [senders, setSenders] = useState(scout.filterConfig?.senders ?? []); const [senderInput, setSenderInput] = useState(''); const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery( { scoutId: scout.id }, { enabled: !!scout.oauthConnected }, ); useEffect(() => { const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI; if (!electronAI?.onScoutGmailOAuthCallback) return; const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => { try { await completeOAuth.mutateAsync({ code, state }); notify('success', 'toast.scout.gmailConnected'); void utils.scout.cloud.list.invalidate(); } catch (err) { notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err))); } }); return () => { off?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function toggleLabel(id: string) { setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]); } function addSender() { const v = senderInput.trim(); if (v && !senders.includes(v)) setSenders(prev => [...prev, v]); setSenderInput(''); } function handleSave() { updateMutation.mutate( { id: scout.id, promptTemplate: focus, autoTrashSpam: autoTrash, filterConfig: { labels, senders } }, { onSuccess: () => { notify('success', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); }, onError: (err) => notifyError('toast.scout.updateError', err), }, ); } function handleDisconnect() { disconnect.mutate({ scoutId: scout.id }, { onSuccess: () => { notify('warning', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); }, onError: (err) => notifyError('toast.scout.updateError', err), }); } const connected = !!scout.oauthConnected; return (
{scout.provider}
{/* Connection status */} {connected ? (
{t('scouts.connectedAs')} {scout.gmailAddress ?? '—'}
) : (
{t('scouts.gmailAccessRequired')}
)} {/* Focus */}
setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
{/* Filter — labels */} {connected && (
{labelsQuery.isPending &&

Loading labels…

}
{(labelsQuery.data ?? []).map(lbl => ( ))}
)} {/* Filter — senders */}
setSenderInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }} placeholder={t('scouts.filterSendersPlaceholder')} />
{senders.length > 0 && (
{senders.map(s => ( {s} ))}
)}
{/* Auto-trash */}

{t('scouts.autoTrashSpam')}

{t('scouts.autoTrashHint')}

); } ``` Note: `onOpenJourney` is kept in the prop type for compatibility with `ScoutRow` but no longer used — verify `ScoutRow` still passes it; if `ScoutRow` errors on an unused prop, leave the prop in the signature (it's accepted, just ignored). If TypeScript complains about the unused prop, prefix with `_`: `onOpenJourney: _onOpenJourney`. Simplest: keep the prop in the destructure but don't reference it — TS won't error on an unused destructured prop. - [ ] **Step 2: Typecheck** ```bash cd adiuvAI npx tsc --noEmit 2>&1 | grep -i "CloudScoutConfigPanel" || echo "no panel errors" ``` Expected: `no panel errors`. - [ ] **Step 3: Commit** ```bash cd adiuvAI git add src/renderer/components/settings/CloudScoutConfigPanel.tsx git commit -m "refactor(scouts): rewrite CloudScoutConfigPanel for slim-model parity" ``` --- ## Task 12: Disable Teams/Outlook template cards **Files:** - Modify: `adiuvAI/src/renderer/components/settings/TemplateSelectCard.tsx` - [ ] **Step 1: Read the card to find the catalog-item type discriminator** ```bash cd adiuvAI cat src/renderer/components/settings/TemplateSelectCard.tsx ``` Note the `item` prop shape and how `onSelect` is wired. Cloud non-Gmail providers are identified by `item.provider === 'teams' || item.provider === 'outlook'` (or `item.type` — confirm from the file). - [ ] **Step 2: Gate selection + add "coming soon"** In the card component, compute: ```typescript const comingSoon = item.provider === 'teams' || item.provider === 'outlook'; ``` Apply to the root element: when `comingSoon`, add `opacity-50 pointer-events-none` to the className and render a small badge `{comingSoon && {t('scouts.comingSoon')}}` near the title. Guard `onSelect`: `onClick={comingSoon ? undefined : onSelect}`. Use `useTranslation` if not already imported. Confirm the actual field (`item.provider` vs `item.type`) from Step 1 and use the correct one. - [ ] **Step 3: Typecheck** ```bash cd adiuvAI npx tsc --noEmit 2>&1 | grep -i "TemplateSelectCard" || echo "no card errors" ``` Expected: `no card errors`. - [ ] **Step 4: Commit** ```bash cd adiuvAI git add src/renderer/components/settings/TemplateSelectCard.tsx git commit -m "feat(scouts): disable Teams/Outlook template cards (coming soon)" ``` --- ## Task 13: i18n keys (5 languages) **Files:** - Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` - [ ] **Step 1: Add the keys under the `scouts` namespace in each file** English (`en/translation.json`), add to the `scouts` object: ```json "nameRequired": "Scout name is required.", "namePlaceholder": "scout name.", "gmailBasicsHint": "Name it, tell it what to focus on, then connect your Gmail.", "focusLabel": "What should this scout watch for?", "focusPlaceholder": "e.g. client requests, invoices, project updates", "autoTrashSpam": "Auto-trash spam", "autoTrashHint": "Move emails it judges as spam to Gmail Trash (recoverable for 30 days).", "connecting": "Connecting…", "filterHint": "Leave empty to watch your whole inbox, or narrow by label and sender.", "filterLabels": "Labels", "filterSenders": "Senders", "filterSendersPlaceholder": "alice@example.com or @client.co", "watchAllInbox": "No filter = watch the whole inbox.", "skipFilter": "Skip", "finish": "Finish", "connectedAs": "Connected as", "reconnect": "Reconnect", "disconnect": "Disconnect", "gmailAccessRequired": "Gmail access required to start receiving email suggestions.", "comingSoon": "Coming soon" ``` Italian (`it`): ```json "nameRequired": "Il nome dello scout è obbligatorio.", "namePlaceholder": "nome scout.", "gmailBasicsHint": "Dagli un nome, indica su cosa concentrarsi, poi connetti Gmail.", "focusLabel": "Su cosa deve vigilare questo scout?", "focusPlaceholder": "es. richieste clienti, fatture, aggiornamenti progetto", "autoTrashSpam": "Cestina spam automaticamente", "autoTrashHint": "Sposta le email giudicate spam nel Cestino di Gmail (recuperabili per 30 giorni).", "connecting": "Connessione…", "filterHint": "Lascia vuoto per monitorare tutta la casella, o restringi per etichetta e mittente.", "filterLabels": "Etichette", "filterSenders": "Mittenti", "filterSendersPlaceholder": "alice@example.com oppure @client.co", "watchAllInbox": "Nessun filtro = monitora tutta la casella.", "skipFilter": "Salta", "finish": "Fine", "connectedAs": "Connesso come", "reconnect": "Riconnetti", "disconnect": "Disconnetti", "gmailAccessRequired": "Accesso a Gmail necessario per ricevere suggerimenti email.", "comingSoon": "Prossimamente" ``` Spanish (`es`): ```json "nameRequired": "El nombre del scout es obligatorio.", "namePlaceholder": "nombre del scout.", "gmailBasicsHint": "Ponle nombre, indícale en qué centrarse y conecta tu Gmail.", "focusLabel": "¿Qué debe vigilar este scout?", "focusPlaceholder": "p. ej. solicitudes de clientes, facturas, novedades de proyectos", "autoTrashSpam": "Enviar spam a la papelera automáticamente", "autoTrashHint": "Mueve los correos que considere spam a la Papelera de Gmail (recuperables 30 días).", "connecting": "Conectando…", "filterHint": "Déjalo vacío para vigilar toda la bandeja, o filtra por etiqueta y remitente.", "filterLabels": "Etiquetas", "filterSenders": "Remitentes", "filterSendersPlaceholder": "alice@example.com o @client.co", "watchAllInbox": "Sin filtro = vigila toda la bandeja de entrada.", "skipFilter": "Omitir", "finish": "Finalizar", "connectedAs": "Conectado como", "reconnect": "Reconectar", "disconnect": "Desconectar", "gmailAccessRequired": "Se requiere acceso a Gmail para recibir sugerencias de correo.", "comingSoon": "Próximamente" ``` French (`fr`): ```json "nameRequired": "Le nom du scout est requis.", "namePlaceholder": "nom du scout.", "gmailBasicsHint": "Nommez-le, indiquez sur quoi se concentrer, puis connectez votre Gmail.", "focusLabel": "Que doit surveiller ce scout ?", "focusPlaceholder": "ex. demandes clients, factures, mises à jour de projet", "autoTrashSpam": "Mettre le spam à la corbeille automatiquement", "autoTrashHint": "Déplace les e-mails jugés indésirables vers la corbeille Gmail (récupérables 30 jours).", "connecting": "Connexion…", "filterHint": "Laissez vide pour surveiller toute la boîte, ou filtrez par libellé et expéditeur.", "filterLabels": "Libellés", "filterSenders": "Expéditeurs", "filterSendersPlaceholder": "alice@example.com ou @client.co", "watchAllInbox": "Aucun filtre = surveille toute la boîte de réception.", "skipFilter": "Ignorer", "finish": "Terminer", "connectedAs": "Connecté en tant que", "reconnect": "Reconnecter", "disconnect": "Déconnecter", "gmailAccessRequired": "Accès Gmail requis pour recevoir des suggestions d'e-mails.", "comingSoon": "Bientôt disponible" ``` German (`de`): ```json "nameRequired": "Scout-Name ist erforderlich.", "namePlaceholder": "Scout-Name.", "gmailBasicsHint": "Benenne ihn, lege den Fokus fest und verbinde dann dein Gmail.", "focusLabel": "Worauf soll dieser Scout achten?", "focusPlaceholder": "z. B. Kundenanfragen, Rechnungen, Projekt-Updates", "autoTrashSpam": "Spam automatisch in den Papierkorb", "autoTrashHint": "Verschiebt als Spam eingestufte E-Mails in den Gmail-Papierkorb (30 Tage wiederherstellbar).", "connecting": "Verbinde…", "filterHint": "Leer lassen, um den ganzen Posteingang zu überwachen, oder nach Label und Absender filtern.", "filterLabels": "Labels", "filterSenders": "Absender", "filterSendersPlaceholder": "alice@example.com oder @client.co", "watchAllInbox": "Kein Filter = überwacht den gesamten Posteingang.", "skipFilter": "Überspringen", "finish": "Fertig", "connectedAs": "Verbunden als", "reconnect": "Neu verbinden", "disconnect": "Trennen", "gmailAccessRequired": "Gmail-Zugriff erforderlich, um E-Mail-Vorschläge zu erhalten.", "comingSoon": "Demnächst" ``` Merge these into each file's existing `scouts` object (don't create a second `scouts` key). Keep valid JSON (commas). - [ ] **Step 2: Validate JSON** ```bash cd adiuvAI for f in en it es fr de; do node -e "JSON.parse(require('fs').readFileSync('src/renderer/locales/$f/translation.json','utf8')); console.log('$f ok')"; done ``` Expected: `en ok` … `de ok`. - [ ] **Step 3: Confirm `common.save` exists** (used by the config panel Save button) ```bash cd adiuvAI node -e "const j=require('./src/renderer/locales/en/translation.json'); console.log(j.common && j.common.save ? 'has common.save' : 'MISSING')" ``` If `MISSING`, add `"save": "Save"` (and translations: it `"Salva"`, es `"Guardar"`, fr `"Enregistrer"`, de `"Speichern"`) to the `common` object in each file. - [ ] **Step 4: Commit** ```bash cd adiuvAI git add src/renderer/locales/ git commit -m "i18n: add cloud scout creation + config keys (5 languages)" ``` --- ## Task 14: Full typecheck + manual smoke **Files:** none (verification) - [ ] **Step 1: Full Electron typecheck** ```bash cd adiuvAI npx tsc --noEmit 2>&1 | grep -iE "Scout|settings/" || echo "no scout-surface errors" ``` Expected: `no scout-surface errors`. Pre-existing unrelated errors (drizzle-executor, ChatChartBlock, etc.) are acceptable. - [ ] **Step 2: Lint** ```bash cd adiuvAI npm run lint 2>&1 | tail -5 ``` Expected: no new errors in touched files. - [ ] **Step 3: Manual smoke (document result, don't block on it)** Start the app, open Settings → Scouts → Create scout → Gmail. Verify: only name + focus + auto-trash on first cloud step; Connect Gmail opens consent; after consent, label/sender filter step appears; Finish creates a scout row showing "Connected as ". Open the scout's config panel: focus/filter/auto-trash editable, Disconnect works. Verify Teams/Outlook cards are disabled. Verify local-directory creation is unchanged. --- # PHASE E — Submodule bumps ## Task 15: Bump submodule pointers - [ ] **Step 1: Confirm both submodules committed** ```bash cd c:/Users/PC-Roby/Documents/_adiuvai_workspace git -C api status --short git -C adiuvAI status --short ``` Expected: clean (all work committed in prior tasks). - [ ] **Step 2: Bump pointers** ```bash cd c:/Users/PC-Roby/Documents/_adiuvai_workspace git add api adiuvAI git commit -m "chore: bump submodules — cloud scout creation flow (Gmail)" ``` --- ## Plan Self-Review **Spec coverage:** - Stepper branch (local untouched, cloud slim) — Tasks 9, 10. - OAuth during creation, scout created at connect step — Task 10. - Label + sender filter — Tasks 5, 6, 10. - Focus → prompt_template → scout_purpose — Tasks 2, 10 (created with `promptTemplate: focus`). - Auto-trash toggle — Tasks 1, 2, 10. - Config panel parity — Task 11. - BE label-list + disconnect routes — Task 6. - Serializer `oauthConnected` + `filterConfig` + `gmail_address` — Tasks 2, 4. - `gmail_address` column (Alembic 009) — Task 3. - Catalog gating Teams/Outlook — Task 12. - i18n 5 langs — Task 13. - **Added beyond spec (necessary):** foundational cloud CRUD routes (Tasks 1, 2) — the spec assumed these existed; they don't. **Placeholder scan:** no TBDs. Where a real symbol name might differ (e.g. `get_session` patch target, `item.provider` vs `item.type`), the task gives a grep/cat to confirm and adapt — task-time verification, not a placeholder. **Type consistency:** `CloudScoutResponse` (snake_case, BE) ↔ `CloudScoutConfigSchema` (camelCase, TS) fields align: `auto_trash_spam`↔`autoTrashSpam`, `filter_config`↔`filterConfig`, `gmail_address`↔`gmailAddress`, `oauth_connected`↔`oauthConnected`. `filterConfig` shape `{ labels?, senders? }` consistent across Tasks 2, 7, 8, 10, 11. `scout.cloud.create` returns `{ data, error }` — consumed correctly in Task 10 (`res.data.id`). **Scope:** one coherent track — BE foundational CRUD + Gmail connector additions + Electron flow split. Shippable as one PR. Phase 4 (categorization/brief) remains out of scope. --- ## Execution Handoff **Plan complete and saved to** `docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md`. **Two execution options:** **1. Subagent-Driven (recommended)** — fresh subagent per task, review between, fast iteration. **2. Inline Execution** — execute in this session with checkpoints. **Note:** planning uncovered that the backend cloud-CRUD routes never existed (cloud scouts 404 today). Tasks 1–2 build them — larger than the spec implied but required for any cloud scout to function. **Which approach?**