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, manual_escalation_config: ManualEscalationConfig | None = None, escalation_repository: EscalationRepository | None = None, dashboard_setting_resolver: DashboardSettingResolver | None = None, budget_extension_repo: BudgetExtensionRepository | None = None, escalation_gate: EscalationGate | None = None, escalation_delivery_loop: EscalationDeliveryLoop | None = None, claude_code_poller: ClaudeCodePoller | None = None, owner_discord_id: int | None = None, tool_request_repository: Any | None = None, tool_gap_surfacer: Any | None = None, requires_rebuild_nagger: Any | None = None, event_bus: Any | None = None, tz: ZoneInfo | None = None, calendar_client: Any | None = None, calendar_id: str = 'primary', 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

manual_escalation_config class-attribute instance-attribute

manual_escalation_config: ManualEscalationConfig | None = None

escalation_repository class-attribute instance-attribute

escalation_repository: EscalationRepository | None = None

dashboard_setting_resolver class-attribute instance-attribute

dashboard_setting_resolver: DashboardSettingResolver | None = None

budget_extension_repo class-attribute instance-attribute

budget_extension_repo: BudgetExtensionRepository | None = None

escalation_gate class-attribute instance-attribute

escalation_gate: EscalationGate | None = None

escalation_delivery_loop class-attribute instance-attribute

escalation_delivery_loop: EscalationDeliveryLoop | None = None

claude_code_poller class-attribute instance-attribute

claude_code_poller: ClaudeCodePoller | None = None

owner_discord_id class-attribute instance-attribute

owner_discord_id: int | None = None

tool_request_repository class-attribute instance-attribute

tool_request_repository: Any | None = None

tool_gap_surfacer class-attribute instance-attribute

tool_gap_surfacer: Any | None = None

requires_rebuild_nagger class-attribute instance-attribute

requires_rebuild_nagger: Any | None = None

event_bus class-attribute instance-attribute

event_bus: Any | None = None

tz class-attribute instance-attribute

tz: ZoneInfo | None = None

calendar_client class-attribute instance-attribute

calendar_client: Any | None = None

calendar_id class-attribute instance-attribute

calendar_id: str = 'primary'

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
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
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)
    _source_root = Path(__file__).resolve().parents[2]
    if (_source_root / "prompts").is_dir():
        project_root = _source_root
    else:
        project_root = Path(os.environ.get("DONNA_PROJECT_ROOT", "/app"))

    # 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)
    manual_escalation_config = load_manual_escalation_config(config_dir)

    # Load timezone from calendar.yaml for all cron/prompt scheduling.
    user_tz: zoneinfo.ZoneInfo | None = None
    try:
        from donna.config import load_calendar_config as _load_cal
        _cal = _load_cal(config_dir)
        user_tz = zoneinfo.ZoneInfo(_cal.timezone)
    except Exception:
        log.warning("calendar_tz_load_failed_defaulting_utc")

    # Slice 23 — fail boot if any task type declares
    # ``manual_escalation.mode='claude_code'`` without a target_paths or
    # reference_module (spec §10.7 row 3). Catches drift from config
    # edits that would otherwise produce un-actionable specs.
    from donna.config import validate_manual_escalation_config

    validate_manual_escalation_config(task_types=task_types_config)

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

    # Wire Supabase write-through sync when credentials are available.
    supabase_sync = None
    if os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_SERVICE_ROLE_KEY"):
        try:
            from donna.integrations.supabase_sync import SupabaseSync

            supabase_sync = SupabaseSync()
            log.info("supabase_sync_constructed", configured=supabase_sync.configured)
        except Exception:
            log.warning("supabase_sync_unavailable", exc_info=True)

    db = Database(db_path, state_machine, supabase_sync=supabase_sync)
    await db.connect()
    await db.run_migrations()

    # Wire the TaskEventBus so task mutations emit events that
    # AutoScheduler and other subscribers can react to.
    from donna.tasks.events import TaskEventBus

    event_bus = TaskEventBus()
    db.set_event_bus(event_bus)

    from donna.preferences.correction_subscriber import CorrectionSubscriber

    correction_subscriber = CorrectionSubscriber(db)
    event_bus.subscribe("task_updated", correction_subscriber.on_task_updated)

    # Initialise model layer and input parser
    invocation_logger = InvocationLogger(db.connection)

    from donna.collection.payload_writer import PayloadWriter

    payload_dir = Path(os.environ.get("DONNA_PAYLOAD_DIR", "data/payloads"))
    payload_writer = PayloadWriter(base_dir=payload_dir)
    await payload_writer.sync_size_from_disk()

    router = ModelRouter(
        models_config, task_types_config, project_root,
        invocation_logger=invocation_logger,
        payload_writer=payload_writer,
    )
    from donna.preferences.rule_applier import PreferenceApplier

    preference_applier = PreferenceApplier(db)
    input_parser = InputParser(
        router, invocation_logger, project_root,
        tz=user_tz, preference_applier=preference_applier,
    )

    # Slice 17/18 — over-budget escalation infrastructure. Built before
    # the bot so the gate's delivery callback can capture a bot reference
    # once that's constructed below; the gate itself is wired only when
    # the bot is available.
    escalation_repository = EscalationRepository(db.connection)
    dashboard_setting_resolver = DashboardSettingResolver(escalation_repository)
    budget_extension_repo = BudgetExtensionRepository(db.connection)

    # Slice 22 — tool gap surfacing. Repository is always wired (table
    # is non-optional). Surfacer is built without a ping_poster here;
    # the bot-aware poster is bolted on later once the bot exists
    # (see _wire_tool_gap_ping_poster below).
    from donna.cost.tool_gap_surfacer import ToolGapSurfacer
    from donna.cost.tool_request_repository import ToolRequestRepository

    tool_request_repository = ToolRequestRepository(db.connection)
    tool_gap_surfacer = ToolGapSurfacer(
        repository=tool_request_repository,
        conn=db.connection,
        ping_poster=None,
    )

    # Crash-recovery scan (§10.6 row 4): void extensions granted before a
    # previous crash that never ran their associated API call.
    await _run_crash_recovery(
        extension_repo=budget_extension_repo,
        escalation_repo=escalation_repository,
        conn=db.connection,
        user_id=os.environ.get("DONNA_USER_ID", "nick"),
        log=log,
    )

    # OWNER_DISCORD_ID — parsed up front. The env-var requirement is
    # enforced below only once a Discord bot is actually being wired
    # (with no bot, manual escalation has no surface to land on, so
    # missing the env var is benign).
    owner_discord_id_str = os.environ.get("DONNA_OWNER_DISCORD_ID")
    owner_discord_id: int | None
    if owner_discord_id_str is not None and owner_discord_id_str.strip():
        owner_discord_id = int(owner_discord_id_str.strip())
    else:
        owner_discord_id = None

    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

        digest_channel_id_str = os.environ.get("DISCORD_DIGEST_CHANNEL_ID")
        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,
            digest_channel_id=int(digest_channel_id_str) if digest_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,
            event_bus=event_bus,
            owner_discord_id=owner_discord_id,
            owner_user_id=user_id,
        )

        # Boot-time reconcile (Bug 2): link the configured owner's Discord ID
        # to their existing user row so Discord messages are attributed to the
        # real donna_user_id instead of a forked username-derived identity.
        # Self-healing — runs every boot and is idempotent once linked.
        if owner_discord_id is not None:
            await db.link_owner_discord_id(user_id, str(owner_discord_id))

        # Wave 1 (F-6 Step 6a): construct NotificationService with the live bot.
        from donna.config import load_calendar_config

        twilio_sms_instance = None
        try:
            from donna.config import load_sms_config
            from donna.integrations.twilio_sms import TwilioSMS

            sms_cfg = load_sms_config(config_dir)
            twilio_sms_instance = TwilioSMS(sms_cfg)
            log.info("twilio_sms_constructed")
        except Exception:
            log.warning("twilio_sms_unavailable")

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

        # Wire TwilioVoice for Tier 4 phone escalation.
        twilio_voice_instance = None
        try:
            from donna.integrations.twilio_voice import TwilioVoice

            if twilio_sms_instance is not None:
                twilio_voice_instance = TwilioVoice(max_per_day=1)
                log.info("twilio_voice_constructed")
        except Exception:
            log.warning("twilio_voice_unavailable", exc_info=True)

        # Wire EscalationManager for tiered notification escalation.
        _escalation_manager = None
        if notification_service is not None and twilio_sms_instance is not None:
            try:
                from donna.notifications.escalation import EscalationManager

                gmail_client = _try_build_gmail_client(config_dir)
                user_email = os.environ.get("DONNA_EMAIL_FROM", "")

                _prefs_path = config_dir / "preferences.yaml"
                _tier4_voice_enabled = True
                if _prefs_path.exists():
                    import yaml as _yaml
                    with open(_prefs_path) as _f:
                        _prefs = _yaml.safe_load(_f) or {}
                    _tier4_voice_enabled = _prefs.get("escalation", {}).get(
                        "tier4_voice_enabled", True
                    )

                _escalation_manager = EscalationManager(
                    db=db,
                    service=notification_service,
                    sms=twilio_sms_instance,
                    sms_config=sms_cfg,
                    user_id=user_id,
                    user_phone=os.environ.get("DONNA_USER_PHONE", ""),
                    gmail=gmail_client,
                    user_email=user_email,
                    voice=twilio_voice_instance,
                    tier4_enabled=_tier4_voice_enabled and twilio_voice_instance is not None,
                )
                log.info(
                    "escalation_manager_wired",
                    tier4_enabled=_tier4_voice_enabled and twilio_voice_instance is not None,
                )
            except Exception:
                log.warning("escalation_manager_unavailable", exc_info=True)

    # Boot-time health diagnostics — runs all checks and sends warnings
    # to the Discord debug channel if available.
    from donna.resilience.health_check import SelfDiagnostic

    async def _debug_notify(message: str) -> None:
        if bot is not None and debug_channel_id_str:
            try:
                channel = bot.get_channel(int(debug_channel_id_str))
                if channel:
                    await channel.send(message[:2000])
            except Exception:
                log.warning("debug_notify_failed", exc_info=True)

    logs_db_path = Path(
        os.environ.get("DONNA_LOGS_DB_PATH", str(Path(db_path).parent / "donna_logs.db"))
    )
    diagnostics = SelfDiagnostic(
        tasks_db_path=Path(db_path),
        logs_db_path=logs_db_path,
        donna_mount=Path(os.environ.get("DONNA_DATA_PATH", "/donna")),
        last_supabase_sync_path=(
            Path(os.environ.get("DONNA_DB_DIR", str(Path(db_path).parent)))
            / ".supabase_last_sync"
        ),
        notify=_debug_notify,
    )
    try:
        boot_warnings = await diagnostics.run()
        if boot_warnings:
            log.warning("boot_diagnostics_issues", count=len(boot_warnings))
    except Exception:
        log.exception("boot_diagnostics_failed")

    # Slice 17 — assemble the gate + delivery loop only when the bot is
    # available. Without a bot the four-button view has nowhere to land;
    # callers can still detect over-budget tasks via BudgetGuard.
    escalation_gate: EscalationGate | None = None
    escalation_delivery_loop: EscalationDeliveryLoop | None = None
    if (
        bot is not None
        and manual_escalation_config.enabled
        and owner_discord_id is None
    ):
        # In production an operator should set DONNA_OWNER_DISCORD_ID so
        # buttons can be resolved. Log loudly and continue with the gate
        # disabled — failing to boot here would cripple every other
        # subsystem for an issue scoped to over-budget escalations only.
        log.warning(
            "escalation_gate_disabled_no_owner",
            reason="DONNA_OWNER_DISCORD_ID is unset; "
            "manual escalation buttons cannot be authorised",
        )
    claude_code_poller: ClaudeCodePoller | None = None
    if bot is not None and owner_discord_id is not None:
        cost_tracker_for_gate = CostTracker(db.connection)
        deliver = _make_escalation_delivery_callback(
            bot=bot,
            owner_discord_id=owner_discord_id,
            gate_holder=lambda: escalation_gate,  # late binding
            prompt_delivery=manual_escalation_config.prompt_delivery,
        )
        # Slice 20 — chat-mode prompt builder. Renders chat_question.md,
        # generates the local-Ollama summary, persists prompt_body /
        # summary / prompt_path on the escalation_request row, and
        # writes the workspace .md the delivery callback attaches.
        from donna.cost.escalation_chat_prompt import ChatPromptBuilder

        chat_prompt_builder = ChatPromptBuilder(
            router=router,
            project_root=project_root,
            config=manual_escalation_config.prompt_delivery,
        )

        # Slice 21 — resolve the host repo + spec builder if claude_code
        # mode is enabled. Fails soft: missing env / missing repo just
        # disables claude_code button rendering (chat / api_extended /
        # pause / cancel still work).
        claude_code_cfg = manual_escalation_config.modes.claude_code
        host_repo: GitRepo | None = None
        spec_builder: ClaudeCodeSpecBuilder | None = None
        if claude_code_cfg.enabled:
            host_repo_path_env = claude_code_cfg.host_repo_path_env
            host_repo_path_str = os.environ.get(host_repo_path_env)
            if host_repo_path_str:
                host_repo_path = Path(host_repo_path_str).expanduser()
                if (host_repo_path / ".git").exists():
                    host_repo = GitRepo(root=host_repo_path)
                    workspace_path = expand_workspace_path(
                        os.environ.get("DONNA_WORKSPACE_PATH", str(project_root))
                    )
                    worktree_root = expand_workspace_path(
                        claude_code_cfg.worktree_root
                    )
                    spec_builder = ClaudeCodeSpecBuilder(
                        prompt_dir=project_root / "prompts" / "escalation",
                        workspace_path=workspace_path,
                        host_repo_path=host_repo_path,
                        worktree_root=worktree_root,
                        dashboard_base_url=f"http://localhost:{port}",
                        iteration_limit=manual_escalation_config.triggers.manual_iteration_limit,
                    )
                    log.info(
                        "claude_code_mode_wired",
                        host_repo_path=str(host_repo_path),
                        worktree_root=str(worktree_root),
                    )
                else:
                    log.warning(
                        "claude_code_mode_disabled_invalid_host_repo",
                        host_repo_path=str(host_repo_path),
                        reason="path is not a git repo",
                    )
            else:
                log.info(
                    "claude_code_mode_disabled_no_host_repo_env",
                    env_var=host_repo_path_env,
                )

        escalation_gate = EscalationGate(
            repository=escalation_repository,
            tracker=cost_tracker_for_gate,
            config=manual_escalation_config,
            daily_pause_threshold_usd=models_config.cost.daily_pause_threshold_usd,
            resolver=dashboard_setting_resolver,
            deliver=deliver,
            extension_repo=budget_extension_repo,
            task_types_config=task_types_config,
            chat_prompt_builder=chat_prompt_builder,
            spec_builder=spec_builder,
            host_repo=host_repo,
        )
        escalation_delivery_loop = EscalationDeliveryLoop(
            db=db,
            repository=escalation_repository,
            timeout_minutes=manual_escalation_config.triggers.escalation_timeout_minutes,
            deliver=deliver,
        )
        # The router is constructed before the bot, so we late-bind the
        # gate here so estimate-bearing calls can reach it.
        router.set_escalation_gate(escalation_gate)
        log.info("escalation_gate_wired")

    ctx_obj = 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,
        manual_escalation_config=manual_escalation_config,
        db=db,
        state_machine=state_machine,
        router=router,
        invocation_logger=invocation_logger,
        input_parser=input_parser,
        escalation_repository=escalation_repository,
        dashboard_setting_resolver=dashboard_setting_resolver,
        budget_extension_repo=budget_extension_repo,
        escalation_gate=escalation_gate,
        escalation_delivery_loop=escalation_delivery_loop,
        claude_code_poller=claude_code_poller,
        owner_discord_id=owner_discord_id,
        tool_request_repository=tool_request_repository,
        tool_gap_surfacer=tool_gap_surfacer,
        requires_rebuild_nagger=None,  # late-bound below once the bot is up
        event_bus=event_bus,
        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,
        tz=user_tz,
    )
    if escalation_delivery_loop is not None:
        ctx_obj.tasks.append(
            asyncio.create_task(
                escalation_delivery_loop.run(),
                name="escalation_delivery_loop",
            )
        )

    # Slice 20 — chat-mode submission ingestion. Always start when
    # manual escalation is enabled in YAML, regardless of whether a
    # Discord bot is wired: the dashboard submit endpoint is the
    # canonical surface and works even without Discord.
    if manual_escalation_config.enabled:
        from donna.skills.chat_escalation_ingestion_poller import (
            ChatEscalationIngestionPoller,
        )

        chat_ingestion_poller = ChatEscalationIngestionPoller(db=db)
        ctx_obj.tasks.append(
            asyncio.create_task(
                chat_ingestion_poller.run(),
                name="chat_escalation_ingestion_poller",
            )
        )
        log.info("chat_escalation_ingestion_poller_started")

    # Supabase keep-alive — periodic HEAD ping to prevent idle disconnect.
    if supabase_sync is not None and supabase_sync.configured:
        ctx_obj.tasks.append(
            asyncio.create_task(
                supabase_sync.keep_alive(),
                name="supabase_keepalive",
            )
        )
        log.info("supabase_keepalive_started")

    # Email parser poller — monitors a Gmail alias for forwarded task emails.
    email_monitor_alias = os.environ.get("DONNA_EMAIL_MONITOR_ALIAS", "").strip()
    if email_monitor_alias:
        gmail_for_poller = _try_build_gmail_client(config_dir)
        if gmail_for_poller is not None:
            from donna.integrations.email_parser import poll_and_create_tasks

            async def _email_poll_loop() -> None:
                while True:
                    try:
                        created = await poll_and_create_tasks(
                            gmail=gmail_for_poller,
                            input_parser=input_parser,
                            db=db,
                            user_id=user_id,
                            monitor_alias=email_monitor_alias,
                        )
                        if created > 0:
                            log.info("email_poll_created_tasks", count=created)
                    except Exception:
                        log.exception("email_poll_error")
                    await asyncio.sleep(300)

            ctx_obj.tasks.append(
                asyncio.create_task(
                    _email_poll_loop(),
                    name="email_poll_loop",
                )
            )
            log.info("email_poll_loop_started", monitor_alias=email_monitor_alias)
        else:
            log.info("email_poll_skipped_no_gmail", monitor_alias=email_monitor_alias)

    return ctx_obj

wire_claude_code_poller async

wire_claude_code_poller(ctx: StartupContext, skill_h: SkillSystemHandle) -> ClaudeCodePoller | None

Slice 21 — construct and start the claude_code ingestion poller.

Runs after :func:wire_skill_system so the lifecycle manager and validation executor are available. Idempotent: returns existing poller if already wired (e.g. across reruns in tests).

Realizes docs/superpowers/specs/manual-escalation.md §5.3 ingestion paragraph (Donna ingestion / poller).

Source code in src/donna/cli_wiring.py
async def wire_claude_code_poller(
    ctx: StartupContext,
    skill_h: SkillSystemHandle,
) -> ClaudeCodePoller | None:
    """Slice 21 — construct and start the claude_code ingestion poller.

    Runs after :func:`wire_skill_system` so the lifecycle manager and
    validation executor are available. Idempotent: returns existing
    poller if already wired (e.g. across reruns in tests).

    Realizes docs/superpowers/specs/manual-escalation.md §5.3 ingestion
    paragraph (Donna ingestion / poller).
    """
    log = ctx.log
    if ctx.escalation_gate is None:
        log.info("claude_code_poller_skip_no_gate")
        return None
    if ctx.manual_escalation_config is None or ctx.escalation_repository is None:
        log.info("claude_code_poller_skip_no_config")
        return None
    bundle = skill_h.bundle
    if bundle is None:
        log.info("claude_code_poller_skip_no_skill_bundle")
        return None
    # The gate's host_repo / spec_builder are the ground truth for
    # whether claude_code mode is enabled (see build_startup_context).
    host_repo = ctx.escalation_gate._host_repo
    if host_repo is None:
        log.info("claude_code_poller_skip_no_host_repo")
        return None

    cc_cfg = ctx.manual_escalation_config.modes.claude_code
    triggers = ctx.manual_escalation_config.triggers

    def _executor_factory() -> Any:
        from donna.skills.validation_executor import ValidationExecutor
        return ValidationExecutor(
            model_router=skill_h.subsystem_router,
            config=ctx.skill_config,
        )

    # Slice 22 — pass the tool_request repo + lint config so the router
    # can validate tool_request_fulfillment branches via _validate_tool.
    from donna.cost.tool_lint import ToolLintConfig as _SLC22ToolLintConfig

    _tool_gap_cfg = (
        ctx.manual_escalation_config.tool_gap
        if ctx.manual_escalation_config is not None
        else None
    )
    _tool_lint_config = _SLC22ToolLintConfig(
        detect_secrets_enabled=(
            _tool_gap_cfg.lint.detect_secrets_enabled
            if _tool_gap_cfg is not None
            else False
        ),
        requires_rebuild_default=(
            _tool_gap_cfg.lint.requires_rebuild_default
            if _tool_gap_cfg is not None
            else False
        ),
        default_timeout_seconds=(
            _tool_gap_cfg.lint.default_timeout_seconds
            if _tool_gap_cfg is not None
            else 5
        ),
    )
    router = ManualValidationRouter(
        conn=ctx.db.connection,
        host_repo=host_repo,
        executor_factory=_executor_factory,
        lifecycle=bundle.lifecycle_manager,
        fixture_pass_rate=ctx.skill_config.auto_draft_fixture_pass_rate,
        tool_request_repo=ctx.tool_request_repository,
        tool_lint_config=_tool_lint_config,
        host_repo_path=host_repo.root,
    )

    feedback = _make_claude_code_feedback_callback(
        bot=ctx.bot,
    ) if ctx.bot is not None else None

    poller = ClaudeCodePoller(
        repository=ctx.escalation_repository,
        host_repo=host_repo,
        validation_router=router,
        base_ref=cc_cfg.base_ref,
        feedback=feedback,
        manual_iteration_limit=triggers.manual_iteration_limit,
        feedback_max_failing_cases=cc_cfg.feedback_max_failing_cases,
        dashboard_base_url=f"http://localhost:{ctx.port}",
        tick_seconds=cc_cfg.poll_tick_seconds,
    )
    ctx.claude_code_poller = poller
    ctx.tasks.append(
        asyncio.create_task(poller.run(), name="claude_code_poller")
    )
    log.info(
        "claude_code_poller_wired",
        tick_seconds=cc_cfg.poll_tick_seconds,
        base_ref=cc_cfg.base_ref,
    )
    return poller

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,
        invocation_logger=ctx.invocation_logger,
        payload_writer=ctx.router._payload_writer,
    )

    # 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,
            surfacer=ctx.tool_gap_surfacer,
            boot_owner_user_id=getattr(ctx, "user_id", "boot") or "boot",
        )
        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),
            extension_repo=ctx.budget_extension_repo,
        )

        _skill_fallback_alert = (
            notification_service.dispatch_fallback_alert
            if notification_service is not None
            else None
        )
        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
            tool_gap_surfacer=ctx.tool_gap_surfacer,
            tool_registry=_skill_tools_module.DEFAULT_TOOL_REGISTRY,
            fallback_alert=_skill_fallback_alert,
        )

        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,
                tz=ctx.tz,
            )
            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)
        # Slice 22 — runtime tool-availability check for the dispatcher.
        runtime_tool_check = None
        if ctx.tool_gap_surfacer is not None:
            try:
                from donna.capabilities.runtime_tool_check import RuntimeToolCheck
                from donna.capabilities.tool_requirements import (
                    SkillToolRequirementsLookup,
                )
                from donna.skills import tools as _slc22_skill_tools

                runtime_tool_check = RuntimeToolCheck(
                    registry=_slc22_skill_tools.DEFAULT_TOOL_REGISTRY,
                    lookup=SkillToolRequirementsLookup(ctx.db.connection),
                )
            except Exception:
                log.exception("runtime_tool_check_wire_failed")
        # Fable critique #1 — wire the evidence loop. Without a run repository
        # the executor produces no skill_run / skill_step_result rows, and
        # without the shadow sampler shadow_primary/trusted skills run live with
        # zero divergence recording — so the §23.4 trust gates and auto-demotion
        # are structurally inert. Construct the run repository here and reuse the
        # already-built ShadowSampler from the bundle.
        _skill_bundle = skill_h.bundle
        _run_repository = SkillRunRepository(ctx.db.connection)
        _shadow_sampler = (
            _skill_bundle.shadow_sampler if _skill_bundle is not None else None
        )
        _exec_fallback_alert = (
            ctx.notification_service.dispatch_fallback_alert
            if ctx.notification_service is not None
            else None
        )

        def _automation_skill_executor_factory() -> Any:
            from donna.skills.executor import SkillExecutor
            return SkillExecutor(
                model_router=skill_h.subsystem_router,
                config=ctx.skill_config,
                tool_gap_surfacer=ctx.tool_gap_surfacer,
                run_repository=_run_repository,
                shadow_sampler=_shadow_sampler,
                fallback_alert=_exec_fallback_alert,
            )

        # Boot-time invariant: if the skill system is enabled and any skill is
        # already live (shadow_primary / trusted), the executor MUST have both
        # run persistence and a shadow sampler, otherwise those skills execute
        # with real tools and zero monitoring. Alert loudly rather than
        # hard-crash boot (Fable critique #1).
        await _verify_evidence_loop_wired(
            ctx=ctx,
            run_repository=_run_repository,
            shadow_sampler=_shadow_sampler,
        )

        automation_dispatcher = AutomationDispatcher(
            connection=ctx.db.connection,
            repository=automation_repo,
            model_router=skill_h.subsystem_router,
            skill_executor_factory=_automation_skill_executor_factory,
            budget_guard=skill_h.budget_guard,
            alert_evaluator=AlertEvaluator(),
            cron=CronScheduleCalculator(),
            notifier=ctx.notification_service,
            config=ctx.skill_config,
            runtime_tool_check=runtime_tool_check,
            tool_gap_surfacer=ctx.tool_gap_surfacer,
        )
        gpu_home_model: str | None = None
        try:
            from donna.llm.types import load_gateway_config
            gw = load_gateway_config(ctx.config_dir)
            gpu_home_model = gw.gpu.home_model
        except Exception:
            log.debug("gpu_home_model_not_available")

        automation_scheduler = AutomationScheduler(
            repository=automation_repo,
            dispatcher=automation_dispatcher,
            poll_interval_seconds=ctx.skill_config.automation_poll_interval_seconds,
            gpu_home_model=gpu_home_model,
        )
        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
        if skill_h.bundle is not None:
            ctx.bot._skill_candidate_repo = skill_h.bundle.candidate_repo
        # 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")
        # Wire default_alert_conditions lookup so AutomationCreationPath
        # falls back to capability-level defaults when the LLM returns null.
        from donna.capabilities.default_alerts_lookup import (
            CapabilityDefaultAlertsLookup,
        )

        _caps_yaml = ctx.config_dir / "capabilities.yaml"
        if _caps_yaml.exists():
            _default_alerts = CapabilityDefaultAlertsLookup(_caps_yaml)

            async def _async_default_alerts(name: str) -> dict[str, Any] | None:
                return _default_alerts.get(name)

            ctx.bot._automation_default_alerts_lookup = _async_default_alerts

        log.info("discord_intent_dispatcher_wired")

    # Slice 22 — wire the tool-gap ping poster onto the surfacer once
    # we have a bot + escalation_gate + owner_discord_id. The poster is
    # a small closure that builds a ToolGapPingView and posts it to the
    # configured channel. Without these dependencies the surfacer
    # records rows + audits but doesn't ping (boot fail-soft).
    if (
        ctx.bot is not None
        and ctx.escalation_gate is not None
        and ctx.owner_discord_id is not None
        and ctx.tool_gap_surfacer is not None
        and ctx.tool_request_repository is not None
        and ctx.manual_escalation_config is not None
    ):
        try:
            from donna.integrations.discord_views import ToolGapPingView

            _bot = ctx.bot
            _gate = ctx.escalation_gate
            _repo = ctx.tool_request_repository
            _owner = ctx.owner_discord_id
            _channel = ctx.manual_escalation_config.tool_gap.realtime_channel
            _snooze = ctx.manual_escalation_config.tool_gap.snooze_seconds

            async def _post_tool_gap_ping(row: Any) -> bool:
                blocking = (
                    f"capability `{row.blocking_capability_id}`"
                    if row.blocking_capability_id
                    else "skill draft"
                )
                text = (
                    f":rotating_light: **Tool gap (high blocking):** "
                    f"`{row.tool_name}` is required by {blocking}.\n"
                    f"_{row.rationale}_\n"
                    f"Detected at: `{row.detection_point}`"
                )
                view = ToolGapPingView(
                    tool_request_id=row.id,
                    tool_name=row.tool_name,
                    owner_discord_id=_owner,
                    gate=_gate,
                    tool_request_repo=_repo,
                    snooze_seconds=_snooze,
                )
                msg = await _bot.send_message_with_view(_channel, text, view)
                return msg is not None

            ctx.tool_gap_surfacer.set_ping_poster(_post_tool_gap_ping)
            ctx.bot._tool_gap_surfacer = ctx.tool_gap_surfacer
            log.info("tool_gap_ping_poster_wired", channel=_channel)
        except Exception:
            log.exception("tool_gap_ping_poster_wire_failed")

    # Slice 24 — wire the requires_rebuild=True hourly nag (spec §10.5
    # row 1). Mirrors the slice-22 poster pattern: closure over the bot
    # + owner + channel, plus a ticker task on the same task list as
    # the other escalation loops. The provider returns the live
    # ToolRegistry tool-name set so the nagger stops once the user
    # rebuilds + restarts.
    if (
        ctx.bot is not None
        and ctx.owner_discord_id is not None
        and ctx.tool_request_repository is not None
        and ctx.manual_escalation_config is not None
    ):
        try:
            from donna.cost.requires_rebuild_nag import RequiresRebuildNagger
            from donna.skills.tools import DEFAULT_TOOL_REGISTRY

            _nag_bot = ctx.bot
            _nag_owner = ctx.owner_discord_id
            _nag_channel = (
                ctx.manual_escalation_config.tool_gap.realtime_channel
            )

            async def _post_rebuild_nag(row: Any) -> bool:
                text = (
                    f":wrench: **Rebuild reminder:** "
                    f"`{row.tool_name}` is built (branch "
                    f"`{row.resolved_branch}`) but the orchestrator "
                    f"hasn't been restarted with the new image yet. "
                    f"Run `docker compose build && docker compose up -d` "
                    f"once you've merged."
                )
                _ = _nag_owner  # owner-DM scoping is the channel itself
                msg = await _nag_bot.send_message(_nag_channel, text)
                return msg is not None

            nagger = RequiresRebuildNagger(
                repository=ctx.tool_request_repository,
                registered_tools_provider=DEFAULT_TOOL_REGISTRY.list_tool_names,
                ping_poster=_post_rebuild_nag,
            )
            ctx.requires_rebuild_nagger = nagger

            async def _nag_loop() -> None:
                # Tick every minute, same cadence as the other
                # escalation loops. The nagger's per-row cooldown
                # (1 h default) makes the tick rate cheap.
                import asyncio as _asyncio

                while True:
                    try:
                        await nagger.tick_once()
                    except Exception:
                        log.exception("requires_rebuild_nag_tick_failed")
                    await _asyncio.sleep(60)

            ctx.tasks.append(
                asyncio.create_task(
                    _nag_loop(), name="requires_rebuild_nag_loop"
                )
            )
            log.info(
                "requires_rebuild_nagger_wired", channel=_nag_channel
            )
        except Exception:
            log.exception("requires_rebuild_nagger_wire_failed")

    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
        from donna.integrations.discord_submit_command import register_submit_command

        discord_config = load_discord_config(ctx.config_dir)
        if discord_config.commands.enabled:
            register_commands(
                bot, ctx.db, ctx.user_id,
                calendar_client=ctx.calendar_client,
                calendar_id=ctx.calendar_id,
            )
            # Slice 20 — register `/donna_submit` for the chat-mode
            # fallback path. Skipped silently when the bot is unavailable
            # or when manual escalation hasn't been wired (single-user
            # boot without a Discord integration leaves the config None).
            manual_cfg = ctx.manual_escalation_config
            if bot is not None and manual_cfg is not None:
                register_submit_command(
                    bot=bot,
                    conn=ctx.db.connection,
                    config=manual_cfg.prompt_delivery,
                    iteration_limit=manual_cfg.triggers.manual_iteration_limit,
                    owner_discord_id=ctx.owner_discord_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,
    )