Skip to content

donna.cli_wiring

donna.cli_wiring

Orchestrator startup wiring — extracted from cli._run_orchestrator (F-W2-E).

This module splits the formerly-330-line _run_orchestrator into a StartupContext + three typed helpers:

  • build_startup_context(args) — config loading, DB open, router, input_parser, bot construction, notification service, user/port.
  • wire_skill_system(ctx) — registers default tools, seeds capabilities, constructs the SkillSystemBundle, nightly cron, and manual-draft poller.
  • wire_automation_subsystem(ctx, skill_h) — AutomationRepository, AutomationDispatcher, AutomationScheduler.
  • wire_discord(ctx, skill_h, automation_h) — registers slash commands, logs proactive-prompt config, and schedules bot.start() onto the shared asyncio task list.

Each helper returns a typed @dataclass handle so the next stage can read the concrete objects it needs (skill_h.bundle.lifecycle_manager, automation_h.scheduler, etc.) without reaching into a grab-bag dict.

Behaviour must match the pre-refactor _run_orchestrator exactly — all log keys, task-creation order, and guarded try/except blocks are preserved verbatim.

logger module-attribute

logger = get_logger()

StartupContext dataclass

StartupContext(args: Namespace, config_dir: Path, project_root: Path, log: Any, models_config: ModelsConfig, task_types_config: TaskTypesConfig, skill_config: SkillSystemConfig, db: Database, state_machine: StateMachine, router: ModelRouter, invocation_logger: InvocationLogger, input_parser: InputParser, port: int, user_id: str, discord_token: str | None, tasks_channel_id_str: str | None, debug_channel_id_str: str | None, agents_channel_id_str: str | None, guild_id_str: str | None, bot: Any | None, notification_service: NotificationService | None, tasks: list[Task[Any]] = list())

Shared resources needed by every wire_* helper.

Built once in build_startup_context, then passed to each wire_* helper. Mutable tasks list is appended to by helpers that spawn long-running asyncio tasks; _run_orchestrator awaits the final list.

args instance-attribute

args: Namespace

config_dir instance-attribute

config_dir: Path

project_root instance-attribute

project_root: Path

log instance-attribute

log: Any

models_config instance-attribute

models_config: ModelsConfig

task_types_config instance-attribute

task_types_config: TaskTypesConfig

skill_config instance-attribute

skill_config: SkillSystemConfig

db instance-attribute

db: Database

state_machine instance-attribute

state_machine: StateMachine

router instance-attribute

router: ModelRouter

invocation_logger instance-attribute

invocation_logger: InvocationLogger

input_parser instance-attribute

input_parser: InputParser

port instance-attribute

port: int

user_id instance-attribute

user_id: str

discord_token instance-attribute

discord_token: str | None

tasks_channel_id_str instance-attribute

tasks_channel_id_str: str | None

debug_channel_id_str instance-attribute

debug_channel_id_str: str | None

agents_channel_id_str instance-attribute

agents_channel_id_str: str | None

guild_id_str instance-attribute

guild_id_str: str | None

bot instance-attribute

bot: Any | None

notification_service instance-attribute

notification_service: NotificationService | None

tasks class-attribute instance-attribute

tasks: list[Task[Any]] = field(default_factory=list)

SkillSystemHandle dataclass

SkillSystemHandle(subsystem_router: ModelRouter, budget_guard: BudgetGuard | None, cost_tracker: CostTracker | None, bundle: SkillSystemBundle | None, notifier: Callable[[str], Any])

Return value of wire_skill_system.

bundle is None when skill_config.enabled is false; downstream helpers (automation + discord) still wire correctly in that case. subsystem_router + budget_guard are always present because the automation subsystem needs them even without the skill system.

subsystem_router is a ModelRouter instance shared by BOTH the skill system and the automation subsystem (F-W3-J). It is distinct from ctx.model_router / ctx.router which handles the primary orchestrator request path; the subsystem router exists so the skill + automation pipelines can be swapped, rate-limited, or budget- capped independently of the interactive path.

subsystem_router instance-attribute

subsystem_router: ModelRouter

budget_guard instance-attribute

budget_guard: BudgetGuard | None

cost_tracker instance-attribute

cost_tracker: CostTracker | None

bundle instance-attribute

bundle: SkillSystemBundle | None

notifier instance-attribute

notifier: Callable[[str], Any]

AutomationHandle dataclass

AutomationHandle(repository: AutomationRepository | None, dispatcher: AutomationDispatcher | None, scheduler: AutomationScheduler | None)

Return value of wire_automation_subsystem.

repository instance-attribute

repository: AutomationRepository | None

dispatcher instance-attribute

dispatcher: AutomationDispatcher | None

scheduler instance-attribute

scheduler: AutomationScheduler | None

DiscordHandle dataclass

DiscordHandle(bot: Any | None, intent_dispatcher: Any | None = None)

Return value of wire_discord.

bot is the DonnaBot instance (or None if the token/channel env vars aren't present). Callers that need the notification service should reach through StartupContext.notification_service; this handle used to duplicate that field but no consumer read it off the handle (F-W3-I).

bot instance-attribute

bot: Any | None

intent_dispatcher class-attribute instance-attribute

intent_dispatcher: Any | None = None

build_startup_context async

build_startup_context(args: Namespace) -> StartupContext

Open the DB, load config, construct router/input_parser/bot/notifier.

Extracted from the top of the pre-refactor _run_orchestrator. The Discord bot + NotificationService are constructed here (but not started) so that skill-system + automation wiring — which run before bot.start() — can receive a live NotificationService.

Source code in src/donna/cli_wiring.py
async def build_startup_context(args: argparse.Namespace) -> StartupContext:
    """Open the DB, load config, construct router/input_parser/bot/notifier.

    Extracted from the top of the pre-refactor `_run_orchestrator`. The
    Discord bot + NotificationService are constructed here (but not
    started) so that skill-system + automation wiring — which run before
    `bot.start()` — can receive a live NotificationService.
    """
    log = structlog.get_logger()
    log.info(
        "donna_starting", config_dir=args.config_dir, log_level=args.log_level,
    )

    config_dir = Path(args.config_dir)
    project_root = Path(__file__).resolve().parents[2]

    # Load configuration
    models_config = load_models_config(config_dir)
    task_types_config = load_task_types_config(config_dir)
    state_machine_config = load_state_machine_config(config_dir)
    skill_config = load_skill_system_config(config_dir)

    # Initialise state machine and database
    state_machine = StateMachine(state_machine_config)
    db_path = os.environ.get("DONNA_DB_PATH", "donna_tasks.db")
    db = Database(db_path, state_machine)
    await db.connect()
    await db.run_migrations()

    # Initialise model layer and input parser
    router = ModelRouter(models_config, task_types_config, project_root)
    invocation_logger = InvocationLogger(db.connection)
    input_parser = InputParser(router, invocation_logger, project_root)

    port: int = args.port or int(os.environ.get("DONNA_PORT", "8100"))

    # Pre-parse Discord env so wire_discord can consume them; we need
    # them here too because bot + NotificationService are constructed
    # up-front so skill/automation wiring can see the live notifier.
    discord_token = os.environ.get("DISCORD_BOT_TOKEN")
    tasks_channel_id_str = os.environ.get("DISCORD_TASKS_CHANNEL_ID")
    debug_channel_id_str = os.environ.get("DISCORD_DEBUG_CHANNEL_ID")
    agents_channel_id_str = os.environ.get("DISCORD_AGENTS_CHANNEL_ID")
    guild_id_str = os.environ.get("DISCORD_GUILD_ID")
    user_id = os.environ.get("DONNA_USER_ID", "nick")

    bot: Any | None = None
    notification_service: NotificationService | None = None
    if discord_token and tasks_channel_id_str:
        from donna.integrations.discord_bot import DonnaBot

        bot = DonnaBot(
            input_parser=input_parser,
            database=db,
            tasks_channel_id=int(tasks_channel_id_str),
            debug_channel_id=int(debug_channel_id_str) if debug_channel_id_str else None,
            agents_channel_id=int(agents_channel_id_str) if agents_channel_id_str else None,
            guild_id=int(guild_id_str) if guild_id_str else None,
        )

        # Wave 1 (F-6 Step 6a): construct NotificationService with the live bot.
        # Tasks 14 and 15 will wire this into the skill-system bundle and the
        # AutomationDispatcher. SMS/Gmail wiring is Wave 2+.
        from donna.config import load_calendar_config

        try:
            calendar_config = load_calendar_config(config_dir)
            notification_service = NotificationService(
                bot=bot,
                calendar_config=calendar_config,
                user_id=user_id,
                sms=None,
                gmail=None,
            )
            log.info("notification_service_wired")
        except Exception:
            log.exception("notification_service_init_failed")

    return StartupContext(
        args=args,
        config_dir=config_dir,
        project_root=project_root,
        log=log,
        models_config=models_config,
        task_types_config=task_types_config,
        skill_config=skill_config,
        db=db,
        state_machine=state_machine,
        router=router,
        invocation_logger=invocation_logger,
        input_parser=input_parser,
        port=port,
        user_id=user_id,
        discord_token=discord_token,
        tasks_channel_id_str=tasks_channel_id_str,
        debug_channel_id_str=debug_channel_id_str,
        agents_channel_id_str=agents_channel_id_str,
        guild_id_str=guild_id_str,
        bot=bot,
        notification_service=notification_service,
    )

wire_skill_system async

wire_skill_system(ctx: StartupContext, *, gmail_client: Any | None = None, calendar_client: Any | None = None, vault_client: Any | None = None, vault_writer: Any | None = None, memory_store: Any | None = None) -> SkillSystemHandle

Register default tools, seed capabilities, assemble skill bundle.

Always returns a handle. When skill_config.enabled is false, the bundle is None but subsystem_router + budget_guard=None are still populated so the automation subsystem can wire.

Integration clients are threaded through to register_default_tools so the dependent skill tools register when clients are available at boot. Each defaults to None (backward-compat: tests + degraded-mode boot that don't supply a client still work correctly); the task_db handle reuses ctx.db and the cost_tracker is constructed here regardless.

Source code in src/donna/cli_wiring.py
async def wire_skill_system(
    ctx: StartupContext,
    *,
    gmail_client: Any | None = None,
    calendar_client: Any | None = None,
    vault_client: Any | None = None,
    vault_writer: Any | None = None,
    memory_store: Any | None = None,
) -> SkillSystemHandle:
    """Register default tools, seed capabilities, assemble skill bundle.

    Always returns a handle. When `skill_config.enabled` is false, the
    bundle is None but `subsystem_router` + `budget_guard=None` are still
    populated so the automation subsystem can wire.

    Integration clients are threaded through to ``register_default_tools``
    so the dependent skill tools register when clients are available at
    boot. Each defaults to None (backward-compat: tests + degraded-mode
    boot that don't supply a client still work correctly); the
    ``task_db`` handle reuses ``ctx.db`` and the ``cost_tracker`` is
    constructed here regardless.
    """
    log = ctx.log
    from donna.skills import tools as _skill_tools_module
    from donna.skills.crons import (
        AsyncCronScheduler,
        NightlyDeps,
        run_nightly_tasks,
    )
    from donna.skills.startup_wiring import assemble_skill_system

    # Shared ModelRouter used by both the skill system and the automation
    # subsystem (F-W3-J). Distinct from ctx.router which serves the
    # interactive request path. Pre-defined here so the automation
    # subsystem wires even when skill_system.enabled=false. The
    # automation dispatcher tolerates a None budget_guard (see
    # AutomationDispatcher._run_one).
    subsystem_router = ModelRouter(
        ctx.models_config, ctx.task_types_config, ctx.project_root,
    )

    # CostTracker is constructed here (rather than later, inside the
    # skill-enabled branch) so it can be injected into register_default_tools
    # as the backing client for the cost_summary skill tool. The same
    # instance is reused downstream by BudgetGuard.
    cost_tracker_early = CostTracker(ctx.db.connection)

    # Wave 2 Task 16: register default tools (web_fetch, etc.) on the module-level
    # registry so SkillExecutor instances without an explicit registry can dispatch.
    # Must happen before assemble_skill_system, because the bundle will construct
    # SkillExecutor instances that look up the default registry.
    _skill_tools_module.DEFAULT_TOOL_REGISTRY.clear()
    _skill_tools_module.register_default_tools(
        _skill_tools_module.DEFAULT_TOOL_REGISTRY,
        gmail_client=gmail_client,
        calendar_client=calendar_client,
        task_db=ctx.db,
        cost_tracker=cost_tracker_early,
        vault_client=vault_client,
        vault_writer=vault_writer,
        memory_store=memory_store,
    )
    log.info(
        "default_tools_registered",
        tools=_skill_tools_module.DEFAULT_TOOL_REGISTRY.list_tool_names(),
    )

    notification_service = ctx.notification_service

    async def _skill_system_notifier(message: str) -> None:
        if notification_service is None:
            log.info(
                "skill_system_notification_no_service",
                message=message,
            )
            return
        from donna.notifications.service import (
            CHANNEL_TASKS,
            NOTIF_AUTOMATION_FAILURE,
        )

        await notification_service.dispatch(
            notification_type=NOTIF_AUTOMATION_FAILURE,
            content=message,
            channel=CHANNEL_TASKS,
            priority=4,
        )

    skill_budget_guard: BudgetGuard | None = None
    cost_tracker: CostTracker | None = None
    bundle: SkillSystemBundle | None = None

    if ctx.skill_config.enabled:
        # Wave 2 Task 16: sync capability rows from config/capabilities.yaml on
        # every startup. Redundant for rows already seeded by Alembic, but
        # lets Nick add capabilities via YAML edit + restart without a new
        # migration. Idempotent (UPSERT).
        from donna.skills.seed_capabilities import SeedCapabilityLoader

        cap_yaml = ctx.config_dir / "capabilities.yaml"
        if cap_yaml.exists():
            try:
                loader = SeedCapabilityLoader(connection=ctx.db.connection)
                count = await loader.load_and_upsert(cap_yaml)
                log.info("capabilities_loader_ran", upserted=count)
            except Exception:
                log.exception("capabilities_loader_failed")

        # Wave 1 followup: verify every capability's declared tools are in
        # the registry. Fail-loud rather than silently falling back to the
        # ad_hoc path at runtime.
        from donna.capabilities.capability_tool_check import (
            CapabilityToolRegistryCheck,
        )

        check = CapabilityToolRegistryCheck(
            registry=_skill_tools_module.DEFAULT_TOOL_REGISTRY,
            connection=ctx.db.connection,
        )
        await check.validate_all()

        cost_tracker = cost_tracker_early

        skill_budget_guard = BudgetGuard(
            tracker=cost_tracker,
            models_config=ctx.models_config,
            notifier=lambda channel, message: _skill_system_notifier(message),
        )

        bundle = assemble_skill_system(
            connection=ctx.db.connection,
            model_router=subsystem_router,
            budget_guard=skill_budget_guard,
            notifier=_skill_system_notifier,
            config=ctx.skill_config,
            validation_executor_factory=None,  # default real ValidationExecutor
        )

        if bundle is not None:
            async def _nightly_job() -> None:
                deps = NightlyDeps(
                    detector=bundle.detector,
                    auto_drafter=bundle.auto_drafter,
                    degradation=bundle.degradation,
                    evolution_scheduler=bundle.evolution_scheduler,
                    correction_cluster=bundle.correction_cluster,
                    cost_tracker=cost_tracker,
                    daily_budget_limit_usd=ctx.models_config.cost.daily_pause_threshold_usd,
                    config=ctx.skill_config,
                )
                report = await run_nightly_tasks(deps)
                log.info(
                    "nightly_skill_tasks_done",
                    new_candidates=len(report.new_candidates),
                    drafted=len(report.drafted),
                    evolved=len(report.evolved),
                    degraded=len(report.degraded),
                    correction_flagged=len(report.correction_flagged),
                    errors=len(report.errors),
                )

            scheduler = AsyncCronScheduler(
                hour_utc=ctx.skill_config.nightly_run_hour_utc,
                task=_nightly_job,
            )
            ctx.tasks.append(asyncio.create_task(scheduler.run_forever()))
            log.info(
                "skill_system_started",
                nightly_run_hour_utc=ctx.skill_config.nightly_run_hour_utc,
            )

            # Wave 2 F-W1-D: poll skill_candidate_report.manual_draft_at for
            # manual draft triggers from the API process.
            from donna.skills.manual_draft_poller import ManualDraftPoller

            manual_draft_poller = ManualDraftPoller(
                connection=ctx.db.connection,
                auto_drafter=bundle.auto_drafter,
                candidate_repo=bundle.candidate_repo,
            )

            async def _manual_draft_loop() -> None:
                while True:
                    try:
                        await manual_draft_poller.run_once()
                    except Exception:
                        log.exception("manual_draft_poller_tick_failed")
                    await asyncio.sleep(
                        ctx.skill_config.automation_poll_interval_seconds,
                    )

            ctx.tasks.append(asyncio.create_task(_manual_draft_loop()))
            log.info("manual_draft_poller_started")
    else:
        log.info("skill_system_disabled_in_config")

    return SkillSystemHandle(
        subsystem_router=subsystem_router,
        budget_guard=skill_budget_guard,
        cost_tracker=cost_tracker,
        bundle=bundle,
        notifier=_skill_system_notifier,
    )

wire_automation_subsystem async

wire_automation_subsystem(ctx: StartupContext, skill_h: SkillSystemHandle) -> AutomationHandle

Construct AutomationRepository + Dispatcher + Scheduler.

Runs regardless of skill_config.enabled (F-W1-H). Failure is logged but not raised — the pre-refactor code had the entire block wrapped in a try/except and this preserves that behaviour.

Source code in src/donna/cli_wiring.py
async def wire_automation_subsystem(
    ctx: StartupContext, skill_h: SkillSystemHandle,
) -> AutomationHandle:
    """Construct AutomationRepository + Dispatcher + Scheduler.

    Runs regardless of `skill_config.enabled` (F-W1-H). Failure is
    logged but not raised — the pre-refactor code had the entire block
    wrapped in a try/except and this preserves that behaviour.
    """
    log = ctx.log
    from donna.automations.alert import AlertEvaluator
    from donna.automations.cadence_policy import CadencePolicy
    from donna.automations.cadence_reclamper import CadenceReclamper
    from donna.automations.cron import CronScheduleCalculator

    try:
        automation_repo = AutomationRepository(ctx.db.connection)
        automation_dispatcher = AutomationDispatcher(
            connection=ctx.db.connection,
            repository=automation_repo,
            model_router=skill_h.subsystem_router,
            skill_executor_factory=lambda: None,  # OOS-W1-2
            budget_guard=skill_h.budget_guard,
            alert_evaluator=AlertEvaluator(),
            cron=CronScheduleCalculator(),
            notifier=ctx.notification_service,
            config=ctx.skill_config,
        )
        automation_scheduler = AutomationScheduler(
            repository=automation_repo,
            dispatcher=automation_dispatcher,
            poll_interval_seconds=ctx.skill_config.automation_poll_interval_seconds,
        )
        ctx.tasks.append(asyncio.create_task(automation_scheduler.run_forever()))
        log.info(
            "automation_scheduler_started",
            poll_interval_seconds=ctx.skill_config.automation_poll_interval_seconds,
        )

        # Wave 3 Bug-fix: register CadenceReclamper on the skill lifecycle hook
        # so promoting a skill (sandbox → shadow_primary → trusted) recomputes
        # active_cadence_cron for every automation bound to that capability.
        # The harness at tests/e2e/harness.py registers the reclamper for E2E
        # tests; production wiring must do the same or cadence-uplift is inert.
        bundle = skill_h.bundle
        if bundle is not None:
            cadence_path = ctx.config_dir / "automations.yaml"
            if cadence_path.exists():
                try:
                    policy = CadencePolicy.load(cadence_path)
                    reclamper = CadenceReclamper(
                        repo=automation_repo,
                        policy=policy,
                        scheduler=_ReclamperSchedulerAdapter(),
                    )
                    # reclamp_for_capability returns int; the hook signature
                    # expects Awaitable[None], so adapt via a wrapper that
                    # discards the return value.
                    _reclamp_fn: Callable[[str, str], Awaitable[int]] = (
                        reclamper.reclamp_for_capability
                    )

                    async def _reclamp_adapter(
                        capability_name: str, new_state: str,
                    ) -> None:
                        await _reclamp_fn(capability_name, new_state)

                    bundle.lifecycle_manager.after_state_change.register(
                        _reclamp_adapter,
                    )
                    log.info("cadence_reclamper_registered")
                except Exception:
                    log.exception("cadence_reclamper_wiring_failed")

        return AutomationHandle(
            repository=automation_repo,
            dispatcher=automation_dispatcher,
            scheduler=automation_scheduler,
        )
    except Exception:
        log.exception("automation_scheduler_wiring_failed")
        return AutomationHandle(
            repository=None, dispatcher=None, scheduler=None,
        )

wire_discord async

wire_discord(ctx: StartupContext, skill_h: SkillSystemHandle, automation_h: AutomationHandle) -> DiscordHandle

Register slash commands, proactive-prompt config, start bot task.

If DISCORD_BOT_TOKEN / DISCORD_TASKS_CHANNEL_ID were not present in the environment (checked in build_startup_context), no bot was constructed — this helper logs discord_bot_disabled and returns a handle with bot=None.

Wave 3 Task 13: constructs a DiscordIntentDispatcher from the ChallengerAgent + ClaudeNoveltyJudge + PendingDraftRegistry + CadencePolicy + lifecycle adapter + candidate-report writer, and wires it onto the running DonnaBot so on_message routes new utterances through the Wave 3 intent pipeline.

Source code in src/donna/cli_wiring.py
async def wire_discord(
    ctx: StartupContext,
    skill_h: SkillSystemHandle,
    automation_h: AutomationHandle,
) -> DiscordHandle:
    """Register slash commands, proactive-prompt config, start bot task.

    If `DISCORD_BOT_TOKEN` / `DISCORD_TASKS_CHANNEL_ID` were not present
    in the environment (checked in `build_startup_context`), no bot was
    constructed — this helper logs `discord_bot_disabled` and returns a
    handle with `bot=None`.

    Wave 3 Task 13: constructs a DiscordIntentDispatcher from the
    ChallengerAgent + ClaudeNoveltyJudge + PendingDraftRegistry +
    CadencePolicy + lifecycle adapter + candidate-report writer, and
    wires it onto the running `DonnaBot` so `on_message` routes new
    utterances through the Wave 3 intent pipeline.
    """
    log = ctx.log

    if ctx.bot is None:
        log.warning(
            "discord_bot_disabled",
            reason="DISCORD_BOT_TOKEN or DISCORD_TASKS_CHANNEL_ID not set",
        )
        return DiscordHandle(bot=None, intent_dispatcher=None)

    # Wave 3: construct the intent dispatcher. Failure here is logged
    # but non-fatal — the bot falls back to the legacy InputParser path.
    intent_dispatcher = await _build_intent_dispatcher(
        ctx, skill_h, automation_h, log,
    )
    if intent_dispatcher is not None:
        # Attach to the already-constructed DonnaBot so `on_message`
        # routes via the Wave 3 path. automation_repo is needed for the
        # AutomationConfirmationView approval coordinator.
        ctx.bot._intent_dispatcher = intent_dispatcher
        ctx.bot._automation_repo = automation_h.repository
        # Wave 4: wire capability-availability guard into AutomationCreationPath.
        from donna.capabilities.tool_requirements import SkillToolRequirementsLookup
        from donna.skills import tools as _skill_tools_module

        ctx.bot._automation_tool_registry = _skill_tools_module.DEFAULT_TOOL_REGISTRY
        _cap_lookup = SkillToolRequirementsLookup(ctx.db.connection)
        ctx.bot._automation_capability_lookup = _cap_lookup.list_required_tools

        # F-W4-K: wire optional-input defaulting lookup into AutomationCreationPath.
        from donna.capabilities.repo_input_schema_lookup import (
            CapabilityInputSchemaDBLookup,
        )

        _input_schema_lookup = CapabilityInputSchemaDBLookup(ctx.db.connection)
        ctx.bot._automation_input_schema_lookup = _input_schema_lookup.lookup
        # Pull the Discord automation default min-interval from config so
        # AutomationCreationPath doesn't hardcode the 300-second floor.
        from donna.automations.cadence_policy import (
            load_discord_automation_default_min_interval_seconds,
        )

        cadence_path = ctx.config_dir / "automations.yaml"
        if cadence_path.exists():
            try:
                ctx.bot._automation_default_min_interval_seconds = (
                    load_discord_automation_default_min_interval_seconds(
                        cadence_path
                    )
                )
            except Exception:
                log.exception("automation_default_min_interval_load_failed")
        log.info("discord_intent_dispatcher_wired")

    bot = ctx.bot
    # Load Discord config and register slash commands if enabled.
    try:
        from donna.config import load_discord_config
        from donna.integrations.discord_commands import register_commands

        discord_config = load_discord_config(ctx.config_dir)
        if discord_config.commands.enabled:
            register_commands(bot, ctx.db, ctx.user_id)
            log.info("discord_slash_commands_registered")

        # Wire agent activity feed if agents channel is configured.
        if ctx.agents_channel_id_str:
            from donna.integrations.discord_agent_feed import AgentActivityFeed

            agent_feed = AgentActivityFeed(bot)  # noqa: F841 — side-effect ctor
            log.info("discord_agent_feed_enabled")

        # Start proactive prompt background tasks.
        prompts_cfg = discord_config.proactive_prompts

        # NotificationService is needed for proactive prompts — lazy import.
        try:
            from donna.notifications.proactive_prompts import (  # noqa: F401
                AfternoonInactivityCheck,
                EveningCheckin,
                PostMeetingCapture,
                StaleTaskDetector,
            )

            # Proactive prompts need NotificationService. If it's not yet
            # wired (e.g., no calendar config), skip gracefully.
            # For now, log that proactive prompts are configured but will
            # be started once the full notification stack is wired in server.py.
            log.info(
                "discord_proactive_prompts_configured",
                evening_checkin=prompts_cfg.evening_checkin.enabled,
                stale_detection=prompts_cfg.stale_detection.enabled,
                post_meeting=prompts_cfg.post_meeting_capture.enabled,
                afternoon_inactivity=prompts_cfg.afternoon_inactivity.enabled,
            )
        except Exception:
            log.exception("discord_proactive_prompts_load_failed")

    except Exception:
        log.exception("discord_config_load_failed")

    ctx.tasks.append(asyncio.create_task(bot.start(ctx.discord_token)))
    log.info("discord_bot_enabled", tasks_channel_id=ctx.tasks_channel_id_str)

    return DiscordHandle(
        bot=bot,
        intent_dispatcher=intent_dispatcher,
    )