"""FastAPI application factory and lifespan management."""

from __future__ import annotations

import asyncio
import json
import logging
import os
import re
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from agntspace.agent.agent import Agent
from agntspace.agent.orchestrator import Orchestrator
from agntspace.api.routes.activity import router as activity_router
from agntspace.api.routes.audit import router as audit_router
from agntspace.api.routes.autopilot import router as autopilot_router
from agntspace.api.routes.bibliography import router as bibliography_router
from agntspace.api.routes.canvas import router as canvas_router
from agntspace.api.routes.capabilities import router as capabilities_router
from agntspace.api.routes.channels import router as channels_router
from agntspace.api.routes.chat import router as chat_router
from agntspace.api.routes.contract import router as contract_router
from agntspace.api.routes.costs import router as costs_router
from agntspace.api.routes.cron import router as cron_router
from agntspace.api.routes.daily import router as daily_router
from agntspace.api.routes.domains import router as domains_router
from agntspace.api.routes.email import router as email_router
from agntspace.api.routes.emergency import router as emergency_router
from agntspace.api.routes.finance import router as finance_router
from agntspace.api.routes.goals import router as goals_router
from agntspace.api.routes.graph import router as graph_router
from agntspace.api.routes.guardian import router as guardian_router
from agntspace.api.routes.health import router as health_router
from agntspace.api.routes.heartbeat import router as heartbeat_router
from agntspace.api.routes.insights import router as insights_router
from agntspace.api.routes.integrations import router as integrations_router
from agntspace.api.routes.journal import router as journal_router
from agntspace.api.routes.kb import router as kb_router
from agntspace.api.routes.launch import router as launch_router
from agntspace.api.routes.llm_status import router as llm_status_router
from agntspace.api.routes.memory import router as memory_router
from agntspace.api.routes.onboarding import router as onboarding_router
from agntspace.api.routes.orchestrator import router as orchestrator_router
from agntspace.api.routes.pairing import router as pairing_router
from agntspace.api.routes.pipeline import router as pipeline_router
from agntspace.api.routes.projects import router as projects_router
from agntspace.api.routes.reflection import router as reflection_router
from agntspace.api.routes.relationship import router as relationship_router
from agntspace.api.routes.search import router as search_router
from agntspace.api.routes.settings import router as settings_router
from agntspace.api.routes.simulate import router as simulate_router
from agntspace.api.routes.skills import router as skills_router
from agntspace.api.routes.sync import router as sync_router
from agntspace.api.routes.tasks import router as tasks_router
from agntspace.api.routes.trust import router as trust_router
from agntspace.api.routes.vault import router as vault_router
from agntspace.api.routes.vault_browse import router as vault_browse_router
from agntspace.api.routes.video import router as video_router
from agntspace.api.routes.watcher import router as watcher_router
from agntspace.api.routes.wiki import router as wiki_router
from agntspace.api.routes.work_queue import router as work_queue_router
from agntspace.api.websocket.canvas import CanvasService, ws_canvas_handler
from agntspace.api.websocket.handler import ws_chat_handler
from agntspace.api.websocket.transcribe import ws_transcribe_handler
from agntspace.automation.autopilot import AutopilotService
from agntspace.automation.cron import CronScheduler
from agntspace.automation.daily_cycle import DailyCycle
from agntspace.automation.heartbeat import Heartbeat
from agntspace.automation.scheduler import TaskScheduler
from agntspace.channels.manager import ChannelManager
from agntspace.coach.service import CoachService
from agntspace.domains.base import DOMAIN_NAMES, DomainService
from agntspace.infra.config import get_settings
from agntspace.infra.credentials import CredentialStore
from agntspace.infra.database import get_db, init_schema
from agntspace.infra.language_patterns import contains_any, load_keywords
from agntspace.infra.logging import setup_logging
from agntspace.infra.settings_service import SettingsService
from agntspace.integrations.email_client import EmailIntegration
from agntspace.integrations.gcal import GCalIntegration
from agntspace.integrations.github import GitHubIntegration
from agntspace.integrations.gmail import GmailIntegration
from agntspace.integrations.manager import IntegrationManager
from agntspace.journal.reflection import ReflectionService
from agntspace.journal.service import JournalService
from agntspace.llm.factory import get_provider
from agntspace.mcp.client import MCPClientManager
from agntspace.memory.conversation import ConversationMemory
from agntspace.memory.engine import MemoryEngine
from agntspace.memory.graph import KnowledgeGraph
from agntspace.security.audit import AuditTrail
from agntspace.security.contract import ContractEnforcer
from agntspace.security.guardian import GuardianService
from agntspace.skills.loader import SkillLoader
from agntspace.skillschool.service import SkillSchoolService
from agntspace.tasks.service import TaskService
from agntspace.trust.service import TrustService
from agntspace.vault.reader import VaultReader
from agntspace.vault.search import VaultSearch
from agntspace.vault.sync import VaultSync
from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)


def _migrate_app_dir() -> None:
    """One-time migration: move ~/agntspace/ to ~/.agntspace/ (dot-prefix).

    The old visible directory is non-standard. All well-behaved apps use a
    dot-prefix for their config directory (~/.docker, ~/.npm, ~/.claude, etc.).
    This runs once — if the new path already exists, it's a no-op.
    """
    old_path = Path.home() / "agntspace"
    new_path = Path.home() / ".agntspace"

    if new_path.exists():
        return  # already migrated or fresh install

    if not old_path.is_dir():
        return  # nothing to migrate

    try:
        old_path.rename(new_path)
        print("  Migrated app home: ~/agntspace/ -> ~/.agntspace/", flush=True)
    except OSError:
        # rename failed (cross-device, permissions, open file handles) —
        # fall back to copy + leave old dir in place
        import shutil
        try:
            shutil.copytree(str(old_path), str(new_path))
            print(
                "  Copied app home: ~/agntspace/ -> ~/.agntspace/\n"
                "  You can safely delete ~/agntspace/ after verifying.",
                flush=True,
            )
        except Exception:
            log.warning("Could not migrate ~/agntspace/ to ~/.agntspace/", exc_info=True)


def _migrate_shared_db(settings) -> None:
    """One-time migration: copy old shared DB into the per-vault location.

    Before this fix, the database lived at ~/.agntspace/agntspace.db — shared
    across all vaults. Now each vault owns its database at
    root_dir/agntspace/agntspace.db. On first boot after the change, if the
    old shared DB exists and the per-vault DB does not, copy it over so the
    user keeps their conversation history, decisions, and everything else.

    The old file is left in place (renamed to .migrated) as a safety net.
    """
    import shutil

    old_path = settings.shared_db_path  # ~/.agntspace/agntspace.db
    new_path = settings.db_path         # root_dir/agntspace/agntspace.db

    if old_path == new_path:
        return  # vault IS the app_dir — no migration needed

    if new_path.exists():
        return  # per-vault DB already exists — nothing to do

    if not old_path.exists():
        return  # no old shared DB — fresh install

    try:
        new_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(str(old_path), str(new_path))
        # Rename old file so we don't re-migrate if user creates another vault
        migrated_path = old_path.with_suffix(".db.migrated")
        old_path.rename(migrated_path)
        log.info(
            "Migrated database from %s to %s (old file renamed to %s)",
            old_path, new_path, migrated_path.name,
        )
        print(f"  Migrated database into vault ({new_path.name})", flush=True)
    except Exception:
        log.warning("Could not migrate shared database", exc_info=True)


def _migrate_app_dir_state_to_vault(settings) -> None:
    """One-time migration of per-user state from ``~/.agntspace/`` into the vault.

    Moves WhatsApp session, plugin data, and MCP client config from
    app_dir into the vault so each vault owns its channel state,
    plugin state, and MCP server registry. Safe to run every boot:
    only acts when the source exists AND the destination does not.

    Legacy paths are renamed to ``.migrated`` suffix on success so a
    retry after a crash can't duplicate state. All failures are
    logged and swallowed — never break startup over a migration.
    """
    import shutil

    def _move_tree(src: Path, dst: Path, label: str) -> None:
        if not src.exists():
            return
        if dst.exists():
            return
        try:
            dst.parent.mkdir(parents=True, exist_ok=True)
            if src.is_dir():
                shutil.copytree(str(src), str(dst))
            else:
                shutil.copy2(str(src), str(dst))
            migrated = src.with_name(src.name + ".migrated")
            # Rename to a free name so reruns don't collide
            suffix_n = 0
            while migrated.exists():
                suffix_n += 1
                migrated = src.with_name(f"{src.name}.migrated.{suffix_n}")
            src.rename(migrated)
            log.info("Migrated %s from %s to %s", label, src, dst)
            print(f"  Migrated {label} into vault", flush=True)
        except Exception:
            log.warning("Could not migrate %s from %s", label, src, exc_info=True)

    # 1. WhatsApp: session + bridge state + bridge log
    legacy_root = settings.legacy_whatsapp_dir
    wa_dst = settings.whatsapp_dir
    _move_tree(
        legacy_root / "data" / "whatsapp-session",
        wa_dst / "data" / "whatsapp-session",
        "WhatsApp session",
    )
    _move_tree(
        legacy_root / "data" / "whatsapp-bridge-state.json",
        wa_dst / "data" / "whatsapp-bridge-state.json",
        "WhatsApp bridge state",
    )
    _move_tree(
        legacy_root / "whatsapp-bridge.log",
        wa_dst / "whatsapp-bridge.log",
        "WhatsApp bridge log",
    )

    # 2. Plugin runtime data — each plugin's ``<name>/data/`` subtree.
    #    Plugin CODE stays at ~/.agntspace/plugins/<name>/ for discovery.
    legacy_plugins = settings.legacy_plugin_data_dir
    if legacy_plugins.is_dir():
        for plugin_dir in legacy_plugins.iterdir():
            if not plugin_dir.is_dir():
                continue
            src_data = plugin_dir / "data"
            if not src_data.is_dir():
                continue
            dst_data = settings.plugin_data_dir / plugin_dir.name / "data"
            _move_tree(src_data, dst_data, f"plugin data for '{plugin_dir.name}'")

    # 3. Plugin enable/disable registry (per-vault now, matches plugin_data_dir).
    _move_tree(
        settings.legacy_plugin_state_path,
        settings.plugin_state_path,
        "plugin enable state",
    )

    # 4. MCP client config
    _move_tree(
        settings.legacy_mcp_config_path,
        settings.mcp_config_path,
        "MCP server registry",
    )


def _load_journal_config_deferred(skills):
    """Load journal-template sections.yaml after SkillLoader is ready."""
    try:
        return skills.load_skill_config_file("journal-template", "sections.yaml")
    except Exception:
        return None


def _sync_contract_with_config(contract, vault, writer, settings):
    """Keep CONTRACT.md privacy mode in sync with config.toml on startup."""
    rules = contract.rules
    cfg_mode = settings.llm.mode  # "cloud" or "local"

    if rules.privacy_mode == cfg_mode:
        return  # already in sync

    contract_path = vault.resolve_identity_path("CONTRACT.md")
    try:
        doc = vault.read(contract_path)
    except Exception:
        return
    content = doc.raw

    content = re.sub(
        r"(\*\*Mode\*\*:\s*)\w+", rf"\g<1>{cfg_mode}", content, count=1,
    )
    log.info("Contract privacy mode synced: %s → %s", rules.privacy_mode, cfg_mode)

    writer.write(contract_path, content, versioned=False)
    contract.reload()


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Startup: load settings, init DB, create services. Shutdown: close DB."""
    # Migrate ~/agntspace/ -> ~/.agntspace/ BEFORE loading settings
    # so all subsequent code sees the new path.
    _migrate_app_dir()

    # ── Default ThreadPoolExecutor sizing ────────────────────────────
    # asyncio's default executor is created lazily on first
    # ``loop.run_in_executor(None, ...)`` call with size
    # ``min(32, cpu_count + 4)`` — typically 12-16 threads on a personal
    # machine. Every ``asyncio.to_thread()`` call in the codebase shares
    # this pool: middleware, dashboard endpoint helpers (llm/status,
    # capabilities, goals/progress-summary), aiosqlite worker bridges,
    # FastAPI's own sync-handler shim, etc. Under realistic dashboard
    # load (20+ parallel polls) the pool saturates and every endpoint
    # queues — measured today as 7.7-second p95 with sub-50ms p50
    # because requests serialize on the pool, not on the work itself.
    #
    # Boosting to 64 gives every dashboard request its own thread with
    # headroom. Threads are cheap (~80 KB stack each, ~5 MB total).
    # Heavy work (graph build, video render, batch ingest) still uses
    # its own dedicated executors — this default is for short-burst
    # request handling, not long-running CPU.
    import asyncio as _aio_lifespan
    import concurrent.futures as _cf_lifespan
    _default_executor = _cf_lifespan.ThreadPoolExecutor(
        max_workers=64,
        thread_name_prefix="agnt-default",
    )
    _aio_lifespan.get_event_loop().set_default_executor(_default_executor)
    app.state.default_executor = _default_executor

    settings = get_settings()
    setup_logging(log_dir=settings.log_dir)

    # Initialize i18n with configured locale
    from agntspace.infra.i18n import init as i18n_init
    i18n_init(settings.i18n.locale)

    # Initialize timezone from config (schedule.timezone)
    from agntspace.infra.timezone import configure as tz_configure
    tz_configure(settings.schedule.timezone)

    # SECURITY: refuse to use home directory as vault root unless explicitly configured.
    # Home contains sensitive files (SSH keys, credentials, browser data) — the inbox
    # watcher would scan them and the agent could read them.
    _needs_setup = (
        not settings.root_explicit
        and settings.root_dir == Path.home()
        and not (settings.vault_dir / "system" / "identity" / "SOUL.md").exists()
    )
    if _needs_setup:
        print(
            "\n  \033[33mNo vault configured — starting in setup mode.\033[0m\n"
            "  Open http://localhost:8000 to choose your vault folder.\n",
            flush=True,
        )
        # Mark app as setup mode — create_app will only mount setup + UI routes
        app.state.setup_mode = True
        yield
        return

    # app_dir = ~/.agntspace/ (fixed — config, creds, db, logs)
    # vault_dir = root_dir/agntspace/ (agent internals)
    vault_path = settings.vault_dir
    log.debug("App: %s", settings.app_dir)
    log.debug("Root: %s", settings.root_dir)
    log.debug("Vault: %s", vault_path)

    # Readiness state — surfaced via /api/health so clients can distinguish
    # "still booting" from "dead" during the slow graph-build phase. The
    # port binds before `yield` but background work (graph build, embedding
    # warm-up) continues after, and `ready` only flips true when that's done.
    app.state.ready = False
    app.state.boot_phase = "initializing"
    app.state.graph_progress = None

    def _step(msg: str) -> None:
        """Print a startup progress step + mirror to /api/health readiness."""
        print(f"  \033[90m{msg}\033[0m", flush=True)
        # Normalize "  scanning files: 12000/30000" → "scanning files" so the
        # boot_phase field stays one short token; progress lives in its own
        # structured graph_progress field.
        _phase = msg.strip().rstrip(".…").split(":", 1)[0].strip().lower()
        if _phase:
            app.state.boot_phase = _phase

    # Ensure app_dir exists (config, db, logs)
    settings.app_dir.mkdir(parents=True, exist_ok=True)
    settings.log_dir.mkdir(parents=True, exist_ok=True)

    # Ensure vault is fully initialized (dirs, default files)
    from agntspace.setup.init import run_init

    try:
        run_init(vault_dir=settings.root_dir, minimal=True)
    except FileNotFoundError as e:
        print(f"\n  \033[31mError: {e}\033[0m", flush=True)
        print("  This usually means the package was not installed correctly.", flush=True)
        print("  Try: pip install --force-reinstall agntspace\n", flush=True)
        raise SystemExit(1) from e

    # ── Vault-level singleton lock ──────────────────────────────────
    # PRIMARY DEFENSE against orphan-process races (e.g. two AgntSpace
    # instances polling the same Telegram bot, two cron schedulers
    # firing the same job, two writers competing for SQLite locks).
    # See ``infra/process_lock.py`` for the full design rationale.
    #
    # Skipped in setup mode (vault doesn't exist yet — handled above
    # by the ``_needs_setup`` early return) and in dev-reload mode
    # (set ``AGNTSPACE_SKIP_LOCK=1``; the CLI's ``start --reload``
    # passes this so dev iteration isn't blocked by reload races).
    #
    # On busy → exit 2 with a clear message: another AgntSpace owns
    # this vault. The user resolves manually via ``agntspace stop``,
    # ``agntspace ps``, or by waiting for the other instance to finish.
    # NEVER kill the other process from here — see the 2026-04-25
    # post-mortem in ``cli.py:_try_reclaim_port``.
    process_lock = None
    lock_heartbeat_task = None
    if not os.environ.get("AGNTSPACE_SKIP_LOCK"):
        from agntspace.infra.process_lock import VaultProcessLock
        process_lock = VaultProcessLock(vault_path, version=_get_version())
        acquire_result = process_lock.acquire()
        if not acquire_result.acquired:
            if acquire_result.reason == "busy":
                holder_age = acquire_result.holder_heartbeat_age_seconds
                age_str = f" (heartbeat {holder_age:.1f}s ago)" if holder_age else ""
                print(
                    "\n  \033[31mAnother AgntSpace process is running on this vault.\033[0m"
                    f"\n  Holder PID: {acquire_result.holder_pid}{age_str}"
                    f"\n  Host: {acquire_result.holder_host or '?'}"
                    f"\n  Vault: {vault_path}\n"
                    "\n  Resolve with one of:"
                    "\n    \033[1magntspace ps\033[0m       — show all AgntSpace processes"
                    "\n    \033[1magntspace stop\033[0m     — stop the running server"
                    "\n    \033[1magntspace start --port 8001\033[0m  — run a second instance on a different vault"
                    "\n",
                    flush=True,
                )
                raise SystemExit(2)
            elif acquire_result.reason == "vault_missing":
                # Couldn't create lock dir — log and continue.
                # Lifespan teardown won't try to release.
                log.warning("Process lock skipped: %s", acquire_result.reason)
        else:
            log.debug(
                "Process lock acquired (%s) at %s",
                acquire_result.reason, process_lock.lock_path,
            )

            # Heartbeat task: refresh every HEARTBEAT_INTERVAL seconds.
            # If we lose the lock (heartbeat returns False) we are an
            # orphan — graceful self-shutdown via SIGINT, with a hard
            # fallback to ``os._exit(2)`` if the signal doesn't take
            # effect within 10 seconds.
            from agntspace.infra.process_lock import HEARTBEAT_INTERVAL

            async def _heartbeat_loop() -> None:
                # Visible at INFO so we can confirm the task is running
                # without enabling DEBUG everywhere. Logged once per
                # minute (6 ticks × 10s) to avoid log spam while still
                # giving forensic evidence the task didn't die.
                log.info("ProcessLock: heartbeat task started (interval=%ds)", int(HEARTBEAT_INTERVAL))
                tick = 0
                try:
                    while True:
                        try:
                            await asyncio.sleep(HEARTBEAT_INTERVAL)
                        except asyncio.CancelledError:
                            log.info("ProcessLock: heartbeat task cancelled (graceful shutdown)")
                            return
                        tick += 1
                        try:
                            still_owned = process_lock.heartbeat()
                        except Exception:
                            log.warning("ProcessLock: heartbeat raised (tick=%d)", tick, exc_info=True)
                            continue
                        if not still_owned:
                            log.error(
                                "ProcessLock: lock lost — another AgntSpace took over this vault. "
                                "Initiating graceful self-shutdown."
                            )
                            try:
                                import signal as _sig
                                _sig.raise_signal(_sig.SIGINT)
                            except Exception:
                                log.warning("ProcessLock: SIGINT raise failed", exc_info=True)

                            # Hard fallback if graceful shutdown stalls.
                            async def _force_exit() -> None:
                                await asyncio.sleep(10.0)
                                log.error("ProcessLock: graceful shutdown stalled; forcing exit.")
                                os._exit(2)
                            asyncio.create_task(_force_exit())
                            return  # stop heartbeating
                        if tick % 6 == 0:
                            log.info("ProcessLock: heartbeat alive (tick=%d, ~%ds)", tick, tick * int(HEARTBEAT_INTERVAL))
                except BaseException as exc:
                    # Catch-all so a dying task leaves a trace. BaseException
                    # covers SystemExit / KeyboardInterrupt that would
                    # otherwise vanish silently.
                    log.error("ProcessLock: heartbeat task exiting unexpectedly: %r", exc)
                    raise

            lock_heartbeat_task = asyncio.create_task(_heartbeat_loop(), name="agntspace-process-lock-heartbeat")

    app.state.process_lock = process_lock
    app.state.lock_heartbeat_task = lock_heartbeat_task

    # Migrate KB data from app_dir to vault_dir when they differ
    # (happens when user switches to direct mode — old data stays at ~/.agntspace/)
    if settings.app_dir != vault_path:
        from agntspace.kb.migrate import migrate_kb_data

        migrate_kb_data(src=settings.app_dir, dest=vault_path)

    _step("Initializing database...")
    # One-time migration: move data from old shared DB (~/.agntspace/agntspace.db)
    # into the per-vault DB (root_dir/agntspace/agntspace.db). Each vault owns
    # its own database — conversations, decisions, everything travels with the
    # vault folder. See config.py:db_path for rationale.
    _migrate_shared_db(settings)
    # One-time migration of per-user state (WhatsApp session, plugin data,
    # MCP client config) from ``~/.agntspace/`` into the vault. Follows the
    # same idempotent pattern as ``_migrate_shared_db`` — skipped when the
    # destination already exists.
    _migrate_app_dir_state_to_vault(settings)
    db = await get_db(settings.db_path)
    await init_schema(db)

    # Persistent error logger — SQLite-backed error history
    from agntspace.infra.error_logger import ErrorLogger
    error_logger = ErrorLogger(db)
    await error_logger.init_schema()
    app.state.error_logger = error_logger

    # Vault — path resolver maps logical paths to physical layout
    from agntspace.vault.paths import VaultPaths

    vault_paths = VaultPaths(settings.root_dir)
    vault = VaultReader(vault_path, paths=vault_paths)
    writer = VaultWriter(vault_path, paths=vault_paths)

    # Core services
    memory = ConversationMemory(db)
    # Clean up any legacy empty conversation rows left behind by earlier versions
    # that auto-created a row on WebSocket connect.
    try:
        purged = await memory.purge_empty()
        if purged:
            log.info("Purged %d empty conversation rows", purged)
    except Exception:
        log.debug("Empty-conversation purge failed", exc_info=True)
    credential_store = CredentialStore(settings.credentials_path)

    # Settings service moved up here (was line ~813) so the channel-credential
    # migration can run BEFORE channels are read. Order: credential_store first,
    # settings_service second, migration third, channels read fourth — this is
    # the binding sequence that keeps plaintext bot tokens off disk.
    settings_service = SettingsService(settings.config_path)

    # One-time idempotent migration: any [channels.<name>] bot_token still in
    # config.toml gets moved to the encrypted credential store and stripped
    # from the plaintext config. Safe to call on every boot.
    from agntspace.infra.credentials import migrate_plaintext_channel_credentials
    try:
        _migrated = migrate_plaintext_channel_credentials(
            credential_store=credential_store,
            settings_service=settings_service,
            activity_logger=None,  # logger isn't constructed yet; safe to omit
        )
        if _migrated:
            log.warning(
                "Migrated plaintext channel credentials to encrypted store: %s. "
                "Plaintext bot tokens have been stripped from config.toml.",
                ", ".join(_migrated),
            )
    except Exception:
        log.exception("Channel credential migration failed; continuing with whatever's in config.toml")

    llm = get_provider(settings, credential_store=credential_store)
    journal = JournalService(vault, writer)  # skill_loader wired below after SkillLoader init

    # Activity Logger — centralized event collector for deep agent awareness
    from agntspace.activity.logger import ActivityLogger
    activity_logger = ActivityLogger(journal)  # skill_loader wired below after SkillLoader init

    # Decision Logger — traceable "why" behind every agent action (Rule #13)
    from agntspace.activity.decisions import DecisionLogger
    decision_logger = DecisionLogger(db)
    await decision_logger.initialize()

    # Entity-resolution audit log — captures the BEFORE state of every
    # destructive ``merge_entities`` call so the operation can be reversed
    # via ``unmerge_entities``. See `tasks/er-minimal-plan.md`. Wired onto
    # both ``app.state.entity_audit`` (for route handlers) and
    # ``knowledge_graph._audit`` (post-hoc below, after the graph instance exists).
    from agntspace.memory.entity_resolution_audit import EntityResolutionAuditLog
    entity_audit = EntityResolutionAuditLog(db)
    await entity_audit.initialize()

    # Finance subsystem — Phase 1 (receipts → drafts → ledger).
    # FinanceLedger + MerchantResolver share the per-vault db connection;
    # ReceiptIngest is constructed below once the LLM + writer + skills
    # exist. The ledger's schema migration is idempotent, so first-boot
    # vaults that never use finance still pay zero cost beyond the
    # CREATE TABLE IF NOT EXISTS calls.
    from agntspace.finance.ledger import FinanceLedger
    from agntspace.finance.merchants import MerchantResolver
    finance_ledger = FinanceLedger(db)
    await finance_ledger.initialize()
    merchant_resolver = MerchantResolver(db)
    await merchant_resolver.initialize()
    app.state.finance_ledger = finance_ledger
    app.state.merchant_resolver = merchant_resolver
    # ReceiptIngest is constructed after writer + skills are wired (~line 1075)

    # Evolution Service — repo-native Bayesian self-improvement control plane.
    # Wired here (right after decision_logger) so the ledger tables exist
    # before any invoke_skill call; skill_loader + vault_writer are wired
    # below via attribute assignment once they're constructed.
    from agntspace.evolution.service import EvolutionService
    evolution = EvolutionService(
        db=db,
        decision_logger=decision_logger,
        # skill_loader + vault_writer + cost_tracker attached post-hoc below
    )
    await evolution.initialize()

    # Review History — persistent record of all approve/reject actions
    from agntspace.memory.review_history import ReviewHistoryService
    review_history = ReviewHistoryService(db)
    await review_history.initialize()
    app.state.review_history = review_history

    # Rate limiter — attach SQLite so bucket state survives restart
    from agntspace.infra.rate_limit import get_limiter
    await get_limiter().attach_db(db)

    # Load vault-editable cross-language entity aliases (spec v1.1 follow-up)
    try:
        from agntspace.infra.language import load_user_aliases
        aliases_path = vault_paths.resolve("system") / "entity_aliases.yaml"
        count = load_user_aliases(aliases_path)
        if count:
            log.info("Loaded %d user-defined entity aliases from %s", count, aliases_path)
    except Exception:
        log.debug("Failed to load user entity aliases", exc_info=True)

    # Load signed-skill trust store (publishers + keys)
    try:
        from agntspace.skills.signing import load_trust_store
        trust_path = vault_paths.resolve("system") / "skill_publishers.yaml"
        trust_count = load_trust_store(trust_path)
        if trust_count:
            log.info("Loaded %d skill publishers from %s", trust_count, trust_path)
    except Exception:
        log.debug("Failed to load skill trust store", exc_info=True)

    task_service = TaskService(vault, writer)
    trust_service = TrustService(vault, writer)
    contract = ContractEnforcer(vault_path, paths=vault_paths)

    # Sync contract privacy mode + model with actual LLM config
    _sync_contract_with_config(contract, vault, writer, settings)

    # Intelligence layer
    vault_search = VaultSearch(db, vault)
    await vault_search.index_vault()

    # Semantic search (embeddings) — lazy-loaded, won't download model until first query
    from agntspace.vault.embeddings import VaultEmbeddings
    vault_embeddings = VaultEmbeddings(db, vault)
    await vault_embeddings.init_schema()

    _step("Loading skills and agents...")
    # One-time vault migration: the user-skill category was renamed from
    # `custom/` → `private/` to clarify its load-bearing property (these
    # skills are NEVER shipped in the wheel and NEVER touched by the
    # defaults sync). Idempotent: only fires when legacy exists and new
    # doesn't; never merges the two. Runs BEFORE the sync so the sync
    # scanner sees the renamed tree.
    _legacy_custom = settings.skills_dir / "custom"
    _new_private = settings.skills_dir / "private"
    if _legacy_custom.is_dir() and not _new_private.exists():
        try:
            _legacy_custom.rename(_new_private)
            log.info("Migrated skills category: custom/ -> private/")
        except Exception:
            log.exception("Failed to migrate custom/ -> private/ (manual rename needed)")

    # Hybrid defaults sync: shipped `defaults/skills/` and `defaults/vault/agents/`
    # are reconciled against the vault copies using hash gating, so shipped
    # fixes reach existing vaults without clobbering user edits. Skills have
    # a `_meta/*` engine tier that silently overwrites; agents have no meta
    # tier (every agent is user-tunable). Real conflicts land on the pending
    # queues reviewable at /api/skills/sync/pending and /api/agents/sync/pending.
    # Safe no-op when shipped defaults can't be located (broken dev env).
    try:
        from agntspace.setup.init import _find_defaults_dir as _find_app_defaults
        from agntspace.skills.sync import AgentSync, SkillSync, _load_release_notes
        _defaults_root = _find_app_defaults()
        _shipped_skills_dir = _defaults_root / "skills"
        _shipped_agents_dir = _defaults_root / "vault" / "agents"
        _vault_agents_dir = settings.vault_dir / "system" / "agents"
        # Release-notes classifications gate silent-vs-pending decisions.
        # Missing file/unknown entries degrade to "behavior" (pending) by
        # design (transparent default). ActivityLogger receives high-
        # importance events for every meta + safety silent upgrade so the
        # user has a full audit trail.
        _release_notes = _load_release_notes(_defaults_root)

        _skill_report = SkillSync(
            settings.skills_dir, _shipped_skills_dir,
            release_notes=_release_notes, activity_logger=activity_logger,
        ).sync_on_boot()
        _agent_report = AgentSync(
            _vault_agents_dir, _shipped_agents_dir,
            release_notes=_release_notes, activity_logger=activity_logger,
        ).sync_on_boot()

        _skill_summary = _skill_report.summary()
        _agent_summary = _agent_report.summary()
        if any(_skill_summary.values()):
            log.info("skill sync: %s", _skill_summary)
        if any(_agent_summary.values()):
            log.info("agent sync: %s", _agent_summary)
        if _skill_report.pending:
            log.info("skill sync: %d skill(s) need review at /skills/sync", len(_skill_report.pending))
        if _agent_report.pending:
            log.info("agent sync: %d agent(s) need review at /agents/sync", len(_agent_report.pending))
        app.state.skill_sync_last_report = _skill_summary  # noqa: SLF001
        app.state.agent_sync_last_report = _agent_summary  # noqa: SLF001
    except Exception:
        log.exception("defaults sync failed — continuing with existing vault copies")

    skills = SkillLoader(settings.skills_dir)
    skills.load_all()

    # ── Voyager-style semantic retrieval (2026-04-24) ──
    # Wire the VaultEmbeddings provider AFTER load_all() so every active
    # skill's descriptor gets a cached embedding at boot. Failure modes
    # (Ollama offline, embedding model not pulled, network hiccup) are
    # all silent — the loader falls back to priority-ordered non-semantic
    # paths if embeddings aren't available, preserving pre-Voyager
    # behavior exactly.
    try:
        skills.set_embedder(vault_embeddings)
    except Exception:
        log.debug("Skill embedding wiring failed — falling back to non-semantic paths", exc_info=True)

    # Wire skill_loader into journal + activity (created before SkillLoader was ready)
    journal._skill_loader = skills
    memory._skill_loader = skills
    cfg = _load_journal_config_deferred(skills)
    if cfg:
        from agntspace.journal.service import (
            _FALLBACK_AGENT_SECTIONS,
            _FALLBACK_FILE_TYPES,
            _FALLBACK_TIMESTAMP_FORMAT,
            _FALLBACK_USER_SECTIONS,
            _parse_section_list,
        )
        journal._user_sections = _parse_section_list(cfg.get("user_sections")) or _FALLBACK_USER_SECTIONS
        journal._agent_sections = _parse_section_list(cfg.get("agent_sections")) or _FALLBACK_AGENT_SECTIONS
        journal._file_types = cfg.get("file_types") if isinstance(cfg.get("file_types"), dict) else _FALLBACK_FILE_TYPES
        journal._timestamp_format = cfg.get("timestamp_format") or _FALLBACK_TIMESTAMP_FORMAT
    from agntspace.activity.logger import (
        _FALLBACK_CATEGORY_ORDER,
        _FALLBACK_SECTION_MAP,
        _FALLBACK_SILENCE_THRESHOLDS,
        _load_activity_config,
    )
    act_cfg = _load_activity_config(skills)
    if act_cfg:
        activity_logger._section_map = act_cfg.get("section_map") if isinstance(act_cfg.get("section_map"), dict) else _FALLBACK_SECTION_MAP
        activity_logger._silence_thresholds = act_cfg.get("silence_thresholds") if isinstance(act_cfg.get("silence_thresholds"), dict) else _FALLBACK_SILENCE_THRESHOLDS
        activity_logger._category_order = act_cfg.get("category_order") if isinstance(act_cfg.get("category_order"), list) else _FALLBACK_CATEGORY_ORDER

    # ── Phase 4: skill-driven config for trust + outreach ──
    try:
        from agntspace.trust.service import (
            set_trust_domain_map,
            set_trust_signals,
            set_trust_thresholds,
        )
        trust_cfg = skills.load_skill_config_file("trust-evolution", "thresholds.yaml")
        set_trust_thresholds(trust_cfg)
        domains_cfg = skills.load_skill_config_file("trust-evolution", "domains.yaml")
        set_trust_domain_map(domains_cfg)
        signals_cfg = skills.load_skill_config_file("trust-evolution", "signals.yaml")
        set_trust_signals(signals_cfg)
    except Exception:
        log.exception("Failed to load trust config YAML -- using built-in defaults")
    try:
        from agntspace.activity.outreach import set_outreach_triggers
        outreach_cfg = skills.load_skill_config_file("proactive-outreach", "triggers.yaml")
        set_outreach_triggers(outreach_cfg)
    except Exception:
        log.exception("Failed to load outreach triggers.yaml -- using built-in defaults")

    # Model router — resolves agent model tiers to specific models based on user's quality profile
    from agntspace.llm.model_router import ModelRouter
    active_provider = settings.llm.local_provider if settings.llm.mode == "local" else settings.llm.cloud_provider
    active_profile = "local" if settings.llm.mode == "local" else settings.llm.quality_profile

    # Reconcile the local.ollama tiers in models.yaml against what's actually
    # installed in Ollama. Prevents "Model not found" errors when shipped
    # defaults (qwen2.5) don't match the user's installed models.
    #
    # Runs on every startup regardless of current LLM mode — a cloud-mode
    # user who has Ollama installed (typical setup: cloud for chat + Ollama
    # for local embeddings via bge-m3) still deserves a correct local tier
    # mapping so that (a) switching to local mode later Just Works, (b) any
    # path that routes to the local provider at runtime (work-queue fallback,
    # health-recovery, privacy-sensitive flows) picks real installed models.
    # Pre-session-fix this was gated on `mode == "local"` which meant users
    # who installed models via Settings UI but stayed in cloud mode watched
    # their local tier map rot forever.
    from agntspace.llm.ollama_discovery import (
        _installed_models,
        ensure_ollama_running,
        reconcile_local_tiers,
    )

    _ollama_reachable = False
    if settings.llm.mode == "local":
        # Local mode: Ollama is required, so auto-start it if possible and
        # surface status to the boot banner.
        if ensure_ollama_running():
            _step("  Ollama is running")
            _ollama_reachable = True
        else:
            _step("\033[33m!!  Could not reach or start Ollama\033[0m")
    else:
        # Cloud mode: don't try to start Ollama, but if it's already running
        # (e.g. for embeddings) still reconcile the tier map against it.
        try:
            _ollama_reachable = bool(_installed_models())
        except Exception:
            _ollama_reachable = False

    if _ollama_reachable:
        reconcile_result = reconcile_local_tiers(settings.skills_dir)
        if reconcile_result["status"] == "updated":
            log.info(
                "Local tiers auto-configured: %s",
                reconcile_result["after"],
            )
        elif reconcile_result["status"] == "skipped":
            log.debug("Local tier reconcile skipped: %s", reconcile_result["reason"])

    # Sanity-check the configured local model only when local mode is selected —
    # in cloud mode the local_model setting is just a default for the fallback
    # tier and the user hasn't opted into using it, so a warning would be noise.
    if settings.llm.mode == "local":
        try:
            installed = _installed_models()
            if not installed:
                _step("\033[33m!!  Ollama is running but has no models installed.\033[0m")
                _step("\033[33m   Run: ollama pull llama3:latest\033[0m")
                _step("\033[33m   Or switch to cloud mode in Settings.\033[0m")
            else:
                installed_names = {m["name"] for m in installed}
                configured = settings.llm.local_model
                if configured not in installed_names and f"{configured}:latest" not in installed_names:
                    # Base name not found at all
                    base = configured.split(":")[0]
                    if not any(n.startswith(f"{base}:") for n in installed_names):
                        _step(
                            f"\033[33m!!  Configured model '{configured}' not found in Ollama.\033[0m"
                        )
                        _step(
                            f"\033[33m   Installed: {', '.join(sorted(installed_names)[:5])}\033[0m"
                        )
                        _step(f"\033[33m   Run: ollama pull {configured}\033[0m")
        except Exception:
            # Ollama unreachable — will surface later via the work queue
            _step("\033[33m!!  Ollama is not reachable at localhost:11434\033[0m")
            _step("\033[33m   Start Ollama or switch to cloud mode in Settings.\033[0m")

    model_router = ModelRouter(
        provider=active_provider,
        profile=active_profile,
        default_model=settings.active_model,
        skills_dir=settings.skills_dir,
        mode=settings.llm.mode,
    )

    orchestrator = Orchestrator(vault, llm, skill_loader=skills, contract=contract, db=db)
    orchestrator.load_agents()

    from agntspace.projects.service import ProjectService
    project_service = ProjectService(vault, writer)

    reflection_service = ReflectionService(vault, writer, llm, skill_loader=skills, model_router=model_router)  # memory_engine wired below
    coach = CoachService(vault, writer)
    skillschool = SkillSchoolService(vault, writer, task_service, llm)

    # Domains
    domains = {name: DomainService(name, vault, writer) for name in DOMAIN_NAMES}

    # Channels — read persisted tokens from the encrypted credential store
    # first; fall back to config.toml only when the migration hasn't run yet
    # (legacy vaults). load_channel_credentials() handles the precedence so
    # callers don't have to.
    channel_manager = ChannelManager()
    from agntspace.infra.credentials import load_channel_credentials as _load_ch_creds

    # Telegram: auto-connect if bot token is persisted
    _tg_cfg = _load_ch_creds(
        "telegram",
        credential_store=credential_store,
        settings_service=settings_service,
    )
    telegram_token = _tg_cfg.get("bot_token", "") or settings.__dict__.get("telegram_bot_token", "")
    if telegram_token:
        from agntspace.channels.telegram import TelegramChannel

        tg = TelegramChannel(telegram_token, _tg_cfg.get("webhook_url", ""))
        channel_manager.register(tg)

        async def _tg_bg_connect() -> None:
            try:
                await tg.connect()
                log.debug("Telegram auto-connected: @%s", tg._bot_username)
            except Exception:
                log.debug("Telegram auto-connect failed", exc_info=True)

        asyncio.create_task(_tg_bg_connect())

    # Discord: auto-connect if bot token is persisted
    _dc_cfg = _load_ch_creds(
        "discord",
        credential_store=credential_store,
        settings_service=settings_service,
    )
    if _dc_cfg.get("bot_token"):
        from agntspace.channels.discord import DiscordChannel

        dc = DiscordChannel(_dc_cfg["bot_token"])
        channel_manager.register(dc)

        async def _dc_bg_connect() -> None:
            try:
                await dc.connect()
                log.debug("Discord auto-connected")
            except Exception:
                log.debug("Discord auto-connect failed", exc_info=True)

        asyncio.create_task(_dc_bg_connect())

    # Slack: auto-connect if bot token is persisted
    _sl_cfg = _load_ch_creds(
        "slack",
        credential_store=credential_store,
        settings_service=settings_service,
    )
    if _sl_cfg.get("bot_token"):
        from agntspace.channels.slack import SlackChannel

        sl = SlackChannel(_sl_cfg["bot_token"])
        channel_manager.register(sl)

        async def _sl_bg_connect() -> None:
            try:
                await sl.connect()
                log.debug("Slack auto-connected")
            except Exception:
                log.debug("Slack auto-connect failed", exc_info=True)

        asyncio.create_task(_sl_bg_connect())

    _step("Connecting channels...")
    # WhatsApp: auto-reconnect if a previous session exists.
    # Bridge stores session at <vault>/agntspace/channels/whatsapp/data/
    # whatsapp-session/ (migrated from ~/.agntspace/data/... on boot).
    whatsapp_session = settings.whatsapp_dir / "data" / "whatsapp-session" / "creds.json"
    if whatsapp_session.exists():
        # Validate session file is readable JSON before attempting reconnect
        session_valid = False
        try:
            import json as _json
            _json.loads(whatsapp_session.read_text(encoding="utf-8"))
            session_valid = True
        except (ValueError, OSError):
            log.warning("WhatsApp session file corrupted — deleting for fresh QR scan")
            whatsapp_session.unlink(missing_ok=True)

        if session_valid:
            from agntspace.channels.whatsapp import WhatsAppChannel

            wa = WhatsAppChannel()
            wa.set_db(db)
            channel_manager.register(wa)

            async def _wa_bg_connect() -> None:
                """Connect WhatsApp in background — never block server startup."""
                try:
                    await wa.connect()
                    log.debug("WhatsApp auto-reconnected from saved session")
                except Exception:
                    log.debug("WhatsApp auto-reconnect failed (may need re-scan)", exc_info=True)

            asyncio.create_task(_wa_bg_connect())

    # settings_service is created earlier (alongside credential_store) so the
    # channel-credential migration can run before channels are read. The line
    # below is intentionally a no-op — kept as a documentation anchor for
    # anyone searching for the SettingsService construction site.
    # (was: settings_service = SettingsService(settings.config_path))

    # Vault adoption (Obsidian direct mode)
    sync_config = settings_service.get_section("sync")
    vault_sync = VaultSync(
        vault_dir=vault_path,
        external_dir=(
            Path(sync_config["external_path"]) if sync_config.get("external_path") else None
        ),
        mode=sync_config.get("mode", "off"),
    )

    # Integrations
    integration_manager = IntegrationManager()
    from agntspace.integrations.email_client import EmailRegistry
    email_registry = EmailRegistry()
    # Legacy shim — points at the first connected account for backward compat
    email_integration = EmailIntegration()
    integration_manager.register(email_integration)
    integration_manager.register(GmailIntegration())
    integration_manager.register(GCalIntegration())
    integration_manager.register(GitHubIntegration())

    # Auto-reconnect integrations from saved credentials
    for service_name in credential_store.list_services():
        creds = credential_store.get(service_name)
        if creds:
            try:
                if service_name == "email":
                    # Legacy single-account key → registry as "default"
                    await email_registry.connect("default", creds)
                    log.debug("Auto-reconnected email: default")
                elif service_name.startswith("email."):
                    account_id = service_name.split(".", 1)[1]
                    await email_registry.connect(account_id, creds)
                    log.debug("Auto-reconnected email: %s", account_id)
                else:
                    await integration_manager.connect(service_name, creds)
                    log.debug("Auto-reconnected: %s", service_name)
            except Exception:
                log.debug("Auto-reconnect failed for %s", service_name, exc_info=True)

    # Point legacy shim at the default connected account
    _default_em = email_registry.get_default()
    if _default_em:
        email_integration = _default_em

    # Autopilot
    autopilot = AutopilotService()
    audit = AuditTrail(vault_paths.resolve("audit.log"))
    contract._audit_fn = audit.log_action  # Wire contract checks to audit trail
    guardian = GuardianService(vault, writer)

    # DM pairing service
    from agntspace.security.pairing import PairingService
    pairing = PairingService(db)
    app.state.pairing = pairing

    # Canvas service (A2UI) — persistent via SQLite
    canvas = CanvasService(db)
    app.state.canvas = canvas

    # Entity registry (single source of truth for all entities)
    from agntspace.memory.entity_registry import EntityRegistry
    entity_registry = EntityRegistry(vault=vault, writer=writer)

    # Knowledge graph + memory engine (SPO triples stored in vault JSON files).
    #
    # The graph *instance* is created synchronously so downstream services can
    # hold a reference (coach._graph, project_service._graph, etc.) — but the
    # *build* (scanning 30k+ markdown files) is deferred to a background task
    # so the HTTP port binds immediately. Until the build completes, query
    # results are empty (graceful degrade); /api/health reports
    # {ready: false, boot_phase: "building_graph", graph_progress: {...}}
    # so clients can distinguish "still booting" from "dead".
    _step("Loading knowledge graph...")
    knowledge_graph = KnowledgeGraph(vault, writer=writer, llm=llm, entity_registry=entity_registry, skill_loader=skills)

    def _graph_progress(scanned: int, total: int) -> None:
        # Emitted every 1000 files + once at completion — lets the user see
        # that the scan is still progressing on large vaults (30k+ md files).
        app.state.graph_progress = {"scanned": scanned, "total": total}
        _step(f"  scanning files: {scanned}/{total}")
    # Phase 6D: wire the embedding model so rerank_by_attention and
    # resolve_by_description can use the real BGE-M3 in production.
    # The model is lazy-loaded (first use triggers download), so this
    # assignment is cheap. The graph stores the VaultEmbeddings instance
    # and calls _get_model() only when an embedder is actually needed.
    knowledge_graph._embeddings_service = vault_embeddings  # noqa: SLF001
    # Share the embedder with the entity registry for Tier 4 dedup
    entity_registry._embedder = knowledge_graph.get_embedder()  # noqa: SLF001

    # Post-wire knowledge graph into services that mutate project/task/goal
    # files — lets their structural relationships (project→goal, task→project,
    # agent→project) land in the graph the instant a file is written, not on
    # the next full rebuild.
    coach._graph = knowledge_graph  # noqa: SLF001
    coach._trust = trust_service  # noqa: SLF001  # live trust gate in get_pursuit_status
    coach._contract = contract  # noqa: SLF001  # proactivity for trust formula
    coach._llm_mode = settings.llm.mode  # noqa: SLF001  # "local" / "cloud" — budget gate mode
    project_service._graph = knowledge_graph  # noqa: SLF001
    task_service._graph = knowledge_graph  # noqa: SLF001

    memory_engine = MemoryEngine(
        vault_reader=vault,
        vault_writer=writer,
        llm=llm,
        conversation_memory=memory,
        journal=journal,
        task_service=task_service,
        trust_service=trust_service,
        coach=coach,
        email=email_integration,
        cost_tracker=None,  # wired below
        knowledge_graph=knowledge_graph,
        skill_loader=skills,
        model_router=model_router,
    )

    # Cost tracker
    from agntspace.infra.cost_tracker import CostTracker, set_pricing
    cost_settings = settings_service.get_section("costs") or {}
    cost_tracker = CostTracker(
        db,
        daily_budget=float(cost_settings.get("daily_budget", 10.0)),
        monthly_budget=float(cost_settings.get("monthly_budget", 100.0)),
        enforce_budget=bool(cost_settings.get("enforce_budget", True)),
    )
    await cost_tracker.init_schema()
    # Load pricing from skill config YAML (vault override → bundled default)
    set_pricing(skill_loader=skills)
    memory_engine._cost_tracker = cost_tracker  # noqa: SLF001

    # Wire memory_engine into reflection (created before memory_engine existed)
    reflection_service._memory_engine = memory_engine  # noqa: SLF001

    # Background tasks
    sched = settings.schedule
    heartbeat = Heartbeat(
        journal,
        task_service,
        db=db,
        interval_seconds=sched.heartbeat_seconds,
        goal_pursuit_seconds=sched.goal_pursuit_seconds,
        llm=llm,
    )
    await heartbeat.init_schema()
    scheduler = TaskScheduler(task_service, journal, interval_seconds=sched.scheduler_seconds)

    # Persistent cron scheduler
    cron = CronScheduler(db, journal, heartbeat=heartbeat)
    await cron.init_schema()

    # Persistent work queue — guarantees all LLM-dependent work completes
    from agntspace.automation.work_queue import WorkQueue

    work_queue = WorkQueue(db, heartbeat=heartbeat)
    await work_queue.init_schema()
    # Set initial LLM health and active model
    model_name = getattr(llm, "model_name", getattr(llm, "_model", "unknown"))
    work_queue.set_llm_health(getattr(llm, "healthy", True), model=model_name)
    work_queue._llm = llm  # noqa: SLF001 — self-healing reprobe when heartbeat is too slow
    app.state.work_queue = work_queue

    daily_cycle = DailyCycle(
        llm=llm,
        journal=journal,
        task_service=task_service,
        coach=coach,
        trust_service=trust_service,
        reflection_service=reflection_service,
        vault_reader=vault,
        vault_writer=writer,
        memory_engine=memory_engine,
        heartbeat=heartbeat,
        morning_hour=sched.morning_hour,
        evening_hour=sched.evening_hour,
        weekly_compaction_day=sched.weekly_compaction_day,
        skillschool=skillschool,
        vault_search=vault_search,
        orchestrator=orchestrator,
        skill_loader=skills,
        model_router=model_router,
    )

    _step("Registering tools...")
    from agntspace.agent.tools import ToolExecutor

    # Finance receipt ingest — needs LLM + writer + skills, so it's
    # constructed here (after all three exist) rather than alongside the
    # ledger/merchant resolver up at line ~493. The skill_loader is
    # optional but lets the engine read receipt-image / receipt-pdf
    # ``prompt_focus`` from YAML (Rule #12).
    from agntspace.finance.ingest import ReceiptIngest
    receipt_ingest = ReceiptIngest(
        llm=llm,
        merchants=merchant_resolver,
        writer=writer,
        ledger=finance_ledger,
        skill_loader=skills,
        vault_paths=vault_paths,
    )
    app.state.receipt_ingest = receipt_ingest

    # One-time idempotent migration: move historical receipts out of
    # finance/inbox/ into finance/library/YYYY-MM/{txn_id}{ext} so the
    # vault state matches the post-2026-04 attachment layout. Bounded at
    # 500 rows/pass so a huge backlog can't block startup; the next boot
    # picks up the rest. Failures are logged + counted, never raised.
    try:
        from agntspace.finance.migrations import migrate_inbox_attachments

        _finance_migration_counters = await migrate_inbox_attachments(
            finance_ledger, vault_paths, writer, reader=vault,
        )
        if _finance_migration_counters.get("migrated"):
            log.info(
                "Finance: attached %d historical receipt(s) to library/",
                _finance_migration_counters["migrated"],
            )
        if _finance_migration_counters.get("markdown_rewrites"):
            log.info(
                "Finance: healed %d stale draft/transaction frontmatter pointer(s)",
                _finance_migration_counters["markdown_rewrites"],
            )
    except Exception:
        log.exception("Finance inbox-attachment migration failed (non-fatal)")

    # Wire the receipt pipeline into the Telegram channel so dropping a
    # photo into the bot routes directly to finance/inbox/ → ReceiptIngest
    # → reply with draft summary, without invoking the chat agent. The
    # channel's photo intercept is a no-op when either dep is None — so
    # this stays safe for vaults that disable Telegram or finance.
    # ConversationMemory is also wired so the photo + bot reply are
    # mirrored into the AgntSpace chat conversation
    # (channel-telegram-<chat_id>) for the web UI to render.
    _tg_channel_obj = channel_manager.get_channel("telegram")
    if _tg_channel_obj is not None:
        _tg_channel_obj._receipt_ingest = receipt_ingest  # noqa: SLF001
        _tg_channel_obj._vault_paths = vault_paths  # noqa: SLF001
        _tg_channel_obj._conversation_memory = memory  # noqa: SLF001
        # ActivityLogger so cooperative-shutdown transitions
        # (telegram_polling_dormant / telegram_polling_resumed) surface
        # in /api/activity and the agent's relationship context. See
        # ``TelegramChannel._poll_loop`` for the conflict handling design.
        _tg_channel_obj._activity = activity_logger  # noqa: SLF001

    tool_executor = ToolExecutor(
        contract=contract,
        email=email_integration,
        vault_reader=vault,
        vault_writer=writer,
        vault_search=vault_search,
        task_service=task_service,
        journal=journal,
        coach=coach,
        memory_engine=memory_engine,
        orchestrator=orchestrator,
        project_service=project_service,
        knowledge_graph=knowledge_graph,
        vault_embeddings=vault_embeddings,
        channel_manager=channel_manager,
        finance_ledger=finance_ledger,
        merchant_resolver=merchant_resolver,
        receipt_ingest=receipt_ingest,
    )

    # Post-hoc wiring so research_scrape can emit high-importance failure
    # events (Rule #13) without widening ToolExecutor's constructor.
    tool_executor._activity = activity_logger  # noqa: SLF001
    # Skill loader → research-scrape reads the browser-automation skill's
    # quality + relevance YAML blocks at call time so users tune scraping
    # gates by editing skill YAML, not Python.
    tool_executor._skill_loader = skills  # noqa: SLF001
    # Canvas + main-loop references — used by create_presentation's
    # show_on_canvas path to push themed HTML cards from the sync
    # worker-thread executor onto the live canvas via
    # asyncio.run_coroutine_threadsafe.
    import asyncio as _aio_main
    tool_executor._canvas = canvas  # noqa: SLF001
    try:
        _running_loop = _aio_main.get_running_loop()
        tool_executor._main_loop = _running_loop  # noqa: SLF001
        # Wire the module-level main-loop reference so _run_async()
        # can route bridge coroutines back to the loop that owns
        # aiosqlite Connections + vault writers + service singletons,
        # instead of spawning a fresh loop in a worker thread (which
        # captures none of those bindings, hangs on cross-loop futures,
        # and saturates the default ThreadPoolExecutor — that's the
        # "/api/health takes 5 minutes" symptom).
        from agntspace.agent.tools import set_main_loop as _set_tool_main_loop
        _set_tool_main_loop(_running_loop)
    except RuntimeError:
        tool_executor._main_loop = None  # noqa: SLF001

    # Expose tool_executor on app.state so the MCP SSE transport
    # (remote MCP clients) can reuse it. Same executor as chat — full
    # contract enforcement, activity logging, decision records, trust
    # gating. No duplicate state.
    app.state.tool_executor = tool_executor

    # MCP bearer-token store — auth for remote SSE clients. Needs the
    # async DB handle, so it's initialised here (not in create_app()).
    from agntspace.mcp.sessions import SseSessionTracker
    from agntspace.mcp.tokens import TokenStore
    from agntspace.mcp.tokens import init_schema as _init_mcp_token_schema

    await _init_mcp_token_schema(db)
    app.state.mcp_tokens = TokenStore(db)
    # Live SSE session registry. The SSE handler registers each
    # connection here; the revoke API signals matching sessions to
    # close mid-stream so a leaked token can't keep streaming.
    app.state.mcp_sse_tracker = SseSessionTracker()

    # Install the scraping strategy from the browser-automation skill into
    # integrations/browser.py. Lets users tune backend preference, timeouts,
    # concurrency, and anti-bot retry by editing SKILL.md frontmatter — no
    # code changes, no restart beyond the usual one. Fails-safe: unknown
    # skill or empty config leaves the defaults in place.
    try:
        from agntspace.integrations import browser as _browser_mod
        _scrape_cfg = skills.get_skill_config("browser-automation", "scraping")
        _browser_mod.set_scraping_config(_scrape_cfg)
    except Exception:
        log.debug("Failed to install scraping config from browser-automation skill", exc_info=True)

    # Build tool registry — single source of truth for capabilities
    from agntspace.agent.namespaces import NamespaceResolver
    from agntspace.agent.registry import ToolRegistry

    registry = ToolRegistry()
    # Attach the namespace resolver BEFORE registering any tools so each
    # registration picks up its namespace from the skill YAML. The resolver
    # is stateless after construction; safe to share across the process.
    namespace_resolver = NamespaceResolver.from_skills_dir(settings.skills_dir)
    registry.set_namespace_resolver(namespace_resolver)
    app.state.namespace_resolver = namespace_resolver
    tool_executor.populate_registry(registry)

    # Wire tool access and model routing into orchestrator
    orchestrator._registry = registry  # noqa: SLF001
    orchestrator._tool_executor = tool_executor  # noqa: SLF001
    orchestrator._model_router = model_router  # noqa: SLF001
    orchestrator._heartbeat = heartbeat  # noqa: SLF001
    orchestrator._writer = writer  # noqa: SLF001  # for trusted_scope on subagent tool execution
    orchestrator._namespace_resolver = namespace_resolver  # noqa: SLF001  # narrow tools by turn relevance when skill_scope is None

    # Register KB research as an agent tool
    registry.register_integration(
        "kb", "Knowledge Base",
        "Research questions against personal knowledge bases (wiki articles compiled from raw sources).",
        lambda: True,
    )

    async def _kb_research_handler(inp: dict) -> dict:
        topic = inp.get("topic", "")
        query = inp.get("query", "")
        if not topic or not query:
            return {"error": "Both topic and query are required."}
        return await kb_service.research(topic, query, save_output=inp.get("save_output", False))

    # Wrap async handler for sync registry
    def _kb_research_sync(inp: dict) -> dict:
        import asyncio
        return asyncio.get_event_loop().run_until_complete(_kb_research_handler(inp))

    registry.register_tool(
        name="research_kb",
        description="Research a question against a personal knowledge base. Use when the user asks complex questions about a topic they have a KB for.",
        input_schema={
            "type": "object",
            "properties": {
                "topic": {"type": "string", "description": "KB slug (e.g., 'ai-safety', 'quantum-computing')"},
                "query": {"type": "string", "description": "The research question"},
                "save_output": {"type": "boolean", "description": "Save the research output to the KB", "default": False},
            },
            "required": ["topic", "query"],
        },
        handler=_kb_research_sync,
        async_handler=_kb_research_handler,
        integration="kb",
        contract_action="search",
    )

    # Register KB creation tool — agent can create KBs autonomously
    def _create_kb_handler(inp: dict) -> dict:
        topic = inp.get("topic", "").strip()
        if not topic:
            return {"error": "topic is required — the name/slug for the KB."}
        return kb_service.create_kb(
            topic,
            description=inp.get("description", ""),
            focus_questions=inp.get("focus_questions") or [],
            source_types=inp.get("source_types") or [],
            notes=inp.get("notes", ""),
        )

    registry.register_tool(
        name="create_kb",
        description=(
            "Create a new knowledge base. Use when the user asks to research a new topic, "
            "build a wiki on a subject, or track knowledge about a domain. "
            "Always create a KB BEFORE using research_scrape or scrape_url — those tools need an existing KB."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "topic": {
                    "type": "string",
                    "description": "KB name/slug (e.g., 'floods-in-colombia', 'ai-safety', 'competitor-analysis')",
                },
                "description": {
                    "type": "string",
                    "description": "What is this KB about? What questions should it answer?",
                },
                "focus_questions": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Key questions this KB should help answer",
                },
                "source_types": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Expected source types (e.g., 'news articles', 'academic papers', 'government reports')",
                },
                "notes": {
                    "type": "string",
                    "description": "Domain-specific instructions for the agent when processing this KB",
                },
            },
            "required": ["topic"],
        },
        handler=_create_kb_handler,
        integration="kb",
        contract_action="create_task",
    )

    # Register wiki job tool — full pipeline via chat.
    # The handler wraps in writer.trusted_scope so every vault write
    # inside the pipeline passes the runtime guard (Rule #3). Without
    # this, the tool handler runs inside the calling scope's correlation
    # chain (e.g. watcher-*, channel-*, chat-*) and writer.write()
    # rejects every write as ContractBypassError because no specific
    # contract_action was authorized for the pipeline's dozen internal
    # writes. trusted_scope is the correct fix — each service method
    # (ingest, compile, reflect, lint) has its own scoped_pipeline that
    # handles its own authorization, but run_wiki_job orchestrates them
    # all and the glue code between them also writes (log.md, etc.).
    async def _wiki_job_handler(inp: dict) -> dict:
        slug = inp.get("kb", "")
        skip_lint = inp.get("skip_lint", False)
        skip_reflect = inp.get("skip_reflect", False)
        with writer.trusted_scope("tool_run_wiki_job"):
            if not slug:
                # Run across all KBs
                kbs = kb_service.list_kbs()
                if not kbs:
                    return {"error": "No knowledge bases found. Create one first."}
                results = []
                for kb_info in kbs:
                    r = await kb_service.run_wiki_job(
                        kb_info["slug"], skip_lint=skip_lint, skip_reflect=skip_reflect,
                    )
                    results.append(r)
                total_ingested = sum(r.get("ingested", 0) for r in results)
                total_compiled = sum(r.get("compiled", 0) for r in results)
                total_issues = sum(r.get("issues", 0) for r in results)
                total_decisions = sum(r.get("decisions_created", 0) for r in results)
                return {
                    "kbs_processed": len(results),
                    "total_ingested": total_ingested,
                    "total_compiled": total_compiled,
                    "total_decisions": total_decisions,
                    "total_issues": total_issues,
                    "details": results,
                }
            return await kb_service.run_wiki_job(
                slug, skip_lint=skip_lint, skip_reflect=skip_reflect,
            )

    def _wiki_job_sync(inp: dict) -> dict:
        import asyncio
        return asyncio.get_event_loop().run_until_complete(_wiki_job_handler(inp))

    registry.register_tool(
        name="run_wiki_job",
        description=(
            "Run the full wiki processing pipeline: snapshot → ingest → compile → reflect → lint. "
            "Use when the user asks to process the wiki, run KB maintenance, or update knowledge bases. "
            "If no KB slug is provided, runs across ALL knowledge bases."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "kb": {"type": "string", "description": "KB slug to process (empty = all KBs)", "default": ""},
                "skip_lint": {"type": "boolean", "description": "Skip the lint/health-check step", "default": False},
                "skip_reflect": {"type": "boolean", "description": "Skip the structural reflection step", "default": False},
            },
        },
        handler=_wiki_job_sync,
        async_handler=_wiki_job_handler,
        integration="kb",
        contract_action="search",
    )

    # Register generic workflow execution tool (supervisor-driven)
    async def _run_workflow_handler(inp: dict) -> dict:
        wf_id = inp.get("workflow", "")
        wf_defs = app.state.workflow_defs
        if not wf_id:
            return {"available_workflows": list(wf_defs.keys())}
        wf = wf_defs.get(wf_id)
        if not wf:
            return {"error": f"Unknown workflow: {wf_id}", "available": list(wf_defs.keys())}
        execution = await supervisor.execute(wf, context=inp.get("context", ""))
        return {
            "workflow": execution.workflow_id,
            "status": execution.status,
            "iterations": execution.total_iterations,
            "steps_executed": len(execution.step_results),
            "results": [
                {"step": r.step_id, "agent": r.agent_key, "summary": r.content[:300], "attempt": r.iteration}
                for r in execution.step_results
            ],
            "supervisor_decisions": [
                {"action": d["action"], "steps": d["steps"], "reason": d["reason"]}
                for d in execution.supervisor_log
            ],
        }

    def _run_workflow_sync(inp: dict) -> dict:
        import asyncio
        return asyncio.get_event_loop().run_until_complete(_run_workflow_handler(inp))

    registry.register_integration(
        "workflows", "Workflows",
        "Execute multi-agent workflows with adaptive supervisor coordination.",
        lambda: bool(app.state.workflow_defs),
    )

    registry.register_tool(
        name="run_workflow",
        description=(
            "Execute a multi-agent workflow through the supervisor. "
            "The supervisor adaptively decides step order, feedback loops, parallelism, and skips. "
            "Available workflows: wiki-management, email-processing, project-kickoff, daily-cycle, "
            "journal-management, memory-management, content-review, autoresearch. "
            "autoresearch is an iterative hypothesis → experiment → evaluate → record loop deployable on "
            "a specific goal/task — use it when a question needs several rounds of investigation and you "
            "want a structured experiment log (keep/discard/crash) feeding the per-goal Bayesian trust."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "workflow": {"type": "string", "description": "Workflow ID (e.g., 'wiki-management', 'autoresearch')"},
                "context": {"type": "string", "description": "Additional context for the workflow. For autoresearch: pass the target as 'goal:<slug>' or 'task:<slug>' or an ad-hoc question string.", "default": ""},
            },
            "required": ["workflow"],
        },
        handler=_run_workflow_sync,
        async_handler=_run_workflow_handler,
        integration="workflows",
        contract_action="delegate_task",
    )

    # ─── submit_shape_proposals — Ship 2 structured output ────────────
    # The goal-shape skill uses tool-calling to force structured output.
    # Local LLMs can't reliably follow "output only JSON" prompts, so we
    # give the subagent a tool whose JSON Schema IS the contract. When
    # the subagent calls this tool, the args are captured into
    # `app.state.shape_proposals[correlation_id]` and the shape route
    # reads them back deterministically — no prose parsing.
    app.state.shape_proposals = {}  # correlation_id → args dict

    async def _submit_shape_proposals_handler(inp: dict) -> dict:
        from agntspace.activity.decisions import current_correlation_id
        corr_id = current_correlation_id() or ""
        if corr_id:
            app.state.shape_proposals[corr_id] = dict(inp)
        return {"status": "recorded", "correlation_id": corr_id, "fields": list(inp.keys())}

    def _submit_shape_proposals_sync(inp: dict) -> dict:
        import asyncio
        return asyncio.get_event_loop().run_until_complete(
            _submit_shape_proposals_handler(inp)
        )

    registry.register_tool(
        name="submit_shape_proposals",
        description=(
            "Submit your shape proposals for a goal. Call this ONCE with all your "
            "proposals structured as JSON arguments. Do NOT narrate — the tool "
            "arguments ARE the response. After calling this, you are done."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "relevant_projects": {
                    "type": "array",
                    "description": "Existing projects that match the goal (confidence >= 0.4). Empty if nothing matches.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "slug": {"type": "string", "description": "Existing project slug from list_projects"},
                            "title": {"type": "string"},
                            "reason": {"type": "string", "description": "Why this project is relevant to the goal"},
                            "confidence": {"type": "number", "description": "0.0–1.0 confidence score"},
                        },
                        "required": ["slug", "reason", "confidence"],
                    },
                },
                "new_project": {
                    "type": "object",
                    "description": "Propose a NEW project only when no existing project fits. Set to null otherwise.",
                    "properties": {
                        "title": {"type": "string"},
                        "description": {"type": "string"},
                    },
                },
                "draft_tasks": {
                    "type": "array",
                    "description": "3–5 concrete 1–2h task units that move the goal forward.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "title": {"type": "string"},
                            "description": {"type": "string"},
                            "project_hint": {"type": "string", "description": "Existing project slug or 'NEW'"},
                        },
                        "required": ["title"],
                    },
                },
                "success_metric": {
                    "type": "object",
                    "description": "Measurable criterion (count/date/boolean). Set to null if unsure.",
                    "properties": {
                        "text": {"type": "string", "description": "e.g. 'Publish 5 wiki articles by 2026-06-01'"},
                        "rationale": {"type": "string", "description": "Why this is measurable"},
                    },
                },
                "suggested_team": {
                    "type": "array",
                    "description": "Specialists that fit the goal's work.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "agent": {"type": "string"},
                            "reason": {"type": "string"},
                        },
                    },
                },
            },
            "required": ["relevant_projects", "draft_tasks"],
        },
        handler=_submit_shape_proposals_sync,
        async_handler=_submit_shape_proposals_handler,
        integration="goals",
        # Pure in-memory capture (no vault write, no external side effect).
        # Use a routine-allowed action so users don't need to edit
        # CONTRACT.md to enable goal shaping. `list_tasks` is in
        # routine_actions per domains.yaml — always permitted.
        contract_action="list_tasks",
    )

    # ─── ask_goal_question — Ship 3 human-in-the-loop channel ─────────
    # When the autonomous goal-pursuit loop hits ambiguity (target
    # audience? priority? scope?), the main agent queues a question
    # for the owner via this tool instead of guessing. The question
    # lands in the goal's pending_questions frontmatter list and
    # surfaces on the dashboard + goal modal. The owner's answer
    # becomes context for the next pursuit dispatch — the agent
    # resumes with the clarification baked in.
    async def _ask_goal_question_handler(inp: dict) -> dict:
        coach = app.state.coach
        goal_slug = str(inp.get("goal_slug") or "").strip()
        question = str(inp.get("question") or "").strip()
        importance = str(inp.get("importance") or "medium")
        context_note = str(inp.get("context") or "").strip()
        if not goal_slug:
            return {"error": "goal_slug is required"}
        if not question:
            return {"error": "question is required"}
        try:
            entry = coach.add_pending_question(
                goal_slug, question,
                importance=importance, context=context_note,
            )
        except FileNotFoundError as exc:
            return {"error": f"goal not found: {goal_slug}", "detail": str(exc)}
        except ValueError as exc:
            return {"error": str(exc)}

        # Activity event so the owner sees the question pop up in the
        # background-work toaster even if they're not on the goal page.
        try:
            app.state.activity_logger.log(
                category="system",
                action="goal_question_asked",
                summary=f"Agent has a question on '{goal_slug}': {question[:80]}",
                details={"goal_slug": goal_slug, **entry},
                importance=importance if importance in ("low", "medium", "high") else "medium",
            )
        except Exception:
            pass

        return {"status": "queued", "question_id": entry["id"], "goal_slug": goal_slug}

    def _ask_goal_question_sync(inp: dict) -> dict:
        import asyncio as _asyncio
        return _asyncio.get_event_loop().run_until_complete(
            _ask_goal_question_handler(inp)
        )

    # ─── record_metric_observation — Ship 4A (Check step) ─────────────
    # Agent records observed value of goal's success metric (e.g. "347
    # stars" for a 1000-star goal). Progress-bar UI reads the latest.
    async def _record_metric_handler(inp: dict) -> dict:
        coach = app.state.coach
        slug = str(inp.get("goal_slug") or "").strip()
        value = str(inp.get("value") or "").strip()
        if not slug:
            return {"error": "goal_slug is required"}
        if not value:
            return {"error": "value is required"}
        numeric_raw = inp.get("numeric_value")
        numeric = None
        if numeric_raw is not None:
            try:
                numeric = float(numeric_raw)
            except (TypeError, ValueError):
                numeric = None
        try:
            entry = coach.record_metric_observation(
                slug, value,
                numeric_value=numeric,
                source=str(inp.get("source") or "agent"),
                note=str(inp.get("note") or ""),
            )
        except FileNotFoundError as exc:
            return {"error": f"goal not found: {slug}", "detail": str(exc)}
        except ValueError as exc:
            return {"error": str(exc)}
        return {"status": "recorded", "observation_id": entry["id"], "numeric_value": entry["numeric_value"]}

    def _record_metric_sync(inp: dict) -> dict:
        import asyncio as _asyncio
        return _asyncio.get_event_loop().run_until_complete(_record_metric_handler(inp))

    registry.register_tool(
        name="record_metric_observation",
        description=(
            "Record an observed value of the goal's success metric. Use when "
            "you have concrete evidence — a count, a date reached, a state "
            "achieved. Call this AT LEAST once per dispatch if you produced "
            "something that moves the metric."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "goal_slug": {"type": "string"},
                "value": {"type": "string", "description": "Human-readable observation (e.g. '347 stars', '3 of 5 chapters drafted')"},
                "numeric_value": {"type": "number", "description": "Parsed number for trend math. Omitted = auto-extracted from `value`."},
                "source": {
                    "type": "string",
                    "enum": ["agent", "agent_estimate", "delegated_task", "web_search", "manual", "external_api"],
                },
                "note": {"type": "string", "description": "Short context (max 500 chars)"},
            },
            "required": ["goal_slug", "value"],
        },
        handler=_record_metric_sync,
        async_handler=_record_metric_handler,
        integration="goals",
        contract_action="modify_project",
    )

    # ─── submit_retrospective — Ship 4B (Act step) ────────────────────
    # Every N dispatches the agent runs the goal-retrospective skill
    # and submits a structured verdict + recommendation. Owner accepts/rejects.
    async def _submit_retrospective_handler(inp: dict) -> dict:
        coach = app.state.coach
        slug = str(inp.get("goal_slug") or "").strip()
        if not slug:
            return {"error": "goal_slug is required"}
        try:
            entry = coach.add_retrospective(
                slug,
                verdict=str(inp.get("verdict") or "on_track"),
                insights=str(inp.get("insights") or ""),
                recommended_action=str(inp.get("recommended_action") or "continue"),
                recommendation_note=str(inp.get("recommendation_note") or ""),
                dispatch_count=int(inp.get("dispatch_count") or 0),
            )
        except FileNotFoundError as exc:
            return {"error": f"goal not found: {slug}", "detail": str(exc)}
        try:
            app.state.activity_logger.log(
                category="system",
                action="retrospective_submitted",
                summary=f"Retrospective on '{slug}': {entry['verdict']} -> {entry['recommended_action']}",
                details={"goal_slug": slug, **entry},
                importance="medium" if entry["verdict"] == "on_track" else "high",
            )
        except Exception:
            pass
        return {"status": "submitted", "retro_id": entry["id"], "verdict": entry["verdict"]}

    def _submit_retrospective_sync(inp: dict) -> dict:
        import asyncio as _asyncio
        return _asyncio.get_event_loop().run_until_complete(_submit_retrospective_handler(inp))

    registry.register_tool(
        name="submit_retrospective",
        description=(
            "Submit a retrospective verdict + recommended next action for a goal. "
            "Call this ONCE at the end of a retrospective dispatch. The owner "
            "reviews your verdict + recommendation and accepts or rejects. "
            "Arguments ARE the response - do not narrate."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "goal_slug": {"type": "string"},
                "verdict": {
                    "type": "string",
                    "enum": ["on_track", "off_track", "stuck", "done"],
                },
                "insights": {"type": "string", "description": "2-3 sentences synthesizing what was tried and what happened. Max 1500 chars."},
                "recommended_action": {
                    "type": "string",
                    "enum": ["continue", "pivot", "ask_owner", "pause", "mark_achieved"],
                    "description": "`pause` pauses pursuit; `mark_achieved` marks goal achieved; others are informational.",
                },
                "recommendation_note": {"type": "string", "description": "One concrete sentence on the next step. Max 500 chars."},
                "dispatch_count": {"type": "integer"},
            },
            "required": ["goal_slug", "verdict", "insights", "recommended_action"],
        },
        handler=_submit_retrospective_sync,
        async_handler=_submit_retrospective_handler,
        integration="goals",
        contract_action="modify_project",
    )

    # ─── save_artifact — Gap 14 fix (delegated-artifact convention) ──
    # Specialists (writer, researcher, editor, document-producer) carry
    # the `delegated-artifact` meta-skill which grants this tool.
    # Purpose: persist delegated-task output to a predictable vault path
    # so the main agent's next dispatch can verify the artifact via
    # read_vault_file instead of re-delegating from scratch. Path-scoped
    # to projects/*/notes/ and projects/*/output/ so specialists can't
    # scribble into identity/, system/, or knowledge/ — the contract
    # gate + path guard work together.
    import re as _re_artifact
    _ARTIFACT_PATH_RE = _re_artifact.compile(
        r"^projects/[a-z0-9][a-z0-9-]*/(notes|output)/[a-zA-Z0-9][a-zA-Z0-9._-]*\.md$"
    )
    _ARTIFACT_MAX_BYTES = 50 * 1024

    async def _save_artifact_handler(inp: dict) -> dict:
        path = str(inp.get("path") or "").strip()
        content = str(inp.get("content") or "")
        if not path or not _ARTIFACT_PATH_RE.match(path):
            return {
                "error": "invalid path",
                "detail": "must match projects/{slug}/notes/{name}.md or projects/{slug}/output/{name}.md",
            }
        if len(content.encode("utf-8")) > _ARTIFACT_MAX_BYTES:
            return {"error": "content too large (max 50 KB)"}
        try:
            writer.write(path, content, contract_action="write_vault")
        except Exception as exc:
            return {"error": f"write failed: {exc}"}
        return {"status": "saved", "path": path, "bytes": len(content.encode("utf-8"))}

    def _save_artifact_sync(inp: dict) -> dict:
        import asyncio as _asyncio
        return _asyncio.get_event_loop().run_until_complete(_save_artifact_handler(inp))

    registry.register_tool(
        name="save_artifact",
        description=(
            "Save delegated-task output as a vault artifact. Use this when "
            "a delegation brief asks for a deliverable. Path MUST be "
            "projects/{slug}/notes/{name}.md or projects/{slug}/output/{name}.md "
            "- other paths are rejected. Reply with 'Saved to {path}' plus a "
            "2-sentence summary; do not dump the full artifact in the reply."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "projects/{slug}/notes/{name}.md or projects/{slug}/output/{name}.md"},
                "content": {"type": "string", "description": "Full artifact content (markdown, max 50 KB). Start with frontmatter."},
            },
            "required": ["path", "content"],
        },
        handler=_save_artifact_sync,
        async_handler=_save_artifact_handler,
        integration="vault",
        contract_action="write_vault",
    )

    # ─── add_milestone — Ship 4C (Gap 2 fix) ──────────────────────────
    # Agent creates a milestone during dispatch #1 decomposition so the
    # owner sees tangible waypoints in the goal modal. Previously only
    # the owner could add via UI/API.
    async def _add_milestone_handler(inp: dict) -> dict:
        coach = app.state.coach
        slug = str(inp.get("goal_slug") or "").strip()
        text = str(inp.get("text") or "").strip()
        if not slug or not text:
            return {"error": "goal_slug and text are required"}
        try:
            entry = coach.add_milestone(slug, text, source=str(inp.get("source") or "agent"))
        except FileNotFoundError as exc:
            return {"error": str(exc)}
        except ValueError as exc:
            return {"error": str(exc)}
        return {"status": "added", "milestone_id": entry["id"]}

    def _add_milestone_sync(inp: dict) -> dict:
        import asyncio as _asyncio
        return _asyncio.get_event_loop().run_until_complete(_add_milestone_handler(inp))

    registry.register_tool(
        name="add_milestone",
        description=(
            "Add a milestone (human-readable checkpoint) to a goal. Use on "
            "dispatch #1 to break the metric into 3-5 tangible waypoints the "
            "owner can track. Each milestone is short (one line), concrete, "
            "and verifiable by observation."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "goal_slug": {"type": "string"},
                "text": {"type": "string", "description": "Short concrete waypoint (max 300 chars, 1 line)"},
                "source": {"type": "string", "enum": ["agent", "owner", "goal_shape"], "default": "agent"},
            },
            "required": ["goal_slug", "text"],
        },
        handler=_add_milestone_sync,
        async_handler=_add_milestone_handler,
        integration="goals",
        contract_action="modify_project",
    )

    # ─── complete_milestone — Ship 4C ─────────────────────────────────
    async def _complete_milestone_handler(inp: dict) -> dict:
        coach = app.state.coach
        slug = str(inp.get("goal_slug") or "").strip()
        mid = str(inp.get("milestone_id") or "").strip()
        if not slug or not mid:
            return {"error": "goal_slug and milestone_id are required"}
        try:
            entry = coach.complete_milestone(slug, mid, done=bool(inp.get("done", True)))
        except (FileNotFoundError, KeyError) as exc:
            return {"error": str(exc)}
        return {"status": "completed" if entry["done"] else "reopened", "milestone_id": mid}

    def _complete_milestone_sync(inp: dict) -> dict:
        import asyncio as _asyncio
        return _asyncio.get_event_loop().run_until_complete(_complete_milestone_handler(inp))

    registry.register_tool(
        name="complete_milestone",
        description=(
            "Mark a milestone done (or re-open it). Use when you've produced "
            "evidence the milestone's criterion is met."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "goal_slug": {"type": "string"},
                "milestone_id": {"type": "string", "description": "ID from list_milestones"},
                "done": {"type": "boolean", "default": True},
            },
            "required": ["goal_slug", "milestone_id"],
        },
        handler=_complete_milestone_sync,
        async_handler=_complete_milestone_handler,
        integration="goals",
        contract_action="modify_project",
    )

    registry.register_tool(
        name="ask_goal_question",
        description=(
            "Queue a clarifying question for the goal owner when you hit "
            "ambiguity during autonomous goal pursuit. The question lands "
            "on the dashboard and the goal modal. The owner's answer "
            "becomes context for your next dispatch — resume then, not now. "
            "Use this INSTEAD of guessing when a choice materially changes "
            "which direction the goal goes."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "goal_slug": {"type": "string", "description": "Slug of the goal this question is about"},
                "question": {"type": "string", "description": "The question, phrased as a direct ask. Max 500 chars."},
                "importance": {
                    "type": "string",
                    "enum": ["low", "medium", "high"],
                    "description": "Low = nice-to-know. Medium = affects tasks. High = blocks progress.",
                },
                "context": {"type": "string", "description": "Short note (max 300 chars) on what you were deciding. Helps the owner answer in one line."},
            },
            "required": ["goal_slug", "question"],
        },
        handler=_ask_goal_question_sync,
        async_handler=_ask_goal_question_handler,
        integration="goals",
        # Writes to the goal file — use modify_project which is already
        # mapped to the Project updates trust domain. Keeps the contract
        # gate meaningful (trust-gated) without needing a new action.
        contract_action="modify_project",
    )

    agent = Agent(
        llm=llm,
        memory=memory,
        vault=vault,
        journal=journal,
        task_service=task_service,
        trust_service=trust_service,
        tool_executor=tool_executor,
        memory_engine=memory_engine,
        skill_loader=skills,
        heartbeat=heartbeat,
        registry=registry,
        project_service=project_service,
        local_mode=settings.llm.mode == "local",
        cost_tracker=cost_tracker,
    )
    await agent.initialize()

    # Wire agent into automation services (replaces direct LLM calls)
    agent._model_router = model_router  # noqa: SLF001
    daily_cycle._agent = agent  # noqa: SLF001
    reflection_service._agent = agent  # noqa: SLF001
    reflection_service._orchestrator = orchestrator  # noqa: SLF001  # activity tracking
    memory_engine._agent = agent  # noqa: SLF001
    memory_engine._orchestrator = orchestrator  # noqa: SLF001  # activity tracking
    knowledge_graph._agent = agent  # noqa: SLF001

    # Wire activity logger into all services for deep observability
    agent._activity = activity_logger  # noqa: SLF001
    # Fix 8: agent routes tool-handler exceptions through ErrorLogger so the
    # /logs page surfaces them with full tracebacks instead of silently
    # returning {"error": str(e)} to the LLM.
    agent._error_logger = error_logger  # noqa: SLF001
    # TurnRegistry: process-local in-flight chat-turn tracker so the
    # /api/conversations/{id}/status route can answer "is the agent
    # still working?" to a reconnecting UI. No persistence — in-flight
    # state is ephemeral and dies with the process (clients see a
    # sentinel row from Fix 7 after restart).
    from agntspace.agent.turn_registry import TurnRegistry
    turn_registry = TurnRegistry()
    agent._turn_registry = turn_registry  # noqa: SLF001
    app.state.turn_registry = turn_registry
    orchestrator._activity = activity_logger  # noqa: SLF001
    # Fix 8 (orchestrator): subagent tool crashes also escalate.
    orchestrator._error_logger = error_logger  # noqa: SLF001
    # Rule #17: let the main agent dispatch skills through the orchestrator
    # via Agent.invoke_skill(). Non-chat callers (watcher, heartbeat, cron)
    # go through this path so every mutation is accountable.
    agent._orchestrator = orchestrator  # noqa: SLF001
    memory_engine._activity = activity_logger  # noqa: SLF001
    knowledge_graph._activity = activity_logger  # noqa: SLF001
    knowledge_graph._audit = entity_audit  # noqa: SLF001
    contract._activity = activity_logger  # noqa: SLF001
    llm._activity = activity_logger  # noqa: SLF001

    # Graph curator — Tier-B deterministic auto-apply for safe entity-merge
    # proposals (default: comma-prefix). Closes the "100+ trivia merges
    # waiting on user clicks" gap. Heartbeat drives it on cadence; the
    # list_merge_proposals / apply_merge_proposal agent tools also route
    # through this service for chat-driven curation. See
    # _meta/graph-curator/SKILL.md.
    from agntspace.memory.graph_curator import GraphCuratorService

    graph_curator = GraphCuratorService(
        graph=knowledge_graph,
        audit_log=entity_audit,
        activity=activity_logger,
        decisions=decision_logger,
        skill_loader=skills,
        contract=contract,
    )
    app.state.graph_curator = graph_curator
    # kb_service._activity is wired below, after kb_service is created (line ~1013).

    # Wire decision logger for traceable "why" — links every agent action
    # to the driving skill/contract/context (Rule #13 accountability)
    agent._decisions = decision_logger  # noqa: SLF001
    orchestrator._decisions = decision_logger  # noqa: SLF001
    orchestrator._trust_service = trust_service  # noqa: SLF001  # trust gating on subagent tool calls
    memory_engine._decisions = decision_logger  # noqa: SLF001

    # Wire evolution service — the learning loop sensor + policy actuator.
    # Phase 1+2 live: runs + outcomes + telemetry recorded on every invoke_skill,
    # ReflexMiner + AntiReflexMiner produce candidates, PromotionArena evaluates,
    # ReplayEngine re-dispatches, DriftDetector quarantines regressions.
    evolution._skill_loader = skills  # noqa: SLF001  # for _meta/evolution/config/evolution.yaml hot-reload
    evolution._writer = writer  # noqa: SLF001        # for system/evolution/status.md artifact
    evolution._cost_tracker = cost_tracker  # noqa: SLF001  # for per-correlation cost derivation
    evolution._vault_reader = vault  # noqa: SLF001   # for env_fingerprint on replays
    evolution._activity = activity_logger  # noqa: SLF001  # miner/arena/drift emit events
    evolution._orchestrator = orchestrator  # noqa: SLF001  # replay engine needs dispatch
    agent._evolution = evolution  # noqa: SLF001
    orchestrator._evolution = evolution  # noqa: SLF001

    # Wire contract into automation services for governance
    daily_cycle._contract = contract  # noqa: SLF001
    reflection_service._contract = contract  # noqa: SLF001
    memory_engine._contract = contract  # noqa: SLF001  # for scoped_operation write_vault gate
    # Rule #17: wire activity + decisions so scoped_operation can emit
    # per-flow correlation chains for briefings, EOD, reflections.
    daily_cycle._activity = activity_logger  # noqa: SLF001
    daily_cycle._decisions = decision_logger  # noqa: SLF001
    daily_cycle._work_queue = work_queue  # noqa: SLF001  # for cascade retry on failure
    reflection_service._activity = activity_logger  # noqa: SLF001
    reflection_service._decisions = decision_logger  # noqa: SLF001

    # Wire activity logger into vault writer for write observability
    writer._activity = activity_logger  # noqa: SLF001

    # ── Turn checkpoint + revert (2026-04-23) ──
    # Every write inside a correlation scope drops a breadcrumb so the
    # user can revert the whole scope later. Legacy behavior unchanged
    # when no correlation scope is active (e.g. heartbeat, startup init).
    try:
        from agntspace.vault.scope_writes import ScopeWritesRecorder
        scope_writes_recorder = ScopeWritesRecorder(db_path=settings.db_path)
        writer._scope_writes = scope_writes_recorder  # noqa: SLF001
        app.state.scope_writes = scope_writes_recorder
    except Exception:
        log.debug("ScopeWritesRecorder init failed", exc_info=True)
        app.state.scope_writes = None

    # Proactive outreach — agent-initiated contact on silence/important events
    from agntspace.activity.outreach import ProactiveOutreach
    outreach = ProactiveOutreach(
        activity=activity_logger,
        contract=contract,
        channel_manager=channel_manager,
        vault_dir=vault_path,
    )

    # Plugin system
    from agntspace.plugins.base import PluginContext
    from agntspace.plugins.hooks import HookBus
    from agntspace.plugins.manager import PluginManager

    hook_bus = HookBus(activity_logger=activity_logger)
    agent._hooks = hook_bus  # noqa: SLF001 — post-hoc injection like _activity/_decisions

    # Reply guardrails — placeholder scrub + capability-hallucination
    # detection on every before_agent_reply. Engine in
    # automation/reply_guardrails.py, strategy in
    # defaults/skills/_meta/reply-guardrails/config/guardrails.yaml.
    try:
        from agntspace.automation.reply_guardrails import (
            ReplyGuardrails,
            register_guardrail_handlers,
        )
        from agntspace.automation.reply_guardrails import (
            load_config as _load_guardrail_config,
        )
        guardrail_cfg = _load_guardrail_config(skills)
        reply_guardrails = ReplyGuardrails(
            guardrail_cfg,
            activity_logger=activity_logger,
            decisions=decision_logger,
            registry=registry,
        )
        register_guardrail_handlers(hook_bus, reply_guardrails)
        app.state.reply_guardrails = reply_guardrails
    except Exception:
        log.exception("Failed to register reply guardrails — chat continues unguarded")

    plugin_manager = PluginManager(
        settings.app_dir,
        settings.vault_dir,
        paths=vault_paths,
        state_path=settings.plugin_state_path,
    )
    plugin_manager._hooks = hook_bus  # noqa: SLF001 — needed for unregister on disable/shutdown
    plugin_ctx = PluginContext(
        registry=registry,
        vault=vault,
        vault_writer=writer,
        llm=llm,
        settings=settings,
        db=db,
        skill_loader=skills,
        plugin_data_dir=settings.plugin_data_dir,
        log=logging.getLogger("agntspace.plugins"),
        hooks=hook_bus,
        activity_logger=activity_logger,
        decisions=decision_logger,
        conversation_memory=memory,
    )
    await plugin_manager.load_all(plugin_ctx, app=app)
    app.state.plugin_manager = plugin_manager
    app.state.plugin_ctx = plugin_ctx
    app.state.hook_bus = hook_bus

    # MCP Client — connect to external MCP servers.
    # Per-vault config: each vault has its own server registry so a work
    # vault and a personal vault can point at different MCP sets.
    mcp_config_path = settings.mcp_config_path
    mcp_client = MCPClientManager(config_path=mcp_config_path)
    mcp_client.load_config()
    app.state.mcp_client = mcp_client

    # Auto-connect to saved MCP servers — FIRE-AND-FORGET.
    #
    # Boot-independence rule: no external process spawn may sit on the
    # FastAPI lifespan critical path. A misbehaving MCP server (broken
    # SDK, slow npx install, network stall, malicious refusal) used to
    # block server boot indefinitely; it now runs in the background with
    # per-server timeouts and parallel connection.
    #
    # The task is intentionally not awaited here. The mcp_client object
    # is fully usable immediately — `connect_server()` will be a no-op
    # for already-connected servers, and `get_external_tool_definitions()`
    # returns whatever servers have finished connecting at query time.
    asyncio.create_task(
        mcp_client.auto_connect_all(),
        name="mcp_auto_connect",
    )

    # Layer 4b — start the periodic health probe. First ping fires
    # ``health_probe_interval_seconds`` after start so we don't ping
    # a server we just connected. Cancelled cleanly on shutdown via
    # the existing lifespan teardown that calls ``disconnect_all``;
    # we also explicitly stop the probe in the shutdown branch below.
    mcp_client.start_health_probe()

    # Wire MCP client into registry — external tools appear in agent tool list
    registry._mcp_client = mcp_client  # noqa: SLF001

    # Set up channel message handler with Guardian scanning
    async def handle_channel_message(msg):  # noqa: ANN001
        # Guardian: scan incoming message for threats
        scan = guardian.scan_text(msg.text)
        if not scan.safe:
            threat_names = ", ".join(t["value"] for t in scan.threats)
            journal.append_observation(
                f"Guardian blocked message from {msg.sender} on {msg.channel}: "
                f"matched blacklist ({threat_names})"
            )
            return f"Message blocked by Guardian: matched blacklist entries ({threat_names})"

        # Guardian: scan sender
        sender_scan = guardian.scan_sender(msg.sender)
        if not sender_scan.safe:
            journal.append_observation(
                f"Guardian blocked sender {msg.sender} on {msg.channel}: blacklisted"
            )
            return None  # silently drop messages from blacklisted senders

        # ── message_received hook ──────────────────────────────
        # Plugins can drop spam, translate, or rewrite the inbound text
        # before pairing/conversation/agent costs kick in.
        if hook_bus is not None:
            try:
                from agntspace.plugins.hooks import HookContext, HookName
                mr_ctx = HookContext(
                    hook=HookName.MESSAGE_RECEIVED,
                    message_text=msg.text,
                    channel=msg.channel,
                    sender=msg.sender,
                    message_metadata=dict(msg.metadata),
                )
                await hook_bus.fire(HookName.MESSAGE_RECEIVED, mr_ctx)
                if mr_ctx.veto:
                    log.info(
                        "channel message dropped by plugin %s: %s",
                        mr_ctx.plugin_name,
                        mr_ctx.veto_reason,
                    )
                    return None
                msg.text = mr_ctx.message_text  # plugins may have rewritten
            except Exception:
                log.exception("hook bus error in message_received")

        # DM pairing gate — self-chat and answering mode bypass
        is_self = msg.metadata.get("is_self", False) or msg.metadata.get("from_me", False)
        is_answering_mode = msg.metadata.get("channel_mode") == "dedicated"
        if not is_self and not is_answering_mode:
            policy = await pairing.get_dm_policy()
            if policy == "pairing":
                if not await pairing.is_allowed(msg.channel, msg.sender):
                    from agntspace.infra.i18n import t as _t
                    code = await pairing.generate_pairing_code(msg.channel, msg.sender)
                    return _t("pairing.code_msg", code=code)
            elif policy == "allowlist":
                if not await pairing.is_allowed(msg.channel, msg.sender):
                    return None  # silently drop

        conv_id = f"channel-{msg.channel}-{msg.metadata.get('chat_id', 'default')}"
        is_new_conv = not await memory.conversation_exists(conv_id)
        # In personal mode, the bridge ONLY forwards self-chat messages
        # (non-self are filtered at the bridge level), so personal mode
        # implies self-chat even if from_me is somehow missing.
        channel_mode = msg.metadata.get("channel_mode", "")
        is_self = (
            msg.metadata.get("is_self", False)
            or msg.metadata.get("from_me", False)
            or channel_mode == "personal"
        )
        if is_new_conv:
            # Human-readable title: "WhatsApp — Self Chat" or "WhatsApp — +46701234567"
            chan_label = msg.channel.capitalize()
            if is_self:
                conv_title = f"{chan_label} — Self Chat"
            else:
                # Strip protocol suffixes like @s.whatsapp.net / @lid
                sender_display = msg.sender.split("@")[0] if "@" in msg.sender else msg.sender
                # Prepend + for phone numbers (all digits)
                if sender_display.replace("+", "").isdigit() and not sender_display.startswith("+"):
                    sender_display = f"+{sender_display}"
                conv_title = f"{chan_label} — {sender_display}"
            await memory.create_conversation(
                title=conv_title,
                conversation_id=conv_id,
            )
        await heartbeat.record_pulse("channel", f"{msg.channel}: {msg.sender}")

        # Inject channel context so the agent knows where it's responding
        is_dedicated = channel_mode == "dedicated"

        # Build answering mode instructions with owner identity
        answering_instruction = ""
        if is_dedicated and not is_self:
            # Get owner name from USER.md for proper introductions
            from agntspace.llm.prompt import _extract_user_name
            try:
                user_doc = vault.read("system/identity/USER.md")
                user_md = user_doc.content if user_doc else ""
            except FileNotFoundError:
                user_md = ""
            owner_name = _extract_user_name(user_md)

            # Load answering mode template from channel-protocol skill config
            _am_template = None
            try:
                _am_cfg = skills.load_skill_config_file("channel-protocol", "answering-mode.yaml")
                if _am_cfg and isinstance(_am_cfg.get("instruction_template"), str):
                    _am_template = _am_cfg["instruction_template"].strip()
            except Exception:
                pass
            if not _am_template:
                _am_template = (  # arch:deterministic — fallback when skill config unavailable
                    "ANSWERING MODE: You are answering {owner_name}'s WhatsApp messages "
                    "on their behalf. Rules: "
                    "1) ONLY introduce yourself on the very first message from a new contact. "
                    "Never re-introduce on subsequent messages. "
                    "2) Be friendly, warm, and conversational. Match the contact's language. "
                    "3) Pay attention to what the contact is actually saying. "
                    "Acknowledge their feelings and respond to the specific content. "
                    "4) If they ask to speak to {owner_name} or say it is urgent, "
                    "say you will let {owner_name} know right away. Be empathetic. "
                    "5) If they send images or media you cannot process, let them know warmly. "
                    "6) Do NOT check email, run tools, or do anything besides chat. "
                    "7) Keep responses concise — 1-3 sentences. WhatsApp is casual."
                )
            answering_instruction = " | " + _am_template.format(owner_name=owner_name)

        # Build the message that gets STORED in conversation history.
        # Only minimal channel metadata — answering-mode instructions go
        # in system_suffix so they appear once in the system prompt, not
        # repeated on every stored message (which caused local models to
        # re-introduce on every turn).
        context_prefix = (
            f"[Channel: {msg.channel} | Sender: {msg.sender}"
            f"{' | self-chat' if is_self else ''}"
            f"{' | first-contact' if is_new_conv else ''}]\n\n"
        )
        # Clean version for storage — just the channel prefix + raw text.
        # The prompt guard envelope is ephemeral: the LLM sees it for the
        # current turn only, but it never pollutes conversation history.
        clean_text = context_prefix + msg.text

        # Prompt injection defense: channel messages from external senders
        # are untrusted input that should not be able to override the
        # agent's instructions. Self-chat (the user's own messages) is
        # trusted — the user IS the principal. See threat-model.md §9.1.
        if is_self:
            augmented_text = clean_text
        else:
            from agntspace.security.prompt_guard import sanitize_for_llm
            augmented_text = context_prefix + sanitize_for_llm(
                msg.text,
                source="untrusted_channel",
                context=f"{msg.channel}:{msg.sender}",
            )

        # Build the system-prompt suffix for answering mode.  This is
        # passed to chat_stream(system_suffix=...) so it appears ONCE
        # in the LLM's system prompt for the duration of the call,
        # rather than being baked into every stored user message.
        system_suffix = ""
        if answering_instruction:
            first_contact_note = (
                " This is the FIRST message from this contact — introduce yourself once."
                if is_new_conv else
                " You have already introduced yourself to this contact — do NOT re-introduce."
            )
            system_suffix = answering_instruction.lstrip(" |") + first_contact_note

        # Track activity so constellation shows main agent working on channel messages
        task_label = f"{msg.channel}: {msg.sender}"
        orchestrator._active_tasks["main"] = task_label

        # In answering mode for friend messages, skip tools so the LLM
        # responds conversationally instead of calling read_inbox etc.
        skip_tools = is_dedicated and not is_self

        # Rule #17 Tier A: open a channel-<name>-* correlation scope so
        # every decision, activity event, and tool call triggered by
        # this message is tagged as channel-originated, distinguishable
        # from the UI's chat-* chains and the watcher's watcher-* chains.
        # agent.chat_stream will inherit this id instead of creating a
        # new chat-* one.
        from agntspace.activity.decisions import (
            correlation_scope as _corr_scope,
        )
        from agntspace.activity.decisions import (
            new_correlation_id as _new_corr_id,
        )

        # Use streaming path (same as UI) and collect full response
        try:
            chunks = []
            _provenance = {
                "kind": "channel",
                "source_channel": msg.channel,
                "sender": msg.sender,
            }
            with _corr_scope(_new_corr_id(f"channel-{msg.channel}")):
                async for chunk in agent.chat_stream(
                    conv_id, augmented_text,
                    skip_tools=skip_tools,
                    system_suffix=system_suffix,
                    provenance=_provenance,
                    store_message=clean_text if augmented_text != clean_text else None,
                ):
                    if chunk.delta:
                        chunks.append(chunk.delta)
            reply = "".join(chunks)

            # Notify the owner via self-chat when a friend's message seems urgent.
            # Keywords are loaded from the multilingual YAML in the channel-protocol
            # skill so the check works across any user language (EN/SV/DE/ES/FR/...).
            if is_dedicated and not is_self:
                urgent_keywords = load_keywords(
                    "core/channel-protocol/config/urgency.yaml",
                    vault_dir=settings.root_dir,
                )
                if contains_any(msg.text, urgent_keywords):
                    wa = channel_manager.get_channel("whatsapp")
                    if wa and wa.is_connected:
                        try:
                            # Get the owner's own JID from bridge status
                            status = await wa.get_status()
                            self_number = status.get("self", "")
                            if self_number:
                                notice = (
                                    f"📩 *Urgent message from {msg.sender}*\n\n"
                                    f"> {msg.text}\n\n"
                                    f"_I replied: \"{reply[:150]}{'...' if len(reply) > 150 else ''}\"_"
                                )
                                await wa.send_message(self_number, notice)
                        except Exception:
                            log.debug("Failed to forward urgent notification to owner")

            return reply
        finally:
            orchestrator._active_tasks.pop("main", None)

    channel_manager.set_message_handler(handle_channel_message)
    # Store handler for lazy channel registration (WhatsApp, Telegram, etc.)
    app.state._whatsapp_handler = handle_channel_message  # noqa: SLF001
    app.state._channel_message_handler = handle_channel_message  # noqa: SLF001

    # Store on app.state
    app.state.agent = agent
    app.state.memory = memory
    app.state.vault = vault
    app.state.vault_reader = vault   # canonical alias for new routes
    app.state.vault_writer = writer
    app.state.writer = writer
    app.state.vault_paths = vault_paths
    app.state.db = db
    app.state.settings = settings
    app.state.journal = journal
    app.state.task_service = task_service
    app.state.trust_service = trust_service
    app.state.contract = contract
    app.state.vault_search = vault_search
    app.state.vault_embeddings = vault_embeddings
    app.state.skills = skills
    app.state.orchestrator = orchestrator
    app.state.model_router = model_router
    app.state.activity_logger = activity_logger
    app.state.decisions = decision_logger
    app.state.entity_audit = entity_audit
    app.state.evolution = evolution
    app.state.project_service = project_service

    # ── Regression capture service (2026-04-23) ──
    # Curation layer over the decision log — users snapshot a correlation
    # chain as an expected-behavior baseline, replay it later to detect drift.
    try:
        from agntspace.evolution.regressions import RegressionService
        regressions = RegressionService(
            db=db, decisions=decision_logger, agent=agent,
        )
        app.state.regressions = regressions
    except Exception:
        log.debug("Regression service init failed", exc_info=True)
        app.state.regressions = None

    # ── Architectural gap #3 (2026-04-18): trust domain discovery ──
    # The engine observes the decision log, clusters undomained contract
    # actions, and surfaces proposals via GET /api/trust/domain-proposals.
    # Constructed AFTER trust_service is populated so we can pass the
    # current action→domain map (skips actions already mapped). Config
    # lives in `_meta/trust-domain-discovery/config/discovery.yaml`.
    try:
        from agntspace.trust.domain_discovery import (
            DiscoveryConfig,
            TrustDomainDiscovery,
        )
        from agntspace.trust.service import get_trust_domain_map

        domain_map = get_trust_domain_map()
        existing_names = set(domain_map.action_to_domain.values())
        known_actions = set(domain_map.action_to_domain.keys())
        discovery_config = DiscoveryConfig.from_skills_dir(settings.skills_dir)
        app.state.trust_domain_discovery = TrustDomainDiscovery(
            db=db,
            known_domain_actions=known_actions,
            existing_domain_names=existing_names,
            config=discovery_config,
        )
        log.info(
            "Trust domain discovery wired: %d known actions, %d existing domains, window=%dd",
            len(known_actions), len(existing_names), discovery_config.evidence_window_days,
        )
    except Exception:
        log.exception("Failed to wire trust domain discovery — endpoints will 503")
        app.state.trust_domain_discovery = None

    # ── Phase 2: Chunking config from kb-ingest skill ──
    # set_chunking_config reads chunking.yaml and installs the values
    # into chunked_extract.py's module-level _CONFIG. Falls back to the
    # documented defaults if the YAML is missing. Rule 12 compliant --
    # all four chunking knobs (threshold, size, overlap, max chunks)
    # are user-tunable without touching code.
    try:
        from agntspace.kb.chunked_extract import set_chunking_config
        chunking_cfg = skills.load_skill_config_file("kb-ingest", "chunking.yaml")
        set_chunking_config(chunking_cfg)
    except Exception:
        log.exception("Failed to load chunking.yaml -- using built-in defaults")

    # ── Phase 6: Memory dreaming (CLS three-phase consolidation) ──
    # Constructs the DreamPhaseHandler and registers the three dream
    # cron jobs (light/deep/REM). The handler is contract-gated by the
    # graph + skill loader; gathering helpers read from conversation
    # memory and the graph. All cadence and budget knobs come from each
    # skill's config/schedule.yaml — Rule 12 compliant.
    try:
        from agntspace.memory.dream import DreamPhaseHandler, register_dream_cron

        dream_handler = DreamPhaseHandler(
            graph=knowledge_graph,
            skill_loader=skills,
            llm=llm,
            model_router=model_router,
            activity_logger=activity_logger,
            decision_logger=decision_logger,
        )
        app.state.dream_handler = dream_handler
        dream_registration = await register_dream_cron(
            cron=cron,
            handler=dream_handler,
            conversation_memory=memory,
            graph=knowledge_graph,
            skill_loader=skills,
            vault_reader=vault,
        )
        log.info("Memory dreaming wired: %s", dream_registration)
    except Exception:
        log.exception("Failed to wire memory dreaming — agent will run without it")

    # ── Phase 6E: Prediction loop ──
    # Constructs the PredictionEngine for the falsifiability feedback
    # loop. The engine is exposed on app.state for API/CLI surfaces
    # (GET /api/memory/predictions/stats|/history) and is wired onto the
    # agent for chat-hot-path integration (gap #5, approved 2026-04-18).
    # The agent's _fire_prediction_hook fires as asyncio.create_task from
    # all four chat response paths; see Agent._fire_prediction_hook and
    # PredictionEngine.chat_hook_post_response for the three-mode config
    # (every_turn / daily / every_nth). Mode is tunable via
    # memory-prediction/config/prediction.yaml — no code change required.
    try:
        from agntspace.memory.predictions import PredictionEngine

        prediction_engine = PredictionEngine(
            graph=knowledge_graph,
            skill_loader=skills,
            llm=llm,
            model_router=model_router,
            activity_logger=activity_logger,
        )
        app.state.prediction_engine = prediction_engine
        # Wire the prediction engine onto the agent so chat() can fire
        # the prediction hook. The agent reads _prediction_engine via
        # getattr in _fire_prediction_hook, so this is the only wiring.
        agent._prediction_engine = prediction_engine  # noqa: SLF001
        log.info("Prediction loop wired: mode=%s", prediction_engine._config().get("prediction_mode", "every_nth"))
    except Exception:
        log.exception("Failed to wire prediction loop -- agent will run without it")

    app.state.reflection_service = reflection_service
    app.state.coach = coach
    # Wire SkillSchool into the decision log so analyze_zero_effects()
    # can read skill_invoke decisions and propose fixes (Rule #17 learning loop).
    skillschool._decisions = decision_logger  # noqa: SLF001
    app.state.skillschool = skillschool
    app.state.heartbeat = heartbeat
    app.state.scheduler = scheduler
    app.state.cron = cron
    app.state.registry = registry
    app.state.daily_cycle = daily_cycle
    app.state.domains = domains
    app.state.channel_manager = channel_manager
    app.state.integration_manager = integration_manager
    app.state.email_integration = email_integration
    app.state.email_registry = email_registry
    app.state.settings_service = settings_service
    app.state.credential_store = credential_store
    app.state.vault_sync = vault_sync
    app.state.knowledge_graph = knowledge_graph
    app.state.memory_engine = memory_engine
    app.state.cost_tracker = cost_tracker

    # Bibliography service (global — one registry across all KBs)
    from agntspace.kb.bibliography import BibliographyService

    bibliography_service = BibliographyService(vault, writer)
    app.state.bibliography_service = bibliography_service

    # Knowledge Base service
    from agntspace.kb.service import IngestFrozenError, KnowledgeBaseService

    kb_service = KnowledgeBaseService(vault, writer, llm, skills_dir=settings.skills_dir, skill_loader=skills, model_router=model_router, bibliography=bibliography_service, work_queue=work_queue)
    kb_service._orchestrator = orchestrator  # noqa: SLF001  # activity tracking
    kb_service._activity = activity_logger  # noqa: SLF001  # for activity events
    kb_service._decisions = decision_logger  # noqa: SLF001  # for decision records per file
    kb_service._settings_service = settings_service  # noqa: SLF001  # backs is_pipeline_frozen()
    kb_service._graph = knowledge_graph  # noqa: SLF001  # for populating entities/triples from ingest
    kb_service._contract = contract  # noqa: SLF001  # for authorizing writes inside the ingest scope
    tool_executor._kb = kb_service  # noqa: SLF001  # for research_and_present + KB slug inference
    # Wire global language policy from config.toml [indexing] and the user's
    # UI locale from settings. Override chain: frontmatter > SCHEMA > skill >
    # this global > hardcoded "source".
    try:
        import tomllib
        _cfg_path = settings.app_dir / "config.toml"
        if _cfg_path.is_file():
            with open(_cfg_path, "rb") as _cf:
                _cfg = tomllib.load(_cf)
            kb_service._global_language_policy = (  # noqa: SLF001
                _cfg.get("indexing", {}).get("language_policy")
            )
    except Exception:
        kb_service._global_language_policy = None  # noqa: SLF001
    kb_service._user_locale = getattr(settings, "locale", None) or "en"  # noqa: SLF001
    # Provider-escalation fallback (gap #5): a LiteLLM-style model string
    # like "openai/gpt-5.4" used when tier-escalation exhausts and drift
    # persists. Reuses the existing `llm.fallback_model` setting.
    kb_service._language_drift_fallback_model = (  # noqa: SLF001
        getattr(getattr(settings, "llm", None), "fallback_model", None) or None
    )
    app.state.kb_service = kb_service
    app.state.skills_dir = settings.skills_dir

    # ── Agent Insights Engine (agent-first proactive awareness) ──
    from agntspace.agent.insights import AgentInsightsEngine

    insights_engine = AgentInsightsEngine(
        vault_reader=vault,
        coach=coach,
        task_service=task_service,
        kb_service=kb_service,
        knowledge_graph=knowledge_graph,
        activity_logger=activity_logger,
        vault_dir=vault_path,
    )
    app.state.insights_engine = insights_engine
    agent._insights_engine = insights_engine  # noqa: SLF001  # chat nudges

    # Register work queue handlers — map job types to service methods.
    async def _wq_call(method, *args, job_type: str = "unknown", **kwargs):
        """Call a KB service method as a work queue retry.

        Responsibilities (Rule #17 work-queue carve-out):
          1. Open a ``wq-<job_type>-*`` correlation scope so the retry
             chain is distinguishable from direct API calls in
             /decisions. The inner service-level scope inherits this id.
          2. Set ``_called_from_queue=True`` so LLM failures raise
             directly instead of re-enqueueing (the work queue owns
             retry scheduling, not the service).
          3. All activity events + decisions produced inside share
             the ``wq-*`` correlation id. Nothing extra to add at the
             call sites — the service methods already emit.
        """
        from agntspace.activity.decisions import correlation_scope, new_correlation_id

        kb_service._called_from_queue = True
        try:
            with correlation_scope(new_correlation_id(f"wq-{job_type}")):
                return await method(*args, **kwargs)
        finally:
            kb_service._called_from_queue = False

    async def _wq_ingest(p: dict) -> dict:
        # Honor escalation hints from the retry payload. Three kinds:
        #   1. language-drift tier escalation (escalate_tier)
        #   2. language-drift provider escalation (fallback_model)
        #   3. empty-extraction tier escalation (empty_extraction_tier)
        # All cleared after one ingest so escalation happens at most once
        # per file per kind. Both escalation channels can be set on the
        # same retry; the ingest code path picks drift over empty when
        # both apply (drift correctness is load-bearing).
        escalate_tier = p.get("escalate_tier")
        fallback_model = p.get("fallback_model")
        empty_extraction_tier = p.get("empty_extraction_tier")
        target_file = p.get("target_file")
        if escalate_tier or fallback_model:
            kb_service._language_drift_escalation = {  # noqa: SLF001
                "tier": escalate_tier,
                "fallback_model": fallback_model,
                "file": target_file,
            }
        if empty_extraction_tier:
            kb_service._empty_extraction_escalation = {  # noqa: SLF001
                "tier": empty_extraction_tier,
                "file": target_file,
            }
        try:
            return await _wq_call(kb_service.ingest, p["slug"], job_type="ingest")
        except IngestFrozenError:
            # Pipeline is frozen — return a deferred-completed marker so the
            # queue counts THIS attempt as done (no failed/dead-letter spam),
            # then re-enqueue a fresh job for when the user thaws. Dedup keeps
            # multiple frozen cycles from piling up duplicate jobs for the
            # same KB. The new enqueue happens at the END so the freeze gate
            # has its chance to short-circuit again on the next pass.
            try:
                await work_queue.enqueue(
                    "ingest",
                    {"slug": p["slug"], "_deferred_by_freeze": True},
                    deduplicate=True,
                )
            except Exception:
                log.exception("re-enqueue after freeze failed for slug=%s", p.get("slug"))
            return {"deferred": True, "reason": "pipeline_frozen", "slug": p.get("slug")}
        finally:
            kb_service._language_drift_escalation = None  # noqa: SLF001
            kb_service._empty_extraction_escalation = None  # noqa: SLF001

    async def _wq_compile(p: dict) -> dict:
        return await _wq_call(kb_service.compile_wiki, p["slug"], job_type="compile")

    async def _wq_reflect(p: dict) -> dict:
        return await _wq_call(kb_service.reflect,
            p["slug"], p.get("articles_created", []), p.get("files_ingested", []), job_type="reflect")

    async def _wq_research(p: dict) -> dict:
        return await _wq_call(kb_service.research,
            p.get("slug", "general"), p.get("query", ""),
            save_output=p.get("save_output", False), job_type="research")

    async def _wq_lint(p: dict) -> dict:
        return await _wq_call(kb_service.lint, p["slug"], job_type="lint")

    async def _wq_synthesize(p: dict) -> dict:
        return await _wq_call(kb_service.synthesize, p["slug"], job_type="synthesize")

    async def _wq_consolidate(p: dict) -> dict:
        return await _wq_call(kb_service.consolidate_articles, p["slug"], job_type="consolidate")

    # ── Cascade retry handlers (daily cycle post-EOD failures) ─────
    async def _wq_daily_reflection(p: dict) -> dict:
        from agntspace.infra.timezone import local_today
        date = p.get("date", local_today())
        await reflection_service.run_daily_reflection(date)
        return {"status": "ok", "date": date}

    async def _wq_daily_memory(p: dict) -> dict:
        from agntspace.infra.timezone import local_today
        date = p.get("date", local_today())
        result = await memory_engine.generate_daily_memory(date)
        return {"status": "ok", "path": result.get("path", "?")}

    async def _wq_memory_to_wiki(p: dict) -> dict:
        from agntspace.infra.timezone import local_today
        date = p.get("date", local_today())
        result = await memory_bridge.memory_to_wiki(date)
        return {"status": "ok", "updates": result.get("updates_filed", 0)}

    async def _wq_wiki_to_memory(p: dict) -> dict:
        result = await memory_bridge.wiki_to_memory()
        return {"status": "ok", "count": result.get("count", 0)}

    async def _wq_system_bridge(p: dict) -> dict:
        from agntspace.infra.timezone import local_today
        date = p.get("date", local_today())
        result = await system_bridge.sync_all(date)
        return {"status": "ok", "total": result.get("total", 0)}

    work_queue.register_handler("ingest", _wq_ingest)
    work_queue.register_handler("compile", _wq_compile)
    work_queue.register_handler("reflect", _wq_reflect)
    work_queue.register_handler("research", _wq_research)
    work_queue.register_handler("lint", _wq_lint)
    work_queue.register_handler("synthesize", _wq_synthesize)
    work_queue.register_handler("consolidate", _wq_consolidate)
    async def _wq_weekly_reflection(p: dict) -> dict:
        from agntspace.infra.timezone import local_today
        date = p.get("date", local_today())
        await reflection_service.run_weekly_reflection(date)
        return {"status": "ok", "date": date}

    async def _wq_memory_compact(p: dict) -> dict:
        layer = p.get("layer", "week")
        await memory_engine._compact_layer(layer)
        return {"status": "ok", "layer": layer}

    async def _wq_editor_pass(p: dict) -> dict:
        if orchestrator:
            result = await orchestrator.dispatch(
                agent_name="editor",
                task="Daily wiki quality pass: lint, taxonomy, reflect, curate.",
                context="Retry of failed editor pass.",
            )
            return {"status": "ok", "content": result.content[:200] if result else ""}
        return {"status": "skipped", "reason": "no orchestrator"}

    work_queue.register_handler("daily_reflection", _wq_daily_reflection)
    work_queue.register_handler("daily_memory", _wq_daily_memory)
    work_queue.register_handler("memory_to_wiki", _wq_memory_to_wiki)
    work_queue.register_handler("wiki_to_memory", _wq_wiki_to_memory)
    work_queue.register_handler("system_bridge", _wq_system_bridge)
    work_queue.register_handler("weekly_reflection", _wq_weekly_reflection)
    work_queue.register_handler("memory_compact", _wq_memory_compact)
    work_queue.register_handler("editor_pass", _wq_editor_pass)

    # ── Evolution subsystem handlers ────────────────────────────────
    # Phase 1 registers all four handlers so jobs enqueued from the
    # heartbeat (mine_reflexes) or future replay/promotion/drift paths
    # don't silently drop. Phase 1 bodies either delegate to ReflexMiner
    # (mine_reflexes, Phase 2) or return {"status":"deferred", phase:N}.
    async def _wq_evolution_mine_reflexes(payload: dict | None = None) -> dict:
        return await evolution.handle_mine_reflexes(payload)

    async def _wq_evolution_replay(payload: dict | None = None) -> dict:
        return await evolution.handle_replay(payload)

    async def _wq_evolution_promote(payload: dict | None = None) -> dict:
        return await evolution.handle_promote(payload)

    async def _wq_evolution_drift_scan(payload: dict | None = None) -> dict:
        return await evolution.handle_drift_scan(payload)

    work_queue.register_handler("evolution_mine_reflexes", _wq_evolution_mine_reflexes)
    work_queue.register_handler("evolution_replay", _wq_evolution_replay)
    work_queue.register_handler("evolution_promote", _wq_evolution_promote)
    work_queue.register_handler("evolution_drift_scan", _wq_evolution_drift_scan)

    async def _wq_fact_check_pass(p: dict) -> dict:
        if orchestrator:
            result = await orchestrator.dispatch(
                agent_name="fact-checker",
                task="Daily truth audit: verify all agent-generated content from the last 24h.",
                context="Retry of failed fact-check pass.",
            )
            return {"status": "ok", "content": result.content[:200] if result else ""}
        return {"status": "skipped", "reason": "no orchestrator"}

    work_queue.register_handler("fact_check_pass", _wq_fact_check_pass)

    async def _wq_pursue_goal(p: dict) -> dict:
        """Work queue handler for autonomous goal pursuit.

        Budget gating is mode-aware:
        - Sets the ``_goal_id_var`` contextvar so every LLM call inside this
          scope attributes its cost to the goal in ``cost_log``.
        - After dispatch, queries actual cost from ``cost_tracker`` and passes
          it to ``record_pursuit_attempt`` (was hardcoded to 0.0).
        - In local mode, cost is always 0.0 — dispatch cap is the real gate.
        """
        goal_slug = p.get("goal_slug", "")
        goal_metric = p.get("goal_metric", "")
        budget_remaining = p.get("budget_remaining", 0)
        dispatch_number = p.get("dispatch_number", 1)
        max_dispatches = p.get("max_dispatches", 50)
        is_local = p.get("local_mode", False)

        if not goal_slug or not agent:
            return {"status": "skipped", "reason": "no goal_slug or agent"}

        from agntspace.activity.decisions import (
            correlation_scope,
            mark_actions_authorized,
            new_correlation_id,
            set_goal_id,
        )

        with correlation_scope(new_correlation_id("goal-pursuit")):
            # Pre-authorize standard goal pursuit actions so vault writes succeed
            mark_actions_authorized(["create_task", "search", "write_vault", "delegate_task"])

            # Set goal_id contextvar so _record_cost attributes LLM costs to this goal
            goal_token = set_goal_id(goal_slug)

            # Snapshot cost before dispatch to measure delta
            cost_before = 0.0
            if cost_tracker:
                try:
                    cost_before = await cost_tracker.get_goal_cost(goal_slug)
                except Exception:
                    pass

            try:
                # Build context string appropriate for mode
                if is_local:
                    budget_ctx = f"Local mode (free). Dispatch #{dispatch_number}/{max_dispatches}."
                else:
                    budget_ctx = f"Budget remaining: ${budget_remaining:.2f}. Dispatch #{dispatch_number}/{max_dispatches}."

                task_text = (
                    f"Pursue goal '{goal_slug}'. "
                    f"Metric: '{goal_metric}'. "
                    f"{budget_ctx}"
                )
                outcome = await agent.invoke_skill(
                    "goal-pursue",
                    args={
                        "goal_slug": goal_slug,
                        "goal_metric": goal_metric,
                        "budget_remaining": budget_remaining,
                        "dispatch_number": dispatch_number,
                    },
                    agent_name="planner",
                    caller="goal-pursuit",
                    task_override=task_text,
                )
                # Check if the agent actually produced mutations (not just read-only searches).
                # vault_writes > 0 means tasks created, files written, real progress.
                mutations = outcome.mutations or {}
                made_progress = mutations.get("vault_writes", 0) > 0

                # Calculate actual cost delta for this dispatch
                actual_cost = 0.0
                if cost_tracker:
                    try:
                        cost_after = await cost_tracker.get_goal_cost(goal_slug)
                        actual_cost = max(0.0, cost_after - cost_before)
                    except Exception:
                        pass

                # Build a short note from outcome for the pursuit log
                note = outcome.content[:120].replace("\n", " ").strip() if outcome.content else ""

                # Parse structured dispatch-outcome block (per-experiment
                # record with ternary outcome + "why" fields). Absent block
                # → fail-open, legacy note path preserved. See
                # coach/dispatch_outcome.py.
                from agntspace.coach.dispatch_outcome import (
                    parse_dispatch_outcome,
                )
                parsed = parse_dispatch_outcome(outcome.content or "")
                effective_outcome = parsed.get("outcome", "")

                attempt_result = coach.record_pursuit_attempt(
                    goal_slug,
                    succeeded=made_progress,
                    cost=actual_cost,
                    note=note,
                    outcome=effective_outcome,
                    hypothesis=parsed.get("hypothesis", ""),
                    action=parsed.get("action", ""),
                    observation=parsed.get("observation", ""),
                    metric_delta=parsed.get("metric_delta", ""),
                    reason=parsed.get("reason", ""),
                )

                # Auto-feed outcome → per-goal Bayesian trust.
                # keep  → good                (α += 1.0)
                # discard → needs_improvement (β += 0.3)
                # crash → redo                (β += 3.0)
                # No outcome set = no auto-signal (relies on user feedback
                # + the other signal channels). This closes the loop
                # promised by per-goal Bayesian trust without forcing
                # every dispatch into a binary success/failure signal.
                try:
                    trust_svc = getattr(app.state, "trust_service", None)
                    if trust_svc and effective_outcome:
                        g_status = coach.get_pursuit_status(goal_slug)
                        goal_domain = g_status.get("domain") or "general"
                        rating = {
                            "keep": "good",
                            "discard": "needs_improvement",
                            "crash": "redo",
                        }.get(effective_outcome)
                        if rating:
                            trust_svc.record_feedback(
                                goal_domain, rating, goal_key=goal_slug,
                            )
                except Exception:
                    log.debug(
                        "Bayesian auto-feed failed for goal %s",
                        goal_slug, exc_info=True,
                    )

                # Ship 4B — every N dispatches, fire a retrospective.
                # Cadence lives in goal-retrospective/config/strategy.yaml so
                # users can tune without a code change. Enqueue (not run
                # inline) so retrospective runs in its own correlation
                # scope with its own tools_granted.
                try:
                    retro_cfg = {}
                    if skills is not None:
                        cfg = skills.load_skill_config_file(
                            "goal-retrospective", "strategy.yaml",
                        )
                        if isinstance(cfg, dict):
                            retro_cfg = cfg
                    if retro_cfg.get("enabled", True):
                        every_n = int(retro_cfg.get("every_n_dispatches", 5))
                        new_count = int(dispatch_number)
                        if every_n > 0 and new_count > 0 and new_count % every_n == 0:
                            await work_queue.enqueue(
                                "retrospective_goal",
                                {
                                    "goal_slug": goal_slug,
                                    "dispatch_count": new_count,
                                    "reason": "cadence",
                                },
                                deduplicate=True,
                            )
                except Exception:
                    log.debug("retrospective enqueue failed", exc_info=True)

                return {
                    "status": "ok",
                    "succeeded": made_progress,
                    "outcome": attempt_result.get("outcome"),
                    "mutations": mutations,
                    "cost": actual_cost,
                    "local_mode": is_local,
                    "content": outcome.content[:200] if outcome.content else "",
                }
            except Exception as exc:
                # Record failure; check futility
                try:
                    # Still calculate actual cost even on failure
                    actual_cost = 0.0
                    if cost_tracker:
                        try:
                            cost_after = await cost_tracker.get_goal_cost(goal_slug)
                            actual_cost = max(0.0, cost_after - cost_before)
                        except Exception:
                            pass

                    err_note = str(exc)[:100].replace("\n", " ")
                    # Exception path = explicit crash. Feed the Bayesian
                    # trust pipeline a ``redo`` signal (β += 3.0) so
                    # chronic crashes pull the per-goal posterior down
                    # even when the agent never emits a dispatch-outcome
                    # block.
                    coach.record_pursuit_attempt(
                        goal_slug, succeeded=False, cost=actual_cost,
                        note=err_note,
                        outcome="crash",
                        observation=err_note,
                        reason="handler exception",
                    )
                    try:
                        trust_svc = getattr(app.state, "trust_service", None)
                        if trust_svc:
                            g_status = coach.get_pursuit_status(goal_slug)
                            goal_domain = g_status.get("domain") or "general"
                            trust_svc.record_feedback(
                                goal_domain, "redo", goal_key=goal_slug,
                            )
                    except Exception:
                        log.debug(
                            "Bayesian crash-feed failed for goal %s",
                            goal_slug, exc_info=True,
                        )
                    status = coach.get_pursuit_status(goal_slug)
                    if status["attempt_failures"] >= status["max_attempts"]:
                        coach.set_pursuit_status(goal_slug, "paused", reason="Futility limit reached")
                        if activity_logger:
                            activity_logger.log(
                                "system", "goal_pursuit_paused",
                                f"Goal '{goal_slug}' paused after {status['max_attempts']} failed attempts",
                                {"goal_slug": goal_slug, "reason": "futility"},
                                importance="high",
                            )
                except Exception:
                    log.debug("Failed to record pursuit failure for %s", goal_slug, exc_info=True)
                # Rule #13: surface the failure as a high-importance activity
                # event so the user sees it in the UI instead of having it
                # hide in the work queue's result JSON (which took 10+ silent
                # failures to notice last time).
                if activity_logger:
                    try:
                        activity_logger.log(
                            "system", "goal_pursuit_failed",
                            f"Goal '{goal_slug}' pursuit attempt failed: {str(exc)[:120]}",
                            {"goal_slug": goal_slug, "error": str(exc)[:500]},
                            importance="high",
                        )
                    except Exception:
                        log.debug("activity_logger.log failed", exc_info=True)
                return {"status": "failed", "error": str(exc)[:200]}
            finally:
                # Always reset goal_id contextvar
                try:
                    from agntspace.activity.decisions import _goal_id_var
                    _goal_id_var.reset(goal_token)
                except (LookupError, ValueError):
                    pass

    work_queue.register_handler("pursue_goal", _wq_pursue_goal)

    # Ship 4B — goal-retrospective dispatch. Fires every N pursuit
    # dispatches. Reads pursuit log + metric observations + answered
    # questions, emits verdict + recommendation via submit_retrospective
    # tool. Owner accepts or rejects via /api/goals/{slug}/retrospectives.
    async def _wq_retrospective_goal(p: dict) -> dict:
        goal_slug = p.get("goal_slug", "")
        dispatch_count = int(p.get("dispatch_count", 0))
        reason = str(p.get("reason", "cadence"))
        if not goal_slug or not agent:
            return {"status": "skipped", "reason": "no goal_slug or agent"}

        from agntspace.activity.decisions import (
            correlation_scope,
            mark_actions_authorized,
            new_correlation_id,
        )

        with correlation_scope(new_correlation_id("goal-retro")):
            # Retrospective writes to the goal file (submit_retrospective)
            # and may read tasks/knowledge. Pre-authorize write_vault + search.
            mark_actions_authorized(["modify_project", "write_vault", "search"])
            task_text = (
                f"Run a retrospective on goal '{goal_slug}'. "
                f"This is dispatch count {dispatch_count} (trigger: {reason}). "
                f"Read the goal, synthesize progress against the success_metric, "
                f"and call submit_retrospective with your verdict + recommendation."
            )
            try:
                outcome = await agent.invoke_skill(
                    "goal-retrospective",
                    args={
                        "goal_slug": goal_slug,
                        "dispatch_count": dispatch_count,
                    },
                    agent_name="planner",
                    caller="goal-retro",
                    task_override=task_text,
                )
                return {
                    "status": "ok",
                    "succeeded": bool(outcome.mutations.get("vault_writes", 0) > 0),
                    "content_preview": (outcome.content or "")[:200],
                }
            except Exception as exc:
                log.debug("retrospective dispatch failed for %s", goal_slug, exc_info=True)
                return {"status": "error", "error": str(exc)[:200]}

    work_queue.register_handler("retrospective_goal", _wq_retrospective_goal)

    heartbeat._work_queue = work_queue  # Wire heartbeat → work queue for LLM health updates
    heartbeat._orchestrator = orchestrator  # Wire heartbeat → orchestrator for active dispatch awareness
    # Rule #17 learning loop: heartbeat calls SkillSchool.analyze_zero_effects()
    # on every cycle to surface broken skills as action plan proposals.
    heartbeat._skillschool = skillschool  # noqa: SLF001
    heartbeat._vault_dir = vault_path  # noqa: SLF001  # for action plan write-through
    heartbeat._activity = activity_logger  # noqa: SLF001
    heartbeat._outreach = outreach  # noqa: SLF001  # proactive outreach on silence/important events
    heartbeat._agent = agent  # noqa: SLF001  # for outreach message generation
    heartbeat._coach = coach  # noqa: SLF001  # autonomous goal pursuit
    heartbeat._cost_tracker = cost_tracker  # noqa: SLF001  # goal budget checks
    heartbeat._insights_engine = insights_engine  # noqa: SLF001  # proactive awareness
    heartbeat._kb_service = kb_service  # noqa: SLF001  # autonomous pipeline triggers
    heartbeat._trust_service = trust_service  # noqa: SLF001  # trust gating on goal pursuit
    heartbeat._contract = contract  # noqa: SLF001  # proactivity level for trust formula
    heartbeat._evolution = evolution  # noqa: SLF001  # Phase 2 reflex mining scheduler
    heartbeat._skill_loader = skills  # noqa: SLF001  # adaptive pacing config
    heartbeat._settings = settings  # noqa: SLF001  # timezone for quiet-hours resolution
    heartbeat._graph_curator = graph_curator  # noqa: SLF001  # entity-dedup auto-apply (Tier-B)

    # Memory ↔ Wiki bridge
    from agntspace.kb.memory_bridge import MemoryWikiBridge

    memory_bridge = MemoryWikiBridge(llm, vault, writer, kb_service)
    memory_bridge._activity = activity_logger  # noqa: SLF001
    memory_bridge._decisions = decision_logger  # noqa: SLF001
    memory_bridge._contract = contract  # noqa: SLF001
    daily_cycle._memory_bridge = memory_bridge  # inject into already-created daily_cycle

    # System Bridge: all modules → unified graph + wiki
    from agntspace.kb.system_bridge import SystemBridge

    system_bridge = SystemBridge(
        graph=knowledge_graph,
        vault_reader=vault,
        vault_writer=writer,
        kb_service=kb_service,
        journal=journal,
        task_service=task_service,
        coach=coach,
        trust_service=trust_service,
        skill_loader=skills,
        project_service=project_service,
        heartbeat=heartbeat,
        guardian=guardian,
    )
    system_bridge._activity = activity_logger  # noqa: SLF001
    system_bridge._decisions = decision_logger  # noqa: SLF001
    system_bridge._contract = contract  # noqa: SLF001
    daily_cycle._system_bridge = system_bridge

    # Workflow Supervisor — adaptive multi-agent workflow execution
    from agntspace.agent.supervisor import (
        CritiqueSpec,
        WorkflowDef,
        WorkflowStep,
        WorkflowSupervisor,
    )

    supervisor = WorkflowSupervisor(orchestrator, llm, skill_loader=skills)
    app.state.supervisor = supervisor

    # Register workflow definitions (match UI WORKFLOW_DEFS)
    app.state.workflow_defs = {
        "wiki-management": WorkflowDef(
            id="wiki-management",
            name="Wiki Management",
            description="Full knowledge pipeline: create KB (if needed) → scrape → ingest → compile → reflect → lint.",
            supervisor="wiki-supervisor",
            steps=[
                WorkflowStep(id="setup", agent_key="researcher", label="Create KB (if needed)",
                    description="Check if the target KB exists. If not, create it using create_kb with appropriate description, focus questions, and source types. Skip if KB already exists.",
                    skills=["browser-automation"]),
                WorkflowStep(id="scrape", agent_key="researcher", label="Research & Scrape",
                    description="Search the web and scrape relevant pages into KB inbox using research_scrape. Run multiple search angles for breadth. Use scrape_url for specific URLs.",
                    skills=["browser-automation"]),
                WorkflowStep(id="ingest", agent_key="librarian", label="Ingest & Classify",
                    description="Process inbox files. Classify by type. Extract entities, concepts, facts. Create source summaries. Cross-KB dedup.",
                    skills=["kb-ingest"]),
                WorkflowStep(id="compile", agent_key="librarian", label="Compile Articles",
                    description="Generate wiki articles from sources. Entity/concept/source pages with [[backlinks]]. Slug/title dedup.",
                    skills=["kb-compile"]),
                WorkflowStep(id="reflect", agent_key="editor", label="Reflect",
                    description="Structural analysis: what decisions were made? Ingestion bias? Blind spots? Create decision records for significant changes.",
                    skills=["kb-reflect"]),
                WorkflowStep(id="lint", agent_key="editor", label="Lint & Quality",
                    description="Two-phase health check. Structural: orphans, broken links, duplicates. Semantic: contradictions, stale claims, data gaps.",
                    skills=["kb-lint", "kb-curate"]),
                WorkflowStep(id="research", agent_key="researcher", label="Research (on demand)",
                    description="Answer questions against compiled wiki. Tiered context. Output feeds back idempotently.",
                    skills=["kb-research"]),
            ],
        ),
        "email-processing": WorkflowDef(
            id="email-processing",
            name="Email Processing",
            description="Triage inbox, draft responses, security scan, send after approval.",
            supervisor="ops-supervisor",
            steps=[
                WorkflowStep(id="triage", agent_key="assistant", label="Triage Inbox",
                    description="Read and classify emails by urgency. Flag important. Separate newsletters from actionable items."),
                WorkflowStep(id="draft", agent_key="writer", label="Draft Responses",
                    description="Compose reply drafts matching user voice. Pull context from vault. Save to Gmail Drafts."),
                WorkflowStep(id="security", agent_key="guardian", label="Security Scan",
                    description="Check outgoing content against blacklist. Scan for sensitive data leakage."),
                WorkflowStep(id="send", agent_key="assistant", label="Send & Log",
                    description="After user approval, send email. Log action to audit trail. Record as journal observation."),
            ],
        ),
        "project-kickoff": WorkflowDef(
            id="project-kickoff",
            name="Project Kickoff",
            description="Set up a new project: research context, plan structure, create brief, assign agents.",
            supervisor="project-supervisor",
            steps=[
                WorkflowStep(id="gather", agent_key="researcher", label="Gather Context",
                    description="Search vault and KBs for relevant prior work. Identify related goals. Compile background brief."),
                WorkflowStep(id="plan", agent_key="planner", label="Structure & Plan",
                    description="Break project into milestones. Define dependencies. Estimate scope. Recommend agent assignments."),
                WorkflowStep(id="brief", agent_key="writer", label="Create Brief",
                    description="Write PROJECT.md. Document objectives, constraints, success criteria."),
                WorkflowStep(id="setup", agent_key="assistant", label="Set Up & Assign",
                    description="Create project in system. Add milestones. Link goals and KBs. Assign subagents."),
            ],
        ),
        "daily-cycle": WorkflowDef(
            id="daily-cycle",
            name="Daily Cycle",
            description="Morning-to-evening: briefing, priorities, observation logging, EOD reflection.",
            supervisor="ops-supervisor",
            steps=[
                WorkflowStep(id="metrics", agent_key="analyst", label="Review Metrics",
                    description="Check email counts, task status, goal progress. Identify blockers and stalled items."),
                WorkflowStep(id="briefing", agent_key="assistant", label="Morning Briefing",
                    description="Compile overnight activity. List today's priorities. Surface deadlines."),
                WorkflowStep(id="prioritize", agent_key="planner", label="Prioritize Work",
                    description="Reorder tasks by urgency. Detect stalled goals. Generate coaching nudges."),
                WorkflowStep(id="eod", agent_key="assistant", label="EOD Report",
                    description="Summarize today. Log observations. Run memory consolidation. Prepare for tomorrow."),
            ],
        ),
        "journal-management": WorkflowDef(
            id="journal-management",
            name="Journal Management",
            description="Full daily journal lifecycle: morning briefing → observation logging → EOD report → reflection → memory extraction.",
            supervisor="ops-supervisor",
            steps=[
                WorkflowStep(id="briefing", agent_key="assistant", label="Morning Briefing",
                    description="Generate personalized morning briefing. Pull focus, tasks, goals, yesterday's journal, memory, upcoming deadlines. Warm but efficient.",
                    skills=["daily-briefing"]),
                WorkflowStep(id="observe", agent_key="analyst", label="Log Observations",
                    description="Review today's activity — emails, tasks, conversations, state changes. Append observations to agent-observations file.",
                    skills=[]),
                WorkflowStep(id="eod", agent_key="assistant", label="EOD Report",
                    description="Summarize the day: completed tasks, decisions, learnings, memory updates. Write eod-report file.",
                    skills=["eod-report"]),
                WorkflowStep(id="reflect", agent_key="memory-agent", label="Daily Reflection",
                    description="Synthesize user journal + agent observations into structured reflection. Propose MEMORY.md updates (pending user approval).",
                    skills=["daily-reflection"]),
                WorkflowStep(id="weekly", agent_key="memory-agent", label="Weekly Reflection (if due)",
                    description="On compaction day: synthesize past 7 days into weekly patterns, progress, and goal check. Trigger memory compaction.",
                    skills=["weekly-reflection"]),
            ],
        ),
        "memory-management": WorkflowDef(
            id="memory-management",
            name="Memory Management",
            description="Full memory lifecycle: consolidate daily activity → extract triples → compact layers → rebuild graph → health check.",
            supervisor="ops-supervisor",
            steps=[
                WorkflowStep(id="consolidate", agent_key="memory-agent", label="Daily Consolidation",
                    description="Gather all activity (journal, notes, conversations, tasks, goals). Synthesize into daily memory file with [[wiki-links]]. Extract permanent facts (pending approval).",
                    skills=["memory-consolidate"]),
                WorkflowStep(id="extract", agent_key="memory-agent", label="Triple Extraction",
                    description="Extract structured knowledge triples (subject-predicate-object) from daily memory. Validate against ontology. Add to knowledge graph.",
                    skills=["memory-consolidate"]),
                WorkflowStep(id="compact", agent_key="memory-agent", label="Layer Compaction",
                    description="Compact memory through temporal layers: daily → week → month → quarter → year → longterm. Preserve [user-confirmed] tags and provenance.",
                    skills=["memory-compact"]),
                WorkflowStep(id="graph", agent_key="philosopher", label="Graph Maintenance",
                    description="Evaluate decay conditions. Reweight triples by authority, confidence, temporal relevance. Detect contradictions. Resolve orphaned entities.",
                    skills=["ontology", "epistemic-audit"]),
                WorkflowStep(id="health", agent_key="analyst", label="Health Check",
                    description="Run memory health diagnostics: orphaned entities/triples, contradictions, missing daily memories, high decay ratio. Auto-repair if possible.",
                    skills=[]),
            ],
        ),
        "content-review": WorkflowDef(
            id="content-review",
            name="Content Review",
            description="Multi-stage review: draft → quality review → fact check → compliance.",
            supervisor="project-supervisor",
            steps=[
                # The draft step carries a CritiqueSpec: after the writer
                # produces initial content, the fact-checker scores it
                # against the critique-protocol rubric. Below 0.70 triggers
                # one revision pass with the critique's concerns as feedback.
                # Above threshold passes through. This is the reference
                # integration of gap #2's critique loop — opt-in per step.
                WorkflowStep(id="draft", agent_key="writer", label="Draft Content",
                    description="Write initial document. Follow templates and brand voice. Pull supporting data.",
                    critique=CritiqueSpec(agent="fact-checker", threshold=0.70, max_revisions=1)),
                WorkflowStep(id="review", agent_key="editor", label="Quality Review",
                    description="Check clarity, accuracy, completeness. Flag unsupported claims."),
                WorkflowStep(id="factcheck", agent_key="analyst", label="Fact Check",
                    description="Cross-reference claims against KB sources. Flag contradictions. Verify data."),
                WorkflowStep(id="compliance", agent_key="guardian", label="Compliance Check",
                    description="Scan for sensitive data. Check contract permissions. Ensure safe for external sharing."),
            ],
        ),
        # Autoresearch: explicit task-targeted iterative research loop.
        # Unlike the implicit per-goal pursuit (heartbeat-driven, one
        # dispatch per 15min tick), this workflow runs inline:
        # frame → experiment → evaluate → record → LOOP BACK
        # until the success metric is met or the supervisor aborts. Each
        # experiment closes with the same structured `dispatch-outcome`
        # block (keep/discard/crash + hypothesis/action/observation/
        # metric_delta/reason) that the per-goal Bayesian trust consumes —
        # so whether you use the implicit pursuit loop OR this explicit
        # workflow, the outcome bookkeeping is identical and additive.
        #
        # Deployment: `run_workflow(workflow_id="autoresearch",
        # input={"target": "goal:<slug>" | "task:<slug>" | "question: ..."})`.
        # The supervisor is project-supervisor because the typical use is
        # driving a research-heavy goal inside a project, but the workflow
        # is domain-agnostic — the target drives the content, not the
        # agent cast.
        "autoresearch": WorkflowDef(
            id="autoresearch",
            name="Autoresearch Loop",
            description=(
                "Iterative hypothesis → experiment → evaluate → record "
                "loop, deployable on a specific goal or task. Produces "
                "a structured experiment log (keep/discard/crash per "
                "iteration) that feeds the per-goal Bayesian trust "
                "posterior. Supervisor loops back to `experiment` until "
                "the success metric is met or budget exhausts."
            ),
            supervisor="project-supervisor",
            steps=[
                WorkflowStep(
                    id="frame", agent_key="planner", label="Frame the Question",
                    description=(
                        "Read the target (goal slug, task slug, or ad-hoc "
                        "question). Extract or propose the success metric. "
                        "List 1–3 candidate hypotheses to test. Pick the "
                        "most promising for the first experiment. If the "
                        "metric is already met, report `done` immediately."
                    ),
                    skills=["goal-pursue", "progress-analysis"],
                ),
                WorkflowStep(
                    id="experiment", agent_key="researcher", label="Run Experiment",
                    description=(
                        "Execute ONE hypothesis test this iteration: gather "
                        "data (web_search / research_scrape / "
                        "query_knowledge), analyze, produce a concrete "
                        "artifact. Do NOT test multiple hypotheses in "
                        "parallel — one per iteration keeps the experiment "
                        "log clean and the Bayesian signal per-goal "
                        "interpretable."
                    ),
                    skills=["browser-automation", "goal-pursue"],
                ),
                WorkflowStep(
                    id="evaluate", agent_key="analyst", label="Evaluate Against Metric",
                    description=(
                        "Compare the experiment's finding to the success "
                        "metric. Quantify the delta in the metric's own "
                        "units. Classify as keep/discard/crash with "
                        "evidence. The fact-checker's critique (below) "
                        "gates whether the evaluation is accepted or "
                        "sent back for revision."
                    ),
                    skills=["progress-analysis"],
                    critique=CritiqueSpec(
                        agent="fact-checker",
                        threshold=0.65,
                        max_revisions=1,
                    ),
                ),
                WorkflowStep(
                    id="record", agent_key="writer", label="Record Experiment",
                    description=(
                        "Write a structured `dispatch-outcome` block "
                        "(hypothesis, action, observation, metric_delta, "
                        "outcome, reason) and append to the target goal's "
                        "`## Pursuit Log`. This is what feeds the "
                        "per-goal Bayesian trust posterior and what "
                        "future iterations read to avoid repeating "
                        "discarded experiments."
                    ),
                    skills=["goal-pursue"],
                ),
            ],
        ),
        # Receipt processing — Phase 1 of the finance feature.
        # Drop a receipt in agntspace-finance/inbox/ → bookkeeper extracts
        # → categorises → attributes to project → auditor critiques →
        # draft lands in pending/ for user approval. NEVER auto-commits.
        # Three-step pipeline because each phase has a distinct skill +
        # contract + observation surface; collapsing them into one step
        # would mean the auditor critique cannot loop back to the
        # bookkeeper's project-attribution decision specifically.
        "receipt-processing": WorkflowDef(
            id="receipt-processing",
            name="Receipt Processing",
            description=(
                "Receipt or invoice → structured TransactionDraft → "
                "categorised + project-attributed → auditor-critiqued → "
                "pending user approval. Never commits to the verified "
                "ledger; that's a separate user click at /finance."
            ),
            # project-supervisor in v1 — no separate finance-supervisor
            # until the team grows past three specialists. See the plan's
            # "Open decisions" §7 — the choice is reversible without
            # touching workflow logic, just the supervisor key.
            supervisor="project-supervisor",
            steps=[
                WorkflowStep(
                    id="extract", agent_key="bookkeeper", label="Extract Receipt",
                    description=(
                        "Run the receipt-ingest skill on the inbox file. "
                        "Vision/OCR + structured extraction → TransactionDraft "
                        "in pending/. Resolve merchant via the alias index "
                        "(propose new only with user approval). Return "
                        "draft_id so subsequent steps can reference it."
                    ),
                    skills=["receipt-ingest", "merchant-resolve"],
                ),
                WorkflowStep(
                    id="categorize", agent_key="bookkeeper", label="Categorise Line Items",
                    description=(
                        "Apply the categories.yaml taxonomy per line item. "
                        "Per-line classification respects the merchant's "
                        "default_category as a hint but never overrides "
                        "items that contradict it (Costco engine oil is "
                        "automotive, not groceries, regardless of merchant)."
                    ),
                    skills=["transaction-categorize"],
                ),
                WorkflowStep(
                    id="attribute", agent_key="bookkeeper", label="Attribute to Project",
                    description=(
                        "Suggest project assignment per transaction (or per "
                        "line item for split receipts). Auditor critiques "
                        "the attribution; loops back below threshold so the "
                        "bookkeeper can revise with concrete feedback. The "
                        "honest empty-when-unsure default is acceptable."
                    ),
                    skills=["project-attribution"],
                    critique=CritiqueSpec(
                        agent="auditor",
                        skill="transaction-audit",
                        threshold=0.70,
                        max_revisions=1,
                    ),
                ),
            ],
        ),
    }

    # Graph curator tools — Tier-A chat-driven entity-dedup curation.
    # The user can ask the librarian/philosopher to "audit my graph for
    # duplicates" and walk the proposal list. Tier-B heartbeat-driven
    # auto-apply runs in parallel; the two paths share the same service
    # (app.state.graph_curator) and hit the same audit/decision wiring.
    registry.register_integration(
        "graph_curator", "Graph Curator",
        "Resolve duplicate entities in the knowledge graph.",
        lambda: getattr(app.state, "graph_curator", None) is not None,
    )

    async def _list_merge_proposals_handler(inp: dict) -> dict:
        curator = getattr(app.state, "graph_curator", None)
        if curator is None:
            return {"error": "Graph curator service not available"}
        max_pairs = int(inp.get("max_pairs", 50)) if inp else 50
        return curator.list_proposals(max_pairs=max_pairs)

    def _list_merge_proposals_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_list_merge_proposals_handler(inp))

    registry.register_tool(
        name="list_merge_proposals",
        description=(
            "List likely-duplicate entity pairs the system suggests merging. "
            "Pure read — never mutates the graph. Use when the user asks "
            "to audit / clean up / resolve duplicates in their knowledge graph. "
            "Returns proposals grouped by rule (comma_prefix is deterministic; "
            "embedding_cosine needs human judgment)."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "max_pairs": {
                    "type": "integer",
                    "description": "Cap on returned proposals (default 50).",
                    "default": 50,
                },
            },
        },
        handler=_list_merge_proposals_sync,
        async_handler=_list_merge_proposals_handler,
        integration="graph_curator",
        contract_action="search",
    )

    async def _apply_merge_proposal_handler(inp: dict) -> dict:
        curator = getattr(app.state, "graph_curator", None)
        if curator is None:
            return {"error": "Graph curator service not available"}
        kept_slug = (inp.get("kept_slug") or "").strip() if inp else ""
        lost_slug = (inp.get("lost_slug") or "").strip() if inp else ""
        if not kept_slug or not lost_slug:
            return {"error": "kept_slug and lost_slug are required"}
        return await curator.apply_one(
            kept_slug, lost_slug,
            caller="agent_tool",
            source="agent_curator",
        )

    def _apply_merge_proposal_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_apply_merge_proposal_handler(inp))

    registry.register_tool(
        name="apply_merge_proposal",
        description=(
            "Merge two duplicate entities. The shorter / canonical name is "
            "``kept_slug``; the longer / variant is ``lost_slug``. Repoints "
            "every triple referencing ``lost_slug`` to ``kept_slug`` and "
            "writes an audit row so the merge is reversible from the Graph "
            "page's Recent merges panel."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "kept_slug": {"type": "string", "description": "Slug of the entity to keep (canonical / shorter form)."},
                "lost_slug": {"type": "string", "description": "Slug of the entity to retire (variant / longer form). All its triples get repointed."},
            },
            "required": ["kept_slug", "lost_slug"],
        },
        handler=_apply_merge_proposal_sync,
        async_handler=_apply_merge_proposal_handler,
        integration="graph_curator",
        contract_action="auto_merge_entity",
    )

    # Canvas tools — agent-driven visual interface (A2UI)
    registry.register_integration("canvas", "Canvas", "Push visual content to the operator's live canvas.", lambda: True)

    _CANVAS_TYPES = ["markdown", "html", "table", "code", "image", "json", "mermaid"]

    async def _canvas_push_handler(inp: dict) -> dict:
        content = inp.get("content", "")
        content_type = inp.get("type", "markdown")
        title = inp.get("title", "")
        if not content:
            return {"error": "Content is required"}
        item = await canvas.push(content, content_type, title)
        return {"pushed": True, "id": item["id"]}

    def _canvas_push_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_canvas_push_handler(inp))

    registry.register_tool(
        name="canvas_push",
        description="Push content to the operator's live canvas. Use for visual summaries, search results, tables, code, or task boards.",
        input_schema={
            "type": "object",
            "properties": {
                "content": {"type": "string", "description": "Content to display"},
                "type": {"type": "string", "enum": _CANVAS_TYPES, "description": "Content format", "default": "markdown"},
                "title": {"type": "string", "description": "Optional title for the canvas card"},
            },
            "required": ["content"],
        },
        handler=_canvas_push_sync,
        async_handler=_canvas_push_handler,
        integration="canvas",
        contract_action="canvas_push",
    )

    async def _canvas_update_handler(inp: dict) -> dict:
        item_id = inp.get("id", "")
        if not item_id:
            return {"error": "id is required"}
        fields: dict = {}
        if "content" in inp:
            fields["content"] = inp["content"]
        if "title" in inp:
            fields["title"] = inp["title"]
        if "type" in inp:
            fields["content_type"] = inp["type"]
        item = await canvas.update(item_id, **fields)
        if not item:
            return {"error": "Item not found"}
        return {"updated": True, "id": item_id}

    def _canvas_update_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_canvas_update_handler(inp))

    registry.register_tool(
        name="canvas_update",
        description="Update an existing canvas item by ID. Can change content, title, or type.",
        input_schema={
            "type": "object",
            "properties": {
                "id": {"type": "string", "description": "ID of the canvas item to update"},
                "content": {"type": "string", "description": "New content"},
                "title": {"type": "string", "description": "New title"},
                "type": {"type": "string", "enum": _CANVAS_TYPES, "description": "New content type"},
            },
            "required": ["id"],
        },
        handler=_canvas_update_sync,
        async_handler=_canvas_update_handler,
        integration="canvas",
        contract_action="canvas_push",
    )

    async def _canvas_remove_handler(inp: dict) -> dict:
        item_id = inp.get("id", "")
        if not item_id:
            return {"error": "id is required"}
        ok = await canvas.remove(item_id)
        return {"removed": ok, "id": item_id}

    def _canvas_remove_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_canvas_remove_handler(inp))

    registry.register_tool(
        name="canvas_remove",
        description="Remove a specific item from the canvas by ID.",
        input_schema={
            "type": "object",
            "properties": {
                "id": {"type": "string", "description": "ID of the canvas item to remove"},
            },
            "required": ["id"],
        },
        handler=_canvas_remove_sync,
        async_handler=_canvas_remove_handler,
        integration="canvas",
        contract_action="canvas_push",
    )

    async def _canvas_snapshot_handler(inp: dict) -> dict:
        name = inp.get("name", "Untitled")
        snap = await canvas.snapshot_save(name)
        return {"snapshot_id": snap["id"], "name": name}

    def _canvas_snapshot_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_canvas_snapshot_handler(inp))

    registry.register_tool(
        name="canvas_snapshot",
        description="Save the current canvas state as a named snapshot that can be restored later.",
        input_schema={
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Name for the snapshot"},
            },
            "required": ["name"],
        },
        handler=_canvas_snapshot_sync,
        async_handler=_canvas_snapshot_handler,
        integration="canvas",
        contract_action="canvas_push",
    )

    async def _canvas_reset_handler(_inp: dict) -> dict:
        await canvas.reset()
        return {"reset": True}

    def _canvas_reset_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_canvas_reset_handler(inp))

    registry.register_tool(
        name="canvas_reset",
        description="Clear all content from the operator's canvas.",
        input_schema={"type": "object", "properties": {}},
        handler=_canvas_reset_sync,
        async_handler=_canvas_reset_handler,
        integration="canvas",
        contract_action="canvas_push",
    )

    # ── Heal agents tool ──

    # Proposals file path
    _proposals_path = vault_paths.resolve("system/logs/simulation/heal-proposals.json")

    async def _heal_agents_handler(inp: dict) -> dict:
        """Diagnose agent health and propose fixes (user must approve)."""
        action = inp.get("action", "diagnose")

        from agntspace.api.routes.simulate import _check_single_agent

        if action == "diagnose":
            # Run agent-centric health checks and return diagnosis
            available_skills = {s.name for s in skills.skills.values() if s.status == "active"}
            diagnosis: dict = {"agents": {}, "healable": [], "needs_human": []}

            for agent_key in sorted(orchestrator.agents.keys()):
                result = _check_single_agent(orchestrator, agent_key)
                if result["status"] != "pass":
                    diagnosis["agents"][agent_key] = {
                        "status": result["status"],
                        "agent_name": result.get("agent_name", agent_key),
                        "issues": result.get("issues", []),
                    }
                for issue in result.get("issues", []):
                    if issue.startswith("Skill not loaded:"):
                        skill_name = issue.replace("Skill not loaded: ", "").strip()
                        if skill_name in available_skills:
                            diagnosis["healable"].append({
                                "agent_key": agent_key,
                                "skill": skill_name,
                                "step": f"{result.get('agent_name', agent_key)} health check",
                            })
                        else:
                            diagnosis["needs_human"].append({
                                "issue": f"Skill '{skill_name}' does not exist in registry",
                                "agent_key": agent_key,
                            })
            return diagnosis

        elif action == "propose":
            # Diagnose and save proposals for user approval — never auto-apply
            available_skills = {s.name for s in skills.skills.values() if s.status == "active"}
            proposals = []
            skill_drafts = []
            drafted_skills: set[str] = set()

            for agent_key in sorted(orchestrator.agents.keys()):
                result = _check_single_agent(orchestrator, agent_key)
                agent_def = orchestrator.agents.get(agent_key)
                if not agent_def:
                    continue
                for issue in result.get("issues", []):
                    if not issue.startswith("Skill not loaded:"):
                        continue
                    skill_name = issue.replace("Skill not loaded: ", "").strip()

                    agent_name = agent_def.name or agent_key
                    step_label = f"{agent_name} health check"

                    if skill_name in available_skills:
                        # Existing skill — propose assignment
                        if skill_name in (agent_def.skills or []):
                            continue
                        if any(p["agent_key"] == agent_key and p["skill"] == skill_name for p in proposals):
                            continue
                        proposals.append({
                            "type": "assign_skill",
                            "agent_key": agent_key,
                            "agent_name": agent_name,
                            "skill": skill_name,
                            "workflow": "agent-health",
                            "step": step_label,
                            "status": "pending",
                        })
                    else:
                        # Missing skill — draft a new one
                        if skill_name in drafted_skills:
                            continue
                        drafted_skills.add(skill_name)

                        title = skill_name.replace("-", " ").title()
                        draft_content = f"""---
name: {skill_name}
title: "{title}"
description: "Draft skill for {agent_name} — needs review"
category: custom
status: draft
triggers:
  - "{skill_name.replace('-', ' ')}"
---

## Instructions

This skill was flagged as missing from **{agent_name}** during a health check.

## Rules

- [TODO: Add specific rules and constraints]
- [TODO: Define quality criteria]
- [TODO: Specify output format if needed]
"""
                        # Save draft to pending folder
                        draft_dir = vault_paths.resolve(f"system/skills/pending/{skill_name}")
                        draft_dir.mkdir(parents=True, exist_ok=True)
                        draft_path = draft_dir / "SKILL.md"
                        draft_path.write_text(draft_content, encoding="utf-8")

                        skill_drafts.append({
                            "type": "create_skill",
                            "skill": skill_name,
                            "title": title,
                            "agent_key": agent_key,
                            "agent_name": agent_name,
                            "workflow": "agent-health",
                            "step": step_label,
                            "draft_path": f"system/skills/pending/{skill_name}/SKILL.md",
                            "status": "pending",
                        })

                        # Also propose assigning it to the agent (after approval)
                        proposals.append({
                            "type": "assign_skill",
                            "agent_key": agent_key,
                            "agent_name": agent_name,
                            "skill": skill_name,
                            "workflow": "agent-health",
                            "step": step_label,
                            "depends_on_draft": True,
                            "status": "pending",
                        })

            # Save to heal-proposals.json (consumed by the Simulate tab)
            all_proposals = skill_drafts + proposals
            _proposals_path.parent.mkdir(parents=True, exist_ok=True)
            existing = []
            if _proposals_path.exists():
                try:
                    existing = json.loads(_proposals_path.read_text(encoding="utf-8"))
                except Exception:
                    existing = []
            existing_keys = {(p.get("agent_key", ""), p.get("skill", ""), p.get("type", "")) for p in existing if p.get("status") == "pending"}
            for p in all_proposals:
                if (p.get("agent_key", ""), p.get("skill", ""), p.get("type", "")) not in existing_keys:
                    existing.append(p)
            _proposals_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")

            return {
                "skill_drafts": skill_drafts,
                "assign_proposals": proposals,
                "total_skill_drafts": len(skill_drafts),
                "total_assign_proposals": len(proposals),
                "message": (
                    f"{len(skill_drafts)} skill draft(s) and {len(proposals)} assignment proposal(s) saved. "
                    "Review in the Simulate tab."
                ),
            }
        else:
            return {"error": f"Unknown action: {action}. Use 'diagnose' or 'propose'."}

    def _heal_agents_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_heal_agents_handler(inp))

    registry.register_tool(
        name="heal_agents",
        description=(
            "Diagnose agent health and propose fixes for user approval. "
            "Action 'diagnose': check all agents for missing skills, tools, or configuration issues. "
            "Action 'propose': create proposals to add missing skills — saved for user to approve/reject. "
            "Never auto-modifies agents. All changes require user approval."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["diagnose", "propose"],
                    "description": "diagnose = report issues, propose = create proposals for user approval",
                },
            },
            "required": ["action"],
        },
        handler=_heal_agents_sync,
        async_handler=_heal_agents_handler,
        contract_action="delegate_task",
    )

    # ── update_relationship tool ──────────────────────────────────
    async def _update_relationship_handler(inp: dict) -> dict:
        section = inp.get("section", "")
        insight = inp.get("insight", "")
        action = inp.get("action", "add")
        if not section or not insight:
            return {"error": "Both 'section' and 'insight' are required."}

        rel_path = "system/identity/RELATIONSHIP.md"
        try:
            doc = vault.read(rel_path)
            content = doc.content if doc and doc.content else ""
        except Exception:
            content = ""

        heading = f"## {section}"
        if heading not in content:
            return {"error": f"Section '{section}' not found in RELATIONSHIP.md."}

        lines = content.split("\n")
        result_lines: list[str] = []
        in_section = False
        inserted = False

        for line in lines:
            if line.strip().startswith("## "):
                if in_section and action == "add" and not inserted:
                    # Insert before next section header
                    result_lines.append(f"- {insight}")
                    result_lines.append("")
                    inserted = True
                in_section = line.strip() == heading
            if in_section and action == "remove" and insight.lower() in line.lower():
                continue  # skip the matching line
            result_lines.append(line)

        if in_section and action == "add" and not inserted:
            # Section is at the end of the file — strip trailing hint, append
            # Remove italic hint line if it's the only content
            while result_lines and result_lines[-1].strip() == "":
                result_lines.pop()
            if result_lines and result_lines[-1].strip().startswith("*") and result_lines[-1].strip().endswith("*"):
                result_lines.pop()  # remove placeholder hint
            result_lines.append(f"- {insight}")
            result_lines.append("")
            inserted = True

        new_content = "\n".join(result_lines)
        writer.write(rel_path, new_content, trust_reason="tool_update_relationship")

        verb = "added to" if action == "add" else "removed from"
        # Log activity
        try:
            activity_logger.log(
                category="memory",
                action="relationship_updated",
                summary=f"Relationship insight {verb} {section}",
                details={"section": section, "action": action, "insight": insight[:200]},
                importance="medium",
            )
        except Exception:
            pass

        return {"status": "ok", "action": action, "section": section, "message": f"Insight {verb} '{section}'."}

    def _update_relationship_sync(inp: dict) -> dict:
        import asyncio as _aio
        return _aio.get_event_loop().run_until_complete(_update_relationship_handler(inp))

    registry.register_tool(
        name="update_relationship",
        description=(
            "Update RELATIONSHIP.md with a new insight about the user. "
            "Use when learning something meaningful about their preferences, "
            "patterns, boundaries, or when the user corrects you."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "section": {"type": "string"},
                "insight": {"type": "string"},
                "action": {"type": "string", "enum": ["add", "remove"], "default": "add"},
            },
            "required": ["section", "insight"],
        },
        handler=_update_relationship_sync,
        async_handler=_update_relationship_handler,
        contract_action="store_user_insights",
    )

    # Raw folder watcher — auto-ingests files dropped into raw/
    from agntspace.automation.raw_watcher import RawWatcher

    # Only scan root folder if user explicitly configured a vault location
    raw_watcher = RawWatcher(
        vault_path, kb_service,
        root_dir=settings.root_dir, scan_root=settings.root_explicit,
        heartbeat=heartbeat,
        orchestrator=orchestrator,
        work_queue=work_queue,
        settings_service=settings_service,
    )
    raw_watcher._paths = vault_paths  # noqa: SLF001
    raw_watcher._app = app  # noqa: SLF001 — gives the watcher access to app.state.ready so ingest can self-throttle during startup
    # Rule #17: route watcher-triggered ingest through the agent instead
    # of calling kb_service.ingest() directly. Falls back to direct when
    # agent_mediated_flows is off in config.toml.
    raw_watcher._agent = agent  # noqa: SLF001
    raw_watcher._agent_mediated = bool(getattr(settings, "agent_mediated_flows", True))  # noqa: SLF001
    app.state.raw_watcher = raw_watcher

    # When the LLM recovers from an outage, auto-restore any quarantined
    # watcher files so they get retried on the next cycle.
    def _restore_quarantined_on_recovery() -> None:
        try:
            kb_dir = vault_paths.resolve("knowledge")
            if not kb_dir.is_dir():
                return
            restored = 0
            for kb in kb_dir.iterdir():
                if not kb.is_dir():
                    continue
                q = kb / "inbox" / ".quarantine"
                if not q.is_dir():
                    continue
                for f in list(q.iterdir()):
                    if not f.is_file():
                        continue
                    dest = kb / "inbox" / f.name
                    if dest.exists():
                        continue
                    try:
                        f.rename(dest)
                        restored += 1
                    except OSError:
                        continue
            if restored:
                log.info(
                    "Auto-restored %d quarantined file(s) after LLM recovery",
                    restored,
                )
        except Exception:
            log.exception("quarantine auto-restore failed")

    work_queue._restore_quarantine_cb = _restore_quarantined_on_recovery  # noqa: SLF001

    app.state.frozen = False
    app.state.autopilot = autopilot
    app.state.audit = audit
    app.state.guardian = guardian

    # Start background tasks
    # Rule #13 — detect manual user edits to vault files (Obsidian, direct CLI,
    # text editors). Complements ActivityMiddleware (which covers API writes).
    # Journal edits additionally push-trigger a debounced light-dream capture
    # so user prose flows into the knowledge graph within seconds rather than
    # waiting for the 6h cron safety net.
    from agntspace.automation.journal_trigger import JournalLightTrigger
    from agntspace.automation.vault_edit_watcher import VaultEditWatcher
    from agntspace.memory.dream import load_phase_schedule, run_light_over_recent

    journal_trigger: JournalLightTrigger | None = None
    journal_edit_callback = None
    dream_handler_for_trigger = getattr(app.state, "dream_handler", None)
    if dream_handler_for_trigger is not None:
        # Read debounce window from the same skill config that owns the
        # cron cadence — Rule 12: one YAML, one source of truth.
        sched = load_phase_schedule(skills, "light")
        debounce = float(sched.get("journal_push_debounce_seconds", 45) or 0)
        sources = sched.get("sources") or {}
        push_enabled = debounce > 0 and bool(sources.get("journal_entries", True))
        if push_enabled:
            async def _run_light_from_trigger() -> None:
                await run_light_over_recent(
                    dream_handler_for_trigger, memory, vault, skills,
                )

            journal_trigger = JournalLightTrigger(
                _run_light_from_trigger,
                debounce_seconds=debounce,
                activity_logger=activity_logger,
            )

            def journal_edit_callback(_path: Path) -> None:  # noqa: ARG001 — path unused, captured for future use
                journal_trigger.trigger(_path)

    app.state.journal_trigger = journal_trigger

    vault_edit_watcher = VaultEditWatcher(
        vault_path,
        paths=vault_paths,
        activity=activity_logger,
        journal_edit_callback=journal_edit_callback,
    )
    app.state.vault_edit_watcher = vault_edit_watcher

    # Background services are NOT auto-started. The user launches them
    # explicitly via POST /api/launch (or the dashboard Launch button).
    # This gives the user a visible startup sequence and confidence that
    # all systems are operational before the agent starts working.
    app.state.launched = False

    # Prepare the filesystem event bridge (started lazily during launch)
    if settings.root_explicit and settings.root_dir:
        from agntspace.automation.fs_events import FileSystemEventBridge
        fs_bridge = FileSystemEventBridge(raw_watcher, settings.root_dir)
        raw_watcher._fs_bridge = fs_bridge  # noqa: SLF001
        app.state.fs_bridge = fs_bridge

    # Pre-warm wiki article cache in background thread
    from agntspace.api.routes.wiki import warm_article_cache
    warm_article_cache(vault)

    # Auto-sync project wiki namespaces — pull real data from Projects module
    try:
        for proj in project_service.list_projects():
            for kb_slug in proj.get("linked_kbs", []):
                proj_slug = proj["slug"]
                proj_dir = vault_paths.resolve(f"knowledge/{kb_slug}/wiki/projects/{proj_slug}")
                try:
                    detail = project_service.get_project_detail(proj_slug)
                except Exception:
                    detail = proj
                if not proj_dir.is_dir():
                    kb_service.create_project_wiki(kb_slug, proj_slug, proj.get("title", proj_slug), project_data=detail)
                    log.info("Auto-created project wiki: %s in KB %s", proj_slug, kb_slug)
                else:
                    overview_path = proj_dir / f"{proj_slug}.md"
                    if overview_path.exists() and "[TODO: project description]" in overview_path.read_text(encoding="utf-8"):
                        kb_service.create_project_wiki(kb_slug, proj_slug, proj.get("title", proj_slug), project_data=detail)
                        log.info("Updated project wiki with real data: %s in KB %s", proj_slug, kb_slug)
    except Exception:
        log.debug("Project wiki auto-sync failed", exc_info=True)

    # Pre-warm the local Whisper model so the first voice-input request
    # doesn't pay the ~2s cold-load penalty. Runs in the background; failures
    # are silent (faster-whisper isn't installed → voice input simply uses
    # the cloud path or surfaces a 503 later).
    import asyncio as _async_for_prewarm

    async def _prewarm_whisper() -> None:
        try:
            import struct
            import tempfile
            import wave
            from pathlib import Path

            from agntspace.llm.transcribe import _transcribe_faster_whisper  # type: ignore

            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                with wave.open(f, "wb") as w:
                    w.setnchannels(1)
                    w.setsampwidth(2)
                    w.setframerate(16000)
                    w.writeframes(struct.pack("<" + "h" * 1600, *([0] * 1600)))
                tmp_path = Path(f.name)
            try:
                await _async_for_prewarm.to_thread(_transcribe_faster_whisper, tmp_path)
                log.info("Pre-warmed faster-whisper model")
            finally:
                tmp_path.unlink(missing_ok=True)
        except ImportError:
            log.debug("faster-whisper not installed; skipping pre-warm")
        except Exception:
            log.debug("Whisper pre-warm failed", exc_info=True)

    _async_for_prewarm.create_task(_prewarm_whisper())

    # Compact startup summary
    connected = [ch["name"] for ch in channel_manager.list_channels() if ch["connected"]]
    parts = [
        f"{len(registry._tools)} tools",
        f"{len(orchestrator._agents) if orchestrator._agents else 0} agents",
        f"{len(skills._skills)} skills",
    ]
    if connected:
        parts.append(", ".join(connected))
    summary = " | ".join(parts)
    url = f"http://{settings.host}:{settings.port}"
    print(f"\n  \033[1;32mReady\033[0m — {summary}", flush=True)
    print(f"  \033[90m{url} — Press Ctrl+C to stop\033[0m\n", flush=True)

    # Browser auto-open is handled by cli.py start() command — it launches
    # a background thread that waits for the server and opens the right page
    # (/setup, /onboarding, or /dashboard) based on vault state.

    # ── Auto-launch background services if previously launched ──────
    if settings.schedule.auto_launch:
        log.info("Auto-launch enabled — starting background services")
        _auto_launch_services = [
            ("work_queue", work_queue),
            ("heartbeat", heartbeat),
            ("scheduler", scheduler),
            ("daily_cycle", daily_cycle),
            ("cron", cron),
            ("raw_watcher", raw_watcher),
        ]
        vault_edit_w = getattr(app.state, "vault_edit_watcher", None)
        if vault_edit_w:
            _auto_launch_services.append(("vault_edit_watcher", vault_edit_w))
        for svc_name, svc in _auto_launch_services:
            if svc and hasattr(svc, "start"):
                try:
                    svc.start()
                    log.info("Auto-launched %s", svc_name)
                except Exception:
                    log.exception("Auto-launch failed for %s", svc_name)
        # Start filesystem event bridge if available
        fs_bridge = getattr(app.state, "fs_bridge", None)
        if fs_bridge and hasattr(fs_bridge, "start"):
            fs_bridge.start()
        app.state.launched = True
        app.state.frozen = False
        if activity_logger:
            activity_logger.log(
                category="system",
                action="auto_launch",
                summary="Background services auto-launched on startup",
                importance="medium",
            )
        print("  \033[1;34mAuto-launched\033[0m — all background services started", flush=True)

    # ── Cache pre-warm ────────────────────────────────────────────────
    # The dashboard mounts ~12 endpoints in parallel on first load. The
    # two slowest cold endpoints — /api/llm/local-stack and /api/capabilities —
    # both probe Ollama via sync httpx and take 2-5s on a cold cache.
    # Under parallel mount they THUNDERING-HERD: 20 simultaneous requests
    # all see cache miss, all fire the probe, all queue on each other's
    # results, blowing out worst-case latency to ~7s.
    #
    # Fire one probe of each NOW (in the background, never blocking
    # lifespan completion), so by the time the user's browser actually
    # opens the dashboard the caches are already warm. Failures are
    # logged at debug — the cache prime is opportunistic, not load-
    # bearing; the route handlers fall back to live probing if needed.
    async def _prime_dashboard_caches() -> None:
        try:
            from agntspace.api.routes import capabilities as _cap_route
            from agntspace.api.routes import llm_status as _llm_route

            # llm/local-stack cache
            try:
                stack = await asyncio.to_thread(
                    _llm_route._build_local_stack_blocking, settings.skills_dir
                )
                _llm_route._LOCAL_STACK_CACHE["ts"] = __import__("time").monotonic()
                _llm_route._LOCAL_STACK_CACHE["data"] = stack
                log.debug("Cache prime: llm/local-stack warmed")
            except Exception:
                log.debug("Cache prime: llm/local-stack failed (non-fatal)", exc_info=True)

            # capabilities cache (per-app id keyed)
            try:
                # Build a minimal Request-like proxy so _build_capabilities_blocking
                # can read app.state.vault_embeddings. The real type here is
                # starlette.Request which only needs `.app.state` for our probe.
                class _AppOnly:
                    def __init__(self, app_):
                        self.app = app_
                proxy = _AppOnly(app)
                cap = await asyncio.to_thread(
                    _cap_route._build_capabilities_blocking, proxy
                )
                _cap_route._CAPABILITIES_CACHE[id(app)] = {
                    "ts": __import__("time").monotonic(),
                    "data": cap,
                }
                log.debug("Cache prime: capabilities warmed")
            except Exception:
                log.debug("Cache prime: capabilities failed (non-fatal)", exc_info=True)
        except Exception:
            log.debug("Cache prime task failed wholesale (non-fatal)", exc_info=True)

    # Fire-and-forget: never await this. The graph build is the heavy
    # lifespan-resident task; cache priming should not block it.
    asyncio.create_task(_prime_dashboard_caches())

    # ── Background knowledge-graph build ───────────────────────────────
    # Runs AFTER yield's equivalent binding point but inside the lifespan
    # so we can cancel on shutdown.
    #
    # Architectural note (2026-04-25 fix #2):
    #
    #   The build runs on a DEDICATED ThreadPoolExecutor, NOT the
    #   default loop executor. Reason: the default executor is shared
    #   with every ``asyncio.to_thread()`` and ``loop.run_in_executor(None, ...)``
    #   call across the whole server (middleware, settings reads, my
    #   recent dashboard fixes, aiosqlite queues, FastAPI's own internal
    #   sync-handler bridge). Default pool size on a typical machine is
    #   ~12 threads. The graph build is CPU+I/O heavy and holds threads
    #   for minutes at a time — when it shares the pool with API
    #   handlers, the dashboard's `to_thread` calls queue behind the
    #   build, AND curl probes adding load further starve the build
    #   itself, creating a self-reinforcing slowdown that produced the
    #   "stuck at 2000/4396 forever" symptom we observed today.
    #
    #   Reserved capacity: max_workers=2 (one for the build, one slot
    #   spare for any future dedicated worker — e.g. embedding cache
    #   warming — without re-introducing default-pool contention).
    #
    #   Shutdown: explicit ``shutdown(wait=False, cancel_futures=True)``
    #   in the lifespan teardown so a stuck build can't block process
    #   exit beyond the work queue's natural drain.
    import concurrent.futures as _cf_main
    graph_executor = _cf_main.ThreadPoolExecutor(
        max_workers=2,
        thread_name_prefix="agnt-graph",
    )
    app.state.graph_executor = graph_executor

    async def _background_graph_build() -> None:
        import time as _time
        app.state.boot_phase = "building_graph"
        started = _time.perf_counter()
        # Log to the structured logger so headless runs (pythonw.exe via
        # scheduled task) can see this message too — stdout prints are lost
        # when there's no attached console.
        log.info("Background knowledge-graph build starting (dedicated executor, isolated from API request pool)")
        try:
            loop = asyncio.get_running_loop()
            await loop.run_in_executor(
                graph_executor,  # dedicated — NOT the shared default pool
                lambda: knowledge_graph.build(on_progress=_graph_progress),
            )
            elapsed = _time.perf_counter() - started
            app.state.ready = True
            app.state.boot_phase = "ready"
            app.state.graph_progress = None
            msg = f"Graph ready — server fully live ({elapsed:.1f}s)"
            log.info(msg)
            print(f"  \033[1;32m{msg}\033[0m", flush=True)
        except asyncio.CancelledError:
            # Shutdown during build — fine, next boot will rebuild.
            app.state.boot_phase = "cancelled"
            log.info("Background knowledge-graph build cancelled (shutdown)")
            raise
        except Exception:
            log.exception("Knowledge-graph build failed")
            app.state.boot_phase = "graph_build_failed"
            # Leave ready=False so ops tools can see the problem via /api/health.

    # Emergency skip flag for the knowledge-graph build. When set, the
    # background task isn't created — the server boots fully responsive but
    # graph-dependent routes (graph view, semantic re-ranking, "most linked")
    # return empty results until the user runs `POST /api/graph/rebuild` or
    # restarts without the flag. Use this when:
    #   - the graph build is monopolizing the event loop on a large vault and
    #     you need basic features (chat, journal, settings, channels) NOW;
    #   - you're debugging a graph-build hang and want a clean boot to
    #     inspect logs / state.
    # Off by default. Set ``AGNTSPACE_SKIP_GRAPH_BUILD=1`` to enable.
    _skip_graph_raw = os.environ.get("AGNTSPACE_SKIP_GRAPH_BUILD", "0").strip().lower()
    if _skip_graph_raw in ("1", "true", "yes", "on"):
        log.warning(
            "AGNTSPACE_SKIP_GRAPH_BUILD set — skipping knowledge-graph build. "
            "Graph-dependent features (graph view, semantic re-ranking, most-linked) "
            "will return empty results until /api/graph/rebuild is called or the "
            "server is restarted without the flag."
        )
        app.state.boot_phase = "graph_build_skipped"
        app.state.ready = True  # Other subsystems are live; only graph is missing.
        app.state.graph_progress = None
        app.state.graph_build_task = None
        _graph_build_task = None
    else:
        _graph_build_task = asyncio.create_task(_background_graph_build())
        app.state.graph_build_task = _graph_build_task

    yield

    # Cancel the graph build if still running — prevents "task was destroyed
    # but it is pending" warnings during shutdown. Task is None when the
    # AGNTSPACE_SKIP_GRAPH_BUILD flag was set.
    if _graph_build_task is not None and not _graph_build_task.done():
        _graph_build_task.cancel()
        try:
            await _graph_build_task
        except (asyncio.CancelledError, Exception):
            pass

    # Shut down the dedicated graph executor explicitly. wait=False so a
    # stuck build can't block process exit; cancel_futures=True drops any
    # queued (not-yet-started) work. Threads already executing build()
    # finish on their own — knowledge_graph.build is structured to be
    # interrupt-tolerant (mid-build state is discarded, next boot rebuilds).
    try:
        graph_executor.shutdown(wait=False, cancel_futures=True)
    except Exception:
        log.debug("Graph executor shutdown raised (non-fatal)", exc_info=True)

    # Shut down the boosted default executor — paired with the
    # set_default_executor call at the top of lifespan().
    try:
        _default_executor.shutdown(wait=False, cancel_futures=True)
    except Exception:
        log.debug("Default executor shutdown raised (non-fatal)", exc_info=True)

    plugin_manager.shutdown_all()
    # Layer 4b — stop the health probe BEFORE disconnect_all so its
    # next cycle doesn't race the disconnect by trying to ping a
    # server that's already being torn down.
    await mcp_client.stop_health_probe()
    await mcp_client.disconnect_all()
    heartbeat.stop()
    scheduler.stop()
    daily_cycle.stop()
    cron.stop()
    work_queue.stop()
    fs_bridge = getattr(app.state, "fs_bridge", None)
    if fs_bridge:
        try:
            fs_bridge.stop()
        except Exception:
            log.exception("fs_bridge stop failed")
    raw_watcher.stop()
    await channel_manager.disconnect_all()
    await db.close()

    # Release the vault singleton lock LAST — after every other
    # subsystem has stopped. If we hold the lock, deleting the file
    # signals to a future starter that the vault is free immediately
    # (vs. waiting STALE_THRESHOLD seconds for the heartbeat to age
    # out). If we lost the lock, ``release()`` is a safe no-op.
    _heartbeat_task = getattr(app.state, "lock_heartbeat_task", None)
    if _heartbeat_task is not None:
        _heartbeat_task.cancel()
        try:
            await _heartbeat_task
        except (asyncio.CancelledError, Exception):
            pass
    _lock = getattr(app.state, "process_lock", None)
    if _lock is not None:
        try:
            _lock.release()
        except Exception:
            log.debug("Process lock release failed", exc_info=True)

    log.debug("AgntSpace server stopped")


def _get_version() -> str:
    try:
        p = Path(__file__).resolve().parents[2] / "pyproject.toml"
        text = p.read_text(encoding="utf-8")
        m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
        if m:
            return m.group(1)
    except Exception:
        pass
    return "dev"


def create_app() -> FastAPI:
    """Create and configure the FastAPI application."""
    app = FastAPI(
        title="AgntSpace",
        version=_get_version(),
        description="Personal AI agent API",
        lifespan=lifespan,
        docs_url="/api/docs",
        redoc_url="/api/redoc",
        openapi_url="/api/openapi.json",
    )

    # CORS for dev (Vite dev server on :5173)
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    # Global error handling — catches unhandled exceptions, logs to SQLite + file
    from agntspace.api.error_middleware import ErrorMiddleware
    app.add_middleware(ErrorMiddleware)

    # Contract enforcement on ALL write endpoints
    from agntspace.api.contract_middleware import ContractMiddleware
    app.add_middleware(ContractMiddleware)

    # Rule #13 — AGENT AWARENESS OF MANUAL USER ACTIONS
    # Logs every successful user-initiated write to ActivityLogger so the agent
    # sees everything the user did by hand (UI, CLI, direct API calls).
    from agntspace.api.activity_middleware import ActivityMiddleware
    app.add_middleware(ActivityMiddleware)

    # Setup mode guard — returns 503 for non-setup API endpoints when no
    # vault is configured, preventing AttributeError crashes from routes
    # that access app.state.agent before services are initialized.
    from agntspace.api.setup_guard import SetupGuardMiddleware
    app.add_middleware(SetupGuardMiddleware)

    # Correlation ID propagation (gap #1) — added LAST so it becomes the
    # OUTERMOST middleware, running first on the request. This guarantees
    # every downstream handler, contract check, and activity event inside
    # the request sees the correlation_id set by the header.
    from agntspace.api.correlation_middleware import CorrelationMiddleware
    app.add_middleware(CorrelationMiddleware)

    # Setup endpoint — always available (used before vault is configured)
    from agntspace.api.routes.setup import router as setup_router
    app.include_router(setup_router, prefix="/api")

    # REST routes
    app.include_router(health_router, prefix="/api")
    app.include_router(capabilities_router, prefix="/api")
    app.include_router(insights_router, prefix="/api")
    app.include_router(chat_router, prefix="/api")
    app.include_router(vault_router, prefix="/api")
    app.include_router(vault_browse_router, prefix="/api")
    app.include_router(relationship_router, prefix="/api")
    app.include_router(onboarding_router, prefix="/api")
    app.include_router(journal_router, prefix="/api")
    app.include_router(tasks_router, prefix="/api")
    app.include_router(goals_router, prefix="/api")
    app.include_router(skills_router, prefix="/api")
    app.include_router(search_router, prefix="/api")
    app.include_router(reflection_router, prefix="/api")
    app.include_router(channels_router, prefix="/api")
    app.include_router(contract_router, prefix="/api")
    app.include_router(emergency_router, prefix="/api")
    app.include_router(launch_router, prefix="/api")
    app.include_router(daily_router, prefix="/api")
    app.include_router(email_router, prefix="/api")
    app.include_router(finance_router, prefix="/api")
    app.include_router(settings_router, prefix="/api")
    app.include_router(sync_router, prefix="/api")
    app.include_router(graph_router, prefix="/api")
    # memory_review_router BEFORE memory_router — memory_router has a /{date}
    # catch-all that would swallow /memory/affective-map, /memory/dream-review, etc.
    from agntspace.api.routes.memory_review import router as memory_review_router
    app.include_router(memory_review_router, prefix="/api")
    app.include_router(memory_router, prefix="/api")
    app.include_router(cron_router, prefix="/api")
    app.include_router(heartbeat_router, prefix="/api")
    app.include_router(watcher_router, prefix="/api")
    app.include_router(work_queue_router, prefix="/api")
    app.include_router(pipeline_router, prefix="/api")
    app.include_router(llm_status_router, prefix="/api")
    app.include_router(kb_router, prefix="/api")
    app.include_router(wiki_router, prefix="/api")
    app.include_router(integrations_router, prefix="/api")
    app.include_router(autopilot_router, prefix="/api")
    app.include_router(audit_router, prefix="/api")
    app.include_router(activity_router, prefix="/api")
    app.include_router(bibliography_router, prefix="/api")
    app.include_router(guardian_router, prefix="/api")
    app.include_router(domains_router, prefix="/api")
    app.include_router(orchestrator_router, prefix="/api")
    app.include_router(simulate_router, prefix="/api")
    app.include_router(costs_router, prefix="/api")
    app.include_router(trust_router, prefix="/api")
    app.include_router(projects_router, prefix="/api")
    app.include_router(pairing_router, prefix="/api")
    app.include_router(canvas_router, prefix="/api")
    app.include_router(video_router, prefix="/api")

    from agntspace.api.routes.logs import router as logs_router
    app.include_router(logs_router, prefix="/api")

    from agntspace.api.routes.decisions import router as decisions_router
    app.include_router(decisions_router, prefix="/api")

    from agntspace.api.routes.evolution import router as evolution_router
    app.include_router(evolution_router, prefix="/api")

    from agntspace.api.routes.privacy import router as privacy_router
    app.include_router(privacy_router, prefix="/api")

    from agntspace.api.routes.admin import router as admin_router
    app.include_router(admin_router, prefix="/api")

    from agntspace.api.routes.regressions import router as regressions_router
    app.include_router(regressions_router, prefix="/api")

    from agntspace.api.routes.plugins import router as plugins_router
    app.include_router(plugins_router, prefix="/api")

    from agntspace.api.routes.mcp import router as mcp_router
    app.include_router(mcp_router, prefix="/api")

    # MCP SSE transport — lets remote MCP clients (Claude Desktop, Cursor,
    # etc. on other machines) reach this vault over HTTP. The router is
    # mounted here (sync); the token store + tool_executor are wired onto
    # app.state inside lifespan() since both need the async DB connection
    # and the fully-constructed tool_executor. Fail-closed: if no tokens
    # are minted, SSE returns 401 for every request. Cross-device access
    # is opt-in, never default-on.
    from agntspace.mcp.sse import build_sse_router, create_transport
    _sse_transport = create_transport()
    app.include_router(build_sse_router(_sse_transport))  # NOTE: no /api prefix — MCP clients expect /mcp/sse directly.

    # WebSocket
    app.add_api_websocket_route("/ws/chat/{conversation_id}", ws_chat_handler)
    app.add_api_websocket_route("/ws/canvas", ws_canvas_handler)
    app.add_api_websocket_route("/ws/transcribe", ws_transcribe_handler)

    # Serve built UI with SPA fallback
    # Dev repo: repo_root/ui/dist — Installed package: agntspace/ui_dist
    ui_dist = Path(__file__).parent.parent.parent.parent / "ui" / "dist"
    if not ui_dist.is_dir():
        ui_dist = Path(__file__).parent / "ui_dist"
    if ui_dist.is_dir():
        from fastapi import Request
        from fastapi.responses import FileResponse, JSONResponse

        # Serve static assets (JS, CSS, etc.)
        app.mount("/assets", StaticFiles(directory=str(ui_dist / "assets")), name="assets")

        # SPA fallback via 404 exception handler — NOT a catch-all route.
        #
        # Previous implementation used ``@app.get("/{path:path}")`` which
        # greedily matched every GET request including ``/api/plugins/*``
        # routes that plugins mount during the lifespan (which runs AFTER
        # create_app). FastAPI is first-match-wins, so plugin routes were
        # unreachable — the SPA catch-all ate them first.
        #
        # The 404-handler approach: let FastAPI try every registered route
        # including lifespan-mounted plugin routers. Only when nothing
        # matches do we decide: API path → JSON 404; filesystem hit in
        # ui_dist → serve the file; otherwise serve index.html so the SPA
        # router can take over.
        @app.exception_handler(404)
        async def spa_or_404(request: Request, exc):
            path = request.url.path.lstrip("/")
            # Preserve the original HTTPException.detail for API 404s so
            # callers see informative messages (e.g. "Unknown job_id: X"),
            # not a generic "Not Found" for every miss.
            detail = getattr(exc, "detail", None) or "Not Found"
            if path.startswith(("api/", "ws/")):
                return JSONResponse({"detail": detail}, status_code=404)
            # Only GETs for the SPA. Other verbs → real 404.
            if request.method != "GET":
                return JSONResponse({"detail": detail}, status_code=404)
            file_path = ui_dist / path
            if file_path.is_file():
                return FileResponse(str(file_path))
            return FileResponse(str(ui_dist / "index.html"))

    return app
