"""Main Orchestrator — the brain of the Vastian Network."""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import platform
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from vastian.llm.provider import ModelRouter
    from vastian.tasks.inbox import InboxWatcher
    from vastian.tasks.queue import BackgroundTask, TaskQueue

from vastian.agents.registry import AgentRegistry
from vastian.bus import Message, MessageBus, MessageType, Priority
from vastian.config import Settings
from vastian.council.engine import MentorCouncilEngine
from vastian.knowledge import KnowledgeStore
from vastian.llm.provider import BaseLLMProvider
from vastian.orchestrator.router import IntentRouter

logger = logging.getLogger(__name__)

# ── Configurable document size limits (chars) ──────────────────
# Default limits per document type prefix. Override via config/local/doc-limits.yaml
DOC_SIZE_LIMITS: dict[str, int] = {
    "default": 8000,
    "profile": 12000,
    "decision-log": 20000,
    "meeting-transcript": 50000,
    "vkb": 25000,
    "roadmap": 15000,
    "prompt-audit-log": 20000,
}


def _get_doc_size_limit(doc_path: str) -> int:
    """Return the max char limit for a document path.

    Checks if the doc name starts with any key in DOC_SIZE_LIMITS,
    falls back to 'default'.
    """
    name = doc_path.rsplit("/", 1)[-1].lower() if "/" in doc_path else doc_path.lower()
    for prefix, limit in DOC_SIZE_LIMITS.items():
        if prefix != "default" and name.startswith(prefix):
            return limit
    return DOC_SIZE_LIMITS["default"]


def _load_doc_size_overrides(data_root: Path | None) -> None:
    """Load doc size limit overrides from config/local/doc-limits.yaml if it exists."""
    if data_root is None:
        return
    override_path = data_root / "config" / "local" / "doc-limits.yaml"
    if override_path.exists():
        try:
            import yaml

            overrides = yaml.safe_load(override_path.read_text(encoding="utf-8")) or {}
            if isinstance(overrides, dict):
                for k, v in overrides.items():
                    if isinstance(v, int) and v > 0:
                        DOC_SIZE_LIMITS[k] = v
                logger.info("Loaded doc size overrides from %s", override_path)
        except Exception:
            logger.warning("Could not load doc-limits.yaml", exc_info=True)


def _emit_council_neuron(result: dict, topic: str, council_type: str) -> None:
    """Mars M3b: Write council synthesis neuron to Giga outbox."""
    from vastian.bridges.giga_dev_bridge.handlers import write_giga_neuron

    summary = result.get("summary") or result.get("synthesis") or ""
    session_id = result.get("session_id") or result.get("id") or ""
    body = f"Council meeting ({council_type}): {topic}\n\n{summary}"
    if result.get("action_items"):
        body += "\n\nAction items: " + "; ".join(
            f"{a.get('assigned_to', '?')}: {a.get('description', '')[:60]}"
            for a in result.get("action_items", [])[:5]
        )
    write_giga_neuron(
        agent_id="orchestrator",
        title=f"Council: {topic[:40]}",
        body=body,
        selector_nl=f"council {council_type} {session_id}",
        mind="vastian-network",
    )


# ══════════════════════════════════════════════════════════════════════
# TOOL DEFINITIONS — OpenAI function-calling format
# ══════════════════════════════════════════════════════════════════════


def _tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    """Helper to build a tool definition with less boilerplate."""
    return {
        "type": "function",
        "function": {
            "name": name,
            "description": description,
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


# ── Shared Tools (available to all agents) ───────────────────────────

TOOL_DELEGATE = _tool(
    "delegate_to_agent",
    "Delegate a task or question to another agent in the Vastian Network. "
    "Use when the request falls in another agent's domain or you need specialist input.",
    {
        "target_agent_id": {"type": "string", "description": "The agent_id to delegate to."},
        "task_description": {
            "type": "string",
            "description": "Clear description of what you need this agent to do.",
        },
        "context": {
            "type": "string",
            "description": "Additional context the target agent needs.",
            "default": "",
        },
    },
    ["target_agent_id", "task_description"],
)

TOOL_ASK_COLLEAGUE = _tool(
    "ask_colleague",
    "Ask a colleague agent for quick input, advice, or perspective. Use this to avoid silos — "
    "get specialist insight before responding to the user. Returns the colleague's response directly.",
    {
        "colleague_agent_id": {
            "type": "string",
            "description": "The agent_id to ask (e.g. henry, finley, elias).",
        },
        "question": {
            "type": "string",
            "description": "Your question or what input you need from this colleague.",
        },
        "context": {
            "type": "string",
            "description": "Brief context (user request, your reasoning so far).",
            "default": "",
        },
    },
    ["colleague_agent_id", "question"],
)

TOOL_READ_DOC = _tool(
    "read_document",
    "Read one of the Vastian Network's managed documents to get real data.",
    {
        "doc_path": {
            "type": "string",
            "description": "Path to the document, e.g. 'liam/prioritized-todo-list.md'",
        }
    },
    ["doc_path"],
)

TOOL_WRITE_DOC = _tool(
    "write_document",
    "Write or update a Vastian Network document. IMPORTANT: This REPLACES the entire file. "
    "You MUST call read_document FIRST to get the current content, then write the COMPLETE "
    "updated document with all existing sections preserved plus your changes. If you only "
    "write your new data without including existing content, the document will lose data.",
    {
        "doc_path": {
            "type": "string",
            "description": "Path to document you own, e.g. 'liam/daily-task-flow.md'",
        },
        "content": {
            "type": "string",
            "description": "COMPLETE Markdown content including ALL existing sections plus your updates.",
        },
    },
    ["doc_path", "content"],
)

# ── Liam: Coordinator Tools ──────────────────────────────────────────

TOOL_BATCH_DELEGATE = _tool(
    "batch_delegate",
    "Fan out a task to MULTIPLE agents at once. Each agent responds independently. "
    "Use when you need input from several agents on the same topic.",
    {
        "agent_ids": {
            "type": "array",
            "items": {"type": "string"},
            "description": "List of agent_ids to delegate to.",
        },
        "task_description": {"type": "string", "description": "Task to send to ALL agents."},
        "context": {
            "type": "string",
            "description": "Shared context for all agents.",
            "default": "",
        },
    },
    ["agent_ids", "task_description"],
)

# ── Elias: Knowledge Tools ───────────────────────────────────────────

TOOL_SEARCH_KNOWLEDGE = _tool(
    "search_knowledge",
    "Semantic search across the Vastian Knowledge Bank. Returns the most relevant entries.",
    {
        "query": {"type": "string", "description": "The search query."},
        "top_k": {"type": "integer", "description": "Number of results to return.", "default": 5},
    },
    ["query"],
)

TOOL_STORE_INSIGHT = _tool(
    "store_insight",
    "Store a new insight, fact, or piece of knowledge in the Vastian Knowledge Bank. "
    "Tag it for future retrieval.",
    {
        "content": {"type": "string", "description": "The insight to store."},
        "tags": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Tags for categorization.",
        },
    },
    ["content", "tags"],
)

# ── Theo: Prompt Engineering Tools ───────────────────────────────────

TOOL_READ_AGENT_CONFIG = _tool(
    "read_agent_config",
    "Read another agent's full YAML configuration to audit their prompt, role, and responsibilities.",
    {"agent_id": {"type": "string", "description": "The agent_id whose config to read."}},
    ["agent_id"],
)

TOOL_SUGGEST_PROMPT_FIX = _tool(
    "suggest_prompt_fix",
    "Log a prompt improvement suggestion for a specific agent. Records the issue and proposed fix.",
    {
        "agent_id": {"type": "string", "description": "Agent whose prompt needs fixing."},
        "issue": {
            "type": "string",
            "description": "What's wrong with the current prompt (include issue type: role_drift, domain_overlap, hallucination, tool_neglect, stale_knowledge, or prompt_drift).",
        },
        "suggested_fix": {"type": "string", "description": "The specific text change recommended."},
        "severity": {
            "type": "string",
            "description": "Issue severity: Low, Medium, High, or Critical.",
            "default": "Medium",
        },
    },
    ["agent_id", "issue", "suggested_fix"],
)

# ── Charles: Command Center Tools ────────────────────────────────────

TOOL_GET_NETWORK_STATUS = _tool(
    "get_network_status",
    "Get the current status of the entire Vastian Network — all agents, their states, and teams.",
    {},
    [],
)

TOOL_GET_TASKS = _tool(
    "get_tasks",
    "Retrieve tasks and delegations from the network, optionally filtered by agent or status.",
    {
        "agent_id": {"type": "string", "description": "Filter by agent_id.", "default": ""},
        "status": {
            "type": "string",
            "description": "Filter by status: pending, completed, all.",
            "default": "all",
        },
    },
    [],
)

# ── Dominic/Victor: Strategy Tools ───────────────────────────────────

TOOL_CONVENE_COUNCIL = _tool(
    "convene_council",
    "Convene a Mentor Council meeting on a topic. Assembles agents for multi-round deliberation.",
    {
        "topic": {"type": "string", "description": "The topic for the council meeting."},
        "council_type": {
            "type": "string",
            "description": "Type: strategic_review, operational_sync, scenario_workshop, full_council, ad_hoc.",
            "default": "ad_hoc",
        },
        "member_ids": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Specific agents to include. Leave empty for defaults.",
            "default": [],
        },
    },
    ["topic"],
)

# ── Liam/Henry: Sub-Agent & Targeted Delegation ─────────────────────

TOOL_TARGETED_BATCH = _tool(
    "targeted_batch_delegate",
    "Fan out DIFFERENT tasks to different agents in one batch. Use when each agent "
    "needs specific, tailored instructions rather than the same prompt. Provide a list "
    "of assignments, each with an agent_id and their specific task.",
    {
        "assignments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "agent_id": {"type": "string", "description": "Target agent_id."},
                    "task_description": {
                        "type": "string",
                        "description": "Specific task for THIS agent.",
                    },
                },
                "required": ["agent_id", "task_description"],
            },
            "description": "List of {agent_id, task_description} assignments.",
        },
        "context": {
            "type": "string",
            "description": "Shared context for all agents.",
            "default": "",
        },
    },
    ["assignments"],
)

TOOL_SPAWN_SUB_AGENT = _tool(
    "spawn_sub_agent",
    "Spawn a new temporary specialist sub-agent for a specific task. The sub-agent "
    "exists for the current session only. You MUST coordinate with Theo (prompt quality) "
    "and Henry (if technical) before spawning.",
    {
        "name": {"type": "string", "description": "Name for the sub-agent (e.g. 'James')."},
        "title": {"type": "string", "description": "Title/role (e.g. 'DevOps Engineer')."},
        "task_description": {"type": "string", "description": "What this agent should work on."},
        "tone": {
            "type": "string",
            "description": "Personality/tone for this agent.",
            "default": "professional and focused",
        },
        "expertise": {
            "type": "string",
            "description": "Domain expertise (e.g. 'Kubernetes, Docker, CI/CD').",
        },
    },
    ["name", "title", "task_description", "expertise"],
)

# ── Concord: Simulation Tools ────────────────────────────────────────

TOOL_RUN_SIMULATION = _tool(
    "run_simulation",
    "Run a what-if scenario simulation. Provide the scenario description, the "
    "simulation variables affected, baseline values (if known), and the proposed "
    "change. Returns a structured projection with impact analysis across metrics.",
    {
        "scenario_name": {"type": "string", "description": "Short name for this scenario."},
        "scenario_description": {
            "type": "string",
            "description": "Full description of the what-if scenario.",
        },
        "affected_variables": {
            "type": "array",
            "items": {"type": "string"},
            "description": "List of simulation variable names affected (e.g. ['Purpose Clarity', 'Focus Intensity']).",
        },
        "baselines": {
            "type": "object",
            "description": "Current baseline values for affected variables as {variable_name: score_0_to_100}.",
            "default": {},
        },
        "proposed_change": {
            "type": "string",
            "description": "What decision or change is being explored.",
        },
        "timeframe": {
            "type": "string",
            "description": "Timeframe for the projection (e.g. '30 days', '6 months').",
            "default": "90 days",
        },
    },
    ["scenario_name", "scenario_description", "affected_variables", "proposed_change"],
)

# ── Jake: Metrics Tools ──────────────────────────────────────────────

TOOL_SCORE_METRICS = _tool(
    "score_metrics",
    "Run a structured metric assessment. Provide the variables to score and "
    "assessment data (observations, self-reported answers). Returns calculated "
    "scores (0-100) for each variable with reasoning.",
    {
        "variables": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Simulation variables to score (e.g. ['Purpose Clarity', 'Emotional Resilience Factor']).",
        },
        "assessment_data": {
            "type": "object",
            "description": "Assessment inputs as {variable_name: {observations, self_report, evidence}}.",
        },
        "period": {
            "type": "string",
            "description": "Assessment period (e.g. 'past 7 days', 'this month').",
            "default": "past 7 days",
        },
    },
    ["variables", "assessment_data"],
)

# ── Web Search (for research-capable agents) ────────────────────────

TOOL_SUGGEST_MENTORS = _tool(
    "suggest_mentors",
    "Recommend 2-3 relevant mentor personas for the user based on career profile, therapy insights, "
    "sprint status, or current context. Returns mentor_ids with rationale for each suggestion.",
    {
        "career_profile": {
            "type": "string",
            "description": "Brief career context if known.",
            "default": "",
        },
        "therapy_insights": {
            "type": "string",
            "description": "Relevant therapy/growth insights.",
            "default": "",
        },
        "sprint_status": {
            "type": "string",
            "description": "Current cycle/sprint context.",
            "default": "",
        },
        "current_topic": {
            "type": "string",
            "description": "Topic or question the user is working on.",
            "default": "",
        },
    },
    [],
)

# ── Wave 8: Add Mentor (Victor, Sage) — add-only, never overwrite ──
TOOL_ADD_MENTOR = _tool(
    "add_mentor",
    "Create a NEW mentor config. Never overwrites existing mentors. Use when therapy, career, or vision "
    "suggests a new mentor persona would help the user. Rejects if mentor_id already exists.",
    {
        "mentor_id": {
            "type": "string",
            "description": "Unique ID (alphanumeric, underscores). Becomes filename: <mentor_id>.yaml.",
        },
        "name": {"type": "string", "description": "Display name of the mentor."},
        "archetype": {
            "type": "string",
            "description": "Short archetype label (e.g. Peaceful Warrior, Compassionate Teacher).",
        },
        "voice": {"type": "string", "description": "Tone description — how they speak."},
        "worldview": {"type": "string", "description": "1-2 sentence philosophy."},
        "expertise_areas": {
            "type": "array",
            "items": {"type": "string"},
            "description": "List of expertise areas.",
        },
        "communication_style": {"type": "string", "description": "How they communicate."},
        "famous_quotes": {
            "type": "array",
            "items": {"type": "string"},
            "description": "2-3 famous quotes.",
        },
        "suggested_topics": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Topics this mentor is good for.",
        },
    },
    [
        "mentor_id",
        "name",
        "archetype",
        "voice",
        "worldview",
        "expertise_areas",
        "communication_style",
        "famous_quotes",
        "suggested_topics",
    ],
)

TOOL_WEB_SEARCH = _tool(
    "web_search",
    "Search the web for real-world information. Use for researching current events, "
    "places, prices, recommendations, reviews, and any information that needs to be "
    "factual and up-to-date rather than generated from training data.",
    {
        "query": {"type": "string", "description": "The search query to look up on the web."},
        "num_results": {
            "type": "integer",
            "description": "Number of results to return.",
            "default": 5,
        },
    },
    ["query"],
)

# ── Henry: Technical Tools ───────────────────────────────────────────

TOOL_SEARCH_CODEBASE = _tool(
    "search_codebase",
    "Search the Vastian Network codebase for implementation details. "
    "Use when answering questions about how the system works technically.",
    {
        "query": {"type": "string", "description": "What to search for in the code."},
        "file_pattern": {
            "type": "string",
            "description": "Glob pattern to filter files, e.g. '*.py'",
            "default": "*.py",
        },
    },
    ["query"],
)

TOOL_READ_SOURCE_FILE = _tool(
    "read_source_file",
    "Read a source file from the Vastian Network codebase. Use for reviewing "
    "implementation details or planning improvements.",
    {
        "file_path": {
            "type": "string",
            "description": "Relative path from src/, e.g. 'vastian/tasks/queue.py'",
        },
    },
    ["file_path"],
)

TOOL_REVIEW_COUNCIL = _tool(
    "review_council_actions",
    "Review recent council meeting sessions — see topics, members, synthesis, and action items. "
    "Returns up to 5 sessions, prioritised by relevance to you (sessions where YOU have action "
    "items appear first, with YOUR actions highlighted). Action items assigned to you are marked "
    "with '<-- YOUR ACTION'. Use this to see what the council decided and what you need to do.",
    {
        "session_id": {
            "type": "string",
            "description": "Specific session ID to review, or leave empty to see recent sessions (prioritised by your relevance).",
            "default": "",
        },
        "delegate": {
            "type": "boolean",
            "description": "If true, auto-delegate all action items from the first session as background tasks.",
            "default": False,
        },
    },
    [],
)

# ── Maya: Meeting Signal Extraction ──────────────────────────────────

TOOL_EXTRACT_SIGNALS = _tool(
    "extract_signals",
    "Extract structured signals from meeting text: decisions, action_items, blockers, risks, ideas. "
    "Use when processing meeting transcripts or notes.",
    {
        "meeting_text": {
            "type": "string",
            "description": "The meeting transcript or notes text to analyze.",
        },
    },
    ["meeting_text"],
)

# ── Elias: DIKW Promotion Tools ──────────────────────────────────────

TOOL_PROMOTE_KNOWLEDGE = _tool(
    "promote_knowledge",
    "Promote a knowledge item up one DIKW level: Data->Information->Knowledge->Wisdom. "
    "Adds context, extracts patterns, or distills principles based on the target level.",
    {
        "content": {"type": "string", "description": "The knowledge content to promote."},
        "from_level": {
            "type": "string",
            "description": "Current DIKW level: data, information, knowledge.",
        },
        "to_level": {
            "type": "string",
            "description": "Target DIKW level: information, knowledge, wisdom.",
        },
    },
    ["content", "from_level", "to_level"],
)

TOOL_MERGE_INSIGHTS = _tool(
    "merge_insights",
    "Merge multiple related knowledge items into a single higher-level insight. "
    "Identifies common themes and synthesizes understanding.",
    {
        "items": {"type": "string", "description": "JSON array of content strings to merge."},
        "current_level": {"type": "string", "description": "Current DIKW level of the items."},
    },
    ["items", "current_level"],
)

TOOL_ASSESS_KNOWLEDGE_QUALITY = _tool(
    "assess_knowledge_quality",
    "Score a knowledge item on relevance (0-1), accuracy (0-1), and actionability (0-1). "
    "Suggests improvements if quality is low.",
    {
        "content": {"type": "string", "description": "The knowledge content to assess."},
        "level": {
            "type": "string",
            "description": "DIKW level: data, information, knowledge, wisdom.",
        },
    },
    ["content", "level"],
)

# ── Adrian: Persona Tools ────────────────────────────────────────────

TOOL_LOAD_PERSONA = _tool(
    "load_persona_config",
    "Load a persona archetype config from config/personas/archetypes/. Optional Ojas integration: "
    "use team_id and tier to resolve allies from config/personas/ojas/; include_ojas adds traits/virtues/dimensions.",
    {
        "persona_id": {
            "type": "string",
            "description": "Persona ID (1-12) or name fragment (e.g. 'integrator', 'creator').",
        },
        "include_ojas": {
            "type": "boolean",
            "description": "Include Ojas traits, virtues, dimensions.",
            "default": True,
        },
        "team_id": {
            "type": "integer",
            "description": "Ojas team_id to resolve allies (1-13).",
            "default": None,
        },
        "tier": {"type": "integer", "description": "Ojas ally tier (1-3).", "default": 1},
    },
    ["persona_id"],
)

TOOL_SCORE_PERSONA_TRAITS = _tool(
    "score_persona_traits",
    "Score a user's persona traits using the trait lookup tables. Returns personalized insights "
    "and suggestions based on their scores.",
    {
        "trait_scores": {
            "type": "string",
            "description": "JSON object of trait_name: score (0.0-1.0) pairs.",
        },
    },
    ["trait_scores"],
)

# ── Wave 6: Scenario config (Kiran, Concord) ──
TOOL_LOAD_SCENARIO_CONFIG = _tool(
    "load_scenario_config",
    "Load scenario domains and progression from config/scenarios/. Returns domains (for filtering) "
    "and progression (levels, points, embers). Optional: load challenges by team_id and tier.",
    {
        "include_domains": {
            "type": "boolean",
            "description": "Include domains from domains.yaml.",
            "default": True,
        },
        "include_progression": {
            "type": "boolean",
            "description": "Include progression (levels, points).",
            "default": True,
        },
        "team_id": {
            "type": "integer",
            "description": "Optional: load challenges for this Ojas team_id.",
        },
        "tier": {
            "type": "integer",
            "description": "Optional: ally tier for challenges.",
            "default": 1,
        },
    },
    [],
)

# ── Metrics config (Jake, Kiran) ──
TOOL_LOAD_METRICS_CONFIG = _tool(
    "load_metrics_config",
    "Load metric definitions and baselines from config/metrics/. Jake: non-Ojas metrics. Kiran: Ojas metrics.",
    {
        "owner": {
            "type": "string",
            "description": "Filter by owner: jake (validation metrics) or kiran (Ojas metrics). Omit for all.",
        },
        "include_baselines": {
            "type": "boolean",
            "description": "Include default baselines and thresholds.",
            "default": True,
        },
    },
    [],
)

# ── Arjuna: Ticket Decomposition ─────────────────────────────────────

TOOL_DECOMPOSE_TASK = _tool(
    "decompose_task",
    "Break a ticket into subtasks with time estimates. Use when a task is too large "
    "for a single sprint or needs finer-grained tracking.",
    {
        "title": {"type": "string", "description": "The parent task title."},
        "description": {"type": "string", "description": "The task description to decompose."},
    },
    ["title", "description"],
)

TOOL_SPRINT_HEALTH = _tool(
    "sprint_health",
    "Check the health of the current sprint: burndown, blocked tickets, velocity. "
    "Returns a health report.",
    {},
    [],
)

# ── Sage: Career Tools ──────────────────────────────────────────────

TOOL_CAREER_PROFILE = _tool(
    "career_profile_update",
    "Update the user's career profile or therapy insights. Career: role, goals, strengths, achievements. "
    "Therapy (routes to Adrian for persona evolution): triggers, patterns, growth_themes, persona_resonance. "
    "Reads and updates docs/sage/career-profile.md or docs/sage/therapy-insights.md.",
    {
        "field": {
            "type": "string",
            "description": "Profile field: current_role, target_role, strengths, goals, interests, achievements, triggers, patterns, growth_themes, persona_resonance.",
        },
        "value": {"type": "string", "description": "New value for the field."},
    },
    ["field", "value"],
)

TOOL_GROWTH_SUGGESTION = _tool(
    "growth_suggestion",
    "Generate a personalized growth suggestion based on career profile and trait scores. "
    "Uses career coach prompts from SignalFlow.",
    {
        "context": {
            "type": "string",
            "description": "Current context or question from the user about their growth.",
        },
    },
    ["context"],
)

TOOL_SUGGEST_KANBAN_TASK = _tool(
    "suggest_kanban_task",
    "Suggest a task for the Kanban board. The user will see it and decide whether "
    "to approve it. Use this for improvements, refactors, or new features you identify. "
    "When suggesting a task, choose ONE board: company=Maria/business (REL-), user=user-facing (USR-), "
    "comms=meetings (MSG-), agent=internal (CYC-), ops=Orion/codebase (OPS-). Do not mix boards.",
    {
        "title": {
            "type": "string",
            "description": "Short title for the task (shown on the board).",
        },
        "description": {
            "type": "string",
            "description": "Detailed description of what should be done and why.",
        },
        "category": {
            "type": "string",
            "description": "Category: improvement, bugfix, feature, refactor, documentation.",
            "default": "improvement",
        },
        "priority": {
            "type": "string",
            "description": "Priority: high, normal, low.",
            "default": "normal",
        },
        "assigned_to": {
            "type": "string",
            "description": "Agent best suited to do this work (agent_id).",
            "default": "",
        },
        "board": {
            "type": "string",
            "description": "Board: user (USR-), comms (MSG-), company (REL-), agent (CYC-), ops (OPS-). Default: agent.",
            "default": "agent",
        },
        "tags": {
            "type": "string",
            "description": "Comma-separated tags for categorization (optional).",
            "default": "",
        },
    },
    ["title", "description"],
)

# ── Kanban Management tool (for Operations — Orion) ──

TOOL_MANAGE_KANBAN = _tool(
    "manage_kanban_task",
    "Manage an existing Kanban task: archive duplicates, reject invalid suggestions, "
    "or add notes. Use this to consolidate duplicate tasks or clean up the board. "
    "Only Orion (Operations Lead) should use this tool.",
    {
        "kanban_id": {"type": "string", "description": "The Kanban task ID to act on."},
        "action": {
            "type": "string",
            "description": "Action to take: 'archive' (remove duplicate), 'reject' (invalid suggestion), "
            "'add_note' (append operational note to the task).",
        },
        "reason": {
            "type": "string",
            "description": "Reason for the action (e.g., 'Duplicate of KBN-abc123').",
            "default": "",
        },
    },
    ["kanban_id", "action"],
)

# ── Cursor Session Protocol: Request tool (agents can request from Cursor) ──

TOOL_CREATE_REQUEST = _tool(
    "create_request",
    "Create a request for the external architect (Cursor). Use this when you need "
    "code changes, new tools, architecture decisions, or anything that requires the "
    "coding agent to implement. The request will appear when Cursor runs "
    "'vastian request list'. Other agents can be notified to weigh in.",
    {
        "topic": {"type": "string", "description": "Short title for the request."},
        "details": {
            "type": "string",
            "description": "Full description of what you need and why.",
        },
        "notify_agents": {
            "type": "string",
            "description": "Comma-separated agent IDs to loop in (optional).",
            "default": "",
        },
    },
    ["topic", "details"],
)

# ── Mercury Banking Tools (for Finley and approved agents) ───────────

TOOL_CHECK_BALANCE = _tool(
    "check_bank_balance",
    "Check bank account balances on Mercury. Pass account_id='all' (or leave empty) "
    "to see balances across ALL accounts. Pass a specific account ID to check just "
    "that one. Returns account name, balance, available balance, and status. Read-only.",
    {
        "account_id": {
            "type": "string",
            "description": "Mercury account ID, or 'all' to see every account. "
            "Leave empty to default to all accounts.",
            "default": "all",
        },
    },
    [],
)

TOOL_LIST_TRANSACTIONS = _tool(
    "list_bank_transactions",
    "List recent bank transactions from Mercury. Returns date, amount, counterparty, "
    "type, and status for each transaction. Read-only. Works on any account.",
    {
        "account_id": {
            "type": "string",
            "description": "Mercury account ID. Leave empty to use the primary account.",
            "default": "",
        },
        "limit": {
            "type": "integer",
            "description": "Number of transactions to return.",
            "default": 10,
        },
        "search": {
            "type": "string",
            "description": "Optional keyword to filter transactions.",
            "default": "",
        },
    },
    [],
)

TOOL_CREATE_TRANSACTION = _tool(
    "create_bank_transaction",
    "Request a bank payment / transfer via Mercury. This does NOT execute immediately — "
    "it creates a pending approval that the user must confirm with /approve <id>. "
    "Use for paying vendors, contractors, or making transfers.",
    {
        "recipient_name": {"type": "string", "description": "Name of the payment recipient."},
        "amount": {"type": "number", "description": "Payment amount in USD."},
        "payment_method": {
            "type": "string",
            "description": "Payment method: ach, domesticWire, internationalWire, or check.",
            "default": "ach",
        },
        "note": {
            "type": "string",
            "description": "Optional note / memo for the transaction.",
            "default": "",
        },
    },
    ["recipient_name", "amount"],
)

# ── Proactive Agent Outreach ────────────────────────────────────────

TOOL_REQUEST_USER_INPUT = _tool(
    "request_user_input",
    "Request input from the user when you need clarification, a decision, or additional "
    "information to proceed with your work. The user will be notified and can respond "
    "at their convenience. Use this instead of guessing when you lack critical info.",
    {
        "subject": {"type": "string", "description": "Brief topic (shown in notification)."},
        "message": {
            "type": "string",
            "description": "Your question or request, written conversationally as if speaking to the user.",
        },
        "priority": {
            "type": "string",
            "description": "Priority: critical (interrupt immediately), high (interrupt when idle), "
            "normal (shown at next check-in), low (can wait).",
            "default": "normal",
        },
    },
    ["subject", "message"],
)

TOOL_SCHEDULE_OUTREACH = _tool(
    "schedule_outreach",
    "Schedule a follow-up with the user for a specific future date. Use this when the user "
    "says 'remind me on Monday' or 'follow up next week' or any time they ask you to reach "
    "out later. The outreach will be delivered on the scheduled date when the user is online.",
    {
        "subject": {"type": "string", "description": "Brief topic for the reminder."},
        "message": {
            "type": "string",
            "description": "The message to deliver on the scheduled date. Be specific about what you need.",
        },
        "scheduled_date": {
            "type": "string",
            "description": "When to deliver: YYYY-MM-DD format (e.g., '2026-02-10' for Feb 10).",
        },
        "priority": {
            "type": "string",
            "enum": ["critical", "high", "normal", "low"],
            "description": "Priority level. High = delivered when user is idle. Normal = shown at /check-in.",
        },
    },
    ["subject", "message", "scheduled_date"],
)

# ── Jupiter J2: Send Message (SMS/email via Zo) ──────────────────────
TOOL_SEND_MESSAGE = _tool(
    "send_message",
    "Send an SMS or email to the user via the configured messaging provider (Twilio or Zo). "
    "Use for career insights, daily briefings, or urgent outreach.",
    {
        "channel": {
            "type": "string",
            "enum": ["sms", "email"],
            "description": "Delivery channel: sms for quick alerts, email for longer content.",
        },
        "body": {
            "type": "string",
            "description": "Message content (plain text or markdown for email).",
        },
        "subject": {
            "type": "string",
            "description": "Email subject line (required for channel=email, ignored for sms).",
        },
    },
    ["channel", "body"],
)

# ── Giga Bridge Tools (write to outbox) ────────────────────────────

TOOL_WRITE_GIGA_PLAN = _tool(
    "write_giga_plan",
    "Write a plan to the Giga outbox for syncing to the universal memory layer. "
    "Use for cycle updates, phase progress, or any structured plan with task checkboxes.",
    {
        "key_name": {"type": "string", "description": "Giga plan key (e.g., 'cycle-review-feb')."},
        "content": {
            "type": "string",
            "description": "Markdown content with task checkboxes (e.g., '- [x] Done\\n- [ ] Pending').",
        },
        "mind": {
            "type": "string",
            "description": "Target Giga mind. Default: 'vastian-network'.",
            "default": "vastian-network",
        },
    },
    ["key_name", "content"],
)

TOOL_WRITE_GIGA_NEURON = _tool(
    "write_giga_neuron",
    "Write a knowledge neuron to the Giga outbox for syncing to universal memory. "
    "Use for architecture decisions, process improvements, agent hierarchy updates, "
    "or any insight that should persist across sessions and be available to all coding agents.",
    {
        "title": {
            "type": "string",
            "description": "Short label for the neuron (e.g., 'Agent hierarchy update').",
        },
        "body": {
            "type": "string",
            "description": "The knowledge content — prompt-ready instruction or fact.",
        },
        "selector_nl": {
            "type": "string",
            "description": "When to surface this neuron (natural language hint for relevance matching).",
            "default": "",
        },
        "mind": {
            "type": "string",
            "description": "Target Giga mind. Default: 'vastian-network'.",
            "default": "vastian-network",
        },
    },
    ["title", "body"],
)

TOOL_READ_RELAY_OUTBOX = _tool(
    "read_relay_outbox",
    "Read relay report JSON files from state/relay/outbox/. Use this to aggregate "
    "backend agent reports (Henry, Theo, Orion, Elias, Adrian, Jake, Kiran, Felix) "
    "and create REL- tickets via suggest_kanban_task.",
    {},
    [],
)

TOOL_WRITE_RELAY_REPORT = _tool(
    "write_relay_report",
    "Submit a report to the Relay outbox for Maria to triage. Use this instead of "
    "suggest_kanban_task when you are a backend agent (Theo: category 'prompts'; "
    "Orion: category 'ops'). Maria reads the outbox and creates REL- tickets.",
    {
        "category": {
            "type": "string",
            "description": "Category: prompts, ops, ux, pulse, personas, metrics, scenarios, knowledge.",
        },
        "summary": {
            "type": "string",
            "description": "Short summary of the finding or improvement (for REL- ticket title/body).",
        },
        "payload": {
            "type": "object",
            "description": "Optional extra data (e.g. agent_id, file_path, severity).",
            "default": {},
        },
    },
    ["category", "summary"],
)

# ── L7: Config change suggestion (Theo, Orion, Henry) → relay outbox config/ ──
TOOL_SUGGEST_CONFIG_CHANGE = _tool(
    "suggest_config_change",
    "Propose a configuration change for user approval. Writes to state/relay/outbox/config/. "
    "BEFORE proposing: read shared/config-options-overview.md for valid values per field — "
    "the app rejects invalid options. Maria triages and creates config cards; user Accept/Reject in UI. "
    "For list fields (e.g. responsibilities), use list_operation + list_item instead of proposed_value.",
    {
        "config_file": {
            "type": "string",
            "description": "Config file path (e.g. config/automation.yaml, agents/theo.yaml).",
        },
        "field": {
            "type": "string",
            "description": "Field or key to change (e.g. responsibilities, or nested key).",
        },
        "current_value": {"type": "string", "description": "Current value (for display)."},
        "proposed_value": {
            "type": "string",
            "description": "Proposed new value (scalar). Omit when using list_operation.",
        },
        "reason": {"type": "string", "description": "Why this change is recommended."},
        "list_operation": {
            "type": "string",
            "description": "For list fields only: 'add' or 'remove' to merge a single item.",
        },
        "list_item": {
            "type": "string",
            "description": "The single item to add or remove (used with list_operation).",
        },
    },
    ["config_file", "field", "reason"],
)

# ── L7: Maria queues config cards for next session ──
TOOL_QUEUE_CONFIG_CARD = _tool(
    "queue_config_card",
    "Queue a config approval card for the next session. Use when triaging relay outbox "
    "reports with category 'config'. The user will see Accept/Reject in the session UI. "
    "Pass list_operation and list_item from payload when present (for responsibilities add/remove).",
    {
        "config_file": {"type": "string", "description": "Config file path."},
        "field": {"type": "string", "description": "Field or key to change."},
        "current_value": {"type": "string", "description": "Current value."},
        "proposed_value": {"type": "string", "description": "Proposed value (scalar)."},
        "reason": {"type": "string", "description": "Reason for the change."},
        "list_operation": {"type": "string", "description": "For list merge: 'add' or 'remove'."},
        "list_item": {"type": "string", "description": "Single item to add or remove."},
    },
    ["config_file", "field", "reason"],
)

# ── Felix: Data Lifecycle Tools ───────────────────────────────────

TOOL_CHECK_SEED_VERSION = _tool(
    "check_seed_version",
    "Check the current seed version and list any pending migrations. "
    "Returns the version from state/seed-version.json and whether migrations are needed.",
    {},
    [],
)

TOOL_RUN_DATA_MIGRATIONS = _tool(
    "run_data_migrations",
    "Run any pending data migrations for the current user. "
    "This updates state/seed-version.json and applies non-declarative changes.",
    {},
    [],
)

TOOL_CHECK_DATA_INTEGRITY = _tool(
    "check_data_integrity",
    "Verify data integrity: check that all agent docs exist (doc self-heal), "
    "state JSON files have required fields (state fixups), and config seed is installed.",
    {},
    [],
)

TOOL_GET_POOL_STATUS = _tool(
    "get_pool_status",
    "Get the worker pool status (cloud only): active slots, user IDs, idle times, "
    "and whether the default user orchestrator is initialized.",
    {},
    [],
)

# ── Structured Question (agent → user interactive choice card) ────────

TOOL_ASK_STRUCTURED_QUESTION = _tool(
    "ask_structured_question",
    "Present the user with a question in the chat UI. Two modes:\n"
    "1) MULTIPLE-CHOICE: Provide options (clickable buttons) + optional free-text 'Other...'. "
    "Use when reasonable options exist (preferences, choices, categorical answers).\n"
    "2) FREE-ENTRY: Set free_entry_only=true and omit options. Shows only a text input. "
    "Use when guessing options would be wrong (names, numbers, dates, descriptions, stories, "
    "personal details where options would feel presumptuous).",
    {
        "question": {
            "type": "string",
            "description": "The question text to display to the user.",
        },
        "options": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "id": {"type": "string", "description": "Short unique key (e.g. 'opt_a')"},
                    "label": {"type": "string", "description": "Human-readable option text"},
                },
                "required": ["id", "label"],
            },
            "description": "2-6 answer options for the user to choose from. Omit when free_entry_only=true.",
        },
        "free_entry_only": {
            "type": "boolean",
            "description": "If true, show ONLY a free-text input (no option buttons). "
            "Use for questions where guessing options is wrong: names, numbers, dates, "
            "open-ended descriptions, or personal details. Default false.",
        },
        "allow_custom": {
            "type": "boolean",
            "description": "If true (default), show an 'Other...' free-text option alongside buttons. "
            "Ignored when free_entry_only=true.",
        },
        "context": {
            "type": "string",
            "description": "Optional brief context shown above the question to explain why you are asking.",
        },
        "question_id": {
            "type": "string",
            "description": "Optional stable ID for this question (used for interview progress tracking).",
        },
    },
    ["question"],
)


# ── Suggestion tools (agent → user notification/approval) ────────────

_SUGGEST_PROPS = {
    "title": {"type": "string", "description": "Short title for the suggestion"},
    "description": {
        "type": "string",
        "description": "Detailed description of what is being suggested and why",
    },
}

TOOL_SUGGEST_ACTIVITY = _tool(
    "suggest_activity",
    "Suggest an activity for the user. Goes through notification/approval flow.",
    {
        **_SUGGEST_PROPS,
        "payload": {
            "type": "object",
            "description": "Optional activity config (duration, type, etc.)",
        },
    },
    ["title", "description"],
)

TOOL_SUGGEST_CHALLENGE = _tool(
    "suggest_challenge",
    "Suggest a personal challenge for the user.",
    {**_SUGGEST_PROPS, "payload": {"type": "object", "description": "Optional challenge config"}},
    ["title", "description"],
)

TOOL_SUGGEST_COUNCIL = _tool(
    "suggest_council",
    "Suggest a council session topic for the user.",
    {
        **_SUGGEST_PROPS,
        "council_type": {
            "type": "string",
            "description": "Type of council (mentor, advisory, etc.)",
        },
    },
    ["title", "description"],
)

TOOL_SUGGEST_MENTOR = _tool(
    "suggest_mentor_addition",
    "Suggest adding a mentor to the user's council.",
    {
        **_SUGGEST_PROPS,
        "mentor_data": {
            "type": "object",
            "description": "Mentor details (name, expertise, archetype)",
        },
    },
    ["title", "description"],
)

TOOL_SUGGEST_PLAN = _tool(
    "suggest_plan",
    "Suggest a health or spiritual plan for the user.",
    {
        **_SUGGEST_PROPS,
        "plan_type": {"type": "string", "description": "health or spiritual"},
        "plan_id": {"type": "string", "description": "ID of the plan to suggest"},
    },
    ["title", "description"],
)

TOOL_SUGGEST_SCENARIO = _tool(
    "suggest_scenario",
    "Suggest a what-if scenario for the user to explore.",
    {
        **_SUGGEST_PROPS,
        "scenario_config": {"type": "object", "description": "Scenario configuration"},
    },
    ["title", "description"],
)

TOOL_SUGGEST_ROLEPLAY = _tool(
    "suggest_roleplay",
    "Suggest a persona roleplay session for the user (chat-based, shows in notification bell).",
    {**_SUGGEST_PROPS, "persona_id": {"type": "string", "description": "Persona to roleplay with"}},
    ["title", "description"],
)

TOOL_SUGGEST_REAL_COACH = _tool(
    "suggest_real_coach",
    "Suggest the user touch base with a real-world coach (career, life, health, or spiritual). "
    "Shows in notification bell. On accept, user selects from a curated coach bank.",
    {
        **_SUGGEST_PROPS,
        "coach_type": {
            "type": "string",
            "enum": ["career_coach", "life_coach", "health_coach", "spiritual_mentor"],
            "description": "Type of real-world coach to suggest",
        },
        "context": {
            "type": "string",
            "description": "Relevant user context for why this is suggested",
        },
    },
    ["title", "description", "coach_type"],
)

TOOL_CHECK_USER_ENGAGEMENT = _tool(
    "check_user_engagement",
    "Check user engagement metrics: last activity, doc creation rate, chat frequency, session count.",
    {},
    [],
)

TOOL_CHECK_ROADMAP_PROGRESS = _tool(
    "check_roadmap_progress",
    "Check roadmap/kanban progress: completion rates, blocked tasks, milestone status.",
    {},
    [],
)

TOOL_ALERT_INACTIVITY = _tool(
    "alert_inactivity",
    "Create a notification alert if the user has been inactive for too long.",
    {
        "days_threshold": {
            "type": "integer",
            "description": "Number of inactive days to trigger alert",
            "default": 3,
        }
    },
    [],
)

# ══════════════════════════════════════════════════════════════════════
# AGENT TOOL REGISTRY — maps agent_id to their available tools
# ══════════════════════════════════════════════════════════════════════

BASE_TOOLS = [
    TOOL_DELEGATE,
    TOOL_ASK_COLLEAGUE,
    TOOL_READ_DOC,
    TOOL_REQUEST_USER_INPUT,
    TOOL_SCHEDULE_OUTREACH,
    TOOL_ASK_STRUCTURED_QUESTION,
]

AGENT_TOOL_REGISTRY: dict[str, list[dict]] = {
    # ── Liam: Execution Manager ──────────────────────
    "liam": BASE_TOOLS
    + [
        TOOL_BATCH_DELEGATE,
        TOOL_TARGETED_BATCH,
        TOOL_WRITE_DOC,
        TOOL_SPAWN_SUB_AGENT,
        TOOL_SUGGEST_KANBAN_TASK,
        TOOL_CREATE_TRANSACTION,
        TOOL_CREATE_REQUEST,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
    ],
    # ── Charles: Chief of Staff ──────────────────────
    "charles": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_GET_NETWORK_STATUS,
        TOOL_GET_TASKS,
        TOOL_SUGGEST_KANBAN_TASK,
        TOOL_REVIEW_COUNCIL,
        TOOL_WEB_SEARCH,
        TOOL_CREATE_TRANSACTION,
        TOOL_SEND_MESSAGE,
    ],
    # ── Elias: Knowledge & Memory ────────────────────
    "elias": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_SEARCH_KNOWLEDGE,
        TOOL_STORE_INSIGHT,
        TOOL_WEB_SEARCH,
        TOOL_PROMOTE_KNOWLEDGE,
        TOOL_MERGE_INSIGHTS,
        TOOL_ASSESS_KNOWLEDGE_QUALITY,
    ],
    # ── Theo: Prompt Architecture & QA (reports via Relay, not Kanban) ──
    "theo": BASE_TOOLS
    + [
        TOOL_READ_AGENT_CONFIG,
        TOOL_SUGGEST_PROMPT_FIX,
        TOOL_WRITE_DOC,
        TOOL_SEARCH_KNOWLEDGE,
        TOOL_WRITE_RELAY_REPORT,
        TOOL_SUGGEST_CONFIG_CHANGE,
    ],
    # ── Dominic: Strategic Mentor ────────────────────
    "dominic": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_CONVENE_COUNCIL,
        TOOL_SUGGEST_KANBAN_TASK,
        TOOL_WEB_SEARCH,
        TOOL_CREATE_REQUEST,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
        TOOL_SUGGEST_COUNCIL,
    ],
    # ── Victor: Vision ───────────────────────────────
    "victor": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_CONVENE_COUNCIL,
        TOOL_WEB_SEARCH,
        TOOL_SUGGEST_KANBAN_TASK,
        TOOL_SUGGEST_MENTORS,
        TOOL_ADD_MENTOR,
        TOOL_CREATE_REQUEST,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
        TOOL_SUGGEST_COUNCIL,
        TOOL_SUGGEST_MENTOR,
        TOOL_SUGGEST_REAL_COACH,
    ],
    # ── Orion: Operations + Codebase Improvement (reports via Relay; keeps manage_kanban for dedup) ──
    "orion": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_GET_NETWORK_STATUS,
        TOOL_SEARCH_CODEBASE,
        TOOL_READ_SOURCE_FILE,
        TOOL_WRITE_RELAY_REPORT,
        TOOL_SUGGEST_CONFIG_CHANGE,
        TOOL_MANAGE_KANBAN,
        TOOL_CREATE_REQUEST,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
    ],
    # ── Henry: Technical Lead ────────────────────────
    "henry": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_SEARCH_CODEBASE,
        TOOL_READ_SOURCE_FILE,
        TOOL_SPAWN_SUB_AGENT,
        TOOL_WEB_SEARCH,
        TOOL_CREATE_REQUEST,
        TOOL_SUGGEST_CONFIG_CHANGE,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
    ],
    # ── Finley: Financial ────────────────────────────
    "finley": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_CHECK_BALANCE,
        TOOL_LIST_TRANSACTIONS,
        TOOL_CREATE_TRANSACTION,
    ],
    # ── Founder Digital Twin ────────────────────────
    "rowan": BASE_TOOLS + [TOOL_WRITE_DOC, TOOL_CONVENE_COUNCIL, TOOL_SUGGEST_COUNCIL],
    # ── Concord: Scenario Simulation ────────────────────
    "concord": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_SEARCH_KNOWLEDGE,
        TOOL_RUN_SIMULATION,
        TOOL_LOAD_SCENARIO_CONFIG,
        TOOL_SUGGEST_SCENARIO,
    ],
    # ── Jake: Metrics & Validation ───────────────────
    "jake": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_SEARCH_KNOWLEDGE,
        TOOL_SCORE_METRICS,
        TOOL_SUGGEST_CONFIG_CHANGE,
        TOOL_LOAD_METRICS_CONFIG,
    ],
    # ── Adrian: Persona Guide ────────────────────────
    "adrian": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_LOAD_PERSONA,
        TOOL_SCORE_PERSONA_TRAITS,
        TOOL_SUGGEST_CONFIG_CHANGE,
        TOOL_SUGGEST_ACTIVITY,
        TOOL_SUGGEST_CHALLENGE,
        TOOL_SUGGEST_ROLEPLAY,
    ],
    # ── Maya: Meeting Facilitator ────────────────────
    "maya": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_EXTRACT_SIGNALS,
        TOOL_SUGGEST_KANBAN_TASK,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
    ],
    # ── Sage: Mentor Guide ───────────────────────────
    "sage": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_SEARCH_KNOWLEDGE,
        TOOL_CAREER_PROFILE,
        TOOL_GROWTH_SUGGESTION,
        TOOL_SEND_MESSAGE,
        TOOL_ADD_MENTOR,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
        TOOL_SUGGEST_PLAN,
        TOOL_SUGGEST_REAL_COACH,
    ],
    # ── Arjuna: Ticket Master ────────────────────────
    "arjuna": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_GET_TASKS,
        TOOL_SUGGEST_KANBAN_TASK,
        TOOL_DECOMPOSE_TASK,
        TOOL_SPRINT_HEALTH,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
    ],
    # ── Kiran: Scenario Lab Lead ─────────────────────
    "kiran": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_WEB_SEARCH,
        TOOL_SCORE_METRICS,
        TOOL_LOAD_SCENARIO_CONFIG,
        TOOL_LOAD_METRICS_CONFIG,
        TOOL_SUGGEST_CONFIG_CHANGE,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
        TOOL_SUGGEST_ACTIVITY,
        TOOL_SUGGEST_CHALLENGE,
        TOOL_SUGGEST_COUNCIL,
    ],
    # ── Noah: Creative Director ─────────────────────
    "noah": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
    ],
    # ── Maria: UX/Product & Backend Workflow Lead ────
    "maria": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_GET_TASKS,
        TOOL_SUGGEST_KANBAN_TASK,
        TOOL_SPAWN_SUB_AGENT,
        TOOL_READ_RELAY_OUTBOX,
        TOOL_QUEUE_CONFIG_CARD,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
    ],
    # ── Felix: Network Pulse & Stats Lead ───────────
    "felix": BASE_TOOLS
    + [
        TOOL_WRITE_DOC,
        TOOL_GET_NETWORK_STATUS,
        TOOL_GET_TASKS,
        TOOL_WRITE_GIGA_PLAN,
        TOOL_WRITE_GIGA_NEURON,
        TOOL_CHECK_SEED_VERSION,
        TOOL_RUN_DATA_MIGRATIONS,
        TOOL_CHECK_DATA_INTEGRITY,
        TOOL_GET_POOL_STATUS,
        TOOL_CHECK_USER_ENGAGEMENT,
        TOOL_CHECK_ROADMAP_PROGRESS,
        TOOL_ALERT_INACTIVITY,
    ],
}

# Human-readable tool descriptions for /tools command
TOOL_DESCRIPTIONS: dict[str, str] = {
    "delegate_to_agent": "Delegate a task to another agent",
    "ask_colleague": "Ask a colleague for quick input or advice",
    "read_document": "Read any managed document",
    "write_document": "Write/update owned documents",
    "batch_delegate": "Fan out the SAME task to multiple agents",
    "targeted_batch_delegate": "Fan out DIFFERENT tasks to different agents",
    "search_knowledge": "Semantic search the Knowledge Bank",
    "store_insight": "Store knowledge/insights with tags",
    "read_agent_config": "Read another agent's YAML config for audit",
    "suggest_prompt_fix": "Log a prompt improvement for an agent",
    "get_network_status": "Get full network status (agents, teams, states)",
    "get_tasks": "View tasks and delegations",
    "convene_council": "Convene a Mentor Council meeting",
    "suggest_mentors": "Recommend relevant mentor personas for user's context (career, therapy, sprint)",
    "add_mentor": "Create a new mentor config (Victor, Sage) — add-only, rejects if exists",
    "review_council_actions": "Review council meeting results and delegate action items",
    "search_codebase": "Search the Vastian codebase for implementation details",
    "read_source_file": "Read a source file from the codebase",
    "spawn_sub_agent": "Spawn a temporary specialist sub-agent (Liam/Henry)",
    "run_simulation": "Run a what-if scenario simulation (Concord)",
    "score_metrics": "Score simulation variables via structured assessment (Jake)",
    "extract_signals": "Extract structured signals from meeting text (decisions, action items, blockers, risks, ideas)",
    "promote_knowledge": "Promote knowledge up the DIKW pyramid (Data->Information->Knowledge->Wisdom)",
    "merge_insights": "Merge multiple related knowledge items into a unified higher-level insight",
    "assess_knowledge_quality": "Score a knowledge item on relevance, accuracy, and actionability",
    "load_persona_config": "Load a persona archetype config (traits, scores, tools) with optional Ojas allies",
    "score_persona_traits": "Score persona traits and get personalized insights",
    "load_scenario_config": "Load scenario domains, progression, challenges (Kiran, Concord)",
    "load_metrics_config": "Load metric definitions and baselines (Jake, Kiran)",
    "decompose_task": "Break a ticket into subtasks with time estimates",
    "sprint_health": "Check sprint health: burndown, blocked tickets, velocity",
    "career_profile_update": "Update the user's career profile (role, goals, strengths)",
    "growth_suggestion": "Generate personalized growth suggestions based on career profile",
    "suggest_kanban_task": "Suggest a task for the user's Kanban board",
    "manage_kanban_task": "Archive, reject, or annotate Kanban tasks (Orion — operations)",
    "create_request": "Create a request for the external architect (Cursor)",
    "web_search": "Search the web for real-world information",
    "check_bank_balance": "Check Mercury bank account balance (Finley)",
    "list_bank_transactions": "List recent Mercury bank transactions (Finley)",
    "create_bank_transaction": "Request a bank payment (requires user approval)",
    "request_user_input": "Ask the user a question when you need more information",
    "schedule_outreach": "Schedule a follow-up with the user for a specific future date",
    "ask_structured_question": "Present a multiple-choice question card for the user to select from",
    "send_message": "Send SMS or email to user via Zo (career insights, briefings, urgent outreach)",
    "write_giga_plan": "Write a plan to the Giga outbox for universal memory sync",
    "write_giga_neuron": "Write a knowledge neuron to the Giga outbox for universal memory sync",
    "read_relay_outbox": "Read relay report JSONs from state/relay/outbox/ (Maria — aggregation)",
    "write_relay_report": "Submit report to Relay outbox for Maria to triage (Theo/Orion — replaces Kanban for their output)",
    "suggest_config_change": "Propose a config change for user approval (Theo/Orion/Henry/Jake/Kiran — writes to relay outbox config/)",
    "queue_config_card": "Queue a config approval card for the next session (Maria — from relay config reports)",
    "check_seed_version": "Check seed version and pending migrations (Felix)",
    "run_data_migrations": "Run pending data migrations after code update (Felix)",
    "check_data_integrity": "Verify config, docs, and state integrity (Felix)",
    "get_pool_status": "Get worker pool status: active users, slots, idle times (Felix — cloud only)",
    "suggest_activity": "Suggest an activity for the user (Adrian, Kiran)",
    "suggest_challenge": "Suggest a personal challenge (Adrian, Kiran)",
    "suggest_council": "Suggest a council session topic (Victor, Kiran, Dominic, Twin)",
    "suggest_mentor_addition": "Suggest adding a mentor (Victor)",
    "suggest_plan": "Suggest a health/spiritual plan (Sage)",
    "suggest_scenario": "Suggest a what-if scenario (Concord)",
    "suggest_roleplay": "Suggest a persona roleplay session (Adrian)",
    "suggest_real_coach": "Suggest touching base with a real-world coach (Sage, Victor)",
    "check_user_engagement": "Check user engagement metrics (Felix)",
    "check_roadmap_progress": "Check roadmap/kanban progress (Felix)",
    "alert_inactivity": "Create inactivity alert notification (Felix)",
}


def get_tools_for_agent(agent_id: str) -> list[dict]:
    """Get the tool definitions for a specific agent."""
    return AGENT_TOOL_REGISTRY.get(agent_id, BASE_TOOLS)


def get_tool_names_for_agent(agent_id: str) -> list[str]:
    """Get human-readable tool names for an agent."""
    tools = get_tools_for_agent(agent_id)
    return [t["function"]["name"] for t in tools]


# ══════════════════════════════════════════════════════════════════════
# HIERARCHY — maps agent_id to their manager for kanban review gates
# Theo and Orion no longer suggest Kanban tasks; they report via Relay (write_relay_report).
# They remain in this dict for any legacy/approval path; their output is triaged by Maria.
# ══════════════════════════════════════════════════════════════════════

AGENT_MANAGER: dict[str, str] = {
    "charles": "rowan",
    "victor": "rowan",
    "dominic": "rowan",
    "orion": "rowan",
    "finley": "rowan",
    "concord": "rowan",
    "liam": "dominic",
    "theo": "dominic",
    "elias": "dominic",
    "henry": "dominic",
    "adrian": "victor",
    "jake": "concord",
    "rowan": "",  # top of chain — user reviews directly
}


def get_manager_for_agent(agent_id: str) -> str:
    """Get the manager agent_id for a given agent. Empty string = no manager (top-level)."""
    return AGENT_MANAGER.get(agent_id, "")


class Orchestrator:
    """
    Central coordinator for the Vastian Network.

    Responsibilities:
    - Initialize all subsystems
    - Route user requests to appropriate agents
    - Manage agent lifecycle
    - Handle escalations
    - Convene Mentor Council meetings
    - Maintain session context
    """

    def __init__(self, settings: Settings | None = None, *, user_id: str | None = None) -> None:
        self.settings = settings or Settings()
        # Multi-user: use explicit user_id when provided (cloud worker pool),
        # fall back to user_name for single-user desktop mode.
        self.current_user_id = user_id or self.settings.user_name or "user"
        self.bus = MessageBus()
        self.registry = AgentRegistry()
        self.knowledge: KnowledgeStore | None = None
        self.llm: BaseLLMProvider | None = None
        self._alt_providers: dict[str, BaseLLMProvider] = {}  # keyed by provider name
        self.model_router: ModelRouter | None = None  # multi-provider router
        self.council: MentorCouncilEngine | None = None
        self.router: IntentRouter | None = None
        self._agent_tasks: dict[str, asyncio.Task] = {}
        self._running = False
        # Background task queue (persistent, survives CLI exit)
        self.task_queue: TaskQueue | None = None
        # Inbox watcher for file-based async updates
        self.inbox_watcher: InboxWatcher | None = None
        # Session journal for cross-machine continuity
        from vastian.storage.file_store import FileStore

        self._session_journal = FileStore(
            self.settings.state_dir / "session-journal.json",
            id_field="session_id",
        )
        self._session_exchange_buffer: dict[str, list[dict]] = {}  # agent_id -> [{user, assistant}]
        self._session_id = uuid.uuid4().hex[:12]
        # CLI delegation callback — stored so background processes (council
        # delegation, proactive outreach) can surface progress in the terminal.
        self._cli_delegation_callback: Any = None
        # Whether the user is actively in a chat session (for proactive outreach)
        self._user_online: bool = False
        # Mercury banking client (initialized if credentials are present)
        self._mercury_client: Any = None
        # Plaid banking client (additive — aggregates with Mercury when configured)
        self._plaid_client: Any = None
        # Jupiter: Messaging provider (Zo) for SMS/email — lazy-initialized
        self._messaging_provider: Any = None
        # Outreach delivery callback — injected by CLI to push messages to the user
        self._outreach_callback: Any = None
        # Track last user activity for idle-based outreach delivery
        self._last_user_activity: float = 0.0
        # Load doc-size overrides from config
        _load_doc_size_overrides(
            self.settings.data_root if hasattr(self.settings, "data_root") else None
        )
        # Daily briefing text (set by _generate_daily_briefing, consumed by CLI)
        self._daily_briefing: str | None = None
        # Theo flag cache — agents flagged for quality issues, keyed by agent_id.
        # Value is list of {issue, severity, timestamp} dicts.  Populated by
        # _theo_reply_review and consumed by handle_user_message_direct.
        # Also hydrated from prompt-audit-log.md on startup for persistence.
        self._theo_flags: dict[str, list[dict]] = {}
        # Access tier — gates features, boards, and model selection per agent
        self.user_tier: str = "open"
        # Concurrency control: limit simultaneous LLM calls system-wide
        self._llm_semaphore = asyncio.Semaphore(3)
        # Circuit-breaker for provider quota/billing failures
        self._provider_circuit_breaker: dict[str, float] = {}
        # Pending structured questions from tool calls
        self._pending_structured_questions: list[dict] = []
        # Current chat mode (freeform or interview)
        self._current_chat_mode: str = "freeform"
        # Tool execution log for the current chat turn (returned to frontend)
        self._tool_exec_log: list[dict] = []
        # Tool allowlist — tools the user has pre-approved (persistent via config store)
        self._tool_allowlist: dict[str, bool] = {}
        # Pending tool approval — when set, the current tool call is waiting for user approval
        self._pending_tool_approval: dict | None = None
        # Abort flag — set to True to signal current operation to stop
        self._abort_requested: bool = False

    # ── Access-tier model resolution ──────────────────────────────

    def _resolve_model_for_agent(self, agent_id: str) -> str | None:
        """Resolve the concrete model ID for an agent, respecting access-tier config.

        Priority:
          1. If user_tier is set and access-tiers config is loaded, use tier-based
             model mapping (cheap/mid/premium -> concrete model per provider).
          2. Fall back to agent YAML model_override.
          3. Return None (let provider use its default).
        """
        from vastian.access_tiers import get_model_id

        # Determine the provider name for this agent (must match ModelRouter primary)
        config = self.registry.get_config(agent_id)
        provider_name = "openai"  # default
        if config:
            override = getattr(config, "llm_provider_override", None)
            if override:
                provider_name = override
            elif self.model_router and self.model_router._primary:
                provider_name = self.model_router._primary
            elif self.settings.llm_provider.value not in ("auto", "local"):
                provider_name = self.settings.llm_provider.value

        try:
            model_id = get_model_id(agent_id, self.user_tier, provider_name)
            return model_id
        except Exception:
            # Fall back to agent YAML override
            if config and config.model_override:
                return config.model_override
            return None

    def _hydrate_theo_flags(self) -> None:
        """Load open/pending Theo audit flags from prompt-audit-log.md into memory.

        This ensures flags survive across sessions (the in-memory dict is ephemeral
        but the markdown log is persistent).
        """
        try:
            audit_path = Path(self.settings.docs_dir) / "theo" / "prompt-audit-log.md"
            if not audit_path.exists():
                return
            content = audit_path.read_text(encoding="utf-8")

            for line in content.split("\n"):
                line = line.strip()
                if not line.startswith("|") or "| Open |" not in line and "| Pending |" not in line:
                    continue
                # Expected format: | date | agent_id | issue | severity | status | Open/Pending |
                parts = [p.strip() for p in line.split("|") if p.strip()]
                if len(parts) >= 5:
                    agent_id = parts[1]
                    issue = parts[2]
                    severity = parts[3]
                    self._theo_flags.setdefault(agent_id, []).append(
                        {
                            "issue": issue,
                            "severity": severity,
                            "fix": "",
                            "timestamp": parts[0],  # date from the log
                        }
                    )
            # Keep only last 5 per agent
            for aid in self._theo_flags:
                self._theo_flags[aid] = self._theo_flags[aid][-5:]
            if self._theo_flags:
                logger.info(
                    "Hydrated Theo flags for %d agents from audit log",
                    len(self._theo_flags),
                )
        except Exception as e:
            logger.debug("Could not hydrate Theo flags: %s", e)

    async def initialize(self, session_only: bool = False) -> None:
        """Boot up the entire Vastian Network.

        Args:
            session_only: If True, only initialize agents, LLM, and knowledge —
                skip background loops. Used by session commands that need agent
                capabilities but not the full background task system.
        """
        if self._running:
            logger.debug("Orchestrator already initialized — skipping duplicate call")
            return
        self._running = True
        logger.info("Initializing Vastian Network...")

        # 1. Initialize Knowledge Store
        self.knowledge = KnowledgeStore(
            chroma_persist_dir=self.settings.chroma_persist_dir,
            sqlite_path=self.settings.sqlite_path,
            docs_dir=self.settings.docs_dir,
            state_dir=self.settings.state_dir,
        )
        try:
            await self.knowledge.initialize()
        except Exception as e:
            logger.warning(
                "Knowledge Store init failed (non-fatal, vector search unavailable): %s", e
            )
            # Ensure documents sub-store is usable even if ChromaDB fails
            with contextlib.suppress(Exception):
                self.knowledge.documents.initialize()

        # 1b. Rebuild ChromaDB vector index from git-tracked content
        try:
            count = self.knowledge.vector.rebuild_index(
                docs_dir=self.settings.docs_dir,
                state_dir=self.settings.state_dir,
            )
            logger.info("Vector index rebuilt: %d documents", count)
        except Exception:
            logger.warning("Vector index rebuild failed (non-fatal)", exc_info=True)

        # 2. Initialize LLM Providers + Model Router
        try:
            from vastian.llm.provider import ModelRouter

            self.model_router = ModelRouter(self.settings)
            self.llm = self.model_router.primary_provider
            if self.llm:
                logger.info("LLM primary provider: %s", self.model_router._primary)
            else:
                logger.warning("No LLM providers available (running in stub mode)")
            # Populate _alt_providers from the router for backward compatibility
            for prov_name, prov_instance in self.model_router.providers.items():
                self._alt_providers[prov_name] = prov_instance
        except Exception as e:
            logger.warning("LLM provider init failed: %s (running in stub mode)", e)
            self.llm = None

        # 3. Load Agent Configurations (template vars substitute {{user_name}} etc.)
        agent_count = self.registry.load_from_directory(
            self.settings.agents_dir,
            template_vars={"user_name": self.settings.user_name},
        )
        logger.info("Loaded %d agent configurations", agent_count)

        # 3b. Initialize alternate LLM providers for agents with overrides
        # (handled by ModelRouter now, but kept for any custom provider logic)
        self._init_alt_providers()

        # 4. Instantiate All Agents
        self.registry.instantiate_all(
            bus=self.bus,
            knowledge_store=self.knowledge,
            llm_provider=self.llm,
        )

        # 5. Inject live network context into every agent
        self._inject_network_context()

        # 6. Initialize Mentor Council
        self.council = MentorCouncilEngine(
            registry=self.registry,
            bus=self.bus,
            knowledge=self.knowledge,
            max_rounds=self.settings.council_max_rounds,
            consensus_threshold=self.settings.council_consensus_threshold,
        )
        # Wire council action item auto-delegation
        self.council._delegation_callback = self._delegate_council_action

        # 7. Initialize Intent Router
        self.router = IntentRouter(registry=self.registry, llm=self.llm)

        # 8. Subscribe to escalation messages
        self.bus.subscribe(MessageType.ESCALATION, self._handle_escalation)

        # 9. Subscribe to audit logging
        self.bus.subscribe_all(self._audit_log)

        # 10a. Auto-migrate SQLite → JSON (safe to run repeatedly, merges by ID)
        try:
            from vastian.storage.migrate import migrate as run_migration

            tasks_db_path = self.settings.sqlite_path.parent / "tasks.db"
            if self.settings.sqlite_path.exists() or tasks_db_path.exists():
                counts = run_migration(
                    sqlite_path=self.settings.sqlite_path,
                    tasks_db_path=tasks_db_path,
                    state_dir=self.settings.state_dir,
                )
                if any(v > 0 for v in counts.values()):
                    logger.info("Auto-migrated SQLite → JSON: %s", counts)
        except Exception:
            logger.debug("Auto-migration skipped (non-fatal)", exc_info=True)

        # 10b. Initialize Background Task Queue
        from vastian.tasks.queue import TaskQueue

        self.task_queue = TaskQueue(
            self.settings.sqlite_path.parent / "tasks.db",
            state_dir=self.settings.state_dir,
        )
        self.task_queue.initialize()

        # 10b.2. Hydrate Theo quality flags from prompt-audit-log.md
        self._hydrate_theo_flags()

        # 10c. Initialize Mercury only when user configured it in their local installation
        # (config/local/settings.json). Do not init from env so the default build never
        # connects a bank account.
        from vastian.config_store import load_settings_json
        from vastian.storage.data_root import get_data_root

        _data_root = get_data_root(Path.cwd())
        _stored = load_settings_json(_data_root) if _data_root else {}
        if _stored.get("mercury_api_token"):
            from vastian.integrations.mercury import MercuryClient

            self._mercury_client = MercuryClient(
                api_token=self.settings.mercury_api_token,
                default_account_id=self.settings.mercury_account_id,
                readonly_account_ids=self.settings.mercury_readonly_accounts,
                account_names=self.settings.mercury_account_names,
            )
            n_primary = 1 if self.settings.mercury_account_id else 0
            n_ro = len(self.settings.mercury_readonly_accounts)
            logger.info(
                "Mercury banking client initialized (primary: %d, read-only: %d accounts)",
                n_primary,
                n_ro,
            )

        # 10d. Initialize Plaid client if configured (additive to Mercury)
        try:
            from vastian.loader import get as loader_get

            PlaidAdapter = loader_get("plaid_finance")  # noqa: N806
            plaid_adapter = PlaidAdapter(project_root=self.settings.agents_dir.parent)
            if plaid_adapter.is_configured:
                self._plaid_client = plaid_adapter
                logger.info("Plaid banking client initialized (additive to Mercury)")
        except Exception as e:
            logger.debug("Plaid not configured or failed to load: %s", e)

        if session_only:
            logger.info(
                "Vastian Network initialized in session-only mode with %d agents (no background loops)",
                len(self.registry.agent_ids),
            )
            return

        # 11. Resume any queued/running tasks from prior session
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._resume_pending_tasks())
        )

        # 12. Start Inbox Watcher (saved_prompts lives under data root)
        from vastian.storage.data_root import get_data_root
        from vastian.tasks.inbox import InboxWatcher

        data_root = get_data_root(self.settings.agents_dir.parent)
        self.inbox_watcher = InboxWatcher(self, data_root)
        asyncio.get_event_loop().call_soon(lambda: asyncio.create_task(self.inbox_watcher.start()))

        # 13. Start Kanban execution loop (checks for approved tasks)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._kanban_execution_loop())
        )

        # 14. Start proactive suggestion loop (agents suggest tasks on a schedule)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._proactive_suggestion_loop())
        )

        # 15. Start background task polling loop (catches orphaned queued tasks)
        asyncio.get_event_loop().call_soon(lambda: asyncio.create_task(self._task_polling_loop()))

        # 16. Start outreach delivery loop (delivers urgent agent questions to user)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._outreach_delivery_loop())
        )

        # 17. Start Elias knowledge consolidation loop (sweeps agent docs into VKB)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._knowledge_consolidation_loop())
        )

        # 18. Start Kanban dedup loop (Orion — operations owns dedup/consolidation)
        asyncio.get_event_loop().call_soon(lambda: asyncio.create_task(self._kanban_dedup_loop()))

        # 19. Start Finley financial monitoring loop (balance snapshots & anomaly alerts)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._financial_monitoring_loop())
        )

        # 20. Start Liam cycle health loop (stuck tasks, stale cycles)
        asyncio.get_event_loop().call_soon(lambda: asyncio.create_task(self._cycle_health_loop()))

        # 21. Start Theo system audit loop (delegation failure trends, prompt staleness)
        asyncio.get_event_loop().call_soon(lambda: asyncio.create_task(self._system_audit_loop()))

        # 22. Start document staleness detection loop
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._document_freshness_loop())
        )

        # 23. Generate Charles's daily briefing (once per session, if overdue)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._generate_daily_briefing())
        )

        # 24. Start meeting scheduler loop (checks team cadences, sends outreach)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._meeting_scheduler_loop())
        )

        # 25. Start plan watcher loop (monitors giga-outbox + new Cursor plans)
        asyncio.get_event_loop().call_soon(lambda: asyncio.create_task(self._plan_watcher_loop()))

        # 25b. Start Giga sync loop (Mars M1c: push every 30 min, pull on startup)
        asyncio.get_event_loop().call_soon(lambda: asyncio.create_task(self._giga_sync_loop()))

        # 25c. Start Giga auto-neuron loop (Mars M3b: emit neurons from Kanban/council activity)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._giga_auto_neuron_loop())
        )

        # 26. Start session curation loop (Noah curates card series from pending cards)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._session_curation_loop())
        )

        # 27. Start Maria relay triage loop (reads backend reports, creates REL- tickets)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._maria_relay_triage_loop())
        )

        # 28. Start Zo polling loop (Jupiter J1: pull from vastian-inbox → saved_prompts/inbox)
        asyncio.get_event_loop().call_soon(lambda: asyncio.create_task(self._zo_polling_loop()))

        # 29. Start daily career email loop (Jupiter J3: Sage → send_message)
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._daily_career_email_loop())
        )

        # 30. Start Felix engagement/progress monitoring loop
        asyncio.get_event_loop().call_soon(
            lambda: asyncio.create_task(self._felix_engagement_loop())
        )

        logger.info("Vastian Network initialized with %d agents", agent_count)

    async def _resume_pending_tasks(self) -> None:
        """Pick up tasks left queued/running from a previous session.

        Throttled: waits 10s after boot, resumes max 10 tasks sequentially
        with 2s gaps to avoid flooding the LLM API.
        """
        if not self.task_queue:
            return
        await asyncio.sleep(10)  # Let boot complete before resuming
        pending = self.task_queue.get_pending()
        running = self.task_queue.get_running()
        tasks_to_resume = (pending + running)[:10]  # Cap at 10
        for task in tasks_to_resume:
            logger.info("Resuming background task: %s — %s", task.task_id, task.description)
            try:
                await self._execute_background_task(task.task_id)
            except Exception as e:
                logger.error("Failed to resume task %s: %s", task.task_id, e)
            await asyncio.sleep(2)  # Stagger resumptions

    async def _task_polling_loop(self) -> None:
        """Periodically check for orphaned 'queued' tasks and execute them.

        This catches tasks that were enqueued but whose ``asyncio.create_task``
        call was missed or failed silently.  Runs every 30 seconds.
        """
        await asyncio.sleep(15)  # initial delay to let startup settle
        while True:
            try:
                await asyncio.sleep(30)
                if not self.task_queue:
                    continue
                pending = self.task_queue.get_pending()
                if pending:
                    logger.info(
                        "Task polling loop: found %d orphaned queued tasks",
                        len(pending),
                    )
                    for task in pending:
                        asyncio.create_task(self._execute_background_task(task.task_id))
            except Exception as e:
                logger.debug("Task polling loop error: %s", e)

    async def _execute_background_task(self, task_id: str) -> None:
        """Execute a single background task from the queue."""
        if not self.task_queue:
            return
        # Re-read from DB (in case state changed)
        tasks = [
            t
            for t in self.task_queue.get_pending() + self.task_queue.get_running()
            if t.task_id == task_id
        ]
        if not tasks:
            return
        task = tasks[0]

        self.task_queue.mark_running(task_id)

        self._is_background_task = True  # Flag for doc edit queueing
        try:
            if task.task_type == "delegation":
                target_id = task.payload.get("target_agent_id", "")
                prompt = task.payload.get("prompt", "")
                target_agent = self.registry.get_agent(target_id)
                if target_agent:
                    result = await self._run_agent_with_tools(target_agent, prompt)
                    self.task_queue.mark_completed(task_id, result[:500])
                    # Save detailed report
                    self.task_queue.save_delegation_report(
                        parent_task_id=task_id,
                        agent_id=target_id,
                        description=task.description,
                        full_result=result,
                        status="completed",
                        user_id=self.current_user_id,
                    )
                else:
                    self.task_queue.mark_failed(task_id, f"Agent {target_id} not found")

            elif task.task_type == "onboarding":
                await self._run_onboarding(task)
                self.task_queue.mark_completed(task_id, "Onboarding complete")

            elif task.task_type == "batch_delegation":
                delegations = task.payload.get("delegations", [])
                results = []
                for d in delegations:
                    t_id = d.get("agent_id", "")
                    t_prompt = d.get("prompt", "")
                    t_agent = self.registry.get_agent(t_id)
                    if t_agent:
                        self.task_queue.add_notification(
                            t_id, f"Working on: {d.get('description', t_prompt[:80])}", "running"
                        )
                        try:
                            r = await self._run_agent_with_tools(t_agent, t_prompt)
                            results.append(f"{t_id}: done")
                            self.task_queue.add_notification(
                                t_id,
                                f"Finished: {d.get('description', t_prompt[:60])}",
                                "completed",
                            )
                            # Save detailed report per sub-delegation
                            self.task_queue.save_delegation_report(
                                parent_task_id=task_id,
                                agent_id=t_id,
                                description=d.get("description", t_prompt[:120]),
                                full_result=r,
                                status="completed",
                                user_id=self.current_user_id,
                            )
                        except Exception as e:
                            results.append(f"{t_id}: error — {e}")
                            self.task_queue.add_notification(t_id, f"Failed: {e}", "failed")
                            self.task_queue.save_delegation_report(
                                parent_task_id=task_id,
                                agent_id=t_id,
                                description=d.get("description", t_prompt[:120]),
                                full_result=str(e),
                                status="failed",
                                user_id=self.current_user_id,
                            )
                    else:
                        results.append(f"{t_id}: not found")
                self.task_queue.mark_completed(task_id, "; ".join(results))

            elif task.task_type == "post_chat_review":
                agent_id = task.payload.get("agent_id", "")
                user_msg = task.payload.get("user_msg", "")
                assistant_msg = task.payload.get("assistant_msg", "")
                await self._post_chat_processing(agent_id, user_msg, assistant_msg)
                self.task_queue.mark_completed(task_id, "Post-chat review complete")

            elif task.task_type == "knowledge_consolidation":
                elias = self.registry.get_agent("elias")
                if elias:
                    prompt = task.payload.get("prompt", "")
                    result = await self._run_agent_with_tools(elias, prompt, max_rounds=8)
                    self.task_queue.mark_completed(task_id, result[:500])
                    self.task_queue.save_delegation_report(
                        parent_task_id=task_id,
                        agent_id="elias",
                        description="VKB knowledge consolidation sweep",
                        full_result=result,
                        status="completed",
                        user_id=self.current_user_id,
                    )
                else:
                    self.task_queue.mark_failed(task_id, "Elias agent not found")

            elif task.task_type in ("inbox_processing", "kanban_inbox"):
                # Inbox tasks are handled by the InboxWatcher itself — this
                # branch is only hit on resume from a prior session.
                if self.inbox_watcher:
                    from pathlib import Path

                    filename = task.payload.get("filename", "")
                    content = task.payload.get("content", "")
                    intake_path = Path(task.payload.get("intake_path", ""))

                    if task.task_type == "kanban_inbox":
                        # Resume Kanban-linked inbox task
                        kanban_id = task.payload.get("kanban_id", "")
                        if kanban_id and self.task_queue and content:
                            kanban_tasks = self.task_queue.get_kanban_tasks()
                            linked = next(
                                (t for t in kanban_tasks if t["kanban_id"] == kanban_id),
                                None,
                            )
                            if linked:
                                await self.inbox_watcher._execute_kanban_inbox(
                                    task_id,
                                    filename,
                                    content,
                                    intake_path,
                                    linked,
                                )
                            else:
                                self.task_queue.mark_completed(
                                    task_id, "Kanban task no longer exists"
                                )
                        else:
                            self.task_queue.mark_completed(task_id, "Missing kanban data on resume")
                    elif content and intake_path.exists():
                        await self.inbox_watcher._execute_inbox_task(
                            task_id,
                            filename,
                            content,
                            intake_path,
                        )
                    else:
                        self.task_queue.mark_completed(task_id, "Already processed or file missing")
                else:
                    self.task_queue.mark_failed(task_id, "Inbox watcher not initialized")

            else:
                self.task_queue.mark_failed(task_id, f"Unknown task type: {task.task_type}")

        except asyncio.CancelledError:
            # CancelledError inherits BaseException in Python 3.9+, so
            # `except Exception` won't catch it.  Mark as completed (not failed)
            # for routine tasks like post_chat_review, failed for everything else.
            logger.debug("Background task %s cancelled (shutdown)", task_id)
            try:
                if self.task_queue and self.task_queue._conn is not None:
                    if task.task_type == "post_chat_review":
                        self.task_queue.mark_completed(
                            task_id, "Cancelled during shutdown (non-critical)"
                        )
                    else:
                        self.task_queue.mark_failed(task_id, "Cancelled during shutdown")
            except Exception:
                pass
        except Exception as e:
            logger.error("Background task %s failed: %s", task_id, e)
            try:
                if self.task_queue and self.task_queue._conn is not None:
                    self.task_queue.mark_failed(task_id, str(e))
            except Exception:
                pass  # DB already closed during shutdown — safe to ignore
        finally:
            self._is_background_task = False  # Reset flag
            # Safety net: if the task is *still* marked "running" after exiting
            # the try/except, something went wrong silently — mark it failed so
            # it doesn't stay stuck forever.
            try:
                if self.task_queue and self.task_queue._conn is not None:
                    leftover = self.task_queue.get_task(task_id)
                    if leftover and leftover.status.value == "running":
                        self.task_queue.mark_failed(
                            task_id, "Task did not complete cleanly (safety-net catch)"
                        )
            except Exception:
                pass  # DB already closed during shutdown — safe to ignore

    async def _post_chat_processing(self, agent_id: str, user_msg: str, assistant_msg: str) -> None:
        """
        Post-chat hook: runs Elias (user VKB), Theo (reply quality), and
        Rowan (profile sync) in parallel. All background tasks — non-blocking.
        """
        tasks = []

        # Elias: focus on USER content — what should be saved to VKB
        if agent_id != "elias":
            tasks.append(self._elias_auto_ingest(agent_id, user_msg, assistant_msg))

        # Theo: focus on AGENT REPLY quality — detect prompt/tool issues
        if agent_id != "theo":
            tasks.append(self._theo_reply_review(agent_id, user_msg, assistant_msg))

        # Rowan: check if user revealed new personal info to update profile
        if agent_id != "rowan":
            tasks.append(self._rowan_profile_sync(agent_id, user_msg, assistant_msg))

        if tasks:
            await asyncio.gather(*tasks, return_exceptions=True)

    async def _theo_reply_review(self, agent_id: str, user_msg: str, assistant_msg: str) -> None:
        """
        Background task: Theo reviews the agent's reply for quality issues.
        Only flags problems — doesn't log every conversation.
        """
        try:
            theo = self.registry.get_agent("theo")
            if theo is None:
                return

            llm = self._get_llm_for_agent("theo") or self.llm
            if llm is None:
                return

            config = self.registry.get_config(agent_id)
            agent_title = config.title if config else agent_id

            # Gather Kanban duplication context for this agent
            kanban_context = ""
            if self.task_queue:
                try:
                    from difflib import SequenceMatcher

                    kanban_all = self.task_queue.get_kanban_tasks()
                    agent_tasks = [
                        t
                        for t in kanban_all
                        if t.get("suggested_by") == agent_id
                        and t.get("status") not in ("archived", "rejected", "done")
                    ]
                    if len(agent_tasks) >= 2:
                        titles = [
                            (t.get("kanban_id", "?"), t.get("title", "")) for t in agent_tasks
                        ]
                        dups = []
                        for i, (id1, t1) in enumerate(titles):
                            for id2, t2 in titles[i + 1 :]:
                                if SequenceMatcher(None, t1.lower(), t2.lower()).ratio() > 0.6:
                                    dups.append(f'  - "{t1}" ({id1}) <-> "{t2}" ({id2})')
                        if dups:
                            kanban_context = (
                                f"\n\nKANBAN DUPLICATION WARNING for {agent_id}:\n"
                                f"This agent has {len(agent_tasks)} active Kanban suggestions and "
                                f"{len(dups)} potential duplicate pair(s):\n"
                                + "\n".join(dups[:5])
                                + "\nIf this agent is duplicating its own tasks, their background loop "
                                "or prompt may need a deduplication guard. Flag this."
                            )
                        elif len(agent_tasks) >= 5:
                            kanban_context = (
                                f"\n\nKANBAN VOLUME NOTE for {agent_id}: "
                                f"This agent has {len(agent_tasks)} active Kanban suggestions. "
                                f"Consider whether they are over-generating tasks."
                            )
                except Exception:
                    pass  # non-critical — skip if Kanban data unavailable

            review_prompt = (
                f"You are Theo, the system quality monitor. Quickly review this agent interaction "
                f"for quality issues.\n\n"
                f"AGENT: {agent_id} ({agent_title})\n"
                f"USER asked: {user_msg[:400]}\n"
                f"AGENT replied: {assistant_msg[:800]}\n\n"
                f"CHECK FOR:\n"
                f"- **Role drift**: Did the agent stay in its defined role, or did it act outside "
                f"its domain? (e.g., Finley giving scheduling advice = drift into Charles's domain)\n"
                f"- **Domain overlap**: Did the agent handle work that belongs to another agent? "
                f"If so, name which agent should own it.\n"
                f"- **Hallucination**: Did the agent invent data, numbers, or facts not grounded "
                f"in its documents or the conversation?\n"
                f"- **Tool neglect**: Should the agent have called a tool (read_document, "
                f"web_search, etc.) but answered from memory instead?\n"
                f"- **Stale knowledge**: Did the agent reference outdated information that "
                f"contradicts the approved ecosystem consolidation plan?\n"
                f"- **Task duplication**: Is this agent generating repetitive or overlapping tasks "
                f"on the Kanban board? Check the duplication context below.\n\n"
                f"{kanban_context}\n\n"
                f"If NO issues found, respond exactly: 'No issues detected.'\n"
                f"If issues found, call suggest_prompt_fix with severity (Low/Medium/High/Critical), "
                f"the specific issue, and a concrete fix. You may call it multiple times for "
                f"multiple issues.\n"
                f"Be concise. This runs in the background silently."
            )

            result = await self._llm_call_with_backoff(
                llm,
                "You are Theo, system quality monitor. Flag issues only.",
                [{"role": "user", "content": review_prompt}],
                [TOOL_SUGGEST_PROMPT_FIX],
                self._resolve_model_for_agent("theo"),
                0.2,
                "theo",
                0,
            )
            if result is None:
                return  # LLM unavailable — skip background review silently

            if "tool_calls" in result and result["tool_calls"]:
                for tc in result["tool_calls"]:
                    if tc["function"] == "suggest_prompt_fix":
                        tc_args = tc["arguments"]
                        if isinstance(tc_args, str):
                            try:
                                tc_args = json.loads(tc_args)
                            except json.JSONDecodeError:
                                continue
                        issue = tc_args.get("issue", "")
                        fix = tc_args.get("suggested_fix", "")
                        severity = tc_args.get("severity", "Medium")
                        if issue and self.task_queue:
                            # 0. Store in Theo flag cache (consumed by chat injection)
                            from datetime import datetime as _dt_flag

                            self._theo_flags.setdefault(agent_id, []).append(
                                {
                                    "issue": issue,
                                    "severity": severity,
                                    "fix": fix,
                                    "timestamp": _dt_flag.now().isoformat(),
                                }
                            )
                            # Keep only the last 5 flags per agent
                            self._theo_flags[agent_id] = self._theo_flags[agent_id][-5:]

                            # 1. Notification
                            self.task_queue.add_notification(
                                "theo",
                                f"Quality issue in {agent_id}: {issue[:100]}",
                                "info",
                            )
                            # 2. Append to prompt-audit-log.md
                            try:
                                from datetime import datetime as dt

                                audit_path = (
                                    Path(self.settings.docs_dir) / "theo" / "prompt-audit-log.md"
                                )
                                if audit_path.exists():
                                    content = audit_path.read_text(encoding="utf-8")
                                    date_str = dt.now().strftime("%Y-%m-%d")
                                    new_row = f"| {date_str} | {agent_id} | {issue[:80]} | {severity} | Pending | Open |"
                                    # Insert before the Audit Queue section
                                    if "## Audit Queue" in content:
                                        content = content.replace(
                                            "## Audit Queue",
                                            f"{new_row}\n\n## Audit Queue",
                                        )
                                    else:
                                        content += f"\n{new_row}\n"
                                    audit_path.write_text(content, encoding="utf-8")
                                    logger.info("Theo updated prompt-audit-log.md for %s", agent_id)
                            except Exception as e:
                                logger.debug("Could not update audit log: %s", e)

                            # 3. Suggest Kanban task for the fix
                            try:
                                self.task_queue.suggest_kanban_task(
                                    suggested_by="theo",
                                    title=f"Prompt fix: {agent_id} — {issue[:50]}",
                                    description=(
                                        f"Theo detected a quality issue in {agent_id}'s response.\n\n"
                                        f"Issue: {issue}\n\n"
                                        f"Suggested fix: {fix}\n\n"
                                        f"Severity: {severity}"
                                    ),
                                    category="prompt_quality",
                                    priority="high"
                                    if severity.lower() in ("high", "critical")
                                    else "normal",
                                    board="ops",
                                    tags=["prompt-quality", agent_id],
                                    user_id=self.current_user_id,
                                )
                                logger.info("Theo suggested kanban fix for %s", agent_id)
                            except Exception as e:
                                logger.debug("Could not suggest kanban: %s", e)

                        logger.info("Theo flagged issue in %s: %s", agent_id, issue[:80])

            # ── Delegate duplication/volume findings to Orion (operations) ──
            # This is deterministic — doesn't depend on the LLM choosing to delegate.
            if kanban_context and self.task_queue:
                try:
                    orion = self.registry.get_agent("orion")
                    if orion:
                        delegation_prompt = (
                            f"Theo's per-reply review detected a Kanban issue for agent "
                            f"**{agent_id}** ({agent_title}):\n\n"
                            f"{kanban_context}\n\n"
                            f"As Operations Lead, please:\n"
                            f"1. Review the duplicate/overlapping tasks listed above\n"
                            f"2. Consolidate duplicates — archive the redundant one and keep "
                            f"the more descriptive version\n"
                            f"3. If the same agent keeps generating similar tasks, suggest an "
                            f"automation fix (e.g., a deduplication guard in their background loop)\n"
                            f"4. Create a Kanban task with your recommendation if a process "
                            f"change is needed\n"
                            f"5. Update your operations log with the finding"
                        )
                        asyncio.create_task(
                            self._run_agent_with_tools(orion, delegation_prompt, max_rounds=4)
                        )
                        self.task_queue.add_notification(
                            "theo",
                            f"Delegated Kanban duplication issue ({agent_id}) to Orion",
                            "info",
                        )
                        logger.info("Theo delegated Kanban duplication for %s to Orion", agent_id)
                except Exception as e:
                    logger.debug("Orion delegation failed: %s", e)

        except Exception:
            logger.debug("Theo review failed for %s (non-critical)", agent_id)

    async def _rowan_profile_sync(self, agent_id: str, user_msg: str, assistant_msg: str) -> None:
        """
        Background task: Rowan checks if the user revealed new personal info
        (preferences, life changes, decisions) and updates rowan-profile.md.

        Single LLM round — lightweight. Only fires write_document if there's
        something genuinely new to add.
        """
        try:
            rowan = self.registry.get_agent("rowan")
            if rowan is None:
                return

            # Skip short/trivial messages unlikely to contain personal info
            if len(user_msg.strip()) < 30:
                return

            prompt = (
                f"A conversation just happened between the user and agent '{agent_id}'.\n\n"
                f"USER said: {user_msg[:600]}\n\n"
                f"AGENT replied: {assistant_msg[:400]}\n\n"
                f"Did the user reveal anything NEW about themselves that should be "
                f"captured in their profile? Look for:\n"
                f"- Life changes (new job, move, relationship, health)\n"
                f"- Preferences or values expressed\n"
                f"- Financial decisions or goals\n"
                f"- Career or project updates\n"
                f"- New interests or priorities\n\n"
                f"If YES: read `rowan/rowan-profile.md`, then update it with the new info "
                f"(merge, don't overwrite).\n"
                f"If NO new personal info: respond 'No profile update needed.' and do nothing.\n\n"
                f"Be very selective — only update for genuinely new, meaningful information."
            )

            await self._run_agent_with_tools(rowan, prompt, max_rounds=3)

        except Exception:
            logger.debug("Rowan profile sync failed for %s (non-critical)", agent_id)

    async def enqueue_async_delegation(
        self,
        from_agent_id: str,
        target_agent_id: str,
        prompt: str,
        description: str = "",
    ) -> str:
        """Enqueue a delegation as a background task — returns immediately."""
        if not self.task_queue:
            return "Error: Task queue not initialized"
        task = self.task_queue.enqueue(
            task_type="delegation",
            agent_id=target_agent_id,
            description=description or f"Delegation from {from_agent_id}: {prompt[:80]}",
            payload={"target_agent_id": target_agent_id, "prompt": prompt, "from": from_agent_id},
        )
        asyncio.create_task(self._execute_background_task(task.task_id))
        return (
            f"Task queued (#{task.task_id}). {target_agent_id} will work on this in the background."
        )

    async def enqueue_batch_async(
        self,
        from_agent_id: str,
        delegations: list[dict],
        description: str = "",
    ) -> str:
        """Enqueue a batch of delegations as a single background task."""
        if not self.task_queue:
            return "Error: Task queue not initialized"
        task = self.task_queue.enqueue(
            task_type="batch_delegation",
            agent_id=from_agent_id,
            description=description or f"Batch delegation ({len(delegations)} agents)",
            payload={"delegations": delegations, "from": from_agent_id},
        )
        asyncio.create_task(self._execute_background_task(task.task_id))
        return (
            f"Batch queued (#{task.task_id}). {len(delegations)} agents working in the background."
        )

    async def _run_onboarding(self, task: BackgroundTask) -> None:
        """Execute the onboarding flow: read intro.txt and delegate doc creation to agents."""
        from vastian.storage.data_root import get_saved_prompts_dir

        intro_path = get_saved_prompts_dir(self.settings.agents_dir.parent) / "intro.txt"
        if not intro_path.exists():
            if self.task_queue:
                self.task_queue.add_notification(
                    "charles", "Onboarding failed: saved_prompts/intro.txt not found", "failed"
                )
            return

        intro_content = intro_path.read_text(encoding="utf-8")
        user = self.settings.user_name

        # Define which agents get which onboarding tasks
        onboarding_delegations = [
            {
                "agent_id": "rowan",
                "description": f"Build {user}'s profile from intro",
                "prompt": (
                    f"Based on this introduction from {user}, update the rowan-profile.md, "
                    f"founders-values-and-principles.md, and decision-log.md documents with "
                    f"real content extracted from this intro:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "finley",
                "description": "Build financial docs from intro",
                "prompt": (
                    f"Based on {user}'s introduction, update financial-projections.md and "
                    f"growth-metrics-tracker.md with real financial data. Extract budget, "
                    f"income, goals, and distributions from:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "charles",
                "description": "Build command center from intro",
                "prompt": (
                    f"Based on {user}'s introduction, update command-center-dashboard.md, "
                    f"key-metrics-tracker.md, and milestones-dependencies.md with real "
                    f"data from:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "dominic",
                "description": "Build roadmap from intro",
                "prompt": (
                    f"Based on {user}'s introduction, create a phased roadmap in "
                    f"vastian-roadmap.md and update strategic-integration-plan.md. "
                    f"Extract goals, timeline, and priorities from:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "henry",
                "description": "Build technical docs from intro",
                "prompt": (
                    f"Based on {user}'s introduction, update vastian-technical-architecture.md "
                    f"and data-engineering-playbook.md with relevant context about {user}'s "
                    f"tech stack, work, and systems from:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "liam",
                "description": "Build execution tracker from intro",
                "prompt": (
                    f"Based on {user}'s introduction, populate execution-goal-tracker.md "
                    f"and prioritized-todo-list.md with real goals extracted from:\n\n"
                    f"{intro_content}\n\nUse write_document to save each document."
                ),
            },
            {
                "agent_id": "elias",
                "description": "Build knowledge bank from intro",
                "prompt": (
                    f"Based on {user}'s introduction, populate vastian-knowledge-bank.md "
                    f"with key facts, context, and insights extracted from the intro. Also "
                    f"update memory-integration-framework.md with an initial structure. "
                    f"Extract:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "theo",
                "description": "Initialize prompt framework from intro",
                "prompt": (
                    f"Based on {user}'s introduction, update prompt-design-framework.md "
                    f"with initial guidelines tailored to {user}'s communication style and "
                    f"goals. Initialize prompt-audit-log.md with a baseline entry. "
                    f"Extract:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "victor",
                "description": "Extract vision and long-term goals from intro",
                "prompt": (
                    f"Based on {user}'s introduction, populate strategic-vision-log.md "
                    f"with {user}'s long-term vision, values, and aspirations. Update "
                    f"vision-traction-organizer.md with initial traction items. "
                    f"Extract:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "adrian",
                "description": "Initialize persona dimensions from intro",
                "prompt": (
                    f"Based on {user}'s introduction, populate active-personas.md with "
                    f"any persona information you can identify. Update "
                    f"persona-dimensions-framework.md with an initial framework. "
                    f"Extract:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "orion",
                "description": "Set up operational structure from intro",
                "prompt": (
                    f"Based on {user}'s introduction, populate eos-accountability-chart.md "
                    f"with an initial accountability structure. Update "
                    f"systems-implementation-tracker.md with operational priorities. "
                    f"Extract:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "concord",
                "description": "Initialize simulation framework from intro",
                "prompt": (
                    f"Based on {user}'s introduction, populate "
                    f"simulation-framework-reference.md with initial scenario variables "
                    f"and parameters derived from {user}'s goals and situation. Update "
                    f"scenario-status-dashboard.md with an initial status. "
                    f"Extract:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
            {
                "agent_id": "jake",
                "description": "Set up initial metric baselines from intro",
                "prompt": (
                    f"Based on {user}'s introduction, populate metric-baselines.md "
                    f"with initial metric definitions and baseline values derived from "
                    f"{user}'s goals and situation. Update scenario-regression-suite.md "
                    f"with initial test cases. Extract:\n\n{intro_content}\n\n"
                    f"Use write_document to save each document."
                ),
            },
        ]

        if self.task_queue:
            self.task_queue.add_notification(
                "charles",
                f"Onboarding started — delegating to {len(onboarding_delegations)} agents",
                "info",
            )

        for d in onboarding_delegations:
            agent = self.registry.get_agent(d["agent_id"])
            if agent:
                if self.task_queue:
                    self.task_queue.add_notification(
                        d["agent_id"], f"Working on: {d['description']}", "running"
                    )
                try:
                    await self._run_agent_with_tools(agent, d["prompt"])
                    if self.task_queue:
                        self.task_queue.add_notification(
                            d["agent_id"], f"Completed: {d['description']}", "completed"
                        )
                except Exception as e:
                    if self.task_queue:
                        self.task_queue.add_notification(
                            d["agent_id"], f"Failed: {d['description']} — {e}", "failed"
                        )

        if self.task_queue:
            self.task_queue.add_notification(
                "charles", "Onboarding complete! All documents updated.", "completed"
            )

    def _init_alt_providers(self) -> None:
        """Initialize alternate LLM providers for agents with llm_provider_override."""
        from vastian.llm.provider import AnthropicProvider, OpenAIProvider

        needed_providers: set[str] = set()
        for config in self.registry.all_configs:
            override = getattr(config, "llm_provider_override", None)
            if override and override != self.settings.llm_provider.value:
                needed_providers.add(override)

        for provider_name in needed_providers:
            if provider_name in self._alt_providers:
                continue
            try:
                if provider_name == "anthropic":
                    self._alt_providers["anthropic"] = AnthropicProvider(self.settings)
                elif provider_name == "openai":
                    self._alt_providers["openai"] = OpenAIProvider(self.settings)
                logger.info("Initialized alternate LLM provider: %s", provider_name)
            except Exception as e:
                logger.warning("Could not initialize %s provider: %s", provider_name, e)

    def _get_messaging_provider(self) -> Any:
        """Get the messaging provider for SMS/email. Lazy-initialized.

        Priority: Twilio (direct, independent) > Zo (via Zo Computer API).
        """
        if self._messaging_provider is not None:
            return self._messaging_provider

        # Try Twilio first (independent SMS, not coupled to Zo agent)
        try:
            from vastian.loader import get as _loader_get

            twilio_cls = _loader_get("twilio_sms")
            twilio = twilio_cls()
            if twilio.is_configured:
                logger.info("Using Twilio SMS provider")
                self._messaging_provider = twilio
                return self._messaging_provider
        except (KeyError, ImportError, Exception) as e:
            logger.debug("Twilio not available: %s", e)

        # Fall back to Zo messaging
        api_key = getattr(self.settings, "zo_api_key", "") or ""
        if not api_key:
            return None
        try:
            from vastian.loader import get

            cls = get("zo_messaging")
            self._messaging_provider = cls(api_key=api_key)
            return self._messaging_provider
        except (KeyError, ImportError, Exception) as e:
            logger.debug("Messaging provider unavailable: %s", e)
            return None

    def _get_llm_for_agent(self, agent_id: str) -> BaseLLMProvider | None:
        """Get the correct LLM provider for an agent, respecting overrides."""
        config = self.registry.get_config(agent_id)
        if config:
            override = getattr(config, "llm_provider_override", None)
            if override and override in self._alt_providers:
                return self._alt_providers[override]
        return self.llm

    def _load_digital_clone_context(self) -> str:
        """Load a snapshot of the founder's digital clone documents for context injection."""
        docs_path = self.settings.docs_dir / "rowan" if self.settings else Path("docs") / "rowan"
        clone_docs = [
            "rowan-profile.md",
            "founders-values-and-principles.md",
            "communication-style.md",
            "thinking-patterns.md",
            "decision-log.md",
        ]
        parts: list[str] = []
        for doc_name in clone_docs:
            doc_path = docs_path / doc_name
            if doc_path.exists():
                try:
                    text = doc_path.read_text(encoding="utf-8")
                    # Truncate each doc to keep context manageable
                    if len(text) > 2000:
                        text = text[:2000] + "\n...(truncated)..."
                    parts.append(f"=== {doc_name} ===\n{text}")
                except Exception:
                    pass
        return "\n\n".join(parts)

    async def start(self) -> None:
        """Start all agent processing loops."""
        self._running = True
        agents = self.registry.get_all_agents()
        for agent_id, agent in agents.items():
            task = asyncio.create_task(agent.run(), name=f"agent-{agent_id}")
            self._agent_tasks[agent_id] = task
            logger.debug("Started agent loop: %s", agent_id)

        logger.info("All %d agent loops started", len(self._agent_tasks))

    async def stop(self) -> None:
        """Gracefully stop all agent loops."""
        self._running = False
        for agent_id, task in self._agent_tasks.items():
            task.cancel()
            logger.debug("Cancelled agent loop: %s", agent_id)
        self._agent_tasks.clear()
        logger.info("All agent loops stopped")

    # ── User Interaction ─────────────────────────────

    async def handle_user_message(self, content: str, target_agent: str | None = None) -> str:
        """
        Handle an incoming user message.

        If target_agent is specified, route directly. Otherwise, use the
        intent router to determine the best agent.
        """
        if target_agent:
            agent = self.registry.get_agent(target_agent)
            if agent is None:
                return f"Unknown agent: {target_agent}"
        else:
            # Use intent router to find the best agent
            if self.router:
                target_id = await self.router.route(content)
                agent = self.registry.get_agent(target_id)
            else:
                # Default to Charles (Chief of Staff) as intake
                agent = self.registry.get_agent("charles")

            if agent is None:
                return "No available agent to handle this request."

        # Send message to the agent
        message = Message(
            sender="user",
            recipient=agent.agent_id,
            type=MessageType.QUERY,
            payload={"content": content},
            priority=Priority.HIGH,
        )
        await self.bus.send(message)

        # Wait for response (with timeout)
        try:
            response = await asyncio.wait_for(agent.mailbox.get(), timeout=120.0)
            # Process the response through the agent
            result = await agent.process_message(response)
            if result:
                return result.payload.get("content", "No response generated.")
            return "Agent acknowledged the request."
        except TimeoutError:
            return "Request timed out. The agent may still be processing."

    # Circuit-breaker: tracks providers that are down (quota exhausted, auth invalid)
    _provider_circuit_breaker: dict[str, float] = {}  # provider_class -> timestamp when tripped
    _CIRCUIT_BREAKER_COOLDOWN = 300  # 5 minutes before retrying a tripped provider

    async def _llm_call_with_backoff(
        self,
        llm: Any,
        system_prompt: str,
        messages: list,
        tools: list,
        model: str | None,
        temperature: float,
        agent_id: str,
        round_num: int,
        max_retries: int = 3,
    ) -> dict | None:
        """Call LLM with exponential backoff on transient errors.

        Distinguishes between:
        - **Billing/quota errors** (insufficient_quota, billing) → do NOT retry,
          trip the circuit breaker, attempt fallback to an alternate provider.
        - **Transient errors** (rate_limit, timeout) → retry with backoff.
        - **Other errors** → fail immediately.
        """
        import random
        import time

        provider_key = type(llm).__name__

        # Check circuit breaker — if this provider was recently tripped, try fallback immediately
        tripped_at = self._provider_circuit_breaker.get(provider_key, 0)
        if tripped_at and (time.time() - tripped_at) < self._CIRCUIT_BREAKER_COOLDOWN:
            fallback = self._find_fallback_provider(llm)
            if fallback:
                llm = fallback
                provider_key = type(llm).__name__
                # Check fallback isn't also tripped
                fb_tripped = self._provider_circuit_breaker.get(provider_key, 0)
                if fb_tripped and (time.time() - fb_tripped) < self._CIRCUIT_BREAKER_COOLDOWN:
                    logger.warning("All LLM providers are tripped for %s — skipping call", agent_id)
                    return None
            else:
                logger.warning(
                    "Provider %s is tripped and no fallback available for %s",
                    provider_key,
                    agent_id,
                )
                return None

        for attempt in range(max_retries):
            try:
                return await llm.generate_with_tools(
                    system_prompt=system_prompt,
                    messages=messages,
                    tools=tools,
                    model=model,
                    temperature=temperature,
                )
            except Exception as e:
                err_str = str(e).lower()

                # ── Billing / quota errors → non-retryable, trip circuit breaker ──
                is_quota_error = (
                    "insufficient_quota" in err_str
                    or "billing" in err_str
                    or ("quota" in err_str and "exceeded" in err_str)
                    or "account is not active" in err_str
                )
                if is_quota_error:
                    self._provider_circuit_breaker[provider_key] = time.time()
                    logger.error(
                        "Provider %s quota exhausted for %s: %s — circuit breaker tripped for %ds",
                        provider_key,
                        agent_id,
                        str(e)[:120],
                        self._CIRCUIT_BREAKER_COOLDOWN,
                    )
                    # Try fallback provider
                    fallback = self._find_fallback_provider(llm)
                    if fallback:
                        logger.info(
                            "Falling back to %s for %s",
                            type(fallback).__name__,
                            agent_id,
                        )
                        try:
                            return await fallback.generate_with_tools(
                                system_prompt=system_prompt,
                                messages=messages,
                                tools=tools,
                                model=None,  # use fallback's default model
                                temperature=temperature,
                            )
                        except Exception as fb_err:
                            logger.error(
                                "Fallback provider also failed for %s: %s", agent_id, fb_err
                            )
                            return None
                    return None

                # ── Transient errors → retry with backoff ──
                is_retryable = (
                    "429" in err_str
                    or "rate" in err_str
                    or "timeout" in err_str
                    or "timed out" in err_str
                )
                if is_retryable and attempt < max_retries - 1:
                    delay = (2**attempt) + random.uniform(0, 1)
                    logger.warning(
                        "LLM retryable error for %s (round %d, attempt %d/%d): %s — retrying in %.1fs",
                        agent_id,
                        round_num + 1,
                        attempt + 1,
                        max_retries,
                        str(e)[:120],
                        delay,
                    )
                    await asyncio.sleep(delay)
                    continue

                # ── Non-retryable or exhausted retries ──
                logger.error(
                    "LLM error for %s (round %d, attempt %d/%d): %s",
                    agent_id,
                    round_num + 1,
                    attempt + 1,
                    max_retries,
                    str(e)[:200],
                )
                return None
        return None

    def _find_fallback_provider(self, current_llm: Any) -> Any:
        """Find an alternate LLM provider when the current one is down."""
        current_type = type(current_llm).__name__
        for _prov_name, prov_instance in self._alt_providers.items():
            if type(prov_instance).__name__ != current_type:
                # Check this fallback isn't also circuit-broken
                import time

                fb_key = type(prov_instance).__name__
                tripped_at = self._provider_circuit_breaker.get(fb_key, 0)
                if tripped_at and (time.time() - tripped_at) < self._CIRCUIT_BREAKER_COOLDOWN:
                    continue
                return prov_instance
        return None

    async def _run_agent_with_tools(
        self,
        target_agent: Any,
        prompt: str,
        delegation_callback: Any = None,
        max_rounds: int = 5,
    ) -> str:
        """
        Run a delegated agent through the tool-calling loop so it can use its own
        tools (write_document, search_knowledge, etc.) — not just generate text.
        Limited to max_rounds to prevent deep recursion during delegation chains.
        """
        llm = self._get_llm_for_agent(target_agent.agent_id) or self.llm
        if llm is None:
            return await target_agent._generate(prompt)

        system_prompt = target_agent.build_system_prompt()
        messages = [{"role": "user", "content": prompt}]
        agent_tools = get_tools_for_agent(target_agent.agent_id)
        result: dict = {"content": ""}

        default_temp = 0.7
        temperature = (
            target_agent.config.temperature_override
            if target_agent.config.temperature_override is not None
            else default_temp
        )
        for _round in range(max_rounds):
            # Acquire semaphore to limit concurrent LLM calls system-wide
            async with self._llm_semaphore:
                result = await self._llm_call_with_backoff(
                    llm,
                    system_prompt,
                    messages,
                    agent_tools,
                    self._resolve_model_for_agent(target_agent.agent_id),
                    temperature,
                    target_agent.agent_id,
                    _round,
                )
            if result is None:
                return "Error: LLM call failed after retries."

            if "tool_calls" not in result or not result["tool_calls"]:
                return result.get("content", "")

            # Process tool calls for the delegated agent
            assistant_msg: dict[str, Any] = {
                "role": "assistant",
                "content": result.get("content", ""),
            }
            assistant_msg["tool_calls"] = [
                {
                    "id": tc["id"],
                    "type": "function",
                    "function": {
                        "name": tc["function"],
                        "arguments": tc["arguments"]
                        if isinstance(tc["arguments"], str)
                        else json.dumps(tc["arguments"]),
                    },
                }
                for tc in result["tool_calls"]
            ]
            messages.append(assistant_msg)

            for tc in result["tool_calls"]:
                fn_name = tc["function"]
                args_raw = tc["arguments"]
                if isinstance(args_raw, str):
                    try:
                        args = json.loads(args_raw)
                    except json.JSONDecodeError:
                        args = {}
                else:
                    args = args_raw

                logger.debug(
                    "Delegated agent %s calling tool: %s",
                    target_agent.agent_id,
                    fn_name,
                )
                tc_result = await self._handle_tool_call(
                    fn_name,
                    args,
                    target_agent,
                    delegation_callback,
                )
                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tc["id"],
                        "content": tc_result,
                    }
                )

        return result.get("content", "Agent reached max processing rounds.")

    async def handle_user_message_direct(
        self,
        content: str,
        agent_id: str,
        delegation_callback: Any = None,
        research_depth: str = "normal",
    ) -> str:
        """
        Direct message to a specific agent with tool-calling delegation support.

        If the agent decides to delegate to another agent, the orchestrator handles
        the cross-agent communication and feeds the result back. Supports up to 5
        chained delegation rounds to prevent infinite loops.

        delegation_callback: optional async callable(agent_name, target_name, task)
            called when delegation occurs, so the UI can show progress.
        research_depth: "normal" (default), "thorough" (more rounds/results),
            or "deep" (max rounds, multi-search, cross-reference).
        """
        # Persist the callback so background processes can use it too
        if delegation_callback is not None:
            self._cli_delegation_callback = delegation_callback

        # Store research depth so the web_search handler can use it
        self._current_research_depth = research_depth

        agent = self.registry.get_agent(agent_id)
        if agent is None:
            return f"Agent not found: {agent_id}"

        # Get the right LLM for this agent (respects llm_provider_override)
        llm = self._get_llm_for_agent(agent_id) or self.llm
        if llm is None:
            # Stub mode — no tool calling
            return await agent._generate(content)

        # Build messages for the tool-calling loop
        system_prompt = agent.build_system_prompt()

        # ── Special: Digital Clone editing mode for the founder agent ─────
        if agent_id == "rowan":
            clone_context = self._load_digital_clone_context()
            if clone_context:
                user = self.settings.user_name
                system_prompt += (
                    "\n\n--- CURRENT DIGITAL CLONE STATE ---\n"
                    "Below is a snapshot of your current profile documents. Use this to "
                    "know what you already have on file, identify gaps to ask about, and "
                    f"determine what needs updating when {user} shares new information.\n\n"
                    + clone_context
                )

        # ── Inject research depth instructions ─────────────
        if research_depth == "thorough":
            from datetime import datetime as _dt

            _today = _dt.now().strftime("%Y-%m-%d")
            system_prompt += (
                f"\n\n--- RESEARCH MODE: THOROUGH ---\n"
                f"Today is {_today}. The user has requested thorough research. You MUST:\n"
                "1. Perform MULTIPLE web searches with different query angles\n"
                "2. Search at least 3-4 different queries to cover the topic comprehensively\n"
                "3. ALWAYS include specific dates/timeframes in your search queries when the user "
                "mentions dates (e.g., 'Denver events March 4-9 2026' not just 'Denver events')\n"
                "4. Cross-reference findings — ONLY include events/items you can confirm actually "
                "fall within the user's specified timeframe. If you can't confirm a date, say so.\n"
                "5. Include specific details: exact dates, times, prices, locations, URLs\n"
                "6. Do NOT repeat information from earlier in the conversation that the user "
                "told you was wrong. Start fresh with new searches.\n"
                "7. Organize findings clearly with headings and bullet points\n"
                "8. After searching, ALWAYS write findings to your documents\n"
                "Do NOT stop after a single search. Keep researching until you have "
                "comprehensive, specific, actionable information."
            )
        elif research_depth == "deep":
            from datetime import datetime as _dt

            _today = _dt.now().strftime("%Y-%m-%d")
            system_prompt += (
                f"\n\n--- RESEARCH MODE: DEEP ---\n"
                f"Today is {_today}. The user has requested DEEP research. This is the most "
                "thorough level. You MUST:\n\n"
                "CRITICAL RULES:\n"
                "- IGNORE any prior search results or responses from earlier in this conversation. "
                "The user is asking you to research FRESH. Do not repeat old findings.\n"
                "- When the user specifies dates (e.g., 'March 4-9'), EVERY search query MUST "
                "include those dates. Example: 'Denver events March 4 5 6 7 8 9 2026'\n"
                "- ONLY report events/activities you can CONFIRM happen within the user's dates. "
                "If an event's dates are unknown or outside the window, DO NOT include it.\n"
                "- For each event, you MUST state the specific date(s) it occurs. No 'check closer "
                "to your travel date' — either you found the date or you didn't.\n\n"
                "SEARCH STRATEGY (do ALL of these):\n"
                "1. Search for events with specific dates included in the query\n"
                "2. Search each location/area separately (don't combine everything)\n"
                "3. Search for specific categories: concerts, festivals, dining, outdoor activities\n"
                "4. Search for venue-specific schedules (e.g., '10 Mile Music Hall March 2026')\n"
                "5. Search for event websites directly for exact schedules\n"
                "6. Verify each finding with a follow-up search\n\n"
                "OUTPUT REQUIREMENTS:\n"
                "- Exact dates and times for every event\n"
                "- Exact locations/addresses\n"
                "- Prices/ticket info when available\n"
                "- Confidence level: Confirmed / Likely / Unverified\n"
                "- Organize by day of the trip, not by category\n"
                "- ALWAYS persist your full research to your owned documents\n\n"
                "Take your time. Do 5-10+ searches. Quality and date accuracy matter more than "
                "quantity. It is BETTER to report 3 confirmed events than 10 unverified ones."
            )

        # ── Inject Theo quality flags if this agent has been flagged ──
        if agent_id in self._theo_flags and self._theo_flags[agent_id]:
            flags = self._theo_flags[agent_id]
            flag_lines = []
            for f in flags:
                flag_lines.append(
                    f"- [{f['severity']}] {f['issue'][:120]} "
                    f"(suggested fix: {f.get('fix', 'N/A')[:80]})"
                )
            system_prompt += (
                "\n\n--- QUALITY REVIEW NOTES (from Theo, system quality monitor) ---\n"
                "Theo has flagged the following quality issues in your recent responses. "
                "Be aware of these when responding and adjust accordingly:\n"
                + "\n".join(flag_lines)
                + "\n\nAcknowledge any relevant flags naturally in your response if they "
                "apply to this conversation. Do NOT list the flags — just correct the behavior."
            )

        # ── Inject Interview Mode instructions ──────────────────────
        chat_mode = getattr(self, "_current_chat_mode", "freeform")
        if chat_mode == "interview":
            # Build dynamic context: which document is the user viewing (if any)
            # and which of the agent's owned documents have gaps/are empty.
            active_doc_context = getattr(self, "_interview_active_doc", "") or ""
            doc_gap_lines: list[str] = []
            owned_docs = getattr(agent.config, "owned_documents", []) or []
            if owned_docs and self.knowledge:
                for doc_name in owned_docs[:15]:
                    doc_path_str = f"{agent_id}/{doc_name}"
                    try:
                        doc_content = self.knowledge.read_document(doc_path_str)
                        line_count = len((doc_content or "").strip().splitlines())
                        if line_count <= 5:
                            doc_gap_lines.append(f"  - {doc_name} (empty/stub — needs content)")
                        elif line_count <= 15:
                            doc_gap_lines.append(
                                f"  - {doc_name} (sparse — {line_count} lines, could use more detail)"
                            )
                    except Exception:
                        doc_gap_lines.append(f"  - {doc_name} (missing — not yet created)")

            doc_context_hint = ""
            if active_doc_context:
                doc_context_hint += (
                    f"\n\nThe user currently has **{active_doc_context}** open. "
                    "Focus your questions on filling in gaps or expanding this document. "
                    "Read the document first to see what's already there, then ask about "
                    "what's missing."
                )
            if doc_gap_lines:
                doc_context_hint += (
                    "\n\nYour documents that need more information:\n"
                    + "\n".join(doc_gap_lines)
                    + "\n\nPrioritize asking questions that help you fill these documents."
                )
            if not active_doc_context and not doc_gap_lines:
                doc_context_hint = (
                    "\n\nYou have no specific document context. Ask general questions "
                    "related to your domain expertise to learn about the user and build "
                    "your knowledge base."
                )

            system_prompt += (
                "\n\n--- INTERVIEW MODE (ACTIVE — YOU MUST ASK QUESTIONS) ---\n"
                "You are in Interview Mode. You MUST actively ask the user questions.\n\n"
                "CRITICAL RULES:\n"
                "1. Every response MUST include a call to `ask_structured_question`.\n"
                "2. Do NOT just acknowledge or summarize — you must ASK a question.\n"
                "3. Generate questions DYNAMICALLY based on what you find in your documents.\n"
                "4. Do NOT use pre-written questions. Read your documents first, identify "
                "what information is missing, and ask about THAT.\n\n"
                "YOUR PROCESS:\n"
                "1. First, use `read_document` to check your owned documents for gaps.\n"
                "2. Identify what's missing, empty, or incomplete.\n"
                "3. Ask ONE question about a specific gap using `ask_structured_question`.\n"
                "4. When the user answers, save the insight with `write_document`, "
                "then immediately ask the NEXT question.\n\n"
                "QUESTION FORMAT — choose the right mode for each question:\n"
                "- MULTIPLE-CHOICE (options with buttons): Use when there are reasonable "
                "options to suggest — preferences, categorical choices, frequency, style. "
                "Provide 3-5 options + always set allow_custom=true so the user can type "
                "their own answer if none fit.\n"
                "- FREE-ENTRY (text box only): Set free_entry_only=true and omit options. "
                "Use when guessing would be presumptuous or wrong — names, job titles, "
                "specific goals, dollar amounts, dates, personal stories, descriptions. "
                "Don't force the user to pick from made-up options for these.\n\n"
                "Examples of when to use each:\n"
                "  - 'How do you prefer to organize work?' → options (kanban/lists/calendar/etc)\n"
                "  - 'What is your current job title?' → free_entry_only (don't guess)\n"
                "  - 'How often do you want check-ins?' → options (daily/weekly/etc)\n"
                "  - 'What are your top 3 goals this year?' → free_entry_only\n"
                "  - 'Which area feels most important?' → options (health/career/etc)\n"
                "  - 'What is your annual income range?' → free_entry_only (sensitive)\n\n"
                "QUESTION SOURCES (in priority order):\n"
                "- The document the user has open (if any) — fill its gaps first\n"
                "- Your owned documents that are empty or sparse\n"
                "- Your domain expertise — what else do you need to know?\n\n"
                "After 3-5 questions, offer a brief summary and ask if they want to continue."
                + doc_context_hint
            )

        messages = list(agent.conversation_history)
        messages.append({"role": "user", "content": content})

        # Reset pending structured questions for this interaction
        self._pending_structured_questions = []

        # Each agent gets their own specialized tool set
        agent_tools = get_tools_for_agent(agent_id)

        # Adjust max rounds based on research depth
        if research_depth == "deep":
            max_tool_rounds = 25
        elif research_depth == "thorough":
            max_tool_rounds = 15
        else:
            max_tool_rounds = 10  # enough for batch + write cycles
        default_temp = 0.7
        temperature = (
            agent.config.temperature_override
            if agent.config.temperature_override is not None
            else default_temp
        )

        for _round_num in range(max_tool_rounds):
            # Call LLM with tools (using backoff + circuit-breaker)
            async with self._llm_semaphore:
                result = await self._llm_call_with_backoff(
                    llm,
                    system_prompt,
                    messages,
                    agent_tools,
                    self._resolve_model_for_agent(agent.agent_id),
                    temperature,
                    agent.agent_id,
                    _round_num,
                )
            if result is None:
                # LLM call failed — return a user-friendly message
                final_response = (
                    "I'm unable to respond right now — the AI provider returned an error. "
                    "This is usually a temporary issue or a billing/quota problem. "
                    "Check your API key settings or try switching to a different provider in Settings."
                )
                agent.conversation_history.append({"role": "user", "content": content})
                agent.conversation_history.append({"role": "assistant", "content": final_response})
                return final_response

            # Track token usage
            if self.task_queue and hasattr(llm, "last_usage") and llm.last_usage:
                try:
                    u = llm.last_usage
                    self.task_queue.record_token_usage(
                        agent_id=agent.agent_id,
                        provider=u.get("provider", "unknown"),
                        model=u.get("model", "unknown"),
                        input_tokens=u.get("input_tokens", 0),
                        output_tokens=u.get("output_tokens", 0),
                        user_id=self.current_user_id,
                    )
                except Exception:
                    pass  # Non-critical — don't break agent flow

            # If no tool calls, we have the final response
            if "tool_calls" not in result or not result["tool_calls"]:
                final_response = result.get("content", "")
                # Update agent's conversation history and persist
                agent.conversation_history.append({"role": "user", "content": content})
                agent.conversation_history.append({"role": "assistant", "content": final_response})
                await self._persist_conversation(agent.agent_id, content, final_response)
                self._current_research_depth = "normal"  # Reset depth
                return final_response

            # Process tool calls
            # First, add the assistant message with tool calls to the conversation
            assistant_msg: dict[str, Any] = {
                "role": "assistant",
                "content": result.get("content", ""),
            }
            # For OpenAI format, include tool_calls in the message
            assistant_msg["tool_calls"] = [
                {
                    "id": tc["id"],
                    "type": "function",
                    "function": {
                        "name": tc["function"],
                        "arguments": tc["arguments"]
                        if isinstance(tc["arguments"], str)
                        else json.dumps(tc["arguments"]),
                    },
                }
                for tc in result["tool_calls"]
            ]
            messages.append(assistant_msg)

            for tool_call in result["tool_calls"]:
                tool_result = await self._handle_tool_call(
                    tool_call["function"],
                    tool_call["arguments"],
                    agent,
                    delegation_callback,
                )

                # Add tool result to messages
                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tool_call["id"],
                        "content": tool_result,
                    }
                )

        # If we exhausted all rounds, return whatever we have
        final = result.get(
            "content", "I reached the maximum delegation depth. Here's what I have so far."
        )
        agent.conversation_history.append({"role": "user", "content": content})
        agent.conversation_history.append({"role": "assistant", "content": final})
        await self._persist_conversation(agent.agent_id, content, final)
        self._current_research_depth = "normal"  # Reset depth
        return final

    def pop_pending_structured_questions(self) -> list[dict]:
        """Return and clear any structured questions queued during the last interaction."""
        questions = getattr(self, "_pending_structured_questions", [])
        self._pending_structured_questions = []
        return questions

    def _queue_doc_edit(
        self, agent_id: str, doc_path: str, old_content: str, new_content: str
    ) -> None:
        """Queue a document edit for user review instead of writing directly."""
        import time

        if not hasattr(self, "_pending_doc_edits"):
            self._pending_doc_edits: list[dict] = []
        edit = {
            "id": f"edit-{int(time.time() * 1000)}",
            "agent_id": agent_id,
            "doc_path": doc_path,
            "old_content": old_content,
            "new_content": new_content,
            "timestamp": time.time(),
        }
        self._pending_doc_edits.append(edit)
        logger.info("Doc edit queued for review by %s: %s", agent_id, doc_path)

    def pop_tool_exec_log(self) -> list[dict]:
        """Return and clear tool execution events from the current chat turn."""
        log = list(self._tool_exec_log)
        self._tool_exec_log.clear()
        return log

    async def _handle_tool_call(
        self,
        fn_name: str,
        args_raw: Any,
        agent: Any,
        delegation_callback: Any = None,
    ) -> str:
        """
        Execute a single tool call and return the result string.
        Shared by both handle_user_message_direct and _run_agent_with_tools.
        """
        # Parse arguments
        if isinstance(args_raw, str):
            try:
                args = json.loads(args_raw)
            except json.JSONDecodeError:
                args = {"error": f"Invalid JSON: {args_raw}"}
        else:
            args = args_raw

        # Log tool execution start
        tool_friendly: dict[str, str] = {
            "delegate_to_agent": "Delegate",
            "ask_colleague": "Ask Colleague",
            "batch_delegate": "Batch Delegate",
            "targeted_batch_delegate": "Batch Delegate",
            "read_document": "Read Document",
            "write_document": "Write Document",
            "search_knowledge": "Search Knowledge",
            "suggest_kanban_task": "Suggest Task",
            "create_bank_transaction": "Finance Ledger",
            "score_persona_traits": "Score Traits",
            "load_persona": "Load Persona",
            "suggest_config_change": "Config Change",
            "ask_structured_question": "Ask Question",
            "suggest_activity": "Suggest Activity",
            "create_request": "Create Request",
            "write_giga_plan": "Write Plan",
            "write_giga_neuron": "Write Neuron",
            "idea_capture": "Capture Idea",
        }
        tool_label = tool_friendly.get(fn_name, fn_name.replace("_", " ").title())
        # Build a concise command summary for display
        cmd_summary = fn_name
        if fn_name == "delegate_to_agent":
            cmd_summary = f"delegate_to_agent → {args.get('target_agent_id', '?')}"
        elif fn_name == "read_document":
            cmd_summary = f"read_document({args.get('doc_path', '?')})"
        elif fn_name == "write_document":
            cmd_summary = f"write_document({args.get('doc_path', '?')})"
        elif fn_name == "search_knowledge":
            cmd_summary = f'search_knowledge("{args.get("query", "?")[:40]}")'
        elif fn_name == "suggest_kanban_task":
            cmd_summary = f'suggest_kanban_task("{args.get("title", "?")[:30]}")'
        elif fn_name == "ask_structured_question":
            cmd_summary = f'ask_question("{args.get("question", "?")[:30]}")'

        exec_event: dict[str, Any] = {
            "tool": fn_name,
            "label": tool_label,
            "command": cmd_summary,
            "agent_id": agent.agent_id if agent else "system",
            "status": "running",
            "args_summary": {
                k: (str(v)[:80] if isinstance(v, str) else v) for k, v in list(args.items())[:4]
            },
        }
        self._tool_exec_log.append(exec_event)

        tool_result = ""

        # ── ask_colleague ─────────────────────────────────
        if fn_name == "ask_colleague":
            colleague_id = args.get("colleague_agent_id", "")
            question = args.get("question", "")
            extra_context = args.get("context", "")

            colleague = self.registry.get_agent(colleague_id)
            if colleague is None:
                tool_result = f"Error: Agent '{colleague_id}' not found."
            else:
                prompt = (
                    f"Your colleague {agent.config.name} ({agent.config.title}) needs your quick input:\n\n"
                    f"{question}\n\n"
                    f"Share your perspective or advice concisely. You do not need to use tools — "
                    f"just provide your insight so they can incorporate it into their response."
                )
                if extra_context:
                    prompt += f"\n\nContext: {extra_context}"

                tool_result = await self._run_agent_with_tools(
                    colleague,
                    prompt,
                    delegation_callback,
                )
                logger.debug("Ask colleague: %s → %s", agent.agent_id, colleague_id)

        # ── delegate_to_agent ────────────────────────────
        elif fn_name == "delegate_to_agent":
            target_id = args.get("target_agent_id", "")
            task_desc = args.get("task_description", "")
            extra_context = args.get("context", "")

            target_agent = self.registry.get_agent(target_id)
            if target_agent is None:
                tool_result = f"Error: Agent '{target_id}' not found in the network."
            else:
                if delegation_callback:
                    with contextlib.suppress(Exception):
                        await delegation_callback(
                            agent.config.name,
                            target_agent.config.name,
                            task_desc,
                        )

                delegated_prompt = (
                    f"You have been asked by {agent.config.name} ({agent.config.title}) "
                    f"to help with the following:\n\n{task_desc}\n\n"
                    f"IMPORTANT: You have access to your tools. If this task involves "
                    f"creating content, templates, plans, or updates for documents you "
                    f"own, you MUST use your `write_document` tool to SAVE the output to "
                    f"the appropriate document. Do not just respond with text — persist "
                    f"your work to your owned documents."
                )
                if extra_context:
                    delegated_prompt += f"\n\nAdditional context:\n{extra_context}"

                # Run through tool loop so delegated agent can use their tools
                tool_result = await self._run_agent_with_tools(
                    target_agent,
                    delegated_prompt,
                    delegation_callback,
                )

                if self.knowledge:
                    import uuid as _uuid

                    await self.knowledge.structured.save_delegation(
                        delegation_id=_uuid.uuid4().hex[:12],
                        from_agent=agent.agent_id,
                        to_agent=target_id,
                        task_description=task_desc,
                        response=tool_result,
                        status="completed",
                    )

                logger.info("Delegation: %s → %s: %s", agent.agent_id, target_id, task_desc[:100])

        # ── batch_delegate ───────────────────────────────
        elif fn_name == "batch_delegate":
            agent_ids_list = args.get("agent_ids", [])
            task_desc = args.get("task_description", "")
            extra_context = args.get("context", "")

            if not agent_ids_list:
                tool_result = "Error: No agent_ids provided."
            else:
                if delegation_callback:
                    try:
                        targets = ", ".join(agent_ids_list)
                        await delegation_callback(
                            agent.config.name,
                            f"[{targets}]",
                            f"Batch: {task_desc[:100]}",
                        )
                    except Exception:
                        pass

                # Run all delegations in PARALLEL using asyncio.gather
                async def _run_batch_item(tid: str) -> str:
                    ta = self.registry.get_agent(tid)
                    if ta is None:
                        return f"**{tid}**: Agent not found."

                    if delegation_callback:
                        with contextlib.suppress(Exception):
                            await delegation_callback(
                                agent.config.name,
                                ta.config.name,
                                task_desc[:80],
                            )

                    prompt = (
                        f"{agent.config.name} ({agent.config.title}) is coordinating "
                        f"a batch request across the network.\n\n"
                        f"Task: {task_desc}\n\n"
                        f"IMPORTANT: You have access to your tools. If this task asks you "
                        f"to create content, templates, plans, or updates, you MUST use "
                        f"your `write_document` tool to SAVE the output to your owned "
                        f"documents. Do not just respond with text — persist your work."
                    )
                    if extra_context:
                        prompt += f"\n\nContext: {extra_context}"

                    resp = ""
                    try:
                        resp = await self._run_agent_with_tools(ta, prompt, delegation_callback)
                        if self.task_queue:
                            self.task_queue.add_notification(
                                tid, f"Batch task done: {task_desc[:60]}", "completed"
                            )
                    except Exception as e:
                        resp = f"Error — {e}"
                        if self.task_queue:
                            self.task_queue.add_notification(
                                tid, f"Batch task failed: {e}", "failed"
                            )

                    if self.knowledge:
                        import uuid as _uuid

                        await self.knowledge.structured.save_delegation(
                            delegation_id=_uuid.uuid4().hex[:12],
                            from_agent=agent.agent_id,
                            to_agent=tid,
                            task_description=task_desc,
                            response=resp,
                            status="completed" if "Error" not in resp else "failed",
                        )

                    return f"**{ta.config.name}** ({tid}):\n{resp}"

                batch_results = await asyncio.gather(
                    *[_run_batch_item(tid) for tid in agent_ids_list],
                    return_exceptions=True,
                )
                responses = [
                    str(r) if not isinstance(r, Exception) else f"Error: {r}" for r in batch_results
                ]

                tool_result = "\n\n---\n\n".join(responses)
                logger.info(
                    "Batch delegation (parallel): %s → [%s]: %s",
                    agent.agent_id,
                    ", ".join(agent_ids_list),
                    task_desc[:100],
                )

        # ── write_document ───────────────────────────────
        elif fn_name == "write_document":
            doc_path = args.get("doc_path", "")
            doc_content = args.get("content", "")

            if not doc_path or not doc_content:
                tool_result = "Error: Both doc_path and content are required."
            elif len(doc_content) > _get_doc_size_limit(doc_path):
                limit = _get_doc_size_limit(doc_path)
                tool_result = (
                    f"WRITE REJECTED — document size limit exceeded. Content is "
                    f"{len(doc_content):,} chars but the limit for this document type "
                    f"is {limit:,} chars. Please summarize or split the content and try again."
                )
                logger.warning(
                    "Write rejected for %s by %s: %d chars > limit %d",
                    doc_path,
                    agent.agent_id,
                    len(doc_content),
                    limit,
                )
            elif self.knowledge:
                try:
                    # ── Size guard: prevent accidental data loss ──
                    # If existing doc is significantly larger than the new content,
                    # reject the write and instruct the agent to read first.
                    existing = self.knowledge.documents.read(doc_path)
                    if existing and "not found" not in existing.lower():
                        existing_lines = len(existing.strip().splitlines())
                        new_lines = len(doc_content.strip().splitlines())
                        # Reject if existing is 20+ lines and new content is less than 40% of it
                        if existing_lines >= 20 and new_lines < existing_lines * 0.4:
                            tool_result = (
                                f"WRITE REJECTED — data loss protection. The existing document "
                                f"'{doc_path}' has {existing_lines} lines but your new content "
                                f"only has {new_lines} lines. This would destroy existing data.\n\n"
                                f"You MUST first call `read_document('{doc_path}')` to get the "
                                f"current content, then write the COMPLETE updated document that "
                                f"includes all existing sections plus your changes. Never replace "
                                f"a document with only new information — merge it in."
                            )
                            logger.warning(
                                "Write rejected for %s by %s: %d lines -> %d lines (data loss guard)",
                                doc_path,
                                agent.agent_id,
                                existing_lines,
                                new_lines,
                            )
                            # Return early — skip the actual write
                        else:
                            # Queue for user review if running as background task
                            if getattr(self, "_is_background_task", False):
                                self._queue_doc_edit(
                                    agent_id=agent.agent_id,
                                    doc_path=doc_path,
                                    old_content=existing or "",
                                    new_content=doc_content,
                                )
                                tool_result = f"Document edit queued for user review: {doc_path}"
                            else:
                                await self.knowledge.write_document(
                                    doc_path=doc_path,
                                    content=doc_content,
                                    author=agent.agent_id,
                                )
                                tool_result = f"Document written successfully: {doc_path}"
                    else:
                        # New document — write directly (no existing content to diff)
                        await self.knowledge.write_document(
                            doc_path=doc_path,
                            content=doc_content,
                            author=agent.agent_id,
                        )
                        tool_result = f"Document created successfully: {doc_path}"

                    if "REJECTED" not in tool_result and delegation_callback:
                        with contextlib.suppress(Exception):
                            await delegation_callback(
                                agent.config.name, "docs", f"Wrote: {doc_path}"
                            )

                    if "REJECTED" not in tool_result:
                        logger.info("Document written by %s: %s", agent.agent_id, doc_path)
                except Exception as e:
                    tool_result = f"Error writing document: {e}"
            else:
                tool_result = "Knowledge store not available."

        # ── read_document ────────────────────────────────
        elif fn_name == "read_document":
            doc_path = args.get("doc_path", "")
            if self.knowledge:
                tool_result = await self.knowledge.read_document(doc_path)
            else:
                tool_result = "Knowledge store not available."

        # ── search_knowledge ─────────────────────────────
        elif fn_name == "search_knowledge":
            query = args.get("query", "")
            top_k = args.get("top_k", 5)
            if self.knowledge and hasattr(self.knowledge, "vector"):
                try:
                    results = self.knowledge.vector.search(query, top_k=top_k)
                    if results:
                        formatted = []
                        for i, r in enumerate(results, 1):
                            dist = r.get("distance", "?")
                            score_str = (
                                f"{dist:.3f}" if isinstance(dist, (int, float)) else str(dist)
                            )
                            formatted.append(
                                f"**Result {i}** (distance: {score_str}):\n"
                                f"{r.get('content', r.get('document', 'No content'))}"
                            )
                        tool_result = "\n\n---\n\n".join(formatted)
                    else:
                        tool_result = "No results found for that query."
                except Exception as e:
                    tool_result = f"Search error: {e}"
            else:
                tool_result = "Knowledge vector store not available."

        # ── store_insight ────────────────────────────────
        elif fn_name == "store_insight":
            insight_content = args.get("content", "")
            tags = args.get("tags", [])
            if self.knowledge and hasattr(self.knowledge, "vector"):
                try:
                    metadata = {"author": agent.agent_id, "type": "insight"}
                    if tags:
                        metadata["tags"] = ", ".join(tags)
                    doc_id = self.knowledge.vector.add(
                        content=insight_content,
                        metadata=metadata,
                    )
                    tool_result = f"Insight stored (id: {doc_id}, tags: {', '.join(tags)})"
                    logger.info("Insight stored by %s: %s", agent.agent_id, insight_content[:80])
                except Exception as e:
                    tool_result = f"Error storing insight: {e}"
            else:
                tool_result = "Knowledge vector store not available."

        # ── read_agent_config ────────────────────────────
        elif fn_name == "read_agent_config":
            target_id = args.get("agent_id", "")
            target_agent = self.registry.get_agent(target_id)
            if target_agent is None:
                tool_result = f"Agent '{target_id}' not found."
            else:
                import yaml as _yaml

                cfg = target_agent.config
                config_dict = {
                    "agent_id": cfg.agent_id,
                    "name": cfg.name,
                    "title": cfg.title,
                    "tone": cfg.tone,
                    "role_description": cfg.role_description,
                    "responsibilities": cfg.responsibilities,
                    "reports_to": cfg.reports_to,
                    "direct_reports": cfg.direct_reports,
                    "owned_documents": cfg.owned_documents,
                    "tools": getattr(cfg, "tools", []),
                    "system_prompt_extra": getattr(cfg, "system_prompt_extra", ""),
                }
                tool_result = _yaml.dump(config_dict, default_flow_style=False, allow_unicode=True)
                logger.info("Theo audited config for %s", target_id)

        # ── suggest_prompt_fix ───────────────────────────
        elif fn_name == "suggest_prompt_fix":
            target_id = args.get("agent_id", "")
            issue = args.get("issue", "")
            suggested_fix = args.get("suggested_fix", "")

            import datetime as _dt

            entry = (
                f"\n## Prompt Fix — {target_id} ({_dt.datetime.now().isoformat()})\n"
                f"**Issue**: {issue}\n"
                f"**Suggested Fix**: {suggested_fix}\n"
            )
            if self.knowledge:
                try:
                    existing = await self.knowledge.read_document("theo/prompt-audit-log.md")
                    if existing.startswith("Document not found") or existing.startswith("Error"):
                        existing = "# Prompt Audit Log\n"
                    new_content = existing + entry
                    await self.knowledge.write_document(
                        doc_path="theo/prompt-audit-log.md",
                        content=new_content,
                        author="theo",
                    )
                    tool_result = f"Prompt fix suggestion logged for {target_id}."
                except Exception as e:
                    tool_result = f"Error logging suggestion: {e}"
            else:
                tool_result = entry + "\n(Not persisted — knowledge store unavailable)"
            logger.info("Prompt fix suggested for %s by %s", target_id, agent.agent_id)

        # ── get_network_status ───────────────────────────
        elif fn_name == "get_network_status":
            lines = ["# Vastian Network Status\n"]
            for cfg in self.registry.all_configs:
                a = self.registry.get_agent(cfg.agent_id)
                msg_count = len(a.conversation_history) if a else 0
                sigil = f"{cfg.sigil} " if cfg.sigil else ""
                lines.append(
                    f"- **{sigil}{cfg.name}** (`{cfg.agent_id}`) — {cfg.title}\n"
                    f"  Reports to: {cfg.reports_to or 'N/A'} | "
                    f"Messages: {msg_count} | "
                    f"Docs: {len(cfg.owned_documents or [])}"
                )
            if self.knowledge:
                try:
                    all_delegations = await self.knowledge.structured.get_delegations()
                    lines.append(f"\n**Total delegations logged**: {len(all_delegations)}")
                except Exception:
                    pass
            tool_result = "\n".join(lines)

        # ── get_tasks ────────────────────────────────────
        elif fn_name == "get_tasks":
            filter_agent = args.get("agent_id", "")
            filter_status = args.get("status", "all")
            if self.knowledge:
                try:
                    delegations = await self.knowledge.structured.get_delegations(
                        agent_id=filter_agent or None,
                        status=filter_status if filter_status != "all" else None,
                    )
                    if not delegations:
                        tool_result = "No tasks found."
                    else:
                        lines = [f"# Tasks ({len(delegations)} found)\n"]
                        for d in delegations:
                            lines.append(
                                f"- **{d.get('from_agent', '?')} → {d.get('to_agent', '?')}** "
                                f"[{d.get('status', '?')}]\n"
                                f"  {d.get('task_description', '')[:120]}"
                            )
                        tool_result = "\n".join(lines)
                except Exception as e:
                    tool_result = f"Error fetching tasks: {e}"
            else:
                tool_result = "Knowledge store not available."

        # ── read_relay_outbox (Maria) ────────────────────
        elif fn_name == "read_relay_outbox":
            from vastian.relay import read_relay_outbox

            project_root = self.settings.agents_dir.parent if self.settings else None
            reports = read_relay_outbox(project_root)
            if not reports:
                tool_result = "No relay reports in state/relay/outbox/."
            else:
                lines = [f"# Relay outbox ({len(reports)} reports)\n"]
                for r in reports:
                    cat = r.get("_category", r.get("category", "?"))
                    agent = r.get("agent_id", "?")
                    summary = (r.get("summary") or "")[:200]
                    lines.append(f"- **[{cat}]** {agent}: {summary}")
                tool_result = "\n".join(lines)

        # ── write_relay_report (Theo, Orion — report to Relay instead of Kanban) ──
        elif fn_name == "write_relay_report":
            from vastian.relay import write_relay_report

            category = (args.get("category") or "").strip().lower()
            summary = (args.get("summary") or "").strip()
            payload = args.get("payload") or {}
            if isinstance(payload, str):
                try:
                    payload = json.loads(payload) if payload else {}
                except json.JSONDecodeError:
                    payload = {}
            if not category or not summary:
                tool_result = "Error: category and summary are required."
            else:
                project_root = self.settings.agents_dir.parent if self.settings else None
                rel_path = write_relay_report(
                    agent_id=agent.agent_id,
                    category=category,
                    summary=summary,
                    payload=payload,
                    project_root=project_root,
                )
                if rel_path:
                    tool_result = f"Relay report written to {rel_path}. Maria will triage and create REL- tickets as needed."
                else:
                    tool_result = "Failed to write relay report (invalid category or I/O error). Valid categories: prompts, ops, ux, pulse, personas, metrics, scenarios, knowledge, config."

        # ── suggest_config_change (Theo, Orion, Henry — L7) ──
        elif fn_name == "suggest_config_change":
            from vastian.relay import write_relay_report

            config_file = (args.get("config_file") or "").strip()
            field = (args.get("field") or "").strip()
            current_value = (args.get("current_value") or "").strip()
            proposed_value = (args.get("proposed_value") or "").strip()
            reason = (args.get("reason") or "").strip()
            list_op = (args.get("list_operation") or "").strip().lower()
            list_item = (args.get("list_item") or "").strip()
            if not config_file or not field or not reason:
                tool_result = "Error: config_file, field, and reason are required."
            elif list_op and not list_item:
                tool_result = "Error: list_item is required when list_operation is set."
            elif list_op and list_op not in ("add", "remove"):
                tool_result = "Error: list_operation must be 'add' or 'remove'."
            elif not proposed_value and not (list_op and list_item):
                tool_result = (
                    "Error: provide either proposed_value (scalar) or list_operation + list_item."
                )
            else:
                project_root = self.settings.agents_dir.parent if self.settings else None
                payload = {
                    "config_file": config_file,
                    "field": field,
                    "current_value": current_value,
                    "proposed_value": proposed_value,
                    "reason": reason,
                }
                if list_op and list_item:
                    payload["list_operation"] = list_op
                    payload["list_item"] = list_item
                summary = f"Config: {config_file} — {field} — {reason[:100]}"
                rel_path = write_relay_report(
                    agent_id=agent.agent_id,
                    category="config",
                    summary=summary,
                    payload=payload,
                    project_root=project_root,
                )
                if rel_path:
                    tool_result = f"Config suggestion written to {rel_path}. Maria will triage and add a config card for user approval."
                else:
                    tool_result = "Failed to write config suggestion (I/O error)."

        # ── queue_config_card (Maria — L7) ──
        elif fn_name == "queue_config_card":
            config_file = (args.get("config_file") or "").strip()
            field = (args.get("field") or "").strip()
            current_value = (args.get("current_value") or "").strip()
            proposed_value = (args.get("proposed_value") or "").strip()
            reason = (args.get("reason") or "").strip()
            list_op = (args.get("list_operation") or "").strip()
            list_item = (args.get("list_item") or "").strip()
            if not config_file or not field or not reason:
                tool_result = "Error: config_file, field, and reason are required."
            else:
                sd = self.settings.state_dir if self.settings else Path("./state")
                pending_path = sd / "pending-config-cards.json"
                import uuid
                from datetime import datetime

                card = {
                    "suggestion_id": uuid.uuid4().hex[:12],
                    "config_file": config_file,
                    "field": field,
                    "current_value": current_value,
                    "proposed_value": proposed_value,
                    "reason": reason,
                    "created_at": datetime.now(_dt.UTC).isoformat(),
                }
                if list_op and list_item:
                    card["list_operation"] = list_op
                    card["list_item"] = list_item
                try:
                    from vastian.storage.file_store import FileStore

                    store = FileStore(pending_path, id_field="suggestion_id")
                    store.insert(card)
                    tool_result = f"Config card queued (id={card['suggestion_id']}). User will see Accept/Reject in the next session."
                except Exception as e:
                    logger.warning("queue_config_card failed: %s", e)
                    tool_result = f"Failed to queue config card: {e}"

        # ── Felix: Data lifecycle tools ───────────────────
        elif fn_name == "check_seed_version":
            from vastian.seed.migrations import SEED_VERSION, get_seed_version, needs_migration

            sd = self.settings.state_dir if self.settings else Path("./state")
            info = get_seed_version(sd)
            pending = needs_migration(sd)
            tool_result = (
                f"Current seed version: {SEED_VERSION}\n"
                f"User's applied version: {info.get('version', '0.0.0')}\n"
                f"Migrations applied: {info.get('migrations_applied', [])}\n"
                f"Last run: {info.get('last_run', 'never')}\n"
                f"Pending migrations: {'Yes' if pending else 'No'}"
            )

        elif fn_name == "run_data_migrations":
            from vastian.seed.migrations import run_pending

            sd = self.settings.state_dir if self.settings else Path("./state")
            dr = self.settings.data_root if self.settings else Path(".")
            applied = run_pending(sd, dr)
            if applied:
                tool_result = f"Applied {len(applied)} migration(s): {applied}"
            else:
                tool_result = "No pending migrations. Data is up to date."

        elif fn_name == "check_data_integrity":
            from vastian.seed import fixup_state, heal_missing_docs

            agents_dir = self.settings.agents_dir if self.settings else Path("./agents")
            docs_dir = self.settings.docs_dir if self.settings else Path("./docs")
            state_dir = self.settings.state_dir if self.settings else Path("./state")
            missing_docs = heal_missing_docs(agents_dir, docs_dir, dry_run=True)
            state_fixes = fixup_state(state_dir)
            parts = []
            if missing_docs:
                parts.append(f"Missing docs ({len(missing_docs)}): {missing_docs[:10]}")
            else:
                parts.append("All agent docs present.")
            if state_fixes:
                parts.append(f"State files fixed ({len(state_fixes)}): {state_fixes}")
            else:
                parts.append("All state files healthy.")
            tool_result = "\n".join(parts)

        elif fn_name == "get_pool_status":
            from vastian.server.cloud_app import _worker_pool

            if _worker_pool is not None:
                tool_result = json.dumps(_worker_pool.status(), indent=2)
            else:
                tool_result = (
                    "Worker pool not initialized (desktop mode or not running cloud-serve)."
                )

        # ── Suggestion tools (agent → user approval flow) ──
        elif fn_name in (
            "suggest_activity",
            "suggest_challenge",
            "suggest_council",
            "suggest_mentor_addition",
            "suggest_plan",
            "suggest_scenario",
            "suggest_roleplay",
            "suggest_real_coach",
        ):
            _stype_map = {
                "suggest_activity": "activity",
                "suggest_challenge": "challenge",
                "suggest_council": "council",
                "suggest_mentor_addition": "mentor",
                "suggest_plan": "plan",
                "suggest_scenario": "scenario",
                "suggest_roleplay": "roleplay",
                "suggest_real_coach": "real_coach",
            }
            stype = _stype_map[fn_name]
            title = args.get("title", "Untitled suggestion")
            description = args.get("description", "")
            coach_type = args.get("coach_type", "")
            payload = {}
            for k in (
                "payload",
                "mentor_data",
                "scenario_config",
                "plan_type",
                "plan_id",
                "persona_id",
                "council_type",
                "context",
            ):
                if k in args:
                    payload[k] = args[k]
            if self.task_queue:
                try:
                    sid = self.task_queue.create_suggestion(
                        suggestion_type=stype,
                        agent_id=agent.agent_id,
                        title=title,
                        description=description,
                        coach_type=coach_type,
                        payload=payload,
                        user_id=self.current_user_id,
                    )
                    tool_result = f"Suggestion created ({stype}): {sid}. The user will be notified and can accept or reject it."
                except Exception as e:
                    tool_result = f"Error creating suggestion: {e}"
            else:
                tool_result = "TaskQueue not available — cannot create suggestions."

        # ── Felix engagement/roadmap tools ───────────────
        elif fn_name == "check_user_engagement":
            try:
                # Gather engagement stats from state
                from vastian.storage.data_root import get_data_root

                _dr = get_data_root(Path.cwd())
                stats: dict[str, Any] = {}
                # Last chat timestamp
                sj_path = _dr / "state" / "session-journal.json"
                if sj_path.exists():
                    sj_data = json.loads(sj_path.read_text(encoding="utf-8"))
                    if isinstance(sj_data, list) and sj_data:
                        stats["total_sessions"] = len(sj_data)
                        stats["last_session"] = sj_data[-1].get("created_at", "unknown")
                # Doc count
                docs_dir = _dr / "docs"
                if docs_dir.exists():
                    doc_count = sum(1 for _ in docs_dir.rglob("*.md"))
                    stats["total_docs"] = doc_count
                # Kanban task count
                if self.task_queue:
                    all_tasks = self.task_queue.get_kanban_tasks()
                    stats["kanban_total"] = len(all_tasks)
                    stats["kanban_done"] = sum(1 for t in all_tasks if t.get("status") == "done")
                tool_result = json.dumps(stats, indent=2)
            except Exception as e:
                tool_result = f"Error checking engagement: {e}"

        elif fn_name == "check_roadmap_progress":
            try:
                if self.task_queue:
                    tasks = self.task_queue.get_kanban_tasks()
                    total = len(tasks)
                    by_status: dict[str, int] = {}
                    for t in tasks:
                        s = t.get("status", "unknown")
                        by_status[s] = by_status.get(s, 0) + 1
                    completion = (by_status.get("done", 0) / total * 100) if total else 0
                    tool_result = json.dumps(
                        {
                            "total_tasks": total,
                            "by_status": by_status,
                            "completion_pct": round(completion, 1),
                            "blocked": by_status.get("blocked", 0),
                        },
                        indent=2,
                    )
                else:
                    tool_result = "TaskQueue not available."
            except Exception as e:
                tool_result = f"Error checking roadmap: {e}"

        elif fn_name == "alert_inactivity":
            days_threshold = args.get("days_threshold", 3)
            try:
                from vastian.storage.data_root import get_data_root

                _dr = get_data_root(Path.cwd())
                sj_path = _dr / "state" / "session-journal.json"
                if sj_path.exists():
                    sj_data = json.loads(sj_path.read_text(encoding="utf-8"))
                    if isinstance(sj_data, list) and sj_data:
                        last = sj_data[-1].get("created_at", "")
                        if last:
                            from datetime import datetime as _dt

                            last_dt = _dt.fromisoformat(last.replace("Z", "+00:00"))
                            idle_days = (datetime.now(UTC) - last_dt).days
                            if idle_days >= days_threshold:
                                if self.task_queue:
                                    self.task_queue._add_notification(
                                        None,
                                        agent.agent_id,
                                        f"User inactive for {idle_days} days (threshold: {days_threshold}). Consider outreach.",
                                        "warning",
                                    )
                                tool_result = f"Inactivity alert triggered: {idle_days} days idle."
                            else:
                                tool_result = f"User is active (last session {idle_days} day(s) ago, threshold {days_threshold})."
                        else:
                            tool_result = "Could not determine last session time."
                    else:
                        tool_result = "No sessions recorded yet."
                else:
                    tool_result = "No session journal found."
            except Exception as e:
                tool_result = f"Error checking inactivity: {e}"

        # ── convene_council ──────────────────────────────
        elif fn_name == "convene_council":
            topic = args.get("topic", "")
            council_type = args.get("council_type", "ad_hoc")
            member_ids = args.get("member_ids", [])
            if self.council:
                try:
                    result_council = await self.convene_council(
                        topic=topic,
                        council_type=council_type,
                        member_ids=member_ids or None,
                    )
                    # Mars M3b: auto-neuron after council meeting
                    try:
                        _emit_council_neuron(result_council, topic, council_type)
                    except Exception as _e:
                        logger.debug("Council neuron emit: %s", _e)
                    tool_result = json.dumps(
                        result_council.get("summary", result_council),
                        indent=2,
                        default=str,
                    )
                except Exception as e:
                    tool_result = f"Council error: {e}"
            else:
                tool_result = "Council engine not initialized."

        # ── review_council_actions ───────────────────────
        elif fn_name == "review_council_actions":
            session_id = args.get("session_id", "")
            should_delegate = args.get("delegate", False)

            if self.knowledge:
                try:
                    if session_id:
                        session_data = await self.knowledge.structured.get_council_session(
                            session_id
                        )
                        sessions = [session_data] if session_data else []
                    else:
                        # Fetch last 10 sessions, prioritising those relevant to the calling agent
                        all_sessions = await self.knowledge.structured.get_council_sessions(
                            limit=10
                        )
                        calling_agent = agent.agent_id if agent else ""

                        def _agent_relevance(s: dict) -> int:
                            """Higher score = more relevant to calling agent."""
                            score = 0
                            # Check if agent is a council member
                            if calling_agent in s.get("members", []):
                                score += 2
                            # Check if agent appears in action items
                            for item in s.get("action_items", []):
                                if item.get("assigned_to") == calling_agent:
                                    score += 3
                            # Check if agent is mentioned in synthesis text
                            syn = s.get("synthesis", "").lower()
                            if calling_agent and calling_agent.lower() in syn:
                                score += 1
                            return score

                        # Sort: agent-relevant first, then by recency
                        if calling_agent:
                            all_sessions.sort(
                                key=lambda s: (-_agent_relevance(s), s.get("created_at", "")),
                                reverse=False,
                            )
                            # Re-sort: highest relevance first, then newest
                            all_sessions.sort(key=lambda s: _agent_relevance(s), reverse=True)
                        sessions = all_sessions[:5]

                    if not sessions:
                        tool_result = "No council sessions found."
                    else:
                        all_parts: list[str] = []

                        for idx, s in enumerate(sessions):
                            parts = [
                                f"## Council Session {idx + 1}: {s.get('id', 'N/A')}",
                                f"**Topic:** {s.get('topic', 'N/A')}",
                                f"**Type:** {s.get('council_type', 'N/A')}",
                                f"**Members:** {', '.join(s.get('members', []))}",
                                f"**Status:** {s.get('status', 'N/A')}",
                                f"**Created:** {s.get('created_at', 'N/A')}",
                                "",
                                "### Synthesis",
                                s.get("synthesis", "(no synthesis)"),
                                "",
                                "### Action Items",
                            ]
                            action_items = s.get("action_items", [])
                            if action_items:
                                for i, item in enumerate(action_items, 1):
                                    marker = (
                                        " **<-- YOUR ACTION**"
                                        if item.get("assigned_to")
                                        == (agent.agent_id if agent else "")
                                        else ""
                                    )
                                    parts.append(
                                        f"{i}. [{item.get('priority', 'normal').upper()}] "
                                        f"**{item.get('assigned_to', '?')}**: {item.get('description', '?')}{marker}"
                                    )
                            else:
                                parts.append(
                                    "(No structured action items found — check synthesis text for inline actions)"
                                )

                            all_parts.extend(parts)
                            all_parts.append("\n---\n")

                        # Delegate from the first session if requested
                        if (
                            should_delegate
                            and sessions
                            and self.council
                            and self.council._delegation_callback
                        ):
                            s = sessions[0]
                            action_items = s.get("action_items", [])
                            items_to_delegate = list(action_items)
                            if not items_to_delegate:
                                import re

                                synthesis = s.get("synthesis", "")
                                for line in synthesis.split("\n"):
                                    clean = re.sub(r"\*\*", "", line.strip())
                                    clean = re.sub(r"^\s*[-*]\s*", "", clean).strip()
                                    if not re.search(r"\[?ACTION\]?", clean, re.IGNORECASE):
                                        continue
                                    assigned = "charles"
                                    for aid in self.registry.agent_ids:
                                        if re.search(
                                            rf"\[{re.escape(aid)}\]|(?<!\w){re.escape(aid)}(?!\w)",
                                            clean,
                                            re.IGNORECASE,
                                        ):
                                            assigned = aid
                                            break
                                    desc_parts = clean.split(":", 1)
                                    desc = desc_parts[1].strip() if len(desc_parts) == 2 else clean
                                    items_to_delegate.append(
                                        {"assigned_to": assigned, "description": desc}
                                    )

                            delegated = []
                            for item in items_to_delegate:
                                agent_id = item.get("assigned_to", "charles")
                                desc = item.get("description", "")
                                if desc and self.registry.get_agent(agent_id):
                                    try:
                                        await self._delegate_council_action(
                                            agent_id, desc, s.get("topic", "")
                                        )
                                        delegated.append(
                                            f"  -> Delegated to {agent_id}: {desc[:60]}"
                                        )
                                    except Exception as e:
                                        delegated.append(f"  -> FAILED for {agent_id}: {e}")

                            if delegated:
                                all_parts.append("### Delegation Results")
                                all_parts.extend(delegated)
                            else:
                                all_parts.append("(No action items to delegate)")

                        tool_result = "\n".join(all_parts)
                except Exception as e:
                    tool_result = f"Error reviewing council: {e}"
            else:
                tool_result = "Knowledge store not available."

        # ── search_codebase ──────────────────────────────
        elif fn_name == "search_codebase":
            query = args.get("query", "")
            file_pattern = args.get("file_pattern", "*.py")
            import pathlib as _pathlib

            base = self.settings.agents_dir.parent if self.settings else _pathlib.Path(".")
            src_dir = base / "src"
            results_lines: list[str] = []
            try:
                for py_file in src_dir.rglob(file_pattern):
                    try:
                        text = py_file.read_text(encoding="utf-8")
                        for i, line in enumerate(text.splitlines(), 1):
                            if query.lower() in line.lower():
                                results_lines.append(f"{py_file.name}:{i}: {line.strip()}")
                    except Exception:
                        continue
                if results_lines:
                    tool_result = "\n".join(results_lines[:30])
                else:
                    tool_result = f"No matches found for '{query}' in {file_pattern}"
            except Exception as e:
                tool_result = f"Search error: {e}"

        # ── targeted_batch_delegate ──────────────────────
        elif fn_name == "targeted_batch_delegate":
            assignments = args.get("assignments", [])
            shared_context = args.get("context", "")

            if not assignments:
                tool_result = "Error: No assignments provided."
            else:
                if delegation_callback:
                    try:
                        targets = ", ".join(a.get("agent_id", "?") for a in assignments)
                        await delegation_callback(
                            agent.config.name,
                            f"[{targets}]",
                            "Targeted batch",
                        )
                    except Exception:
                        pass

                # Run all targeted delegations in PARALLEL
                async def _run_targeted_item(assignment: dict) -> str:
                    tid = assignment.get("agent_id", "")
                    task_desc = assignment.get("task_description", "")
                    ta = self.registry.get_agent(tid)

                    if ta is None:
                        return f"**{tid}**: Agent not found."

                    if delegation_callback:
                        with contextlib.suppress(Exception):
                            await delegation_callback(
                                agent.config.name,
                                ta.config.name,
                                task_desc[:80],
                            )

                    prompt = (
                        f"{agent.config.name} ({agent.config.title}) has a specific "
                        f"task for you:\n\n{task_desc}\n\n"
                        f"IMPORTANT: You have access to your tools. If this task asks you "
                        f"to create content, templates, plans, or updates, you MUST use "
                        f"your `write_document` tool to SAVE the output to your owned "
                        f"documents. Do not just respond with text — persist your work."
                    )
                    if shared_context:
                        prompt += f"\n\nShared context: {shared_context}"

                    resp = ""
                    try:
                        resp = await self._run_agent_with_tools(ta, prompt, delegation_callback)
                        if self.task_queue:
                            self.task_queue.add_notification(
                                tid, f"Task done: {task_desc[:60]}", "completed"
                            )
                    except Exception as e:
                        resp = f"Error — {e}"
                        if self.task_queue:
                            self.task_queue.add_notification(tid, f"Task failed: {e}", "failed")

                    if self.knowledge:
                        import uuid as _uuid

                        await self.knowledge.structured.save_delegation(
                            delegation_id=_uuid.uuid4().hex[:12],
                            from_agent=agent.agent_id,
                            to_agent=tid,
                            task_description=task_desc,
                            response=resp,
                            status="completed" if "Error" not in resp else "failed",
                        )

                    return f"**{ta.config.name}** ({tid}):\n{resp}"

                batch_results = await asyncio.gather(
                    *[_run_targeted_item(a) for a in assignments],
                    return_exceptions=True,
                )
                responses = [
                    str(r) if not isinstance(r, Exception) else f"Error: {r}" for r in batch_results
                ]

                tool_result = "\n\n---\n\n".join(responses)
                logger.info(
                    "Targeted batch (parallel): %s → [%s]",
                    agent.agent_id,
                    ", ".join(a.get("agent_id", "?") for a in assignments),
                )

        # ── spawn_sub_agent ──────────────────────────────
        elif fn_name == "spawn_sub_agent":
            sub_name = args.get("name", "Specialist")
            sub_title = args.get("title", "Specialist Agent")
            sub_task = args.get("task_description", "")
            sub_tone = args.get("tone", "professional and focused")
            sub_expertise = args.get("expertise", "")

            # Create a temporary agent config
            import uuid as _uuid

            sub_id = f"sub-{_uuid.uuid4().hex[:6]}"

            from vastian.agents.base import AgentConfig, BaseAgent

            sub_config = AgentConfig(
                agent_id=sub_id,
                name=sub_name,
                title=sub_title,
                sigil="🔧",
                tone=sub_tone,
                role_description=(
                    f"Temporary specialist sub-agent spawned by {agent.config.name}. "
                    f"Expertise: {sub_expertise}. Task: {sub_task}"
                ),
                responsibilities=[f"Complete the assigned task: {sub_task[:100]}"],
                owned_documents=[],
                reports_to=agent.agent_id,
                tools=["delegate_to_agent", "read_document", "write_document"],
                system_prompt_extra=(
                    f"You are {sub_name}, a specialist in {sub_expertise}. You were brought "
                    f"in by {agent.config.name} to help with a specific task. Be focused, "
                    f"thorough, and deliver actionable results. When you're done, provide "
                    f"a clear summary of your findings/output."
                ),
            )

            # Register the sub-agent in the registry
            sub_agent = BaseAgent(sub_config, bus=self.bus)
            self.registry._agents[sub_id] = sub_agent
            self.registry._configs[sub_id] = sub_config

            # Inject network context
            if hasattr(self, "_inject_network_context"):
                self._inject_network_context()

            if delegation_callback:
                with contextlib.suppress(Exception):
                    await delegation_callback(
                        agent.config.name,
                        f"🔧 {sub_name}",
                        f"Spawned: {sub_title} — {sub_expertise}",
                    )

            # Run the sub-agent on their task
            delegated_prompt = (
                f"You have been spawned by {agent.config.name} ({agent.config.title}) "
                f"to help with the following task:\n\n{sub_task}\n\n"
                f"Your expertise: {sub_expertise}\n"
                f"Deliver focused, actionable results."
            )
            try:
                tool_result = await self._run_agent_with_tools(
                    sub_agent,
                    delegated_prompt,
                    delegation_callback,
                )
                tool_result = f"**🔧 {sub_name}** ({sub_title}) responded:\n\n{tool_result}"
            except Exception as e:
                tool_result = f"Sub-agent error: {e}"

            logger.info(
                "Sub-agent spawned by %s: %s (%s) — %s",
                agent.agent_id,
                sub_name,
                sub_title,
                sub_expertise[:50],
            )

        # ── run_simulation ───────────────────────────────
        elif fn_name == "run_simulation":
            scenario_name = args.get("scenario_name", "Unnamed Scenario")
            scenario_desc = args.get("scenario_description", "")
            affected_vars = args.get("affected_variables", [])
            baselines = args.get("baselines", {})
            proposed_change = args.get("proposed_change", "")
            timeframe = args.get("timeframe", "90 days")

            if not scenario_desc or not affected_vars:
                tool_result = "Error: scenario_description and affected_variables are required."
            else:
                # Build a structured simulation projection using the LLM
                sim_prompt = (
                    f"You are a simulation engine. Produce a structured what-if analysis.\n\n"
                    f"SCENARIO: {scenario_name}\n"
                    f"DESCRIPTION: {scenario_desc}\n"
                    f"PROPOSED CHANGE: {proposed_change}\n"
                    f"TIMEFRAME: {timeframe}\n"
                    f"AFFECTED VARIABLES: {', '.join(affected_vars)}\n"
                    f"CURRENT BASELINES: {json.dumps(baselines) if baselines else 'Not provided — estimate from context'}\n\n"
                    f"For EACH affected variable, produce:\n"
                    f"1. Current baseline (known or estimated)\n"
                    f"2. Projected value after the change\n"
                    f"3. Confidence level (High/Medium/Low)\n"
                    f"4. Key assumptions\n"
                    f"5. Risk factors\n\n"
                    f"Then provide:\n"
                    f"- OVERALL IMPACT SUMMARY (1-2 sentences)\n"
                    f"- TOP 3 TRADEOFFS to consider\n"
                    f"- RECOMMENDATION (proceed / proceed with caution / reconsider)\n\n"
                    f"Format as clean Markdown. Be specific with numbers."
                )
                llm = self._get_llm_for_agent(agent.agent_id) or self.llm
                if llm:
                    try:
                        sim_result = await llm.generate_with_tools(
                            system_prompt="You are a precise simulation engine for personal growth metrics.",
                            messages=[{"role": "user", "content": sim_prompt}],
                            tools=[],
                            model=self._resolve_model_for_agent(agent.agent_id),
                            temperature=0.4,
                        )
                        tool_result = (
                            f"## Simulation: {scenario_name}\n\n"
                            f"{sim_result.get('content', 'Simulation produced no output.')}"
                        )
                    except Exception as e:
                        tool_result = f"Simulation error: {e}"
                else:
                    tool_result = "Error: No LLM available for simulation."

                logger.info("Simulation run by %s: %s", agent.agent_id, scenario_name)

        # ── score_metrics ───────────────────────────────
        elif fn_name == "score_metrics":
            variables = args.get("variables", [])
            assessment_data = args.get("assessment_data", {})
            period = args.get("period", "past 7 days")

            if not variables:
                tool_result = "Error: variables list is required."
            else:
                # Build a structured assessment using the LLM
                scoring_prompt = (
                    f"You are a metrics scoring engine. Score each variable 0-100.\n\n"
                    f"PERIOD: {period}\n"
                    f"VARIABLES TO SCORE: {', '.join(variables)}\n"
                    f"ASSESSMENT DATA: {json.dumps(assessment_data) if assessment_data else 'Use conversation context'}\n\n"
                    f"For EACH variable, produce:\n"
                    f"1. Score (0-100)\n"
                    f"2. Formula used\n"
                    f"3. Calculation breakdown\n"
                    f"4. Trend (improving / stable / declining) if historical data exists\n"
                    f"5. One actionable recommendation\n\n"
                    f"Then provide:\n"
                    f"- OVERALL COMPOSITE SCORE (weighted average)\n"
                    f"- TOP 3 areas needing attention\n"
                    f"- TOP 3 strengths\n\n"
                    f"Format as clean Markdown with a summary table."
                )
                llm = self._get_llm_for_agent(agent.agent_id) or self.llm
                if llm:
                    try:
                        score_result = await llm.generate_with_tools(
                            system_prompt="You are a precise metrics scoring engine for personal growth assessment.",
                            messages=[{"role": "user", "content": scoring_prompt}],
                            tools=[],
                            model=self._resolve_model_for_agent(agent.agent_id),
                            temperature=0.3,
                        )
                        tool_result = (
                            f"## Metric Assessment ({period})\n\n"
                            f"{score_result.get('content', 'Assessment produced no output.')}"
                        )
                        # E6: Route metrics to Jake and Concord
                        asyncio.get_event_loop().call_soon(
                            lambda: asyncio.create_task(
                                self._route_metrics_results(
                                    {"period": period, "variables": variables}
                                )
                            )
                        )
                    except Exception as e:
                        tool_result = f"Scoring error: {e}"
                else:
                    tool_result = "Error: No LLM available for scoring."

                logger.info("Metrics scored by %s: %s", agent.agent_id, ", ".join(variables[:5]))

        # ── read_source_file ──────────────────────────────
        elif fn_name == "read_source_file":
            file_path = args.get("file_path", "")
            import pathlib as _pathlib

            base = self.settings.agents_dir.parent if self.settings else _pathlib.Path(".")
            src_dir = base / "src"
            target = src_dir / file_path
            try:
                if not target.exists():
                    tool_result = f"File not found: {file_path}"
                elif target.stat().st_size > 50000:
                    tool_result = f"File too large ({target.stat().st_size} bytes). Use search_codebase for specific queries."
                else:
                    tool_result = target.read_text(encoding="utf-8")
            except Exception as e:
                tool_result = f"Error reading file: {e}"

        # ── extract_signals (Maya) ────────────────────────
        elif fn_name == "extract_signals":
            meeting_text = args.get("meeting_text", "")
            if not meeting_text:
                tool_result = "Error: meeting_text is required."
            else:
                try:
                    from vastian.meeting_analyzer import extract_signals_from_sections
                    from vastian.meeting_analyzer.parser import parse_meeting_summary_adaptive

                    parsed = parse_meeting_summary_adaptive(meeting_text)
                    signals = extract_signals_from_sections(parsed)
                    import json as _json

                    tool_result = _json.dumps(signals, indent=2, default=str)
                    logger.info(
                        "extract_signals by %s: %d decisions, %d action_items, %d blockers",
                        agent.agent_id,
                        len(signals.get("decisions", [])),
                        len(signals.get("action_items", [])),
                        len(signals.get("blockers", [])),
                    )
                    # E4: Route signals to downstream agents
                    asyncio.get_event_loop().call_soon(
                        lambda s=signals: asyncio.create_task(self._route_meeting_signals(s))
                    )
                except Exception as e:
                    tool_result = f"Signal extraction error: {e}"

        # ── promote_knowledge (Elias DIKW) ────────────────
        elif fn_name == "promote_knowledge":
            content = args.get("content", "")
            from_level = args.get("from_level", "data")
            to_level = args.get("to_level", "information")
            if not content:
                tool_result = "Error: content is required."
            else:
                # Use LLM to promote the content (based on SignalFlow DIKW promote.jinja2)
                prompts = {
                    "information": f"Transform this data into information by adding context and meaning:\n\n{content[:2000]}",
                    "knowledge": f"Extract actionable knowledge from this information — identify patterns and insights:\n\n{content[:2000]}",
                    "wisdom": f"Distill strategic wisdom from this knowledge — create timeless, broadly applicable principles:\n\n{content[:2000]}",
                }
                prompt = prompts.get(
                    to_level, f"Promote this from {from_level} to {to_level}:\n\n{content[:2000]}"
                )
                try:
                    llm = self._get_llm_for_agent("elias") or self.llm
                    if llm:
                        result = await llm.generate(
                            system_prompt="You are the DIKW Synthesizer. Transform content through the Data-Information-Knowledge-Wisdom pyramid.",
                            messages=[{"role": "user", "content": prompt}],
                            model=self._resolve_model_for_agent("elias"),
                            temperature=0.4,
                        )
                        tool_result = f"[Promoted {from_level} -> {to_level}]\n\n{result.strip()}"
                    else:
                        tool_result = "Error: LLM not available"
                except Exception as e:
                    tool_result = f"Promotion error: {e}"

        # ── merge_insights (Elias DIKW) ───────────────────
        elif fn_name == "merge_insights":
            items_raw = args.get("items", "[]")
            current_level = args.get("current_level", "information")
            try:
                import json as _json

                items = _json.loads(items_raw) if isinstance(items_raw, str) else items_raw
                if not items:
                    tool_result = "Error: items array is required."
                else:
                    items_text = "\n\n---\n\n".join(
                        f"Item {i + 1}: {item}" for i, item in enumerate(items)
                    )
                    llm = self._get_llm_for_agent("elias") or self.llm
                    if llm:
                        result = await llm.generate(
                            system_prompt="You are the DIKW Synthesizer. Merge related items into a unified higher-level insight.",
                            messages=[
                                {
                                    "role": "user",
                                    "content": (
                                        f"Merge these {len(items)} {current_level}-level items into a unified insight:\n\n"
                                        f"{items_text}\n\n"
                                        f"Identify common themes, synthesize understanding, and provide:\n"
                                        f"1. Synthesized content\n2. Key themes\n3. Preserved critical details"
                                    ),
                                }
                            ],
                            model=self._resolve_model_for_agent("elias"),
                            temperature=0.4,
                        )
                        tool_result = result.strip()
                    else:
                        tool_result = "Error: LLM not available"
            except Exception as e:
                tool_result = f"Merge error: {e}"

        # ── assess_knowledge_quality (Elias DIKW) ─────────
        elif fn_name == "assess_knowledge_quality":
            content = args.get("content", "")
            level = args.get("level", "data")
            if not content:
                tool_result = "Error: content is required."
            else:
                try:
                    llm = self._get_llm_for_agent("elias") or self.llm
                    if llm:
                        result = await llm.generate(
                            system_prompt="You are the DIKW quality assessor. Score knowledge items on quality.",
                            messages=[
                                {
                                    "role": "user",
                                    "content": (
                                        f"Assess this {level}-level knowledge item:\n\n{content[:2000]}\n\n"
                                        f"Score on:\n"
                                        f"- Relevance (0.0-1.0): How relevant to the user's goals?\n"
                                        f"- Accuracy (0.0-1.0): How accurate and reliable?\n"
                                        f"- Actionability (0.0-1.0): How actionable?\n\n"
                                        f"Format: RELEVANCE: X.X | ACCURACY: X.X | ACTIONABILITY: X.X\n"
                                        f"Then suggest improvements if any score < 0.7."
                                    ),
                                }
                            ],
                            model=self._resolve_model_for_agent("elias"),
                            temperature=0.3,
                        )
                        tool_result = result.strip()
                    else:
                        tool_result = "Error: LLM not available"
                except Exception as e:
                    tool_result = f"Assessment error: {e}"

        # ── load_persona_config (Adrian) — Wave 6 Ojas integration ──
        elif fn_name == "load_persona_config":
            persona_id = args.get("persona_id", "")
            if not persona_id:
                tool_result = "Error: persona_id is required."
            else:
                try:
                    from vastian.persona_loader import load_persona_config as _load_persona

                    include_ojas = args.get("include_ojas", True)
                    team_id = args.get("team_id")
                    tier = int(args.get("tier", 1) or 1)
                    if team_id is not None:
                        team_id = int(team_id)
                    found = _load_persona(
                        persona_id,
                        include_ojas=bool(include_ojas),
                        team_id=team_id,
                        tier=tier,
                    )
                    if found:
                        tool_result = json.dumps(found, indent=2, default=str)
                    else:
                        tool_result = (
                            f"Persona '{persona_id}' not found. Use IDs 1-12 or name fragments."
                        )
                except Exception as e:
                    tool_result = f"Error loading persona: {e}"

        # ── load_scenario_config (Kiran, Concord) — Wave 6 ──
        elif fn_name == "load_scenario_config":
            try:
                from vastian.scenario_loader import (
                    load_casey_template,
                    load_prompt_components,
                    load_scenario_challenges,
                    load_scenario_domains,
                    load_scenario_progression,
                )

                include_domains = args.get("include_domains", True)
                include_progression = args.get("include_progression", True)
                include_components = args.get("include_components", False)
                include_casey = args.get("include_casey", False)
                team_id = args.get("team_id")
                tier = int(args.get("tier", 1) or 1)
                if team_id is not None:
                    team_id = int(team_id)
                result = {}
                if include_domains:
                    result["domains"] = load_scenario_domains()
                if include_progression:
                    result["progression"] = load_scenario_progression()
                if team_id is not None:
                    result["challenges"] = load_scenario_challenges(team_id=team_id, tier=tier)
                if include_components:
                    result["prompt_components"] = load_prompt_components()
                if include_casey:
                    result["casey_template"] = load_casey_template()
                tool_result = json.dumps(result, indent=2, default=str)
            except Exception as e:
                tool_result = f"Error loading scenario config: {e}"

        # ── load_metrics_config (Jake, Kiran) ──
        elif fn_name == "load_metrics_config":
            try:
                from vastian.scenario_loader import (
                    load_metrics_baselines,
                    load_metrics_definitions,
                )

                owner = (args.get("owner") or "").strip().lower() or None
                include_baselines = args.get("include_baselines", True)
                result = {"definitions": load_metrics_definitions(owner=owner)}
                if include_baselines:
                    result["baselines"] = load_metrics_baselines()
                tool_result = json.dumps(result, indent=2, default=str)
            except Exception as e:
                tool_result = f"Error loading metrics config: {e}"

        # ── score_persona_traits (Adrian) ──────────────────
        elif fn_name == "score_persona_traits":
            scores_raw = args.get("trait_scores", "{}")
            try:
                import json as _json

                import yaml as _yaml

                scores = _json.loads(scores_raw) if isinstance(scores_raw, str) else scores_raw

                # Load insights lookup
                ins_path = (
                    Path(__file__).resolve().parents[2]
                    / "config"
                    / "personas"
                    / "insights_lookup.yaml"
                )
                insights = (
                    _yaml.safe_load(ins_path.read_text(encoding="utf-8"))
                    if ins_path.exists()
                    else []
                )

                results = []
                for trait_name, score in scores.items():
                    score = float(score)
                    for ins in insights:
                        if ins.get("trait_name") == trait_name and ins.get(
                            "min_score", 0
                        ) <= score <= ins.get("max_score", 1):
                            results.append(
                                {
                                    "trait": trait_name,
                                    "score": score,
                                    "range": ins.get("score_range", ""),
                                    "insight": ins.get("insight", ""),
                                }
                            )
                            break
                tool_result = _json.dumps(results, indent=2)
            except Exception as e:
                tool_result = f"Error scoring traits: {e}"

        # ── decompose_task (Arjuna) ────────────────────────
        elif fn_name == "decompose_task":
            title = args.get("title", "")
            description = args.get("description", "")
            if not title or not description:
                tool_result = "Error: title and description required."
            else:
                try:
                    llm = self._get_llm_for_agent("arjuna") or self.llm
                    if llm:
                        result = await llm.generate(
                            system_prompt="You are a task decomposition expert. Break tasks into 3-7 concrete subtasks.",
                            messages=[
                                {
                                    "role": "user",
                                    "content": (
                                        f"Decompose this task into subtasks:\n\n"
                                        f"**{title}**\n{description}\n\n"
                                        f"For each subtask provide:\n"
                                        f"- Title (action-oriented)\n"
                                        f"- Estimated time (hours)\n"
                                        f"- Dependencies (if any)\n"
                                        f"- Suggested board (user/comms/company/agent/ops)"
                                    ),
                                }
                            ],
                            model=self._resolve_model_for_agent("arjuna"),
                            temperature=0.3,
                        )
                        tool_result = result.strip()
                    else:
                        tool_result = "Error: LLM not available"
                except Exception as e:
                    tool_result = f"Decompose error: {e}"

        # ── sprint_health (Arjuna) ─────────────────────────
        elif fn_name == "sprint_health":
            if self.task_queue:
                try:
                    sprints = self.task_queue.get_sprints(status="active")
                    if not sprints:
                        tool_result = "No active sprints found."
                    else:
                        import json as _json

                        reports = []
                        for sprint in sprints[:3]:
                            burn = self.task_queue.get_sprint_burndown(sprint["sprint_id"])
                            reports.append(
                                {
                                    "sprint": sprint.get("name", sprint["sprint_id"]),
                                    "goal": sprint.get("goal", ""),
                                    "total_tickets": burn["total_tickets"],
                                    "total_points": burn["total_points"],
                                    "done_points": burn["done_points"],
                                    "status_breakdown": burn["status_counts"],
                                }
                            )
                        tool_result = _json.dumps(reports, indent=2, default=str)
                except Exception as e:
                    tool_result = f"Sprint health error: {e}"
            else:
                tool_result = "Task queue not available."

        # ── career_profile_update (Sage) ───────────────────
        elif fn_name == "career_profile_update":
            field = args.get("field", "")
            value = args.get("value", "")
            if not field or not value:
                tool_result = "Error: field and value required."
            else:
                # S2b: Therapy fields -> therapy-insights.md; career fields -> career-profile.md
                therapy_fields = ("triggers", "patterns", "growth_themes", "persona_resonance")
                doc_path = (
                    "sage/therapy-insights.md"
                    if field in therapy_fields
                    else "sage/career-profile.md"
                )
                try:
                    if self.knowledge and self.knowledge.documents:
                        content = self.knowledge.documents.read(doc_path) or ""
                        marker = f"**{field}**:"
                        if marker in content:
                            lines = content.split("\n")
                            for i, line in enumerate(lines):
                                if marker in line:
                                    lines[i] = f"{marker} {value}"
                                    break
                            content = "\n".join(lines)
                        else:
                            content += f"\n{marker} {value}\n"
                        self.knowledge.documents.write(doc_path, content, "sage")
                        tool_result = f"Updated {doc_path}: {field} = {value}"
                        # E5: Route to downstream agents
                        asyncio.get_event_loop().call_soon(
                            lambda f=field, v=value: asyncio.create_task(
                                self._route_career_update(agent.agent_id, f, v)
                            )
                        )
                    else:
                        tool_result = "Knowledge store not available."
                except Exception as e:
                    tool_result = f"Profile update error: {e}"

        # ── growth_suggestion (Sage) ───────────────────────
        elif fn_name == "growth_suggestion":
            context = args.get("context", "")
            if not context:
                tool_result = "Error: context is required."
            else:
                try:
                    llm = self._get_llm_for_agent("sage") or self.llm
                    if llm:
                        # Read career profile for context
                        profile = ""
                        if self.knowledge and self.knowledge.documents:
                            profile = self.knowledge.documents.read("sage/career-profile.md") or ""
                        result = await llm.generate(
                            system_prompt=(
                                "You are a supportive career coach. Generate specific, actionable growth suggestions "
                                "based on the user's career profile and current context."
                            ),
                            messages=[
                                {
                                    "role": "user",
                                    "content": (
                                        f"Career Profile:\n{profile[:1500]}\n\n"
                                        f"User's question/context: {context}\n\n"
                                        f"Generate 2-3 specific growth suggestions with:\n"
                                        f"- Type (stretch/skill_building/project/learning)\n"
                                        f"- Title\n- Description\n- Why it helps"
                                    ),
                                }
                            ],
                            model=self._resolve_model_for_agent("sage"),
                            temperature=0.5,
                        )
                        tool_result = result.strip()
                    else:
                        tool_result = "Error: LLM not available"
                except Exception as e:
                    tool_result = f"Growth suggestion error: {e}"

        # ── suggest_kanban_task ──────────────────────────
        elif fn_name == "suggest_kanban_task":
            title = args.get("title", "")
            description = args.get("description", "")
            category = args.get("category", "improvement")
            priority = args.get("priority", "normal")
            assigned_to = args.get("assigned_to", agent.agent_id)
            board = args.get("board", "agent")
            tags_raw = args.get("tags", "")
            tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []

            if not title or not description:
                tool_result = "Error: title and description are required."
            elif self.task_queue:
                try:
                    kanban_id = self.task_queue.suggest_kanban_task(
                        suggested_by=agent.agent_id,
                        title=title,
                        description=description,
                        category=category,
                        priority=priority,
                        assigned_to=assigned_to,
                        board=board,
                        tags=tags,
                        user_id=self.current_user_id,
                    )
                except ValueError as e:
                    tool_result = str(e)
                else:
                    prefix = self.task_queue.BOARD_PREFIXES.get(board, "CYC-")
                    ticket_id = f"{prefix}{kanban_id}"
                    manager_id = get_manager_for_agent(agent.agent_id)
                    manager_note = (
                        f" Your manager ({manager_id}) will review for alignment first."
                        if manager_id and manager_id != "rowan"
                        else ""
                    )
                    tool_result = (
                        f"Task suggested on {board} board ({ticket_id}).{manager_note} "
                        f"After manager review, the user will decide whether to approve it."
                    )
                    logger.info(
                        "Kanban task suggested by %s: %s (manager: %s)",
                        agent.agent_id,
                        title,
                        manager_id,
                    )

                    # Auto-create outreach for high/critical priority suggestions
                    if priority in ("high", "critical"):
                        with contextlib.suppress(Exception):
                            self.task_queue.create_outreach(
                                agent_id=agent.agent_id,
                                subject=f"Kanban: {title}",
                                message=(
                                    f"I've suggested a **{priority}** priority task on the Kanban board "
                                    f"that needs your attention:\n\n"
                                    f"**{title}**\n{description}\n\n"
                                    f"You can approve it in the Kanban board (/feed), or if you have "
                                    f"info to provide, name a file `task-{kanban_id}-yourfile.txt` "
                                    f"and drop it in your inbox (local: `saved_prompts/inbox/` or cloud upload if enabled)."
                                ),
                                priority=priority,
                            )
            else:
                tool_result = "Error: Task queue not initialized."

        # ── manage_kanban_task (Orion — operations) ──────
        elif fn_name == "manage_kanban_task":
            kanban_id = args.get("kanban_id", "")
            action = args.get("action", "")
            reason = args.get("reason", "")

            if not kanban_id or not action:
                tool_result = "Error: kanban_id and action are required."
            elif action not in ("archive", "reject", "add_note"):
                tool_result = f"Error: Invalid action '{action}'. Use: archive, reject, add_note."
            elif not self.task_queue:
                tool_result = "Error: Task queue not initialized."
            else:
                try:
                    task = self.task_queue.get_kanban_task(kanban_id)
                    if not task:
                        tool_result = f"Error: Kanban task '{kanban_id}' not found."
                    elif action == "archive":
                        from datetime import datetime

                        now = datetime.now(_dt.UTC).isoformat()
                        self.task_queue._kanban.update(
                            kanban_id,
                            {
                                "status": "archived",
                                "archived_at": now,
                                "user_notes": (
                                    task.get("user_notes", "") + f"\n[Orion] Archived: {reason}"
                                ).strip(),
                            },
                        )
                        tool_result = f"Kanban task '{kanban_id}' archived. Reason: {reason}"
                        logger.info("Orion archived kanban %s: %s", kanban_id, reason)
                    elif action == "reject":
                        from datetime import datetime

                        now = datetime.now(_dt.UTC).isoformat()
                        self.task_queue._kanban.update(
                            kanban_id,
                            {
                                "status": "rejected",
                                "rejected_at": now,
                                "user_notes": (
                                    task.get("user_notes", "") + f"\n[Orion] Rejected: {reason}"
                                ).strip(),
                            },
                        )
                        tool_result = f"Kanban task '{kanban_id}' rejected. Reason: {reason}"
                        logger.info("Orion rejected kanban %s: %s", kanban_id, reason)
                    elif action == "add_note":
                        existing = task.get("user_notes", "") or ""
                        self.task_queue._kanban.update(
                            kanban_id,
                            {
                                "user_notes": (existing + f"\n[Orion] {reason}").strip(),
                            },
                        )
                        tool_result = f"Note added to kanban task '{kanban_id}'."
                        logger.info("Orion added note to kanban %s", kanban_id)
                except Exception as e:
                    tool_result = f"Error managing kanban task: {e}"

        # ── create_request (Cursor Session Protocol) ──────
        elif fn_name == "create_request":
            topic = args.get("topic", "")
            details = args.get("details", "")
            notify_str = args.get("notify_agents", "")
            notify_list = (
                [n.strip() for n in notify_str.split(",") if n.strip()] if notify_str else None
            )

            if not topic or not details:
                tool_result = "Error: topic and details are required."
            else:
                try:
                    from vastian.sessions.engine import SessionEngine

                    engine = SessionEngine(
                        registry=self.registry,
                        bus=self.bus,
                        knowledge=self.knowledge,
                        state_dir=self.settings.state_dir,
                    )
                    session = await engine.create_request(
                        agent_id=agent.agent_id,
                        topic=topic,
                        details=details,
                        notify_agents=notify_list,
                        orchestrator=self,
                    )
                    tool_result = (
                        f"Request created (ID: {session.id}). "
                        f"The external architect (Cursor) will see this when they run "
                        f"'vastian request list'. Topic: {topic}"
                    )
                    logger.info(
                        "Request created by %s: %s (session %s)",
                        agent.agent_id,
                        topic,
                        session.id,
                    )
                except Exception as e:
                    tool_result = f"Error creating request: {e}"
                    logger.warning("create_request failed: %s", e, exc_info=True)

        # ── add_mentor (Victor, Sage) — Wave 8 add-only ───
        elif fn_name == "add_mentor":
            mentor_id = (args.get("mentor_id") or "").strip().lower()
            if not mentor_id:
                tool_result = "Error: mentor_id is required."
            else:
                # Sanitize: alphanumeric + underscore only
                safe_id = "".join(c for c in mentor_id if c.isalnum() or c == "_")
                if safe_id != mentor_id:
                    tool_result = (
                        f"Error: mentor_id must be alphanumeric/underscore only. Got: {mentor_id}"
                    )
                else:
                    from vastian.storage.data_root import get_config_dir

                    _proj = (
                        self.settings.agents_dir.parent
                        if self.settings
                        else Path(__file__).resolve().parents[2]
                    )
                    mentors_dir = get_config_dir(_proj) / "mentors"
                    mentors_dir.mkdir(parents=True, exist_ok=True)
                    target = mentors_dir / f"{safe_id}.yaml"
                    if target.exists():
                        tool_result = f"Error: Mentor '{safe_id}' already exists. Add-only policy: never overwrite. Use suggest_config_change to edit existing mentors."
                    else:
                        try:
                            import yaml as _yaml

                            mentor_data = {
                                "mentor_id": safe_id,
                                "name": str(args.get("name", "")).strip()
                                or safe_id.replace("_", " ").title(),
                                "archetype": str(args.get("archetype", "")).strip() or "Advisor",
                                "voice": str(args.get("voice", "")).strip()
                                or "Wise and reflective.",
                                "worldview": str(args.get("worldview", "")).strip()
                                or "Seek growth through reflection.",
                                "expertise_areas": args.get("expertise_areas") or [],
                                "communication_style": str(
                                    args.get("communication_style", "")
                                ).strip()
                                or "Direct and supportive.",
                                "famous_quotes": args.get("famous_quotes") or [],
                                "suggested_topics": args.get("suggested_topics") or [],
                            }
                            if not isinstance(mentor_data["expertise_areas"], list):
                                mentor_data["expertise_areas"] = [
                                    str(mentor_data["expertise_areas"])
                                ]
                            if not isinstance(mentor_data["famous_quotes"], list):
                                mentor_data["famous_quotes"] = [str(mentor_data["famous_quotes"])]
                            if not isinstance(mentor_data["suggested_topics"], list):
                                mentor_data["suggested_topics"] = [
                                    str(mentor_data["suggested_topics"])
                                ]
                            target.write_text(
                                _yaml.dump(
                                    mentor_data,
                                    default_flow_style=False,
                                    allow_unicode=True,
                                    sort_keys=False,
                                ),
                                encoding="utf-8",
                            )
                            tool_result = f"Created mentor config: config/mentors/{safe_id}.yaml"
                        except Exception as e:
                            tool_result = f"Error writing mentor config: {e}"

        # ── web_search ────────────────────────────────────
        # ── suggest_mentors (Victor) ───────────────────────
        elif fn_name == "suggest_mentors":
            career = (args.get("career_profile") or "").strip()
            therapy = (args.get("therapy_insights") or "").strip()
            sprint = (args.get("sprint_status") or "").strip()
            topic = (args.get("current_topic") or "").strip()
            mentor_resonance = ""
            # S2c: Auto-load from docs when args empty (career-profile, therapy-insights, mentor-resonance-tracker)
            try:
                if self.knowledge and self.knowledge.documents:
                    if not career:
                        career = (
                            self.knowledge.documents.read("sage/career-profile.md") or ""
                        ).strip()
                    if not therapy:
                        therapy = (
                            self.knowledge.documents.read("sage/therapy-insights.md") or ""
                        ).strip()
                    mentor_resonance = (
                        self.knowledge.documents.read("sage/mentor-resonance-tracker.md") or ""
                    ).strip()
            except Exception:
                pass
            context_text = f"{career} {therapy} {sprint} {topic} {mentor_resonance}".lower().strip()
            from vastian.storage.data_root import get_config_dir

            _proj = (
                self.settings.agents_dir.parent
                if self.settings
                else Path(__file__).resolve().parents[2]
            )
            mentors_dir = get_config_dir(_proj) / "mentors"
            suggestions = []
            try:
                import yaml as _y

                all_mentors = []
                for f in sorted(mentors_dir.glob("*.yaml")):
                    if f.name.startswith("_"):
                        continue
                    data = _y.safe_load(f.read_text(encoding="utf-8"))
                    if data:
                        all_mentors.append(data)
                for m in all_mentors:
                    exp = " ".join(m.get("expertise_areas", [])).lower()
                    topics_str = " ".join(m.get("suggested_topics", [])).lower()
                    score = 0
                    if context_text:
                        for word in context_text.split():
                            if len(word) > 3 and (word in exp or word in topics_str):
                                score += 1
                    else:
                        score = 1
                    if score > 0 or not context_text:
                        suggestions.append(
                            {
                                "mentor_id": m.get("mentor_id", ""),
                                "name": m.get("name", ""),
                                "archetype": m.get("archetype", ""),
                                "score": score,
                                "rationale": (
                                    f"Expertise in {', '.join(m.get('expertise_areas', [])[:3])}; "
                                    f"topics: {', '.join(m.get('suggested_topics', [])[:3])}"
                                ),
                            }
                        )
                suggestions.sort(key=lambda x: -x["score"])
                tool_result = json.dumps({"suggestions": suggestions[:3]}, indent=2)
            except Exception as e:
                tool_result = f"Error loading mentors: {e}"

        elif fn_name == "web_search":
            query = args.get("query", "")
            num_results = args.get("num_results", 5)
            # Boost results based on research depth
            depth = getattr(self, "_current_research_depth", "normal")
            if depth == "thorough":
                num_results = max(num_results, 8)
            elif depth == "deep":
                num_results = max(num_results, 10)
            if not query:
                tool_result = "Error: query is required."
            else:
                tool_result = await self._web_search(query, num_results)

        # ── check_bank_balance ─────────────────────────
        elif fn_name == "check_bank_balance":
            has_mercury = self._mercury_client and self._mercury_client.is_configured
            has_plaid = self._plaid_client and self._plaid_client.is_configured
            if not has_mercury and not has_plaid:
                tool_result = (
                    "Error: No banking configured. Set MERCURY_API_TOKEN or configure Plaid "
                    "(config/local/plaid_credentials.yaml, PLAID_CLIENT_ID, PLAID_SECRET)."
                )
            else:
                try:
                    account_id = (args.get("account_id") or "all").lower()
                    if account_id == "all":
                        parts: list[str] = []
                        if has_mercury:
                            accts = await self._mercury_client.get_all_balances()
                            parts.append(
                                self._mercury_client.format_all_balances(
                                    accts,
                                    self._mercury_client.default_account_id,
                                )
                            )
                        if has_plaid:
                            plaid_accts = await self._plaid_client.get_balances()
                            if plaid_accts:
                                plaid_str = "\n\n".join(
                                    self._plaid_client.format_balance(a) for a in plaid_accts
                                )
                                parts.append(f"--- Plaid ---\n\n{plaid_str}")
                        tool_result = "\n\n".join(parts) if parts else "No accounts found."
                    else:
                        if has_mercury:
                            try:
                                acct = await self._mercury_client.get_account(account_id)
                                tool_result = self._mercury_client.format_balance(acct)
                            except Exception:
                                tool_result = ""
                        else:
                            tool_result = ""
                        if has_plaid:
                            plaid_accts = await self._plaid_client.get_balances()
                            for a in plaid_accts:
                                if str(a.get("account_id", "")) == account_id:
                                    add = self._plaid_client.format_balance(a)
                                    tool_result = f"{tool_result}\n\n{add}" if tool_result else add
                                    break
                        if not tool_result:
                            tool_result = f"Account {account_id} not found."
                except Exception as e:
                    tool_result = f"Error checking balance: {e}"

        # ── list_bank_transactions ────────────────────
        elif fn_name == "list_bank_transactions":
            has_mercury = self._mercury_client and self._mercury_client.is_configured
            has_plaid = self._plaid_client and self._plaid_client.is_configured
            if not has_mercury and not has_plaid:
                tool_result = (
                    "Error: No banking configured. Set MERCURY_API_TOKEN or configure Plaid."
                )
            else:
                try:
                    acct = args.get("account_id", "") or None
                    limit = args.get("limit", 10)
                    search = args.get("search", "")
                    start_date = args.get("start_date") or None
                    end_date = args.get("end_date") or None
                    if has_mercury:
                        txns = await self._mercury_client.list_transactions(
                            account_id=acct,
                            limit=limit,
                            search=search,
                        )
                        tool_result = self._mercury_client.format_transactions(txns, limit)
                    elif has_plaid and acct:
                        txns = await self._plaid_client.get_transactions(
                            account_id=acct,
                            start_date=start_date,
                            end_date=end_date,
                        )
                        tool_result = self._plaid_client.format_transactions(txns[:limit], limit)
                    elif has_plaid:
                        tool_result = "Error: account_id required for Plaid (list accounts via check_bank_balance first)."
                    else:
                        tool_result = "Error listing transactions."
                except Exception as e:
                    tool_result = f"Error listing transactions: {e}"

        # ── create_bank_transaction (approval workflow) ─
        elif fn_name == "create_bank_transaction":
            if not self._mercury_client or not self._mercury_client.is_configured:
                tool_result = "Error: Mercury banking is not configured."
            elif not self.task_queue:
                tool_result = "Error: Task queue not initialized."
            else:
                recipient = args.get("recipient_name", "")
                amount = args.get("amount", 0)
                method = args.get("payment_method", "ach")
                note = args.get("note", "")
                if not recipient or not amount:
                    tool_result = "Error: recipient_name and amount are required."
                else:
                    desc = f"${amount:,.2f} to {recipient} via {method}"
                    if note:
                        desc += f" — {note[:60]}"
                    approval_id = self.task_queue.create_approval(
                        agent_id=agent.agent_id,
                        approval_type="transaction",
                        description=desc,
                        payload={
                            "recipient_name": recipient,
                            "amount": amount,
                            "payment_method": method,
                            "note": note,
                        },
                    )
                    tool_result = (
                        f"Transaction pending user approval (ID: {approval_id}).\n"
                        f"Details: {desc}\n"
                        f"The user must run `/approve {approval_id}` to execute or "
                        f"`/deny {approval_id}` to cancel."
                    )

        # ── request_user_input ────────────────────────
        elif fn_name == "request_user_input":
            if not self.task_queue:
                tool_result = "Error: Task queue not initialized."
            else:
                subject = args.get("subject", "")
                message = args.get("message", "")
                priority = args.get("priority", "normal")
                if not subject or not message:
                    tool_result = "Error: subject and message are required."
                else:
                    if priority not in ("critical", "high", "normal", "low"):
                        priority = "normal"
                    outreach_id = self.task_queue.create_outreach(
                        agent_id=agent.agent_id,
                        subject=subject,
                        message=message,
                        priority=priority,
                    )
                    tool_result = (
                        f"Outreach request created (ID: {outreach_id}). "
                        f"The user will be notified with priority '{priority}'. "
                        f"They can respond when available."
                    )

        # ── schedule_outreach ─────────────────────────────
        elif fn_name == "schedule_outreach":
            if not self.task_queue:
                tool_result = "Error: Task queue not initialized."
            else:
                subject = args.get("subject", "")
                message = args.get("message", "")
                scheduled_date = args.get("scheduled_date", "")
                priority = args.get("priority", "normal")
                if not subject or not message or not scheduled_date:
                    tool_result = "Error: subject, message, and scheduled_date are required."
                else:
                    if priority not in ("critical", "high", "normal", "low"):
                        priority = "normal"
                    # Validate date format
                    import re as _re

                    if not _re.match(r"\d{4}-\d{2}-\d{2}", scheduled_date):
                        tool_result = "Error: scheduled_date must be YYYY-MM-DD format."
                    else:
                        outreach_id = self.task_queue.schedule_outreach(
                            agent_id=agent.agent_id,
                            subject=subject,
                            message=message,
                            scheduled_date=scheduled_date,
                            priority=priority,
                        )
                        tool_result = (
                            f"Scheduled outreach created (ID: {outreach_id}). "
                            f"Will be delivered on {scheduled_date} with priority '{priority}'. "
                            f"The user will see it at their next /check-in on or after that date."
                        )

        # ── ask_structured_question ──────────────────────────
        elif fn_name == "ask_structured_question":
            question_text = (args.get("question") or "").strip()
            options = args.get("options") or []
            allow_custom = args.get("allow_custom", True)
            free_entry_only = args.get("free_entry_only", False)
            context_text = (args.get("context") or "").strip()
            question_id = (args.get("question_id") or "").strip()

            if not question_text:
                tool_result = "Error: question is required."
            elif not free_entry_only and len(options) < 2:
                tool_result = "Error: At least 2 options are required (or set free_entry_only=true for text-only input)."
            else:
                # Build a structured payload that the chat API will detect
                # and pass through to the frontend as a rendered card.
                sq_payload = {
                    "__structured_question__": True,
                    "question": question_text,
                    "options": [
                        {"id": o.get("id", f"opt_{i}"), "label": o.get("label", "")}
                        for i, o in enumerate(options)
                    ]
                    if not free_entry_only
                    else [],
                    "free_entry_only": bool(free_entry_only),
                    "allow_custom": bool(allow_custom),
                    "context": context_text,
                    "question_id": question_id or None,
                    "agent_id": agent.agent_id,
                }
                # Store the pending question on the orchestrator so the chat
                # response can include it as a separate field.
                if not hasattr(self, "_pending_structured_questions"):
                    self._pending_structured_questions: list[dict] = []
                self._pending_structured_questions.append(sq_payload)

                tool_result = (
                    f'Structured question presented to user: "{question_text}" '
                    f"with {len(options)} options. Waiting for their response."
                )

        # ── send_message (Jupiter J2: SMS/email via Zo) ─────
        elif fn_name == "send_message":
            channel = (args.get("channel") or "").lower()
            body = (args.get("body") or "").strip()
            subject = (args.get("subject") or "").strip()
            if channel not in ("sms", "email"):
                tool_result = "Error: channel must be 'sms' or 'email'."
            elif not body:
                tool_result = "Error: body is required."
            elif channel == "email" and not subject:
                tool_result = "Error: subject is required for email."
            else:
                provider = self._get_messaging_provider()
                if not provider:
                    tool_result = "Error: No messaging provider configured. Set TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN/TWILIO_FROM_NUMBER for Twilio, or ZO_API_KEY for Zo messaging."
                else:
                    try:
                        if channel == "sms":
                            resp = await provider.send_sms(body)
                        else:
                            resp = await provider.send_email(subject=subject, body=body)
                        ok = resp.get("ok", False)
                        if ok:
                            mid = resp.get("message_id", "")
                            tool_result = (
                                f"Message sent via {channel}. "
                                f"({f'ID: {mid}' if mid else 'Delivery queued.'})"
                            )
                        else:
                            msg = resp.get("message", resp.get("response", str(resp)))
                            tool_result = f"Send failed: {msg}"
                    except Exception as e:
                        tool_result = f"Send failed: {e}"

        # ── write_giga_plan (Giga Bridge) ─────────────────
        elif fn_name == "write_giga_plan":
            from vastian.bridges.giga_dev_bridge.handlers import write_giga_plan as _write_giga_plan

            key_name = args.get("key_name", "")
            content = args.get("content", "")
            mind = args.get("mind", "vastian-network")
            if not key_name or not content:
                tool_result = "Error: key_name and content are required."
            else:
                tool_result = _write_giga_plan(
                    agent_id=agent.agent_id,
                    key_name=key_name,
                    content=content,
                    mind=mind,
                )

        # ── write_giga_neuron (Giga Bridge) ───────────────
        elif fn_name == "write_giga_neuron":
            from vastian.bridges.giga_dev_bridge.handlers import (
                write_giga_neuron as _write_giga_neuron,
            )

            title = args.get("title", "")
            body = args.get("body", "")
            selector_nl = args.get("selector_nl", "")
            mind = args.get("mind", "vastian-network")
            if not title or not body:
                tool_result = "Error: title and body are required."
            else:
                tool_result = _write_giga_neuron(
                    agent_id=agent.agent_id,
                    title=title,
                    body=body,
                    selector_nl=selector_nl,
                    mind=mind,
                )

        # ── Unknown tool ─────────────────────────────────
        else:
            tool_result = f"Unknown tool: {fn_name}"

        # Update execution event status
        exec_event["status"] = "done" if not tool_result.startswith("Error") else "failed"
        exec_event["result_preview"] = tool_result[:200] if tool_result else ""

        return tool_result

    async def _kanban_execution_loop(self) -> None:
        """Periodically check kanban tasks and advance them through the hierarchy.

        Lifecycle with manager gates:
          1. pending_review → manager auto-reviews alignment → suggested (user-visible)
          2. suggested → user approves → approved
          3. approved → execution → in_progress
          4. in_progress → agent completes → pending_qa (manager quality review)
          5. pending_qa → manager QA → review (user sees result)
          6. review → user accepts/rejects
        """
        while True:
            try:
                await asyncio.sleep(8)
                if not self.task_queue:
                    continue

                # Gate 1: Manager alignment review (pending_review → suggested)
                pending_review = self.task_queue.get_kanban_tasks(status="pending_review")
                for task in pending_review:
                    asyncio.create_task(self._manager_alignment_review(task))

                # Gate 2: Execute approved tasks (approved → in_progress)
                # Skip tasks assigned to the user — those wait for inbox input.
                approved = self.task_queue.get_kanban_tasks(status="approved")
                for task in approved:
                    if task.get("assigned_to") == "user":
                        continue  # User-owned task — wait for inbox input
                    kanban_id = task["kanban_id"]
                    self.task_queue.start_kanban_task(kanban_id)
                    self.task_queue.add_notification(
                        task["suggested_by"],
                        f"Kanban task started: {task['title']}",
                        "running",
                    )
                    asyncio.create_task(self._execute_kanban_task(task))

                # Gate 3: Manager QA review (pending_qa → review)
                pending_qa = self.task_queue.get_kanban_tasks(status="pending_qa")
                for task in pending_qa:
                    asyncio.create_task(self._manager_qa_review(task))

            except Exception as e:
                logger.debug("Kanban loop error: %s", e)

    async def _manager_alignment_review(self, task: dict) -> None:
        """Manager reviews a suggested task for alignment before user sees it."""
        kanban_id = task["kanban_id"]
        suggested_by = task["suggested_by"]
        manager_id = get_manager_for_agent(suggested_by)
        tq = self.task_queue

        if not tq:
            return

        # Top-level agents (report to rowan/no manager) → skip to suggested
        if not manager_id or manager_id == "rowan":
            tq.manager_approve_kanban(kanban_id, "rowan", "")
            return

        manager = self.registry.get_agent(manager_id)
        llm = self._get_llm_for_agent(manager_id) or self.llm
        if not manager or not llm:
            # Can't review — auto-approve
            tq.manager_approve_kanban(kanban_id, manager_id, "auto-approved (no LLM)")
            return

        tq.add_notification(
            manager_id, f"Reviewing suggestion from {suggested_by}: {task['title']}", "running"
        )

        review_prompt = (
            f"You are {manager.config.name} ({manager.config.title}), reviewing a task "
            f"suggested by your direct report {suggested_by}.\n\n"
            f"PROPOSED TASK:\n"
            f"  Title: {task['title']}\n"
            f"  Description: {task['description']}\n"
            f"  Category: {task['category']}\n"
            f"  Priority: {task['priority']}\n"
            f"  Assigned to: {task.get('assigned_to') or suggested_by}\n\n"
            f"REVIEW CRITERIA:\n"
            f"1. Is this aligned with our current strategic priorities?\n"
            f"2. Is the scope reasonable and well-defined?\n"
            f"3. Is it assigned to the right agent?\n"
            f"4. Is the priority appropriate?\n"
            f"5. Are there dependencies or conflicts with other work?\n\n"
            f"Respond with EXACTLY one of:\n"
            f"  APPROVE — task is well-aligned, proceed\n"
            f"  APPROVE_WITH_NOTES: <your adjustment notes> — approved with guidance\n"
            f"  REJECT: <reason> — not aligned, explain why\n"
        )

        try:
            result = await llm.generate(
                system_prompt=f"You are {manager.config.name}, reviewing subordinate work for alignment.",
                messages=[{"role": "user", "content": review_prompt}],
                model=self._resolve_model_for_agent(manager_id),
                temperature=0.3,
            )
            response = result.strip()

            if response.upper().startswith("REJECT"):
                reason = response.split(":", 1)[1].strip() if ":" in response else "Not aligned"
                tq.manager_reject_kanban(kanban_id, manager_id, reason)
                tq.add_notification(
                    suggested_by,
                    f"Task rejected by {manager_id}: {reason[:80]}",
                    "failed",
                )
                tq.add_notification(
                    manager_id, f"Rejected task from {suggested_by}: {task['title']}", "completed"
                )
            else:
                notes = ""
                if "APPROVE_WITH_NOTES:" in response.upper():
                    notes = response.split(":", 1)[1].strip() if ":" in response else ""
                tq.manager_approve_kanban(kanban_id, manager_id, notes)
                tq.add_notification(
                    manager_id,
                    f"Approved task from {suggested_by}: {task['title']}",
                    "completed",
                )
        except Exception as e:
            # On error, auto-approve to avoid blocking
            tq.manager_approve_kanban(kanban_id, manager_id, f"auto-approved (review error: {e})")
            logger.debug("Manager review failed for %s: %s", kanban_id, e)

    async def _manager_qa_review(self, task: dict) -> None:
        """Manager reviews completed work quality before user sees the result."""
        kanban_id = task["kanban_id"]
        suggested_by = task["suggested_by"]
        assigned_to = task.get("assigned_to") or suggested_by
        manager_id = get_manager_for_agent(assigned_to)
        tq = self.task_queue

        if not tq:
            return

        # Top-level agents → skip to user review
        if not manager_id or manager_id == "rowan":
            tq.manager_approve_qa(kanban_id, "rowan")
            return

        manager = self.registry.get_agent(manager_id)
        llm = self._get_llm_for_agent(manager_id) or self.llm
        if not manager or not llm:
            tq.manager_approve_qa(kanban_id, manager_id)
            return

        tq.add_notification(manager_id, f"QA review: {task['title']}", "running")

        qa_prompt = (
            f"You are {manager.config.name} ({manager.config.title}), reviewing completed "
            f"work from {assigned_to}.\n\n"
            f"ORIGINAL TASK:\n"
            f"  Title: {task['title']}\n"
            f"  Description: {task['description']}\n\n"
            f"RESULT:\n{(task.get('result') or 'No result provided')[:3000]}\n\n"
            f"QA CRITERIA:\n"
            f"1. Does the result adequately address the task description?\n"
            f"2. Is the quality sufficient to present to the user?\n"
            f"3. Are there obvious gaps or errors?\n"
            f"4. Is further work needed?\n\n"
            f"Respond with EXACTLY one of:\n"
            f"  PASS — quality is acceptable, send to user for review\n"
            f"  FAIL: <specific feedback> — needs re-work, explain what to fix\n"
        )

        try:
            result = await llm.generate(
                system_prompt=f"You are {manager.config.name}, performing quality review.",
                messages=[{"role": "user", "content": qa_prompt}],
                model=self._resolve_model_for_agent(manager_id),
                temperature=0.3,
            )
            response = result.strip()

            if response.upper().startswith("FAIL"):
                feedback = (
                    response.split(":", 1)[1].strip() if ":" in response else "Quality insufficient"
                )
                tq.manager_reject_qa(kanban_id, manager_id, feedback)
                tq.add_notification(
                    assigned_to,
                    f"QA failed by {manager_id}: {feedback[:80]}",
                    "failed",
                )
                tq.add_notification(manager_id, f"QA rejected for {task['title']}", "completed")
            else:
                tq.manager_approve_qa(kanban_id, manager_id)
                tq.add_notification(
                    manager_id,
                    f"QA passed for {task['title']} — sent to user",
                    "completed",
                )
        except Exception as e:
            # On error, auto-pass to avoid blocking
            tq.manager_approve_qa(kanban_id, manager_id)
            logger.debug("Manager QA failed for %s: %s", kanban_id, e)

    async def _execute_kanban_task(self, task: dict) -> None:
        """Execute a single approved kanban task via the assigned agent."""
        kanban_id = task["kanban_id"]
        assigned_to = task.get("assigned_to") or task["suggested_by"]
        agent = self.registry.get_agent(assigned_to)
        tq = self.task_queue

        if agent is None:
            if tq:
                tq.complete_kanban_task(kanban_id, f"Agent {assigned_to} not found")
            return

        user_notes = task.get("user_notes", "")
        category = task.get("category", "general")
        prompt = (
            f"A task has been approved on the Kanban board for you to work on.\n\n"
            f"TASK: {task['title']}\n"
            f"DESCRIPTION: {task['description']}\n"
            f"CATEGORY: {category}\n"
        )
        if user_notes:
            prompt += f"FEEDBACK (from manager or prior attempt): {user_notes}\n"

        # Tailor expectations based on task category
        if category in ("code_quality", "architecture", "technical"):
            prompt += (
                "\nIMPORTANT — Code-related task expectations:\n"
                "You can ANALYZE code using search_codebase and read_source_file, but you "
                "cannot directly modify source files. Your deliverable should be:\n"
                "1. A thorough analysis of the current code state\n"
                "2. Specific, actionable recommendations with file paths and line numbers\n"
                "3. An implementation plan that the user can execute via Cursor\n"
                "4. Persist your findings and plan to your owned documents using write_document\n"
                "5. Delegate to henry for architecture review if the changes are structural\n"
            )
        else:
            prompt += (
                "\nIMPORTANT — Task execution:\n"
                "Use your tools to complete this task. Persist your analysis, recommendations, "
                "and deliverables to your owned documents using write_document. If the task "
                "requires coordination, use delegate_to_agent. If it involves code review, "
                "use search_codebase and read_source_file to understand the current state.\n"
            )

        try:
            result = await self._run_agent_with_tools(agent, prompt)
            if tq:
                # Goes to pending_qa → manager reviews before user sees it
                tq.complete_kanban_task(kanban_id, result[:5000])
                tq.add_notification(
                    assigned_to,
                    f"Kanban task completed, pending manager QA: {task['title']}",
                    "completed",
                )
        except Exception as e:
            if tq:
                tq.complete_kanban_task(kanban_id, f"Error: {e}")
                tq.add_notification(assigned_to, f"Kanban task failed: {e}", "failed")

    async def run_orion_improvement_cycle(self) -> None:
        """
        Orion's background improvement cycle — scans the codebase for
        improvement opportunities and reports to the Relay (Maria triages to REL- tickets).
        Deprecated: no longer suggests Kanban tasks directly.
        """
        orion = self.registry.get_agent("orion")
        if orion is None or not self.task_queue:
            return

        llm = self._get_llm_for_agent("orion") or self.llm
        if llm is None:
            return

        self.task_queue.add_notification("orion", "Starting operations improvement scan", "running")

        prompt = (
            "You are Orion, the Operations Lead. Run your periodic improvement scan.\n\n"
            "1. Use search_codebase to review the current state of key modules\n"
            "2. Use read_source_file to inspect files that need attention\n"
            "3. Look for: code quality issues, missing error handling, performance "
            "bottlenecks, missing tests, documentation gaps, architecture improvements\n"
            "4. For each improvement you find, use write_relay_report with category='ops' "
            "to submit a report. Maria will triage and create REL- tickets.\n"
            "5. In the payload you can set assigned_to='henry' for architecture work, or 'orion' for operations\n\n"
            "Focus on HIGH-IMPACT improvements. Don't report trivial formatting changes.\n"
            "Submit 2-4 relay reports maximum per cycle."
        )

        try:
            await self._run_agent_with_tools(orion, prompt)
            self.task_queue.add_notification(
                "orion", "Improvement scan complete — reports in Relay outbox", "completed"
            )
        except Exception as e:
            self.task_queue.add_notification("orion", f"Improvement scan failed: {e}", "failed")

    async def run_agent_suggestion_cycle(self, agent_id: str) -> None:
        """
        Ask a specific agent to review their domain and suggest Kanban tasks.
        Each agent gets a tailored prompt based on their role.
        """
        agent = self.registry.get_agent(agent_id)
        if agent is None or not self.task_queue:
            return

        llm = self._get_llm_for_agent(agent_id) or self.llm
        if llm is None:
            return

        # Orion has its own dedicated cycle
        if agent_id == "orion":
            await self.run_orion_improvement_cycle()
            return

        # Tailored suggestion prompts per agent role
        suggestion_prompts: dict[str, str] = {
            "charles": (
                "You are Charles, the Executive Assistant. Review the current state of "
                "the network and suggest tasks for the Kanban board.\n\n"
                "1. Use get_tasks to check for stale, stuck, or overdue delegations\n"
                "2. Use read_document to check your agent-meeting-schedule.md for overdue check-ins\n"
                "3. Use get_network_status for a full overview\n"
                "4. For each actionable issue, use suggest_kanban_task to propose it\n\n"
                "BOARD ROUTING:\n"
                "- board='comms' for communication follow-ups, meeting action items, schedule tasks (MSG-)\n"
                "- board='user' for user-facing tasks and requests (USR-)\n"
                "- board='agent' for system/agent tasks (CYC-)\n\n"
                "Focus on: overdue check-ins, stale tasks, agents needing attention, "
                "dashboard updates, or coordination gaps.\n"
                "Suggest 1-3 tasks maximum. Only suggest things that need real action."
            ),
            "liam": (
                "You are Liam, the Project Manager. Review execution status and suggest "
                "tasks for the Kanban board.\n\n"
                "1. Use read_document to check your execution-goal-tracker.md, cycle-plan.md, "
                "and prioritized-todo-list.md\n"
                "2. Look for: blocked tasks, cycle gaps, unfinished items, tasks that need "
                "delegation or re-prioritization\n"
                "3. For each actionable issue, use suggest_kanban_task to propose it\n\n"
                "Focus on execution gaps and things that will unblock progress.\n"
                "Suggest 1-3 tasks maximum. Only suggest things that need real action."
            ),
            "dominic": (
                "You are Dominic, the Strategy Advisor. Review the strategic landscape and "
                "suggest tasks for the Kanban board.\n\n"
                "1. Use read_document to check your vastian-roadmap.md, strategic-integration-plan.md, "
                "and spike-log.md\n"
                "2. Look for: stale roadmap items, upcoming milestones that need prep, "
                "strategic misalignments, overdue spikes or reviews\n"
                "3. For each actionable issue, use suggest_kanban_task to propose it\n\n"
                "Focus on strategic alignment and upcoming deadlines.\n"
                "Suggest 1-3 tasks maximum. Only suggest things that need real action."
            ),
            "victor": (
                "You are Victor, the Chief Vision Officer. Perform a vision alignment sweep.\n\n"
                "1. Use read_document to review your vision-traction-organizer.md and "
                "strategic-vision-log.md\n"
                "2. Use get_tasks to see recent delegations and their outcomes\n"
                "3. Compare: are the network's current activities aligned with the stated vision? "
                "Are there emerging priorities that the vision docs don't cover?\n"
                "4. For misalignments or course corrections, use suggest_kanban_task\n"
                "5. If the vision docs themselves are outdated, suggest a refresh task\n\n"
                "Focus on big-picture alignment, not day-to-day execution.\n"
                "Suggest 1-3 tasks maximum. Only suggest things with real strategic impact."
            ),
            # Theo reports via Relay (write_relay_report), not Kanban — see _system_audit_loop.
            "maria": (
                "You are Maria, UX/Product and Backend Workflow Lead. Triage the relay "
                "outbox and create Kanban tasks for company/ops improvements.\n\n"
                "1. Use read_relay_outbox to check for new backend reports from Theo, Orion, etc.\n"
                "2. For each actionable finding, use suggest_kanban_task with board='company' "
                "to create REL- tickets on the Company board\n"
                "3. For UX/product improvements, use board='user' for USR- tickets\n\n"
                "Focus on: relay reports that need tickets, ops improvements, UX gaps.\n"
                "Suggest 1-3 tasks maximum. Always specify the correct board."
            ),
            "arjuna": (
                "You are Arjuna, the Ticket Master. Review open work and ensure tasks are "
                "on the correct Kanban board.\n\n"
                "1. Use get_tasks to review current Kanban tasks and delegations\n"
                "2. Use read_document to check your ticket-backlog.md and sprint-dashboard.md\n"
                "3. For new work items, use suggest_kanban_task with the RIGHT board:\n"
                "   - board='user' for user tasks (USR-)\n"
                "   - board='comms' for communication/meeting follow-ups (MSG-)\n"
                "   - board='company' for company/business tasks (REL-)\n"
                "   - board='agent' for system/agent tasks (CYC-)\n\n"
                "Focus on: untracked work, misrouted tickets, sprint readiness.\n"
                "Suggest 1-3 tasks maximum. Always specify the correct board."
            ),
        }

        prompt = suggestion_prompts.get(agent_id)
        if not prompt:
            return

        self.task_queue.add_notification(agent_id, "Running suggestion cycle", "running")

        try:
            await self._run_agent_with_tools(agent, prompt)
            self.task_queue.add_notification(
                agent_id,
                "Suggestion cycle complete — check Kanban board",
                "completed",
            )
        except Exception as e:
            self.task_queue.add_notification(agent_id, f"Suggestion cycle failed: {e}", "failed")

    async def _proactive_suggestion_loop(self) -> None:
        """
        Periodically ask agents to suggest Kanban tasks in the background.

        Uses persistent timestamps so intervals survive login/logout cycles.

        Schedule:
          - Charles: every 60 minutes (operational suggestions)
          - Liam: every 60 minutes (execution gaps)
          - Dominic: every 120 minutes (strategic alignment)
          - Victor: every 8 hours (vision alignment sweep)
        Orion and Theo report via Relay; they are not in this Kanban-suggestion loop.
        """
        # Load intervals from config/automation.yaml if available, else defaults
        default_intervals = {
            "charles": 3600,  # 60 min
            "liam": 3600,  # 60 min
            "dominic": 7200,  # 120 min
            "victor": 28800,  # 8 hours
            "maria": 7200,  # 120 min
            "arjuna": 7200,  # 120 min
        }
        try:
            import yaml as _yaml

            from vastian.storage.data_root import get_config_dir

            _proj = (
                self.settings.agents_dir.parent
                if self.settings
                else Path(__file__).resolve().parents[2]
            )
            _auto_path = get_config_dir(_proj) / "automation.yaml"
            if _auto_path.exists():
                _auto_cfg = _yaml.safe_load(_auto_path.read_text(encoding="utf-8")) or {}
                intervals = _auto_cfg.get("suggestion_intervals", default_intervals)
            else:
                intervals = default_intervals
        except Exception:
            intervals = default_intervals

        # Short startup delay — persistent timestamps handle catch-up
        await asyncio.sleep(30)

        tick_interval = 60  # check every 60 seconds
        while True:
            try:
                await asyncio.sleep(tick_interval)
                if not self.task_queue:
                    continue

                # ── One-time bootstrap: Jake baselines + Concord first sim ──
                if not self.task_queue.kv_get("bootstrap_jake_done") and self.knowledge:
                    try:
                        baselines = self.knowledge.documents.read("jake/metric-baselines.md")
                        if baselines and any(
                            phrase in baselines.lower()
                            for phrase in [
                                "no baselines recorded",
                                "pending initial content",
                                "tbd",
                            ]
                        ):
                            self.task_queue.suggest_kanban_task(
                                title="Run initial metric baselines assessment",
                                description=(
                                    "Jake's metric-baselines.md and metric-assessment-history.md "
                                    "are empty. Run an initial baseline assessment using the user's "
                                    "profile data and current system metrics. This is required "
                                    "before the simulation system can function."
                                ),
                                category="infrastructure",
                                priority="normal",
                                suggested_by="charles",
                                assigned_to="jake",
                                board="agent",
                                tags=["bootstrap", "metrics"],
                                user_id=self.current_user_id,
                            )
                            self.task_queue.suggest_kanban_task(
                                title="Run first scenario simulation",
                                description=(
                                    "Concord's simulation framework has no recorded runs. "
                                    "After Jake completes baselines, run an initial simulation "
                                    "to validate the framework and establish reference points."
                                ),
                                category="infrastructure",
                                priority="low",
                                suggested_by="charles",
                                assigned_to="concord",
                                board="agent",
                                tags=["bootstrap", "simulation"],
                                user_id=self.current_user_id,
                            )
                            logger.info("Bootstrap: suggested Jake baselines + Concord first sim")
                        self.task_queue.kv_set("bootstrap_jake_done", "true")
                    except Exception:
                        pass

                for agent_id, required_interval in intervals.items():
                    loop_key = f"suggestion_{agent_id}"
                    elapsed = self.task_queue.seconds_since_loop_ran(loop_key)
                    # Run if never ran, or if interval has elapsed
                    if elapsed is None or elapsed >= required_interval:
                        self.task_queue.set_loop_last_run(loop_key)
                        logger.info("Proactive suggestion cycle: %s", agent_id)
                        asyncio.create_task(self.run_agent_suggestion_cycle(agent_id))
            except Exception as e:
                logger.debug("Proactive suggestion loop error: %s", e)

    async def _outreach_delivery_loop(self) -> None:
        """Deliver urgent outreach to the user while they are online.

        Runs every 15 seconds. For *critical* priority items the outreach is
        delivered immediately via callback.  For *high* items it is delivered
        after 30 s of idle time.  Normal/low items are held for ``/check-in``.
        """
        await asyncio.sleep(10)  # Let startup settle
        tick = 15  # seconds between checks
        while True:
            try:
                await asyncio.sleep(tick)

                if not self._user_online or not self.task_queue:
                    continue

                # Only deliver if we have a callback wired by the CLI
                if not self._outreach_callback:
                    continue

                items = self.task_queue.get_pending_outreach()
                if not items:
                    continue

                now = asyncio.get_event_loop().time()
                idle_seconds = now - self._last_user_activity if self._last_user_activity else 0

                for item in items:
                    priority = item.get("priority", "normal")
                    if priority == "critical":
                        # Interrupt immediately
                        await self._outreach_callback(item)
                        self.task_queue.mark_outreach_delivered(item["outreach_id"])
                        break  # One at a time
                    elif priority == "high" and idle_seconds >= 30:
                        await self._outreach_callback(item)
                        self.task_queue.mark_outreach_delivered(item["outreach_id"])
                        break
                    # Normal/low: wait for /check-in

            except Exception as e:
                logger.debug("Outreach delivery loop error: %s", e)

    async def _knowledge_consolidation_loop(self) -> None:
        """Periodically have Elias sweep agent documents for new knowledge.

        Runs every 2 hours.  Uses persistent timestamps so the interval
        survives login/logout cycles.
        """
        # Short startup delay — persistent timestamps handle catch-up
        await asyncio.sleep(30)

        interval = 7200  # 2 hours
        while True:
            try:
                # Check persistent timestamp — skip if interval hasn't elapsed
                if self.task_queue:
                    elapsed = self.task_queue.seconds_since_loop_ran("knowledge_consolidation")
                    if elapsed is not None and elapsed < interval:
                        await asyncio.sleep(min(interval - elapsed, 300))
                        continue
                else:
                    await asyncio.sleep(300)
                    continue

                if not self.knowledge or not self.task_queue:
                    await asyncio.sleep(300)
                    continue

                elias = self.registry.get_agent("elias")
                if not elias:
                    await asyncio.sleep(300)
                    continue

                logger.info("Knowledge consolidation: starting VKB sweep")

                # 1. Gather excerpts from all agent documents (excluding elias's own)
                doc_excerpts: list[str] = []
                for config in self.registry.all_configs:
                    if config.agent_id == "elias":
                        continue
                    agent_docs = self.knowledge.documents.list_docs(config.agent_id)
                    for doc_path in agent_docs:
                        try:
                            content = self.knowledge.documents.read(doc_path)
                            if not content or "not found" in content.lower():
                                continue
                            if "pending initial content" in content.lower():
                                continue
                            # Take first 600 chars as a meaningful excerpt
                            clean = "\n".join(
                                line
                                for line in content.split("\n")
                                if line.strip() and not line.startswith("---")
                            )
                            if len(clean) > 50:  # Skip near-empty docs
                                doc_excerpts.append(f"### {doc_path}\n{clean[:600]}")
                        except Exception:
                            continue

                if not doc_excerpts:
                    logger.info("Knowledge consolidation: no agent docs with content, skipping")
                    await asyncio.sleep(300)
                    continue

                # 2. Read the current VKB to see what's already captured
                current_vkb = self.knowledge.documents.read("elias/vastian-knowledge-bank.md")
                if "not found" in current_vkb.lower():
                    current_vkb = "(VKB document not yet created)"

                # 3. Build the consolidation prompt
                excerpts_text = "\n\n".join(doc_excerpts[:30])  # Cap at 30 docs

                prompt = (
                    "## Knowledge Consolidation Task\n\n"
                    "You are performing your periodic VKB sweep. Your job is to scan the "
                    "documents from other agents below and identify any **new knowledge** "
                    "that is NOT already captured in your knowledge bank.\n\n"
                    "### Current VKB (what you already have)\n"
                    f"```\n{current_vkb[:3000]}\n```\n\n"
                    "### Agent Documents to Scan\n"
                    f"{excerpts_text}\n\n"
                    "### Instructions\n"
                    "1. First, use `read_document` to read your full VKB: `elias/vastian-knowledge-bank.md`\n"
                    "2. Compare what's in the agent docs above against what's already in the VKB\n"
                    "3. For any **new** facts, decisions, financial data, career updates, "
                    "strategy changes, or execution progress that are NOT already in the VKB, "
                    "add them as new entries using your ledger format:\n"
                    "   ### {TAG}-{NNN} — {Title}\n"
                    "   - **Date:** YYYY-MM-DD | **Source:** `agent:{id}` | **Quality:** High/Medium\n"
                    "   - **Tags:** `primary_tag`, `secondary_tag`\n"
                    "   - Content...\n\n"
                    "4. Use `write_document` to save the updated VKB with the new entries appended "
                    "to the correct tag sections. Update the VKB Health metrics at the top.\n"
                    "5. If there's nothing new to add, just respond 'VKB is current — no new entries.'\n\n"
                    "IMPORTANT: Do NOT duplicate entries. Check IDs carefully. Only add genuinely "
                    "new information. Quality over quantity."
                )

                # 4. Enqueue as a background task
                task = self.task_queue.enqueue(
                    task_type="knowledge_consolidation",
                    agent_id="elias",
                    description="VKB consolidation sweep — scanning agent docs for new knowledge",
                    payload={"prompt": prompt},
                )
                self.task_queue.add_notification(
                    "elias",
                    "Starting VKB knowledge consolidation sweep",
                    "running",
                )
                asyncio.create_task(self._execute_background_task(task.task_id))
                self.task_queue.set_loop_last_run("knowledge_consolidation")
                logger.info("Knowledge consolidation: task enqueued (%s)", task.task_id)

            except Exception as e:
                logger.debug("Knowledge consolidation loop error: %s", e)
            await asyncio.sleep(300)  # Re-check every 5 minutes

    async def _kanban_dedup_loop(self) -> None:
        """Orion periodically sweeps the Kanban board for duplicates, volume
        issues, and automation opportunities.

        Runs every 90 minutes.  Uses persistent timestamps so the interval
        survives login/logout cycles.

        Ownership: **Orion** (Operations Lead).
        Theo only flags issues — Orion investigates and resolves them.
        """
        # Short startup delay — persistent timestamps handle catch-up
        await asyncio.sleep(30)

        interval = 5400  # 90 minutes
        while True:
            try:
                if not self.task_queue:
                    await asyncio.sleep(300)
                    continue

                # Check persistent timestamp — skip if interval hasn't elapsed
                elapsed = self.task_queue.seconds_since_loop_ran("kanban_dedup")
                if elapsed is not None and elapsed < interval:
                    await asyncio.sleep(min(interval - elapsed, 300))
                    continue

                orion = self.registry.get_agent("orion")
                if not orion:
                    await asyncio.sleep(300)
                    continue

                # Gather all active (non-archived, non-rejected, non-done) tasks
                all_tasks = self.task_queue.get_kanban_tasks()
                active_tasks = [
                    t for t in all_tasks if t.get("status") not in ("archived", "rejected", "done")
                ]
                suggested = [t for t in active_tasks if t.get("status") == "suggested"]

                if len(active_tasks) < 2:
                    self.task_queue.set_loop_last_run("kanban_dedup")
                    await asyncio.sleep(300)
                    continue

                logger.info(
                    "Kanban dedup: Orion reviewing %d active tasks (%d suggested)",
                    len(active_tasks),
                    len(suggested),
                )

                # ── Pre-compute duplicate pairs using SequenceMatcher ──
                from difflib import SequenceMatcher

                titles = [
                    (t.get("kanban_id", "?"), t.get("suggested_by", "?"), t.get("title", ""))
                    for t in active_tasks
                ]
                duplicate_pairs = []
                for i, (id1, a1, t1) in enumerate(titles):
                    for id2, a2, t2 in titles[i + 1 :]:
                        if SequenceMatcher(None, t1.lower(), t2.lower()).ratio() > 0.6:
                            duplicate_pairs.append(
                                f'  - [{a1}] "{t1}" ({id1}) <-> [{a2}] "{t2}" ({id2})'
                            )
                dups_section = "\n".join(duplicate_pairs[:15]) or "(none detected)"

                # ── Per-agent volume analysis ──
                agent_counts: dict[str, int] = {}
                for t in active_tasks:
                    a = t.get("suggested_by", "?")
                    agent_counts[a] = agent_counts.get(a, 0) + 1
                volume_lines = [
                    f"  - {aid}: {cnt} active tasks"
                    for aid, cnt in sorted(agent_counts.items(), key=lambda x: -x[1])
                    if cnt >= 3
                ]
                volume_section = "\n".join(volume_lines) or "(no high-volume agents)"

                # ── Build task list for review ──
                task_lines = []
                for t in active_tasks:
                    task_lines.append(
                        f"- ID: {t['kanban_id']} | Agent: {t['suggested_by']} | "
                        f"Status: {t.get('status', '?')} | Priority: {t.get('priority', 'normal')} | "
                        f"Title: {t['title']}\n"
                        f"  Description: {t.get('description', '')[:200]}"
                    )
                task_list = "\n".join(task_lines)

                # ── Theo's flagged agents (from prompt-audit-log.md) ──
                theo_flags_section = ""
                try:
                    audit_path = Path(self.settings.docs_dir) / "theo" / "prompt-audit-log.md"
                    if audit_path.exists():
                        content = audit_path.read_text(encoding="utf-8")
                        open_rows = [
                            ln.strip()
                            for ln in content.split("\n")
                            if ("| Open |" in ln or "| Pending |" in ln)
                            and ln.strip().startswith("|")
                        ]
                        if open_rows:
                            theo_flags_section = (
                                f"### Theo's Open Audit Flags ({len(open_rows)} issues)\n"
                                "These agents were flagged by Theo for quality/prompt issues. "
                                "Consider whether their Kanban tasks reflect the same problems.\n"
                                + "\n".join(open_rows[:10])
                                + "\n\n"
                            )
                except Exception:
                    pass

                prompt = (
                    "## Kanban Board Operations Sweep\n\n"
                    "You are Orion, the Operations Lead. This is your periodic Kanban board "
                    "review. You OWN the resolution of duplicates, volume issues, and process "
                    "improvements.\n\n"
                    f"### Active Tasks ({len(active_tasks)} total, {len(suggested)} suggested)\n"
                    f"{task_list}\n\n"
                    f"### Pre-Detected Potential Duplicates\n{dups_section}\n\n"
                    f"### Agent Volume (3+ active tasks)\n{volume_section}\n\n"
                    f"{theo_flags_section}"
                    "### Your Responsibilities\n"
                    "1. **RESOLVE DUPLICATES**: For each duplicate pair above, use "
                    "`manage_kanban_task` with action='archive' on the LESS descriptive one. "
                    "Add reason='Duplicate of {other_id}'. If both are weak, archive both and "
                    "submit ONE consolidated summary via `write_relay_report` (category='ops') "
                    "so Maria can create a REL- ticket if needed.\n\n"
                    "2. **VOLUME CONTROL**: If an agent has 5+ active tasks, check if they're "
                    "genuine or if their background loop is over-generating. If over-generating, "
                    "use `write_relay_report` (category='ops') to report a process fix (e.g., add dedup guard "
                    "to that agent's suggestion logic).\n\n"
                    "3. **CROSS-AGENT OVERLAP**: If two different agents suggested tasks about "
                    "the same topic, archive the one from the agent whose domain is LESS relevant. "
                    "Keep the one from the agent who naturally owns that work.\n\n"
                    "4. **ACT ON THEO'S FLAGS**: If Theo flagged an agent above, check if "
                    "that agent's Kanban tasks reflect the same issue (role drift = tasks outside "
                    "their domain, domain overlap = tasks belonging to another agent). Archive "
                    "tasks that don't belong. Add a note to the agent's valid tasks if needed.\n\n"
                    "5. **AUTOMATION OPPORTUNITIES**: If you see recurring patterns (same type "
                    "of task suggested repeatedly), log it as a process improvement and create "
                    "a Kanban task assigned to yourself to implement the fix.\n\n"
                    "FORMAT your actions in your response as:\n"
                    "```\n"
                    "ARCHIVED: <id> — reason\n"
                    "MERGED: <old_id_1>, <old_id_2> → Created consolidated task\n"
                    "VOLUME_FLAG: <agent_id> has <n> tasks — <recommendation>\n"
                    "CLEAN: Board looks healthy\n"
                    "```\n\n"
                    "Be decisive — you are the operations authority. Archive freely when "
                    "duplicates are clear. Err on the side of a clean board."
                )

                try:
                    result = await self._run_agent_with_tools(orion, prompt, max_rounds=8)

                    # Parse ARCHIVED/MERGED lines and ensure the archival happened
                    import re

                    for line in result.split("\n"):
                        line = line.strip()
                        if line.startswith("MERGED:"):
                            ids_part = (
                                line.split("→")[0]
                                if "→" in line
                                else line.split("->")[0]
                                if "->" in line
                                else ""
                            )
                            ids_part = ids_part.replace("MERGED:", "").strip()
                            old_ids = [
                                i.strip()
                                for i in re.split(r"[,\s]+", ids_part)
                                if len(i.strip()) >= 8
                            ]
                            for old_id in old_ids:
                                task = self.task_queue.get_kanban_task(old_id)
                                if task and task.get("status") in ("suggested", "approved"):
                                    self.task_queue.update_kanban_status(
                                        old_id,
                                        "archived",
                                        user_notes="Consolidated by Orion (operations sweep)",
                                    )
                                    logger.info("Kanban hygiene: Orion archived %s", old_id)

                    self.task_queue.add_notification(
                        "orion",
                        "Kanban dedup sweep complete",
                        "completed",
                    )
                    self.task_queue.set_loop_last_run("kanban_dedup")
                    logger.info("Kanban dedup: Orion sweep complete")

                except Exception as e:
                    logger.debug("Kanban dedup execution error: %s", e)
                    self.task_queue.set_loop_last_run("kanban_dedup")
                    self.task_queue.add_notification(
                        "orion",
                        f"Kanban dedup sweep failed: {e}",
                        "failed",
                    )

            except Exception as e:
                logger.debug("Kanban dedup loop error: %s", e)

    # ── New automation loops (Phase 1-3) ────────────────────────

    async def _financial_monitoring_loop(self) -> None:
        """Finley periodically checks Mercury balances and flags anomalies.

        Interval: 4 hours.  Uses persistent timestamps so the interval
        survives login/logout cycles.
        """
        await asyncio.sleep(30)
        interval = 14400  # 4 hours
        tick = 120  # check every 2 minutes

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue or not self._mercury_client:
                    continue

                elapsed = self.task_queue.seconds_since_loop_ran("financial_monitoring")
                if elapsed is not None and elapsed < interval:
                    continue

                finley = self.registry.get_agent("finley")
                if not finley:
                    continue

                logger.info("Financial monitoring: Finley checking balances")

                prompt = (
                    "## Periodic Balance Check\n\n"
                    "You are performing your scheduled financial monitoring sweep.\n\n"
                    "INSTRUCTIONS:\n"
                    "1. Call `check_bank_balance` with account_id='all' to get current balances\n"
                    "2. Call `read_document` on `finley/financial-projections.md` to see prior data\n"
                    "3. Compare current balances against any previously recorded values\n"
                    "4. FLAG via `request_user_input` if:\n"
                    "   - Any account dropped by >$500 since last check\n"
                    "   - Any account is below $200\n"
                    "   - Any unexpected large transaction detected\n"
                    "5. Append a brief 'Balance Snapshot' note to `finley/financial-projections.md` "
                    "with today's date and current balances (merge with existing content)\n"
                    "6. If nothing unusual, just update the document silently.\n\n"
                    "Be concise. This runs in the background."
                )

                try:
                    await self._run_agent_with_tools(finley, prompt, max_rounds=6)
                    self.task_queue.add_notification(
                        "finley",
                        "Balance monitoring sweep complete",
                        "completed",
                    )
                except Exception as e:
                    logger.debug("Financial monitoring execution error: %s", e)
                    self.task_queue.add_notification(
                        "finley",
                        f"Balance monitoring failed: {e}",
                        "failed",
                    )

                self.task_queue.set_loop_last_run("financial_monitoring")

            except Exception as e:
                logger.debug("Financial monitoring loop error: %s", e)

    async def _cycle_health_loop(self) -> None:
        """Liam periodically reviews stuck tasks, failed delegations, stale cycles.

        Interval: 3 hours.  Uses persistent timestamps.
        """
        await asyncio.sleep(30)
        interval = 10800  # 3 hours
        tick = 120

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue

                elapsed = self.task_queue.seconds_since_loop_ran("cycle_health")
                if elapsed is not None and elapsed < interval:
                    continue

                liam = self.registry.get_agent("liam")
                if not liam:
                    continue

                logger.info("Cycle health: Liam reviewing execution status")

                # Gather context for Liam
                running = self.task_queue.get_running()
                running_lines = (
                    "\n".join(
                        f"- {t.task_id[:8]} | {t.agent_id} | {t.description} | running since {t.started_at or '?'}"
                        for t in running
                    )
                    or "(none)"
                )
                failed = [t for t in self.task_queue.get_recent(limit=20) if t.status == "failed"]
                failed_lines = (
                    "\n".join(
                        f"- {t.task_id[:8]} | {t.agent_id} | {t.description} | error: {(t.result or '')[:100]}"
                        for t in failed[:10]
                    )
                    or "(none)"
                )

                kanban_in_progress = self.task_queue.get_kanban_tasks(status="in_progress")
                kanban_lines = (
                    "\n".join(
                        f"- {t['kanban_id']} | {t.get('suggested_by', '?')} | {t['title']} | started: {t.get('started_at', '?')}"
                        for t in kanban_in_progress
                    )
                    or "(none)"
                )

                prompt = (
                    "## Cycle Health Check\n\n"
                    "You are performing your periodic execution health review.\n\n"
                    f"### Tasks Currently Running\n{running_lines}\n\n"
                    f"### Recent Failed Tasks (last 20)\n{failed_lines}\n\n"
                    f"### Kanban Tasks In Progress\n{kanban_lines}\n\n"
                    "INSTRUCTIONS:\n"
                    "1. Read your `liam/cycle-plan.md` and `liam/execution-goal-tracker.md`\n"
                    "2. Identify: tasks stuck >2 hours in running, recurring failures, stalled Kanban items\n"
                    "3. Update your documents with current status\n"
                    "4. If anything needs user attention, call `request_user_input` with details\n"
                    "5. If you find execution gaps, use `suggest_kanban_task` to propose fixes\n\n"
                    "Be concise. Focus on what's actually broken or stuck."
                )

                try:
                    await self._run_agent_with_tools(liam, prompt, max_rounds=6)
                    self.task_queue.add_notification(
                        "liam",
                        "Cycle health check complete",
                        "completed",
                    )
                except Exception as e:
                    logger.debug("Cycle health execution error: %s", e)
                    self.task_queue.add_notification(
                        "liam",
                        f"Cycle health check failed: {e}",
                        "failed",
                    )

                self.task_queue.set_loop_last_run("cycle_health")

            except Exception as e:
                logger.debug("Cycle health loop error: %s", e)

    # ── Wave 6: Cycle Planning Session ─────────────────────

    async def run_cycle_planning_session(self, topic: str = "") -> dict:
        """Run a cycle planning session: Liam creates sprint, Arjuna assigns tickets,
        Noah generates theme, Rowan (digital twin) approves priorities.

        Returns a summary dict with sprint_id, assigned tickets, and session notes.
        """
        result = {"sprint_id": None, "tickets_assigned": 0, "notes": ""}

        if not self.task_queue:
            return result

        # 1. Noah generates the cycle theme
        cycle_topic = topic or "Sprint Planning"
        theme = ""
        noah = self.registry.get_agent("noah")
        if noah:
            try:
                llm = self._get_llm_for_agent("noah") or self.llm
                if llm:
                    theme_result = await llm.generate(
                        system_prompt="You are Noah, Creative Director. Generate a one-line sprint theme.",
                        messages=[
                            {
                                "role": "user",
                                "content": f"Create a creative 1-line theme for a sprint about: {cycle_topic}",
                            }
                        ],
                        model=self._resolve_model_for_agent("noah"),
                        temperature=0.9,
                    )
                    theme = theme_result.strip()
            except Exception:
                theme = ""

        # 2. Liam creates the sprint
        sprint_name = f"Cycle: {cycle_topic}"
        if theme:
            sprint_name = f"Cycle: {theme}"
        sprint_id = self.task_queue.create_sprint(sprint_name, goal=cycle_topic)
        result["sprint_id"] = sprint_id

        # 3. Liam reviews open tickets and assigns top priorities to sprint
        liam = self.registry.get_agent("liam")
        if liam:
            open_tasks = self.task_queue.get_kanban_tasks(status="approved")
            suggested_tasks = self.task_queue.get_kanban_tasks(status="suggested")
            all_available = open_tasks + suggested_tasks

            # Use Liam to pick top priorities
            if all_available:
                task_summary = "\n".join(
                    f"- {t.get('ticket_id', t['kanban_id'])} | {t['title']} | priority: {t.get('priority', 'normal')} | board: {t.get('board', 'agent')}"
                    for t in all_available[:20]
                )
                try:
                    llm = self._get_llm_for_agent("liam") or self.llm
                    if llm:
                        pick_result = await llm.generate(
                            system_prompt="You are Liam, Sprint Coordinator. Pick the top 3-5 tickets for this sprint.",
                            messages=[
                                {
                                    "role": "user",
                                    "content": (
                                        f"Sprint: {sprint_name}\nGoal: {cycle_topic}\n\n"
                                        f"Available tickets:\n{task_summary}\n\n"
                                        f"Return ONLY the ticket IDs (one per line) of the top 3-5 priorities for this sprint."
                                    ),
                                }
                            ],
                            model=self._resolve_model_for_agent("liam"),
                            temperature=0.3,
                        )
                        # Parse ticket IDs from response
                        for line in pick_result.strip().split("\n"):
                            line = line.strip().strip("-").strip()
                            # Match kanban_id from available tasks
                            for t in all_available:
                                kid = t["kanban_id"]
                                tid = t.get("ticket_id", kid)
                                if kid in line or tid in line:
                                    self.task_queue.assign_ticket_to_sprint(kid, sprint_id)
                                    result["tickets_assigned"] += 1
                                    break
                except Exception as e:
                    logger.debug("Cycle planning ticket assignment error: %s", e)

        result["notes"] = (
            f"Sprint '{sprint_name}' created with {result['tickets_assigned']} tickets"
        )
        if theme:
            result["notes"] += f" | Theme: {theme}"

        if self.task_queue:
            self.task_queue.add_notification(
                "liam",
                f"Cycle planning complete: {result['notes']}",
                "completed",
            )

        return result

    # ── Wave 6: Recalibration Session ──────────────────────

    RECALIBRATION_PROMPTS: dict[str, str] = {
        "elias": (
            "You are Elias, Knowledge Manager. For this recalibration session, report:\n"
            "1. Key knowledge items stored or promoted since the last recalibration\n"
            "2. Any patterns or insights that have emerged\n"
            "3. Knowledge gaps you've identified\n"
            "Be concise — 3-5 bullet points max."
        ),
        "sage": (
            "You are Sage, Career Coach & Wellbeing Advisor. For this recalibration, report:\n"
            "1. Current career trajectory and any shifts\n"
            "2. Health/wellness observations or concerns\n"
            "3. Growth opportunities you've identified\n"
            "Be concise — 3-5 bullet points max."
        ),
        "victor": (
            "You are Victor, Vision Keeper. For this recalibration, report:\n"
            "1. Is the user's current activity aligned with their stated vision?\n"
            "2. Any emerging priorities the vision docs don't cover\n"
            "3. Recommended course corrections\n"
            "Be concise — 3-5 bullet points max."
        ),
        "jake": (
            "You are Jake, Metrics Analyst. For this recalibration, report:\n"
            "1. Key metric changes since last assessment\n"
            "2. Areas showing improvement vs decline\n"
            "3. Variables that need user attention\n"
            "Be concise — 3-5 bullet points max."
        ),
        "maya": (
            "You are Maya, Meeting Intelligence Analyst. For this recalibration, report:\n"
            "1. Key themes from recent meetings (if any transcripts processed)\n"
            "2. Recurring action items or blockers\n"
            "3. Communication patterns worth noting\n"
            "Be concise — 3-5 bullet points max."
        ),
        "adrian": (
            "You are Adrian, Personal Growth Coach. For this recalibration, report:\n"
            "1. Persona alignment — any drift from stated identity/values?\n"
            "2. Growth exercises or reflections completed\n"
            "3. Areas where the user may benefit from attention\n"
            "Be concise — 3-5 bullet points max."
        ),
    }

    async def run_recalibration_session(self) -> dict:
        """Run a recalibration session: each agent reports status, Rowan synthesizes.

        Returns a summary dict with per-agent reports and synthesis.
        """
        reports: dict[str, str] = {}

        for agent_id, prompt in self.RECALIBRATION_PROMPTS.items():
            agent = self.registry.get_agent(agent_id)
            if not agent:
                continue
            try:
                llm = self._get_llm_for_agent(agent_id) or self.llm
                if not llm:
                    continue
                result = await llm.generate(
                    system_prompt=prompt,
                    messages=[
                        {
                            "role": "user",
                            "content": (
                                "Perform your recalibration check. Read your latest documents "
                                "and report what has changed or needs attention."
                            ),
                        }
                    ],
                    model=self._resolve_model_for_agent(agent_id),
                    temperature=0.4,
                )
                reports[agent_id] = result.strip()
            except Exception as e:
                reports[agent_id] = f"(Error: {e})"
                logger.debug("Recalibration report error for %s: %s", agent_id, e)

        # Rowan synthesizes
        synthesis = ""
        rowan = self.registry.get_agent("rowan")
        if rowan and reports:
            report_text = "\n\n".join(f"## {aid.title()}\n{text}" for aid, text in reports.items())
            try:
                llm = self._get_llm_for_agent("rowan") or self.llm
                if llm:
                    synthesis = await llm.generate(
                        system_prompt=(
                            f"You are {self.settings.user_name}'s digital twin. Synthesize the "
                            "recalibration reports below into a unified status summary. "
                            "Highlight: (1) areas of alignment, (2) areas of drift, "
                            "(3) recommended actions. Be direct and concise."
                        ),
                        messages=[{"role": "user", "content": report_text}],
                        model=self._resolve_model_for_agent("rowan"),
                        temperature=0.4,
                    )
                    synthesis = synthesis.strip()
            except Exception as e:
                synthesis = f"(Synthesis error: {e})"

        if self.task_queue:
            self.task_queue.add_notification(
                "rowan",
                f"Recalibration complete — {len(reports)} agents reported",
                "completed",
            )

        return {
            "reports": reports,
            "synthesis": synthesis,
            "agents_reported": list(reports.keys()),
        }

    # ── Phase Earth: Data Flow Pipelines ───────────────────

    async def _route_meeting_signals(self, signals: dict) -> None:
        """E4: Route extracted meeting signals to downstream agents.

        action_items -> Liam (Kanban tasks on comms board)
        decisions + key_signals -> Elias (DIKW promotion)
        blockers -> Charles (outreach to user)
        risks -> Dominic (strategic awareness)
        """
        if not self.task_queue:
            return

        # Action items -> Liam creates Kanban tasks
        for item in signals.get("action_items", []):
            with contextlib.suppress(Exception):
                self.task_queue.suggest_kanban_task(
                    suggested_by="maya",
                    title=f"Meeting action: {item[:60]}",
                    description=item,
                    category="meeting_followup",
                    board="comms",
                    tags=["meeting", "action-item"],
                    user_id=self.current_user_id,
                )

        # Decisions + key_signals -> Elias notification for DIKW intake
        knowledge_items = signals.get("decisions", []) + signals.get("key_signals", [])
        if knowledge_items and self.task_queue:
            self.task_queue.add_notification(
                "elias",
                f"New meeting signals for DIKW intake: {len(knowledge_items)} items",
                "info",
            )

        # Blockers -> Charles outreach
        for blocker in signals.get("blockers", []):
            with contextlib.suppress(Exception):
                self.task_queue.create_outreach(
                    agent_id="maya",
                    subject=f"Meeting blocker: {blocker[:60]}",
                    message=f"Maya detected a blocker from a meeting:\n\n{blocker}",
                    priority="high",
                )

        # Risks -> Dominic notification
        if signals.get("risks") and self.task_queue:
            self.task_queue.add_notification(
                "dominic",
                f"Meeting risks detected: {len(signals['risks'])} risk(s) for strategic review",
                "info",
            )

        logger.info(
            "Signal routing complete: %d actions, %d knowledge, %d blockers, %d risks",
            len(signals.get("action_items", [])),
            len(knowledge_items),
            len(signals.get("blockers", [])),
            len(signals.get("risks", [])),
        )

        # E2: Generate session cards from signals
        try:
            await self._generate_session_cards_from_signals(signals)
        except Exception:
            logger.debug("Card generation from signals failed (non-fatal)")

    async def _route_career_update(self, agent_id: str, field: str, value: str) -> None:
        """E5: Route career profile updates to downstream agents.

        Career updates -> Adrian (persona evolution)
        Vision shifts -> Victor (life direction)
        Wellness data -> Kiran (simulation metrics)
        S2b: Therapy triggers/patterns -> Adrian for persona customization.
        """
        if not self.task_queue:
            return

        # Notify Adrian of persona-relevant updates (S2b: includes therapy triggers for persona customization)
        persona_fields = (
            "strengths",
            "goals",
            "values",
            "achievements",
            "interests",
            "triggers",
            "patterns",
            "growth_themes",
            "persona_resonance",
        )
        if field in persona_fields:
            self.task_queue.add_notification(
                "adrian",
                f"Career/therapy update for persona evolution: {field} = {value[:80]}",
                "info",
            )

        # Notify Victor of vision/direction shifts
        if field in ("goals", "target_role", "vision"):
            self.task_queue.add_notification(
                "victor",
                f"Life direction update from Sage: {field} = {value[:80]}",
                "info",
            )

        # Feed Kiran wellness/emotional data for Ojas metrics
        if field in ("wellness", "stress_level", "energy", "mood"):
            self.task_queue.add_notification(
                "kiran",
                f"Wellness metric from Sage: {field} = {value[:80]}",
                "info",
            )

        # E2: Generate session cards from career updates
        try:
            await self._generate_session_cards_from_career(field, value)
        except Exception:
            logger.debug("Card generation from career update failed (non-fatal)")

    async def _route_metrics_results(self, results: dict) -> None:
        """E6: Route Kiran's metric results to Jake and Concord.

        Computed metrics -> Jake (validation)
        Validated metrics -> Concord (scenario simulation)
        """
        if not self.task_queue:
            return

        self.task_queue.add_notification(
            "jake",
            f"New Ojas metrics from Kiran ready for validation: {len(results)} variables",
            "info",
        )

        # E2: Generate session cards from metrics
        try:
            await self._generate_session_cards_from_metrics(results)
        except Exception:
            logger.debug("Card generation from metrics failed (non-fatal)")

    # ── E2: Session Card Generation ─────────────────────

    def _queue_session_card(self, card: dict) -> None:
        """Queue a card for the next session. Cards are stored in state/pending-cards.json
        and picked up by Noah's curation loop (E3) to build session card series."""
        from vastian.storage.file_store import FileStore

        sd = self.settings.state_dir if self.settings else Path("./state")
        store = FileStore(sd / "pending-cards.json", id_field="card_id")
        import uuid
        from datetime import datetime

        card["card_id"] = uuid.uuid4().hex[:10]
        card["created_at"] = datetime.now(UTC).isoformat()
        card["curated"] = False
        store.insert(card)

    async def _generate_session_cards_from_signals(self, signals: dict) -> None:
        """E2: Generate session cards from extracted meeting signals."""
        # Elias nugget from decisions/key_signals
        knowledge_items = signals.get("decisions", []) + signals.get("key_signals", [])
        if knowledge_items:
            self._queue_session_card(
                {
                    "type": "nugget",
                    "title": "Meeting Insight",
                    "content": knowledge_items[0][:300] if knowledge_items else "",
                    "agent_id": "elias",
                    "metadata": {"source": "meeting_signals", "count": len(knowledge_items)},
                }
            )

        # Action items as action card
        actions = signals.get("action_items", [])
        if actions:
            content = "\n".join(f"- {a}" for a in actions[:5])
            self._queue_session_card(
                {
                    "type": "action",
                    "title": "Meeting Action Items",
                    "content": content,
                    "agent_id": "liam",
                    "metadata": {"source": "meeting_signals"},
                }
            )

        # Blockers as insight card
        blockers = signals.get("blockers", [])
        if blockers:
            self._queue_session_card(
                {
                    "type": "insight",
                    "title": "Blockers Detected",
                    "content": "\n".join(f"- {b}" for b in blockers),
                    "agent_id": "charles",
                    "metadata": {"source": "meeting_signals", "priority": "high"},
                }
            )

    async def _generate_session_cards_from_career(self, field: str, value: str) -> None:
        """E2: Generate session cards from career profile updates."""
        if field in ("goals", "target_role"):
            self._queue_session_card(
                {
                    "type": "insight",
                    "title": f"Career Update: {field.replace('_', ' ').title()}",
                    "content": value[:300],
                    "agent_id": "sage",
                    "metadata": {"source": "career_update", "field": field},
                }
            )

    async def _generate_session_cards_from_metrics(self, results: dict) -> None:
        """E2: Generate session cards from Ojas metrics computation."""
        if results:
            self._queue_session_card(
                {
                    "type": "metrics_review",
                    "title": "Metrics Update",
                    "content": "Latest Ojas metrics computed:",
                    "agent_id": "kiran",
                    "metadata": {"source": "ojas_metrics", "metrics": results},
                }
            )
            # Also generate a metrics quiz for user check-in
            self._queue_session_card(
                {
                    "type": "metrics_quiz",
                    "title": "How Are You Feeling?",
                    "content": "Quick check-in on key life areas:",
                    "agent_id": "kiran",
                    "options": [
                        {
                            "question": "How clear is your sense of purpose right now?",
                            "key": "purpose_clarity",
                        },
                        {
                            "question": "How focused have you been this week?",
                            "key": "focus_intensity",
                        },
                        {
                            "question": "How well are you managing stress?",
                            "key": "emotional_resilience",
                        },
                    ],
                }
            )

    # ── E3: Session Curation Loop (Noah) ────────────────

    # ── L2: Maria Relay Triage Loop ─────────────────────

    async def _maria_relay_triage_loop(self) -> None:
        """Maria periodically reads the relay outbox and creates company board tickets.

        Interval: 2 hours. Uses persistent timestamps.
        """
        await asyncio.sleep(45)
        interval = 7200  # 2 hours
        tick = 120

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue

                elapsed = self.task_queue.seconds_since_loop_ran("maria_relay_triage")
                if elapsed is not None and elapsed < interval:
                    continue

                maria = self.registry.get_agent("maria")
                if not maria:
                    self.task_queue.set_loop_last_run("maria_relay_triage")
                    continue

                # Check if relay outbox has new reports
                sd = self.settings.state_dir if self.settings else Path("./state")
                relay_dir = sd / "relay" / "outbox"
                if not relay_dir.exists():
                    self.task_queue.set_loop_last_run("maria_relay_triage")
                    continue

                report_files = []
                for cat_dir in relay_dir.iterdir():
                    if cat_dir.is_dir():
                        for f in sorted(cat_dir.glob("*.json")):
                            report_files.append(f)

                if not report_files:
                    self.task_queue.set_loop_last_run("maria_relay_triage")
                    continue

                logger.info("Maria relay triage: %d reports to process", len(report_files))

                # Build summary of reports for Maria
                import json as _json

                report_summaries = []
                for rf in report_files[-10:]:  # Last 10 reports max
                    try:
                        rpt = _json.loads(rf.read_text(encoding="utf-8"))
                        report_summaries.append(
                            f"- [{rpt.get('category', '?')}] by {rpt.get('agent_id', '?')}: "
                            f"{rpt.get('summary', '')[:120]}"
                        )
                    except Exception:
                        pass

                if not report_summaries:
                    self.task_queue.set_loop_last_run("maria_relay_triage")
                    continue

                prompt = (
                    "You are Maria, UX/Product and Backend Workflow Lead.\n\n"
                    "## Relay Outbox Reports\n" + "\n".join(report_summaries) + "\n\n"
                    "INSTRUCTIONS:\n"
                    "1. Read each report using read_relay_outbox\n"
                    "2. For actionable findings, use suggest_kanban_task with board='company' "
                    "to create REL- tickets\n"
                    "3. For config change suggestions (category 'config'), use queue_config_card "
                    "for each so the user sees Accept/Reject in the next session. Use the report "
                    "payload: config_file, field, current_value, proposed_value, reason\n"
                    "4. Delegate follow-ups to the appropriate agents\n\n"
                    "Create 1-3 tickets maximum. Queue config cards for every config report."
                )

                try:
                    await self._run_agent_with_tools(maria, prompt, max_rounds=6)
                    self.task_queue.add_notification(
                        "maria",
                        "Relay triage complete",
                        "completed",
                    )
                except Exception as e:
                    logger.debug("Maria relay triage error: %s", e)

                self.task_queue.set_loop_last_run("maria_relay_triage")

            except Exception as e:
                logger.debug("Maria relay triage loop error: %s", e)

    async def _zo_polling_loop(self) -> None:
        """Jupiter J1: Pull new messages from Zo's vastian-inbox into saved_prompts/inbox.

        Runs every 5 minutes (configurable in automation.yaml). Writes each message
        with headers so Charles can triage; tracks processed IDs to avoid duplicates.
        """
        await asyncio.sleep(90)  # Let startup settle
        tick = 60
        interval = 300  # 5 minutes default

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue

                # Load zo_polling config from automation.yaml (vastian data path)
                try:
                    import yaml as _yaml

                    from vastian.storage.data_root import get_config_dir

                    _proj = (
                        self.settings.agents_dir.parent
                        if self.settings
                        else Path(__file__).resolve().parents[2]
                    )
                    _auto_path = get_config_dir(_proj) / "automation.yaml"
                    if _auto_path.exists():
                        _cfg = _yaml.safe_load(_auto_path.read_text(encoding="utf-8")) or {}
                        zo_cfg = _cfg.get("zo_polling", {})
                        if not zo_cfg.get("enabled", True):
                            continue
                        interval = int(zo_cfg.get("interval", 300))
                except Exception:
                    pass

                elapsed = self.task_queue.seconds_since_loop_ran("zo_polling")
                if elapsed is not None and elapsed < interval:
                    continue

                provider = self._get_messaging_provider()
                if not provider:
                    self.task_queue.set_loop_last_run("zo_polling")
                    continue

                messages = await provider.pull_inbox()
                if not messages:
                    self.task_queue.set_loop_last_run("zo_polling")
                    continue

                # Load processed IDs
                processed_raw = self.task_queue.kv_get("zo_inbox_processed") or ""
                processed = set(x.strip() for x in processed_raw.split(",") if x.strip())
                from vastian.storage.data_root import get_saved_prompts_dir

                inbox_dir = get_saved_prompts_dir(self.settings.agents_dir.parent) / "inbox"
                inbox_dir.mkdir(parents=True, exist_ok=True)
                new_processed: list[str] = []
                for msg in messages:
                    msg_id = msg.get("id", "")
                    if not msg_id or msg_id in processed:
                        continue
                    content = msg.get("content", "").strip()
                    if not content:
                        new_processed.append(msg_id)
                        continue
                    header = f"---\nsource: zo\nzo_id: {msg_id}\n---\n\n"
                    body = f"{header}{content}"
                    safe_name = "".join(c if c.isalnum() or c in ".-_" else "_" for c in msg_id)
                    if not safe_name.endswith(".txt"):
                        safe_name = f"{safe_name}.txt"
                    out_path = inbox_dir / safe_name
                    try:
                        out_path.write_text(body, encoding="utf-8")
                        logger.info("Zo polling: wrote %s to inbox", safe_name)
                        new_processed.append(msg_id)
                    except OSError as e:
                        logger.warning("Zo polling: failed to write %s: %s", safe_name, e)

                if new_processed:
                    processed.update(new_processed)
                    # Keep last 200 IDs to avoid unbounded growth
                    sorted_ids = sorted(processed)
                    trimmed = sorted_ids[-200:] if len(sorted_ids) > 200 else sorted_ids
                    self.task_queue.kv_set("zo_inbox_processed", ",".join(trimmed))

                self.task_queue.set_loop_last_run("zo_polling")

            except Exception as e:
                logger.debug("Zo polling loop error: %s", e)
                if self.task_queue:
                    self.task_queue.set_loop_last_run("zo_polling")

    async def _session_curation_loop(self) -> None:
        """Noah curates session card series from pending cards.

        Maria aggregates team findings, Noah designs the card series.
        Runs every 4 hours. Uses persistent timestamps.
        """
        await asyncio.sleep(60)  # Initial delay
        interval = 14400  # 4 hours
        tick = 120

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue

                elapsed = self.task_queue.seconds_since_loop_ran("session_curation")
                if elapsed is not None and elapsed < interval:
                    continue

                # ── Roadmap-driven session creation ─────────────────
                from vastian.storage.file_store import FileStore

                sd = self.settings.state_dir if self.settings else Path("./state")
                try:
                    import json as _rjson

                    roadmap_file = sd / "roadmap.json"
                    if roadmap_file.exists():
                        roadmap = _rjson.loads(roadmap_file.read_text(encoding="utf-8"))
                        stages = roadmap.get("stages", [])
                        current_idx = roadmap.get("current_stage", 0)
                        if current_idx < len(stages):
                            stage = stages[current_idx]
                            # Check if a session already exists for this stage
                            session_store = FileStore(sd / "sessions.json", id_field="id")
                            existing = session_store.query(
                                lambda s, _stage=stage: (
                                    s.get("roadmap_stage") == _stage["id"]
                                    and s.get("phase") in ("open", "ready", "active")
                                )
                            )
                            if not existing:
                                # Create a session for this roadmap stage
                                import uuid
                                from datetime import datetime

                                session_id = uuid.uuid4().hex[:12]
                                stype = stage.get("session_type", "onboarding")
                                topic = stage.get("topic", "Getting started")
                                lead = "charles" if stype == "onboarding" else "liam"
                                members = (
                                    ["charles", "victor", "adrian"]
                                    if stype == "onboarding"
                                    else ["liam", "dominic"]
                                )
                                session_record = {
                                    "id": session_id,
                                    "direction": "presentation",
                                    "topic": topic,
                                    "session_type": stype,
                                    "display_name": topic,
                                    "theme": f"Roadmap: {stage['id']}",
                                    "lead_agent": lead,
                                    "presenter_name": lead,
                                    "agent_ids": members,
                                    "phase": "ready",
                                    "roadmap_stage": stage["id"],
                                    "rounds": [],
                                    "current_round": 0,
                                    "cards": [],
                                    "card_index": 0,
                                    "curated_by": "system",
                                    "summary": "",
                                    "created_at": datetime.now(UTC).isoformat(),
                                    "completed_at": None,
                                }
                                session_store.insert(session_record)
                                logger.info(
                                    "Roadmap: created '%s' session for stage '%s'",
                                    topic,
                                    stage["id"],
                                )
                                if self.task_queue:
                                    self.task_queue.add_notification(
                                        "system",
                                        f"New session ready: '{topic}'",
                                        "completed",
                                    )
                except Exception as e:
                    logger.debug("Roadmap session check error: %s", e)

                # ── Pending cards curation ─────────────────────────
                card_store = FileStore(sd / "pending-cards.json", id_field="card_id")
                pending = card_store.query(lambda c: not c.get("curated", False))

                if not pending:
                    self.task_queue.set_loop_last_run("session_curation")
                    continue

                logger.info("Session curation: %d pending cards for Noah to curate", len(pending))

                # Step 1: Maria aggregates — collect relay outbox summaries
                maria_summary = ""
                try:
                    relay_dir = sd / "relay" / "outbox"
                    if relay_dir.exists():
                        reports = []
                        for cat_dir in relay_dir.iterdir():
                            if cat_dir.is_dir():
                                for f in sorted(cat_dir.glob("*.json"))[-3:]:
                                    try:
                                        import json as _json

                                        rpt = _json.loads(f.read_text(encoding="utf-8"))
                                        reports.append(
                                            f"{rpt.get('category', '?')}: {rpt.get('summary', '')[:100]}"
                                        )
                                    except Exception:
                                        pass
                        if reports:
                            maria_summary = "Maria's team findings:\n" + "\n".join(
                                f"- {r}" for r in reports[-5:]
                            )
                except Exception:
                    pass

                # Step 2: Noah curates the card series
                noah = self.registry.get_agent("noah")
                if not noah:
                    self.task_queue.set_loop_last_run("session_curation")
                    continue

                # Build card summary for Noah
                card_summaries = "\n".join(
                    f"- [{c.get('type', '?')}] {c.get('title', '?')}: {c.get('content', '')[:80]}"
                    for c in pending[:15]
                )

                # Get sprint context
                sprints = self.task_queue.get_sprints(status="active")
                sprint_ctx = ""
                if sprints:
                    s = sprints[0]
                    burn = self.task_queue.get_sprint_burndown(s["sprint_id"])
                    sprint_ctx = f"Active sprint: {s.get('name', '?')} — {burn['done_points']}/{burn['total_points']} points done"

                curate_prompt = (
                    f"You are Noah, the Creative Director. You are curating the next session "
                    f"for the user.\n\n"
                    f"## Pending Cards ({len(pending)} available)\n{card_summaries}\n\n"
                    f"{maria_summary}\n\n"
                    f"{sprint_ctx}\n\n"
                    f"## Your Task\n"
                    f"Pick the 4-6 best cards from the pending list and arrange them into a "
                    f"narrative arc for a session. Add a creative session title and theme.\n\n"
                    f"Respond with EXACTLY this JSON format:\n"
                    f'{{"title": "Creative Session Title", "theme": "One-line theme", '
                    f'"card_ids": ["id1", "id2", ...]}}\n\n'
                    f"Pick cards that tell a coherent story. Mix types for variety."
                )

                try:
                    llm = self._get_llm_for_agent("noah") or self.llm
                    if llm:
                        result = await llm.generate(
                            system_prompt="You are Noah, Creative Director. Curate session cards. Return ONLY valid JSON.",
                            messages=[{"role": "user", "content": curate_prompt}],
                            model=self._resolve_model_for_agent("noah"),
                            temperature=0.8,
                        )

                        # Parse Noah's response
                        import json as _json

                        try:
                            curation = _json.loads(result.strip())
                            title = curation.get("title", "Curated Session")
                            theme = curation.get("theme", "")
                            card_ids = curation.get("card_ids", [])

                            # Build the session
                            selected_cards = []
                            for cid in card_ids:
                                card = card_store.get(cid)
                                if card:
                                    card["curated"] = True
                                    card_store.update(cid, {"curated": True})
                                    selected_cards.append(card)

                            if selected_cards:
                                # Create a curated session
                                import uuid
                                from datetime import datetime

                                session_store = FileStore(sd / "sessions.json", id_field="id")
                                session_id = uuid.uuid4().hex[:12]
                                session_record = {
                                    "id": session_id,
                                    "direction": "presentation",
                                    "topic": title,
                                    "session_type": "daily_briefing",
                                    "display_name": title,
                                    "theme": theme,
                                    "lead_agent": "noah",
                                    "presenter_name": "noah",
                                    "agent_ids": list(
                                        {c.get("agent_id", "noah") for c in selected_cards}
                                    ),
                                    "phase": "open",
                                    "rounds": [],
                                    "current_round": 0,
                                    "cards": selected_cards,
                                    "card_index": 0,
                                    "curated_by": "noah",
                                    "summary": "",
                                    "created_at": datetime.now(UTC).isoformat(),
                                    "completed_at": None,
                                }
                                session_store.insert(session_record)
                                logger.info(
                                    "Noah curated session '%s' with %d cards",
                                    title,
                                    len(selected_cards),
                                )

                                if self.task_queue:
                                    self.task_queue.add_notification(
                                        "noah",
                                        f"Session ready: '{title}' ({len(selected_cards)} cards)",
                                        "completed",
                                    )
                        except _json.JSONDecodeError:
                            logger.debug("Noah's curation response was not valid JSON")

                except Exception as e:
                    logger.debug("Session curation error: %s", e)

                self.task_queue.set_loop_last_run("session_curation")

            except Exception as e:
                logger.debug("Session curation loop error: %s", e)

    async def _system_audit_loop(self) -> None:
        """Theo periodically audits system-wide quality and delegation trends.

        Interval: 6 hours.  Uses persistent timestamps.
        """
        await asyncio.sleep(30)
        interval = 21600  # 6 hours
        tick = 120

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue

                elapsed = self.task_queue.seconds_since_loop_ran("system_audit")
                if elapsed is not None and elapsed < interval:
                    continue

                theo = self.registry.get_agent("theo")
                if not theo:
                    continue

                logger.info("System audit: Theo reviewing delegation trends and quality")

                # Gather recent task data for Theo
                recent = self.task_queue.get_recent(limit=30)
                failed_count = sum(1 for t in recent if t.status == "failed")
                completed_count = sum(1 for t in recent if t.status == "completed")
                total = len(recent)

                agent_failures: dict[str, int] = {}
                for t in recent:
                    if t.status == "failed":
                        agent_failures[t.agent_id] = agent_failures.get(t.agent_id, 0) + 1

                failure_summary = (
                    "\n".join(
                        f"- {aid}: {count} failures"
                        for aid, count in sorted(agent_failures.items(), key=lambda x: -x[1])
                    )
                    or "(no failures)"
                )

                # Build recent Kanban activity for drift/overlap analysis
                kanban_recent = []
                agent_suggestion_counts: dict[str, int] = {}
                agent_suggestion_titles: dict[str, list[str]] = {}
                if self.task_queue:
                    kanban_all = self.task_queue.get_kanban_tasks()
                    for t in kanban_all:
                        agent = t.get("suggested_by", "?")
                        agent_suggestion_counts[agent] = agent_suggestion_counts.get(agent, 0) + 1
                        agent_suggestion_titles.setdefault(agent, []).append(t.get("title", ""))
                    kanban_recent = [
                        {
                            "agent": t.get("suggested_by", "?"),
                            "title": t.get("title", ""),
                            "desc": t.get("description", "")[:150],
                            "status": t.get("status", ""),
                        }
                        for t in kanban_all[:20]
                    ]
                kanban_summary = (
                    "\n".join(
                        f"- [{k['agent']}] ({k['status']}) {k['title']}: {k['desc'][:80]}"
                        for k in kanban_recent
                    )
                    or "(no recent kanban tasks)"
                )

                # Per-agent suggestion frequency analysis
                repeat_offenders = (
                    "\n".join(
                        f"- {aid}: {cnt} total suggestions — titles: {', '.join(titles[:5])}"
                        for aid, cnt in sorted(agent_suggestion_counts.items(), key=lambda x: -x[1])
                        if cnt >= 3
                        for titles in [agent_suggestion_titles.get(aid, [])]
                    )
                    or "(no agents with 3+ suggestions)"
                )

                # Duplicate detection — find tasks with very similar titles across agents
                from difflib import SequenceMatcher

                all_titles = [
                    (t.get("suggested_by", "?"), t.get("kanban_id", "?"), t.get("title", ""))
                    for t in kanban_all
                    if t.get("status") not in ("archived", "rejected", "done")
                ]
                duplicate_pairs = []
                for i, (a1, id1, t1) in enumerate(all_titles):
                    for a2, id2, t2 in all_titles[i + 1 :]:
                        if SequenceMatcher(None, t1.lower(), t2.lower()).ratio() > 0.6:
                            duplicate_pairs.append(
                                f'- [{a1}] "{t1}" ({id1}) <-> [{a2}] "{t2}" ({id2})'
                            )
                duplicates_summary = (
                    "\n".join(duplicate_pairs[:10]) or "(no similar titles detected)"
                )

                stats_section = (
                    (
                        f"### Task Stats (last 30 tasks)\n"
                        f"- Total: {total} | Completed: {completed_count} | Failed: {failed_count}\n"
                        f"- Success rate: {completed_count / total * 100:.0f}%\n\n"
                    )
                    if total > 0
                    else "### Task Stats\n- No recent tasks to analyze\n\n"
                )

                prompt = (
                    "## Periodic System Audit\n\n"
                    "You are performing your scheduled system-wide quality review.\n\n"
                    f"{stats_section}"
                    f"### Failure Breakdown by Agent\n{failure_summary}\n\n"
                    f"### Recent Kanban Activity\n{kanban_summary}\n\n"
                    f"### Repeat Suggestion Patterns (agents with 3+ suggestions)\n{repeat_offenders}\n\n"
                    f"### Potential Kanban Duplicates (similar titles)\n{duplicates_summary}\n\n"
                    "INSTRUCTIONS:\n"
                    "1. Read your `theo/system-issues-tracker.md` and `theo/prompt-audit-log.md`\n"
                    "2. Check 2-3 agent configs using `read_agent_config` (pick agents with failures "
                    "or agents you haven't audited recently)\n"
                    "3. Look for: prompt staleness, missing tool instructions, recurring error patterns\n"
                    "4. **AGENT DRIFT**: Check if any agent's recent responses or Kanban tasks "
                    "fall outside their defined role. For example, if Finley suggests tasks about "
                    "knowledge management (Elias's domain), or if Liam writes strategy docs "
                    "(Dominic's domain), that's drift. Flag it.\n"
                    "5. **DOMAIN OVERLAP**: Check if two agents have suggested Kanban tasks about "
                    "the same topic. Identify which agent should own it and suggest consolidation.\n"
                    "6. **REPEAT PATTERNS**: If the same agent suggests similar tasks repeatedly, "
                    "their background loop or prompt may need tuning. Flag this as an automation "
                    "issue, not just a Kanban issue.\n"
                    "7. **DUPLICATE DETECTION**: Review the potential duplicates list above. "
                    "If agents are creating overlapping Kanban tasks, this is a signal that "
                    "their prompts lack clear boundaries. Use `read_agent_config` on both agents "
                    "to check if their system prompts clearly delineate responsibilities. Log "
                    "any prompt boundary issues in `prompt-audit-log.md`.\n"
                    "8. Update `prompt-audit-log.md` with your findings.\n"
                    "9. For drift or stale-knowledge issues, use `suggest_prompt_fix` to log "
                    "the fix needed.\n"
                    "10. **DELEGATE ALL OPERATIONAL ISSUES TO ORION**: Orion owns Kanban "
                    "operations. For duplicate tasks, repeat suggestion patterns, domain "
                    "overlap consolidation, and any automation/process improvements, use "
                    "`delegate` to send the full details to **orion**. Include the specific "
                    "duplicate IDs so Orion can consolidate them directly. Do NOT try to "
                    "resolve duplicates or consolidate tasks yourself — Theo audits and "
                    "reports, Orion resolves and automates.\n"
                    "11. If critical issues found, use `request_user_input` to flag them.\n\n"
                    "Be thorough but concise. Focus on patterns, not one-off errors."
                )

                try:
                    await self._run_agent_with_tools(theo, prompt, max_rounds=8)
                    self.task_queue.add_notification(
                        "theo",
                        "System audit complete",
                        "completed",
                    )
                except Exception as e:
                    logger.debug("System audit execution error: %s", e)
                    self.task_queue.add_notification(
                        "theo",
                        f"System audit failed: {e}",
                        "failed",
                    )

                self.task_queue.set_loop_last_run("system_audit")

            except Exception as e:
                logger.debug("System audit loop error: %s", e)

    async def _document_freshness_loop(self) -> None:
        """Detect stale or placeholder documents and notify owning agents.

        Interval: 8 hours.  Uses persistent timestamps.
        """
        await asyncio.sleep(30)
        interval = 28800  # 8 hours
        tick = 120

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue or not self.knowledge:
                    continue

                elapsed = self.task_queue.seconds_since_loop_ran("document_freshness")
                if elapsed is not None and elapsed < interval:
                    continue

                logger.info("Document freshness: scanning all agent docs")

                stale_docs: list[tuple[str, str, str]] = []  # (agent_id, doc_path, reason)

                for config in self.registry.all_configs:
                    agent_docs = self.knowledge.documents.list_docs(config.agent_id)
                    for doc_path in agent_docs:
                        try:
                            content = self.knowledge.documents.read(doc_path)
                            if not content or "not found" in content.lower():
                                continue

                            lines = content.strip().splitlines()

                            # Check for placeholder content
                            lower_content = content.lower()
                            if (
                                any(
                                    phrase in lower_content
                                    for phrase in [
                                        "pending initial content",
                                        "no baselines recorded",
                                        "tbd",
                                        "placeholder",
                                        "to be completed",
                                    ]
                                )
                                and len(lines) < 15
                            ):
                                stale_docs.append(
                                    (config.agent_id, doc_path, "placeholder content")
                                )
                                continue

                            # Check frontmatter for updated timestamp
                            import re as _re

                            updated_match = _re.search(
                                r"updated:\s*(\d{4}-\d{2}-\d{2})", content[:500]
                            )
                            if updated_match:
                                from datetime import datetime as _dt

                                try:
                                    updated_date = _dt.strptime(
                                        updated_match.group(1), "%Y-%m-%d"
                                    ).replace(tzinfo=UTC)
                                    age_days = (_dt.now(UTC) - updated_date).days
                                    if age_days > 7 and len(lines) > 10:
                                        stale_docs.append(
                                            (
                                                config.agent_id,
                                                doc_path,
                                                f"not updated in {age_days} days",
                                            )
                                        )
                                except ValueError:
                                    pass

                        except Exception:
                            continue

                if stale_docs:
                    logger.info(
                        "Document freshness: found %d stale/placeholder docs", len(stale_docs)
                    )
                    # Group by agent
                    by_agent: dict[str, list[tuple[str, str]]] = {}
                    for agent_id, doc_path, reason in stale_docs:
                        by_agent.setdefault(agent_id, []).append((doc_path, reason))

                    for agent_id, docs in by_agent.items():
                        doc_list = "\n".join(f"  - {dp}: {r}" for dp, r in docs)
                        self.task_queue.create_outreach(
                            agent_id=agent_id,
                            subject=f"Document refresh needed ({len(docs)} doc{'s' if len(docs) > 1 else ''})",
                            message=(
                                f"Your periodic document freshness check found docs that need attention:\n\n"
                                f"{doc_list}\n\n"
                                f"Please review and update these documents with current information."
                            ),
                            priority="low",
                        )
                else:
                    logger.info("Document freshness: all docs look current")

                self.task_queue.set_loop_last_run("document_freshness")

            except Exception as e:
                logger.debug("Document freshness loop error: %s", e)

    async def _generate_daily_briefing(self) -> None:
        """Charles generates a session startup briefing — runs once if >6h since last.

        Not a loop — fires once during initialize() and returns.
        Also checks team meeting cadences from teams/ YAML files.
        """
        await asyncio.sleep(10)  # Let startup settle
        try:
            if not self.task_queue:
                return

            elapsed = self.task_queue.seconds_since_loop_ran("daily_briefing")
            if elapsed is not None and elapsed < 21600:  # 6 hours
                logger.debug("Daily briefing: skipping (last ran %ds ago)", elapsed)
                return

            charles = self.registry.get_agent("charles")
            if not charles:
                return

            logger.info("Daily briefing: Charles generating session summary")

            # Gather context
            recent = self.task_queue.get_recent(limit=20)
            completed = [t for t in recent if t.status == "completed"]
            failed = [t for t in recent if t.status == "failed"]
            pending_outreach = self.task_queue.get_outreach_count()

            kanban_suggested = self.task_queue.get_kanban_tasks(status="suggested")
            kanban_review = self.task_queue.get_kanban_tasks(status="review")
            kanban_in_progress = self.task_queue.get_kanban_tasks(status="in_progress")

            completed_lines = (
                "\n".join(f"- {t.agent_id}: {t.description}" for t in completed[:10]) or "(none)"
            )
            failed_lines = (
                "\n".join(
                    f"- {t.agent_id}: {t.description} — {(t.result or '')[:80]}" for t in failed[:5]
                )
                or "(none)"
            )

            # Read team YAML files for meeting cadence check
            team_info = ""
            try:
                teams_dir = self.settings.agents_dir.parent / "teams"
                if teams_dir.is_dir():
                    import yaml

                    for tf in sorted(teams_dir.glob("*.yaml")):
                        try:
                            data = yaml.safe_load(tf.read_text(encoding="utf-8"))
                            if data and isinstance(data, dict):
                                team_id = data.get("team_id", tf.stem)
                                cadence = data.get("meeting_cadence", "unknown")
                                members = ", ".join(data.get("members", []))
                                focus = ", ".join(data.get("focus_areas", [])[:3])
                                team_info += f"- {data.get('name', team_id)} ({cadence}): {members} | Focus: {focus}\n"
                        except Exception:
                            continue
            except Exception:
                pass

            # E7: Check backup age
            backup_reminder = ""
            try:
                from vastian.tasks.backup import BackupManager

                bm = BackupManager(project_root=self.settings.agents_dir.parent)
                manifest = bm.get_backup_manifest()
                if manifest and manifest.get("last_backup_at"):
                    from datetime import datetime

                    last = datetime.fromisoformat(manifest["last_backup_at"])
                    days_ago = (datetime.now(UTC) - last).days
                    if days_ago > 3:
                        backup_reminder = f"\n### Backup Reminder\nLast backup was {days_ago} days ago. Suggest running `vastian backup`.\n"
            except Exception:
                pass

            prompt = (
                "## Daily Briefing Generation\n\n"
                "Generate a concise briefing for the user's session startup.\n\n"
                f"### Background Task Results (recent)\n"
                f"**Completed:**\n{completed_lines}\n\n"
                f"**Failed:**\n{failed_lines}\n\n"
                f"### Kanban Board Status\n"
                f"- Suggested (awaiting approval): {len(kanban_suggested)}\n"
                f"- In progress: {len(kanban_in_progress)}\n"
                f"- In review: {len(kanban_review)}\n"
                f"- Agent outreach pending: {pending_outreach}\n\n"
                f"### Team Meeting Cadences\n{team_info or '(no teams configured)'}\n\n"
                f"{backup_reminder}"
                "INSTRUCTIONS:\n"
                "1. Read your `charles/agent-meeting-schedule.md` for any scheduled meetings\n"
                "2. Check if any team meetings are overdue based on the cadences above\n"
                "3. Write a brief, organized session briefing to `charles/agent-meeting-schedule.md`\n"
                "   Include: task summary, items needing attention, overdue meetings\n"
                "4. If team meetings are overdue, use `request_user_input` to ask if the user "
                "wants to schedule them (the user confirms, then you convene a council for that team)\n"
                "5. Write the briefing — do NOT just respond with it. Persist it.\n\n"
                "Keep it under 30 lines. Focus on actionable items only."
            )

            try:
                result = await self._run_agent_with_tools(charles, prompt, max_rounds=6)
                # Store the briefing text for CLI display
                self._daily_briefing = result
                self.task_queue.add_notification(
                    "charles",
                    "Daily briefing generated",
                    "completed",
                )
            except Exception as e:
                logger.debug("Daily briefing generation error: %s", e)
                self._daily_briefing = None

            self.task_queue.set_loop_last_run("daily_briefing")

        except Exception as e:
            logger.debug("Daily briefing error: %s", e)

    async def _daily_career_email_loop(self) -> None:
        """Jupiter J3: Sage generates daily career insight and sends via Zo email.

        Replaces the Zo Career Advice agent. Runs daily; respects messaging.daily_career_email.
        """
        await asyncio.sleep(3600)  # 1 hour initial delay
        tick = 120
        interval = 86400  # 24 hours

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue or not self.knowledge:
                    continue

                # Load messaging prefs (vastian data path)
                try:
                    import yaml as _yaml

                    from vastian.storage.data_root import get_config_dir

                    _proj = (
                        self.settings.agents_dir.parent
                        if self.settings
                        else Path(__file__).resolve().parents[2]
                    )
                    _auto_path = get_config_dir(_proj) / "automation.yaml"
                    if _auto_path.exists():
                        _cfg = _yaml.safe_load(_auto_path.read_text(encoding="utf-8")) or {}
                        msg_cfg = _cfg.get("messaging", {})
                        if not msg_cfg.get("daily_career_email", True):
                            continue
                    else:
                        continue
                except Exception:
                    pass

                elapsed = self.task_queue.seconds_since_loop_ran("daily_career_email")
                if elapsed is not None and elapsed < interval:
                    continue

                sage = self.registry.get_agent("sage")
                if not sage:
                    self.task_queue.set_loop_last_run("daily_career_email")
                    continue

                if not self._get_messaging_provider():
                    self.task_queue.set_loop_last_run("daily_career_email")
                    continue

                career = self.knowledge.documents.read("sage/career-profile.md") or ""
                therapy = self.knowledge.documents.read("sage/therapy-insights.md") or ""
                prompt = (
                    "You are Sage, the user's career and personal growth mentor.\n\n"
                    "Generate a brief, personalized career insight (2-4 short paragraphs) based on:\n"
                    "1. docs/sage/career-profile.md (role, goals, strengths)\n"
                    "2. docs/sage/therapy-insights.md (relevant themes)\n\n"
                    "Then send it to the user via send_message with channel='email', "
                    "subject='Daily Career Insight', and body=<your insight>. "
                    "Do NOT just respond here — you MUST call the send_message tool to deliver it."
                )
                if career:
                    prompt += f"\n\n## Career Profile (excerpt)\n{career[:3000]}"
                if therapy:
                    prompt += f"\n\n## Therapy Insights (excerpt)\n{therapy[:2000]}"

                try:
                    await self._run_agent_with_tools(sage, prompt, max_rounds=6)
                    self.task_queue.add_notification("sage", "Daily career email sent", "completed")
                except Exception as e:
                    logger.debug("Daily career email error: %s", e)

                self.task_queue.set_loop_last_run("daily_career_email")

            except Exception as e:
                logger.debug("Daily career email loop error: %s", e)
                if self.task_queue:
                    self.task_queue.set_loop_last_run("daily_career_email")

    def reset_idle_timer(self) -> None:
        """Called after each user message to reset the idle timer."""
        with contextlib.suppress(RuntimeError):
            self._last_user_activity = asyncio.get_event_loop().time()

    async def _persist_conversation(self, agent_id: str, user_msg: str, assistant_msg: str) -> None:
        """Save a user/assistant exchange to persistent storage and trigger post-chat processing."""
        if self.knowledge:
            try:
                await self.knowledge.structured.save_conversation_message(
                    agent_id=agent_id,
                    role="user",
                    content=user_msg,
                )
                await self.knowledge.structured.save_conversation_message(
                    agent_id=agent_id,
                    role="assistant",
                    content=assistant_msg,
                )
            except Exception:
                logger.exception("Failed to persist conversation for %s", agent_id)

            # Fire-and-forget: Elias (VKB) + Theo (quality review) run in background
            if self.task_queue:
                task = self.task_queue.enqueue(
                    task_type="post_chat_review",
                    agent_id=agent_id,
                    description=f"Post-chat review ({agent_id})",
                    payload={
                        "agent_id": agent_id,
                        "user_msg": user_msg[:1000],
                        "assistant_msg": assistant_msg[:1500],
                    },
                )
                asyncio.create_task(self._execute_background_task(task.task_id))
            else:
                # Fallback: just run Elias directly
                asyncio.create_task(self._elias_auto_ingest(agent_id, user_msg, assistant_msg))

        # Accumulate exchange for session journal
        if agent_id not in self._session_exchange_buffer:
            self._session_exchange_buffer[agent_id] = []
        self._session_exchange_buffer[agent_id].append(
            {
                "user": user_msg[:500],
                "assistant": assistant_msg[:500],
            }
        )

    async def _web_search(self, query: str, num_results: int = 5) -> str:
        """Perform a web search using the LLM's built-in knowledge + a real search API.

        Uses DuckDuckGo HTML search (no API key needed) for real results.
        Falls back to LLM-generated research if the search fails.
        """
        try:
            from urllib.parse import quote_plus

            import httpx

            # Use DuckDuckGo HTML lite endpoint (no API key needed)
            url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}"
            headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}

            async with httpx.AsyncClient(timeout=15.0) as client:
                resp = await client.get(url, headers=headers, follow_redirects=True)
                resp.raise_for_status()
                html = resp.text

            # Parse results from the HTML (simple extraction)
            import re

            results: list[str] = []
            # DuckDuckGo lite returns results in <a class="result__a"> or similar
            # Extract snippet blocks — try multiple patterns for resilience
            snippets = re.findall(
                r'class="result__snippet"[^>]*>(.*?)</(?:td|a|span|div)',
                html,
                re.DOTALL,
            )
            titles = re.findall(
                r'class="result__a"[^>]*>(.*?)</a>',
                html,
                re.DOTALL,
            )
            links = re.findall(
                r'class="result__url"[^>]*href="([^"]*)"',
                html,
                re.DOTALL,
            )
            # Fallback: try alternate patterns if primary ones fail
            if not snippets:
                snippets = re.findall(
                    r'class="result-snippet"[^>]*>(.*?)</(?:td|a|span|div)',
                    html,
                    re.DOTALL,
                )
            if not titles:
                titles = re.findall(
                    r'class="result__title"[^>]*>.*?<a[^>]*>(.*?)</a>',
                    html,
                    re.DOTALL,
                )
            if not links:
                links = re.findall(
                    r'class="result__extras__url"[^>]*>.*?<a[^>]*href="([^"]*)"',
                    html,
                    re.DOTALL,
                )

            for i in range(min(num_results, len(snippets))):
                title = re.sub(r"<[^>]+>", "", titles[i]).strip() if i < len(titles) else ""
                snippet = re.sub(r"<[^>]+>", "", snippets[i]).strip()
                link = links[i].strip() if i < len(links) else ""
                results.append(f"**{title}**\n{snippet}\n{link}")

            if results:
                output = f"Web search results for '{query}':\n\n" + "\n\n---\n\n".join(results)
                depth = getattr(self, "_current_research_depth", "normal")
                if depth in ("thorough", "deep"):
                    output += (
                        "\n\n⚠️ RESEARCH MODE REMINDER: Verify that each result above actually "
                        "matches the user's requested dates/timeframe before including it in your "
                        "response. Do not assume — confirm or discard."
                    )
                return output

            # Fallback: no results parsed, try the LLM for web-grounded knowledge
            return await self._llm_web_research_fallback(query)

        except Exception as e:
            logger.debug("Web search failed: %s — falling back to LLM research", e)
            return await self._llm_web_research_fallback(query)

    async def _llm_web_research_fallback(self, query: str) -> str:
        """Fallback web research using the LLM's training data."""
        if not self.llm:
            return f"Web search unavailable and no LLM for fallback. Query was: {query}"

        try:
            result = await self.llm.generate(
                system_prompt=(
                    "You are a research assistant. The user needs factual, real-world information. "
                    "Provide the most accurate, specific, and up-to-date information you can from "
                    "your training data. Include specific names, addresses, prices, hours, and "
                    "URLs where possible. If you're not confident about something, say so."
                ),
                messages=[{"role": "user", "content": f"Research this thoroughly: {query}"}],
                model=getattr(self.llm, "default_model", None),
                temperature=0.3,
            )
            return (
                f"Research results for '{query}' (from knowledge base, not live web):\n\n{result}"
            )
        except Exception:
            return f"Web search and LLM research both failed for query: {query}"

    async def execute_approved_transaction(self, approval: dict) -> str:
        """Execute a previously-approved Mercury transaction.

        Called from the CLI after the user runs /approve <id>.
        Returns a human-readable result string.
        """
        if not self._mercury_client or not self._mercury_client.is_configured:
            return "Error: Mercury banking is not configured."

        payload = approval.get("payload", {})
        try:
            result = await self._mercury_client.create_transaction(
                recipient_name=payload.get("recipient_name", ""),
                amount=payload.get("amount", 0),
                payment_method=payload.get("payment_method", "ach"),
                note=payload.get("note", ""),
            )
            txn_id = result.get("id", "unknown")
            status = result.get("status", "unknown")
            return (
                f"Transaction executed successfully!\n"
                f"  Mercury Transaction ID: {txn_id}\n"
                f"  Amount: ${payload.get('amount', 0):,.2f}\n"
                f"  Recipient: {payload.get('recipient_name', '?')}\n"
                f"  Status: {status}"
            )
        except Exception as e:
            return f"Error executing transaction: {e}"

    async def _elias_auto_ingest(self, agent_id: str, user_msg: str, assistant_msg: str) -> None:
        """
        Background task: Elias triages a conversation exchange, stores valuable
        insights in the vector store, AND writes high-value entries directly to
        the VKB ledger document (``elias/vastian-knowledge-bank.md``).

        Uses ``_run_agent_with_tools`` with Elias's full toolset (capped at 3
        rounds) so he can call ``store_insight``, ``write_document``, and
        ``read_document`` in real time.
        """
        try:
            elias = self.registry.get_agent("elias")
            if elias is None or not self.knowledge:
                return

            # Don't ingest Elias's own conversations (avoid recursion)
            if agent_id == "elias":
                return

            triage_prompt = (
                f"You are Elias, the VKB curator. A conversation just happened with "
                f"agent '{agent_id}'. Decide what (if anything) is worth storing.\n\n"
                f"USER said: {user_msg[:800]}\n\n"
                f"AGENT responded: {assistant_msg[:1200]}\n\n"
                f"RULES:\n"
                f"- Only store HIGH-VALUE insights: decisions, metric data, simulation "
                f"results, persona changes, strategic shifts, action items, novel ideas.\n"
                f"- Skip: greetings, status checks, repetitive queries, low-signal chat.\n"
                f"- If worth storing, call `store_insight` with appropriate tags AND "
                f"append a ledger entry to `elias/vastian-knowledge-bank.md` using your "
                f"standard format (ID, Date, Source, Tags, Content). Read the VKB first "
                f"to get the next entry ID.\n"
                f"- If nothing worth storing, just respond 'Nothing to store.'\n"
                f"- Be concise. This runs in the background. Max 3 tool rounds."
            )

            await self._run_agent_with_tools(elias, triage_prompt, max_rounds=3)

        except Exception:
            logger.debug("VKB auto-ingest failed for %s (non-critical)", agent_id)

    async def restore_conversations(self) -> int:
        """Restore conversation history for all agents from SQLite. Returns count restored."""
        if not self.knowledge:
            return 0
        count = 0
        for agent_id, agent in self.registry.get_all_agents().items():
            try:
                messages = await self.knowledge.structured.load_conversation(agent_id, limit=30)
                if messages:
                    agent.conversation_history = messages
                    count += 1
                    logger.debug("Restored %d messages for %s", len(messages), agent_id)
            except Exception:
                logger.debug("No prior conversation for %s", agent_id)
        if count:
            logger.info("Restored conversations for %d agents", count)
        return count

    # ── Session Journal ────────────────────────────────

    async def generate_session_summary(self) -> dict | None:
        """Generate and persist a session journal entry from accumulated exchanges.

        For each agent that was active this session, uses the LLM (fast model)
        to produce a 2-3 sentence summary of what was discussed/decided.
        Falls back to template-based summary if LLM is unavailable.

        Returns the journal entry dict, or None if no exchanges occurred.
        """
        if not self._session_exchange_buffer:
            return None

        now = datetime.now(UTC).isoformat()
        active_agents = list(self._session_exchange_buffer.keys())
        agent_summaries: dict[str, str] = {}
        decisions: list[str] = []

        for agent_id, exchanges in self._session_exchange_buffer.items():
            # Build a condensed transcript for the LLM
            transcript_parts = []
            for ex in exchanges[-10:]:  # Last 10 exchanges max
                transcript_parts.append(f"User: {ex['user']}")
                transcript_parts.append(f"{agent_id}: {ex['assistant']}")
            transcript = "\n".join(transcript_parts)

            # Try LLM summary
            summary = await self._llm_session_summary(agent_id, transcript)
            if not summary:
                # Fallback: template-based
                msg_count = len(exchanges)
                topics = set()
                for ex in exchanges:
                    # Extract first few words of each user message as topic hints
                    words = ex["user"].split()[:8]
                    topics.add(" ".join(words))
                topic_list = "; ".join(list(topics)[:3])
                summary = f"Had {msg_count} exchange(s). Topics touched: {topic_list}..."

            agent_summaries[agent_id] = summary

        # Build journal entry
        entry = {
            "session_id": self._session_id,
            "timestamp": now,
            "machine": platform.node(),
            "agent_summaries": agent_summaries,
            "decisions": decisions,
            "active_agents": active_agents,
        }

        try:
            self._session_journal.insert(entry)
            # Prune old entries — keep last 50 sessions to avoid unbounded growth
            all_entries = self._session_journal.load_all()
            if len(all_entries) > 50:
                self._session_journal.replace_all(all_entries[-50:])
            logger.info("Session journal entry saved: %d agent summaries", len(agent_summaries))
        except Exception:
            logger.debug("Failed to save session journal entry", exc_info=True)

        # Clear the buffer
        self._session_exchange_buffer.clear()
        return entry

    async def _llm_session_summary(self, agent_id: str, transcript: str) -> str:
        """Use the fast LLM to generate a brief session summary for one agent."""
        llm = self.llm
        if not llm:
            return ""

        prompt = (
            "Summarize this conversation session in 2-3 concise sentences. "
            "Focus on: what was discussed, any decisions made, actions taken, "
            "and any pending items. Be factual and specific.\n\n"
            f"Agent: {agent_id}\n"
            f"Transcript:\n{transcript[:3000]}"
        )

        try:
            result = await llm.generate(
                system_prompt="You are a session summarizer. Output only the summary, nothing else.",
                messages=[{"role": "user", "content": prompt}],
                model=getattr(llm, "fast_model", None),
            )
            return result.strip()[:500]
        except Exception:
            logger.debug("LLM session summary failed for %s", agent_id)
            return ""

    # ── Council ──────────────────────────────────────

    async def _suggest_council_members(self, topic: str) -> list[str]:
        """Suggest council members based on recent delegations/tasks related to the topic.

        Scans recent delegations, background tasks, and council sessions for agents
        who contributed work relevant to the topic. Returns a de-duplicated list
        that always includes the ad_hoc defaults plus any contributors found.
        """
        from vastian.council.engine import DEFAULT_COUNCILS

        topic_lower = topic.lower()
        # Extract keywords (3+ chars) from topic for fuzzy matching
        keywords = [w for w in topic_lower.split() if len(w) >= 3]

        contributor_agents: set[str] = set()

        # 1) Scan recent delegations
        if self.knowledge:
            try:
                delegations = await self.knowledge.structured.get_delegations(limit=50)
                for d in delegations:
                    desc = (d.get("task_description", "") + " " + d.get("response", "")).lower()
                    if any(kw in desc for kw in keywords):
                        if d.get("to_agent"):
                            contributor_agents.add(d["to_agent"])
                        if d.get("from_agent"):
                            contributor_agents.add(d["from_agent"])
            except Exception:
                pass

        # 2) Scan recent background tasks
        if self.task_queue:
            try:
                tasks = self.task_queue.get_recent(limit=30)
                for t in tasks:
                    desc = (getattr(t, "description", "") or "").lower()
                    payload_str = str(getattr(t, "payload", {}) or {}).lower()
                    if any(kw in desc or kw in payload_str for kw in keywords) and getattr(
                        t, "agent_id", None
                    ):
                        contributor_agents.add(t.agent_id)
            except Exception:
                pass

        # 3) Scan recent council sessions for same/similar topic
        if self.knowledge:
            try:
                sessions = await self.knowledge.structured.get_council_sessions(limit=10)
                for s in sessions:
                    s_topic = (s.get("topic", "")).lower()
                    if any(kw in s_topic for kw in keywords):
                        for m in s.get("members", []):
                            contributor_agents.add(m)
            except Exception:
                pass

        # Filter to valid agents only
        valid_contributors = [aid for aid in contributor_agents if self.registry.get_agent(aid)]

        # Merge with ad_hoc defaults — contributors first, then defaults
        defaults = DEFAULT_COUNCILS.get("ad_hoc", [])
        merged: list[str] = []
        seen: set[str] = set()
        for aid in valid_contributors + defaults:
            if aid not in seen:
                merged.append(aid)
                seen.add(aid)

        logger.info(
            "Council member suggestion for topic '%s': %s (contributors: %s)",
            topic[:60],
            merged,
            valid_contributors,
        )
        return merged

    async def convene_council(
        self,
        topic: str,
        council_type: str = "ad_hoc",
        member_ids: list[str] | None = None,
    ) -> dict:
        """Convene a Mentor Council meeting.

        If member_ids is not provided and council_type is 'ad_hoc', automatically
        suggests members based on agents who have contributed work related to the topic.
        """
        if self.council is None:
            return {"error": "Council engine not initialized"}

        # Smart member selection for ad_hoc councils when no explicit members given
        if member_ids is None and council_type == "ad_hoc":
            member_ids = await self._suggest_council_members(topic)

        return await self.council.convene(
            topic=topic,
            council_type=council_type,
            member_ids=member_ids or None,
        )

    async def _delegate_council_action(self, agent_id: str, description: str, topic: str) -> None:
        """Auto-delegate a council action item as a real background task."""
        if not self.task_queue:
            return
        agent = self.registry.get_agent(agent_id)
        if not agent:
            logger.warning("Council action: agent %s not found", agent_id)
            return

        prompt = (
            f"A Mentor Council meeting just concluded on the topic: {topic}\n\n"
            f"You have been assigned this action item:\n{description}\n\n"
            f"Execute this task now using your tools. If you need to write or update "
            f"documents, use write_document. If you need research context, use "
            f"search_knowledge or read_document first. If you need web research, "
            f"use web_search. Persist ALL your work to your owned documents."
        )

        task = self.task_queue.enqueue(
            task_type="delegation",
            agent_id=agent_id,
            description=f"Council action: {description[:80]}",
            payload={"target_agent_id": agent_id, "prompt": prompt},
        )
        self.task_queue.add_notification(
            agent_id,
            f"Council action delegated: {description[:60]}",
            "running",
        )
        asyncio.create_task(self._execute_background_task(task.task_id))
        logger.info("Council action delegated to %s: %s", agent_id, description[:60])

        # Surface delegation in the CLI so the user sees it in real time
        if self._cli_delegation_callback:
            with contextlib.suppress(Exception):
                await self._cli_delegation_callback(
                    "Council",
                    agent.config.name,
                    description[:120],
                )

    # ── Team Meetings ────────────────────────────────

    async def convene_team_meeting(
        self,
        team_id: str,
        user_present: bool = True,
        send_twin: bool | None = None,
    ) -> dict:
        """Convene a scheduled team meeting.

        *user_present*: whether the user is joining the meeting.
        *send_twin*: if True, include the Rowan digital twin agent in the meeting.
                     If None, defaults to the team's ``twin_attends`` config.
        """
        import yaml

        # Load team config
        teams_dir = self.settings.agents_dir.parent / "teams"
        team_file = teams_dir / f"{team_id}.yaml"
        if not team_file.exists():
            return {"error": f"Team config not found: {team_id}"}

        data = yaml.safe_load(team_file.read_text(encoding="utf-8"))
        team_name = data.get("name", team_id)
        members = data.get("members", [])
        lead = data.get("lead", members[0] if members else "charles")
        focus_areas = data.get("focus_areas", [])
        twin_default = data.get("twin_attends", False)

        # Resolve twin attendance
        include_twin = send_twin if send_twin is not None else twin_default

        # Build member list for the council
        meeting_members = list(members)
        if not user_present and include_twin and "rowan" not in meeting_members:
            meeting_members.append("rowan")

        # Build meeting topic from focus areas
        focus_str = ", ".join(focus_areas[:4]) if focus_areas else "general team sync"
        if user_present:
            topic = f"{team_name} — Scheduled meeting. Focus: {focus_str}"
        else:
            twin_name = self.settings.user_name if self.settings else "User"
            twin_note = (
                f" ({twin_name} digital twin attending on behalf of user)" if include_twin else ""
            )
            topic = (
                f"{team_name} — Scheduled meeting (user absent{twin_note}). "
                f"Focus: {focus_str}. The team lead ({lead}) should drive the agenda. "
                f"Produce clear action items and a summary for the user to review later."
            )

        # Record that the meeting happened
        if self.task_queue:
            self.task_queue.kv_set(
                f"meeting_last:{team_id}",
                datetime.now(UTC).isoformat(),
            )
            self.task_queue.add_notification(
                lead,
                f"Team meeting started: {team_name}",
                "running",
            )

        result = await self.convene_council(
            topic=topic,
            council_type="team_meeting",
            member_ids=meeting_members,
        )

        # Notify user if they weren't present
        if not user_present and self.task_queue:
            summary = result.get(
                "synthesis", "Meeting completed — check council history for details."
            )
            self.task_queue.create_outreach(
                agent_id=lead,
                subject=f"Meeting summary: {team_name}",
                message=(
                    f"Your team **{team_name}** held a meeting while you were away.\n\n"
                    f"**Summary:**\n{summary[:1500]}\n\n"
                    f"Use `/council` to review the full meeting transcript and action items."
                ),
                priority="normal",
            )
            self.task_queue.add_notification(
                lead,
                f"Team meeting complete: {team_name}",
                "completed",
            )

        return result

    async def _felix_engagement_loop(self) -> None:
        """Felix periodically checks user engagement, roadmap progress, and inactivity.

        Interval: 24 hours. Uses persistent timestamps.
        """
        await asyncio.sleep(120)  # let system settle
        interval = 86400  # 24 hours
        key = "felix_engagement_last"

        while self._running:
            try:
                if not self.task_queue:
                    await asyncio.sleep(300)
                    continue

                last = self.task_queue.kv_get(key)
                if last:
                    elapsed = (datetime.now(UTC) - datetime.fromisoformat(last)).total_seconds()
                    if elapsed < interval:
                        await asyncio.sleep(min(300, interval - elapsed))
                        continue

                felix = self.registry.get_agent("felix")
                if felix is None:
                    await asyncio.sleep(300)
                    continue

                logger.info("Felix engagement check starting")
                prompt = (
                    "Run your periodic engagement and progress check:\n\n"
                    "1. Use check_user_engagement to see engagement metrics\n"
                    "2. Use check_roadmap_progress to see task completion rates\n"
                    "3. Use alert_inactivity with days_threshold=3 if user seems inactive\n"
                    "4. Write a brief summary to your network-pulse-dashboard.md using write_document\n"
                    "5. If anything looks concerning, delegate to maria or charles for follow-up"
                )

                try:
                    await self._run_agent_with_tools(felix, prompt, max_rounds=5)
                except Exception:
                    logger.error("Felix engagement check failed", exc_info=True)

                self.task_queue.set_kv(key, datetime.now(UTC).isoformat())

            except asyncio.CancelledError:
                break
            except Exception:
                logger.error("Felix engagement loop error", exc_info=True)

            await asyncio.sleep(300)

    async def _meeting_scheduler_loop(self) -> None:
        """Check team meeting cadences and create outreach when meetings are overdue.

        Runs on a 120s tick with persistent timestamps. Uses the team YAML
        ``meeting_cadence`` field (daily, weekly, as-needed) and compares against
        ``meeting_last:{team_id}`` in the KV store.
        """
        import yaml

        await asyncio.sleep(60)  # Let startup settle
        tick = 120
        cadence_seconds = {
            "daily": 86400,  # 24 hours
            "weekly": 604800,  # 7 days
            "biweekly": 1209600,  # 14 days
        }

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue

                # Only check once per 6 hours
                elapsed = self.task_queue.seconds_since_loop_ran("meeting_scheduler")
                if elapsed is not None and elapsed < 21600:
                    continue

                teams_dir = self.settings.agents_dir.parent / "teams"
                if not teams_dir.is_dir():
                    self.task_queue.set_loop_last_run("meeting_scheduler")
                    continue

                for tf in sorted(teams_dir.glob("*.yaml")):
                    try:
                        data = yaml.safe_load(tf.read_text(encoding="utf-8"))
                        if not data or not isinstance(data, dict):
                            continue

                        team_id = data.get("team_id", tf.stem)
                        cadence = data.get("meeting_cadence", "as-needed")
                        if cadence == "as-needed":
                            continue

                        required_interval = cadence_seconds.get(cadence)
                        if not required_interval:
                            continue

                        # Check when this team last met
                        last_meeting = self.task_queue.kv_get(f"meeting_last:{team_id}")
                        if last_meeting:
                            from datetime import datetime as _dt

                            try:
                                last_dt = _dt.fromisoformat(last_meeting)
                                seconds_since = (datetime.now(UTC) - last_dt).total_seconds()
                                if seconds_since < required_interval:
                                    continue  # Not overdue
                            except ValueError:
                                pass

                        team_name = data.get("name", team_id)
                        lead = data.get("lead", "charles")
                        twin_default = data.get("twin_attends", False)

                        # Create outreach asking user to approve the meeting
                        twin_hint = " The digital twin will attend." if twin_default else ""
                        self.task_queue.create_outreach(
                            agent_id=lead,
                            subject=f"Overdue meeting: {team_name} ({cadence})",
                            message=(
                                f"The **{team_name}** team meeting is overdue "
                                f"(cadence: {cadence}).\n\n"
                                f"**Options:**\n"
                                f"- `/meeting {team_id}` — Join the meeting now\n"
                                f"- `/meeting {team_id} --twin` — Send your digital twin instead\n"
                                f"- `/skip` — Snooze for later\n\n"
                                f"The team lead is {lead}.{twin_hint}"
                            ),
                            priority="normal",
                        )
                        logger.info(
                            "Meeting scheduler: %s meeting overdue, outreach created", team_id
                        )

                    except Exception:
                        continue

                self.task_queue.set_loop_last_run("meeting_scheduler")

            except Exception as e:
                logger.debug("Meeting scheduler loop error: %s", e)

    # ── Plan Watcher Loop (monitors giga-outbox + docs/plans/) ──

    async def _plan_watcher_loop(self) -> None:
        """Charles monitors the Giga outbox and docs/plans/ for new items.

        When agents write plans or neurons to state/giga-outbox/, this loop
        detects them and creates a notification so the user knows to run
        /giga-sync.  It also watches for new .md files in the top-level
        plans-related directories and routes them to relevant agents via
        Charles's summarisation.

        Interval: 5 minutes.  Uses persistent timestamps.
        """
        await asyncio.sleep(45)  # let other loops settle first

        interval = 300  # 5 minutes
        tick = 60

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue

                elapsed = self.task_queue.seconds_since_loop_ran("plan_watcher")
                if elapsed is not None and elapsed < interval:
                    continue

                # ── Scan giga-outbox for pending JSON files ──────────────
                from pathlib import Path as _Path

                project_root = _Path(__file__).resolve().parents[2]
                outbox_plans = project_root / "state" / "giga-outbox" / "plans"
                outbox_neurons = project_root / "state" / "giga-outbox" / "neurons"

                pending_plans = sorted(outbox_plans.glob("*.json")) if outbox_plans.exists() else []
                pending_neurons = (
                    sorted(outbox_neurons.glob("*.json")) if outbox_neurons.exists() else []
                )

                total_pending = len(pending_plans) + len(pending_neurons)

                if total_pending > 0:
                    plan_names = ", ".join(p.stem for p in pending_plans[:5]) or "(none)"
                    neuron_names = ", ".join(n.stem for n in pending_neurons[:5]) or "(none)"

                    logger.info(
                        "Plan watcher: %d outbox items pending sync (plans: %s, neurons: %s)",
                        total_pending,
                        plan_names,
                        neuron_names,
                    )

                    # Create a notification so the user sees the pending count
                    self.task_queue.add_notification(
                        "charles",
                        f"Giga outbox: {total_pending} item(s) pending sync — "
                        f"plans: [{plan_names}], neurons: [{neuron_names}]. "
                        f"Run /giga-sync to push them.",
                        "pending",
                    )

                    # Also have Charles create an outreach if many items are stacking up
                    if total_pending >= 3:
                        # Check if there's already a pending outreach about this
                        existing = self.task_queue.get_pending_outreach()
                        already_notified = any(
                            "giga-sync" in (o.get("message", "") or "").lower() for o in existing
                        )
                        if not already_notified:
                            self.task_queue.create_outreach(
                                agent_id="charles",
                                message=(
                                    f"There are {total_pending} items in the Giga outbox waiting "
                                    f"to be synced. You can run /giga-sync to push them now."
                                ),
                                priority="normal",
                            )

                # ── Scan for new plan files in the Cursor plans directory ──
                cursor_plans_dir = _Path.home() / ".cursor" / "plans"
                if cursor_plans_dir.exists():
                    known_key = "plan_watcher_known_files"
                    known_raw = self.task_queue.kv_get(known_key) or ""
                    known_set = set(known_raw.split("|")) if known_raw else set()

                    current_files = {f.name for f in cursor_plans_dir.glob("*.plan.md")}
                    new_files = current_files - known_set

                    if new_files and known_set:  # only notify when we have a baseline
                        for fname in sorted(new_files):
                            logger.info("Plan watcher: new Cursor plan detected — %s", fname)
                            self.task_queue.add_notification(
                                "charles",
                                f"New Cursor plan detected: {fname}",
                                "info",
                            )

                    # Update the known-files baseline
                    if current_files != known_set:
                        self.task_queue.set_kv(known_key, "|".join(sorted(current_files)))

                self.task_queue.set_loop_last_run("plan_watcher")

            except Exception as e:
                logger.debug("Plan watcher loop error: %s", e)

    # ── Giga Sync Loop (Mars M1c: push every 30 min, pull on startup) ──

    async def _giga_sync_loop(self) -> None:
        """Push outbox to Giga every 30 min; pull from Giga on startup."""
        await asyncio.sleep(60)  # let other loops settle first

        try:
            from vastian.mcp.giga_sync import giga_pull, giga_push

            # Pull on startup
            total, pull_errors = await giga_pull()
            if pull_errors:
                for e in pull_errors:
                    logger.debug("Giga pull: %s", e)
            elif total > 0:
                logger.info("Giga sync: pulled %d item(s) on startup", total)
        except Exception as e:
            logger.debug("Giga pull on startup: %s", e)

        interval = 1800  # 30 minutes
        tick = 120  # check every 2 min
        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue
                elapsed = self.task_queue.seconds_since_loop_ran("giga_sync")
                if elapsed is not None and elapsed < interval:
                    continue

                try:
                    from vastian.mcp.giga_sync import giga_push

                    plans_pushed, neurons_pushed, push_errors = await giga_push()
                    if push_errors:
                        for e in push_errors:
                            logger.debug("Giga push: %s", e)
                    if plans_pushed or neurons_pushed:
                        logger.info(
                            "Giga sync: pushed %d plan(s), %d neuron(s)",
                            plans_pushed,
                            neurons_pushed,
                        )
                except Exception as e:
                    logger.debug("Giga push: %s", e)

                self.task_queue.set_loop_last_run("giga_sync")
            except Exception as e:
                logger.debug("Giga sync loop error: %s", e)

    # ── Giga Auto-Neuron Loop (Mars M3b) ──

    async def _giga_auto_neuron_loop(self) -> None:
        """Emit Giga neurons from Kanban task completions (done status)."""
        await asyncio.sleep(180)  # 3 min initial delay

        interval = 600  # 10 min
        tick = 120

        while True:
            try:
                await asyncio.sleep(tick)
                if not self.task_queue:
                    continue
                elapsed = self.task_queue.seconds_since_loop_ran("giga_auto_neuron")
                if elapsed is not None and elapsed < interval:
                    continue

                try:
                    from vastian.bridges.giga_dev_bridge.handlers import write_giga_neuron

                    emitted_raw = self.task_queue.kv_get("giga_neuron_kanban_emitted") or ""
                    emitted = set(x.strip() for x in emitted_raw.split(",") if x.strip())
                    done_tasks = self.task_queue.get_kanban_tasks(status="done")
                    for task in done_tasks:
                        kid = task.get("kanban_id", "")
                        if not kid or kid in emitted:
                            continue
                        title = (task.get("title") or kid)[:50]
                        result = (task.get("result") or "")[:500]
                        body = f"Completed: {title}\n\nResult summary: {result}"
                        write_giga_neuron(
                            agent_id=task.get("assigned_to")
                            or task.get("suggested_by")
                            or "system",
                            title=f"Task done: {title}",
                            body=body,
                            selector_nl=f"kanban {kid}",
                            mind="vastian-network",
                        )
                        emitted.add(kid)
                        logger.info("Giga auto-neuron: Kanban %s", kid)
                    if emitted:
                        self.task_queue.kv_set(
                            "giga_neuron_kanban_emitted", ",".join(sorted(emitted)[-500:])
                        )
                except Exception as e:
                    logger.debug("Giga auto-neuron: %s", e)

                self.task_queue.set_loop_last_run("giga_auto_neuron")
            except Exception as e:
                logger.debug("Giga auto-neuron loop error: %s", e)

    # ── Activity Context (cross-machine continuity) ──

    def _build_agent_activity_context(self, agent_id: str) -> str:
        """Build a 'Recent Activity' text for an agent from git-tracked state.

        Pulls from:
          1. Session journal (last 5 sessions, entries mentioning this agent)
          2. Delegation reports (last 5 involving this agent)
          3. Kanban tasks (last 5 done/review/in_progress involving this agent)
          4. Failed/pending background tasks for this agent (so agent is aware)
          5. Council action items assigned to this agent (from recent sessions)

        Returns a formatted text block, or empty string if nothing found.
        """
        lines: list[str] = []

        # 1. Session journal summaries
        try:
            journal_entries = self._session_journal.load_all()
            recent = journal_entries[-5:][::-1]
            for entry in recent:
                summaries = entry.get("agent_summaries", {})
                if agent_id in summaries:
                    ts = entry.get("timestamp", "")[:10]
                    lines.append(f"- [{ts}] Session: {summaries[agent_id]}")
        except Exception:
            pass

        # 2. Delegation reports
        if self.task_queue:
            try:
                reports = self.task_queue.get_reports_for_agent(agent_id, limit=5)
                for r in reports:
                    ts = r.get("created_at", "")[:10]
                    desc = r.get("description", "")[:100]
                    status = r.get("status", "")
                    lines.append(f"- [{ts}] Delegation ({status}): {desc}")
            except Exception:
                pass

        # 3. Kanban tasks (done, review, or in_progress)
        if self.task_queue:
            try:
                all_kanban = self.task_queue.get_kanban_tasks()
                agent_tasks = [
                    t
                    for t in all_kanban
                    if (t.get("suggested_by") == agent_id or t.get("assigned_to") == agent_id)
                    and t.get("status")
                    in ("done", "review", "in_progress", "approved", "suggested")
                ][:5]
                for t in agent_tasks:
                    ts = t.get("created_at", "")[:10]
                    title = t.get("title", "")[:80]
                    status = t.get("status", "")
                    lines.append(f"- [{ts}] Kanban ({status}): {title}")
            except Exception:
                pass

        # 4. Failed or pending background tasks (so agent is self-aware)
        failed_lines: list[str] = []
        if self.task_queue:
            try:
                recent_tasks = self.task_queue.get_recent(limit=50)
                for t in recent_tasks:
                    if t.agent_id != agent_id:
                        continue
                    if t.status == "failed":
                        ts = (t.completed_at or t.created_at or "")[:16]
                        desc = (t.description or "")[:120]
                        error = (t.result or "")[:100]
                        failed_lines.append(f"- **FAILED** [{ts}]: {desc}\n  Error: {error}")
                    elif t.status in ("queued", "running"):
                        ts = (t.created_at or "")[:16]
                        desc = (t.description or "")[:120]
                        failed_lines.append(f"- **{t.status.upper()}** [{ts}]: {desc}")
            except Exception:
                pass

        # 5. Council action items assigned to this agent
        council_lines: list[str] = []
        if self.knowledge:
            try:
                # Use synchronous access to the FileStore directly
                all_sessions = self.knowledge.structured._councils.query(lambda _: True)
                all_sessions.sort(key=lambda s: s.get("created_at", ""), reverse=True)
                for s in all_sessions[:10]:
                    for item in s.get("action_items", []):
                        if item.get("assigned_to") == agent_id:
                            topic = s.get("topic", "Unknown topic")[:60]
                            desc = item.get("description", "")[:150]
                            sid = s.get("id", "?")
                            council_lines.append(
                                f"- **Council action** (session {sid}): {desc}\n  From meeting: {topic}"
                            )
            except Exception:
                pass

        # Assemble
        result_parts: list[str] = []
        if lines:
            result_parts.append(
                "## Recent Activity (from prior sessions)\n" + "\n".join(lines[:10])
            )
        if failed_lines:
            result_parts.append(
                "## YOUR FAILED/PENDING TASKS (need your attention)\n"
                "These tasks were assigned to you but did not complete. You should retry "
                "or inform the user about them.\n" + "\n".join(failed_lines[:5])
            )
        if council_lines:
            result_parts.append(
                "## YOUR COUNCIL ACTION ITEMS\n"
                "These action items were assigned to you from Mentor Council meetings. "
                "Use `review_council_actions` for full details.\n" + "\n".join(council_lines[:5])
            )

        return "\n\n".join(result_parts)

    # ── Network Context Injection ────────────────────

    def _inject_network_context(self) -> None:
        """Build the real network roster and inject it into every agent."""
        # Build roster string from actual loaded configs — include owned documents
        roster_lines = []
        for config in self.registry.all_configs:
            sigil = f"{config.sigil} " if config.sigil else ""
            reports = (
                f" (reports to: {config.reports_to})" if config.reports_to else " (top of chain)"
            )
            roster_lines.append(
                f"- **{config.agent_id}**: {sigil}{config.name}, {config.title}{reports}"
            )
            if config.owned_documents:
                for doc in config.owned_documents:
                    roster_lines.append(f"    - owns: {doc}")
            if config.direct_reports:
                roster_lines.append(f"    - direct reports: {', '.join(config.direct_reports)}")

        # Add team info
        roster_lines.append("\n**Teams:**")
        for team_id in self.bus.team_ids:
            members = self.registry.get_team_members(team_id)
            roster_lines.append(f"- {team_id}: {', '.join(members)}")

        roster = "\n".join(roster_lines)

        # Build shared plans context from docs/plans/
        shared_plans = self._build_shared_plans_context()

        # Build document states for each agent — auto-discover from filesystem
        agents = self.registry.get_all_agents()
        for agent_id, agent in agents.items():
            doc_states: dict[str, str] = {}
            all_doc_names: list[str] = []

            if self.knowledge:
                # Discover ALL documents on disk for this agent (excludes archive/)
                discovered_paths = self.knowledge.documents.list_docs(agent_id)
                # discovered_paths are like "elias/vacation-activities.md"
                for doc_path in discovered_paths:
                    # Strip the agent_id prefix for the display name
                    doc_name = doc_path.split("/", 1)[1] if "/" in doc_path else doc_path
                    all_doc_names.append(doc_name)
                    try:
                        content = self.knowledge.documents.read(doc_path)
                        if "not found" in content.lower():
                            doc_states[doc_name] = "not yet created"
                        elif "pending initial content" in content.lower():
                            doc_states[doc_name] = "placeholder — needs real content"
                        else:
                            content_lines = [
                                line
                                for line in content.split("\n")
                                if line.strip() and not line.startswith("---")
                            ]
                            preview = content_lines[0][:100] if content_lines else "empty"
                            doc_states[doc_name] = f"has content — {preview}"
                    except Exception:
                        doc_states[doc_name] = "not yet created"

                # Also include any YAML-declared docs not yet on disk
                for doc_name in agent.config.owned_documents or []:
                    if doc_name not in doc_states:
                        doc_states[doc_name] = "not yet created"
                        all_doc_names.append(doc_name)

            # Build recent activity context from session journal + state
            activity_context = self._build_agent_activity_context(agent_id)

            # Auto-inline briefing content from docs/{agent_id}/briefs/
            briefings_content = self._build_agent_briefings(agent_id)

            agent.set_network_context(
                roster=roster,
                document_states=doc_states,
                all_documents=all_doc_names,
                recent_activity=activity_context,
                user_name=self.settings.user_name,
                shared_plans=shared_plans,
                briefings=briefings_content,
            )

        logger.info("Network context injected into %d agents", len(agents))

    def _build_agent_briefings(self, agent_id: str) -> str:
        """Read and inline briefing files from docs/{agent_id}/briefs/.

        Briefing files contain session outcomes, council meeting results, and
        announcements that the agent participated in. The content is inlined
        directly into the system prompt so agents always have this knowledge
        without needing to call read_document.

        Files are sorted newest-first. Only the most recent 3 briefings are
        included to keep prompt size manageable (max ~4000 chars total).
        """
        from pathlib import Path

        briefs_dir = Path(self.settings.docs_dir) / agent_id / "briefs"
        if not briefs_dir.exists():
            return ""

        brief_sections = []
        total_chars = 0
        max_chars = 4000
        max_briefs = 3

        # Sort newest-first by filename (which starts with date)
        for brief_file in sorted(briefs_dir.glob("*.md"), reverse=True):
            if len(brief_sections) >= max_briefs:
                break
            try:
                content = brief_file.read_text(encoding="utf-8")
                # Focus on the "Key Takeaways" section if present
                key_takeaways_marker = "## Key Takeaways"
                if key_takeaways_marker in content:
                    takeaways_idx = content.index(key_takeaways_marker)
                    key_section = content[takeaways_idx:]
                    # Also grab the title/date header
                    header_lines = content[:takeaways_idx].split("\n")[:8]
                    header = "\n".join(ln for ln in header_lines if ln.strip())
                    content = header + "\n\n" + key_section

                if total_chars + len(content) > max_chars:
                    remaining = max_chars - total_chars
                    if remaining > 500:
                        content = content[:remaining] + "\n... [truncated]"
                    else:
                        break

                brief_sections.append(content)
                total_chars += len(content)
            except Exception as e:
                logger.warning("Failed to read briefing %s: %s", brief_file, e)

        if not brief_sections:
            return ""

        return "\n\n---\n\n".join(brief_sections)

    def _build_shared_plans_context(self) -> str:
        """Read approved plans from docs/plans/ and build a summary for all agents.

        Each file in docs/plans/ is treated as an approved, network-wide plan that
        every agent should be aware of. The content is truncated to keep the system
        prompt manageable.
        """
        from pathlib import Path

        plans_dir = Path(self.settings.docs_dir) / "plans"
        if not plans_dir.exists():
            return ""

        plan_sections = []
        # Sort so status/summary files come first (alphabetically, "approved" < "ecosystem")
        for plan_file in sorted(plans_dir.glob("*.md")):
            try:
                content = plan_file.read_text(encoding="utf-8")
                # Truncate long plans to keep system prompt size reasonable.
                # Short status files (< 2500 chars) are included in full.
                # Longer briefs are truncated with a pointer to read_document.
                if len(content) > 2500:
                    content = (
                        content[:2500]
                        + f"\n\n... [truncated — use `read_document` with path `plans/{plan_file.name}` for full content]"
                    )
                plan_sections.append(
                    f"### {plan_file.stem.replace('-', ' ').title()}\n"
                    f"*File: plans/{plan_file.name}*\n\n"
                    f"{content}"
                )
            except Exception as e:
                logger.warning("Failed to read plan file %s: %s", plan_file, e)

        if not plan_sections:
            return ""

        return (
            "The following plans have been APPROVED and are now in execution. "
            "You must be aware of these and align your work accordingly. "
            "Use `read_document` with path `plans/<filename>` for full details.\n\n"
            + "\n\n---\n\n".join(plan_sections)
        )

    # ── Internal Handlers ────────────────────────────

    async def _handle_escalation(self, message: Message) -> None:
        """Handle escalation messages that reach the top of the chain."""
        chain = self.registry.get_reporting_chain(message.sender)
        if not chain:
            logger.warning(
                "Escalation from %s has nowhere to go — triggering council",
                message.sender,
            )
            if self.council:
                await self.council.convene(
                    topic=f"Escalation from {message.sender}: {message.payload.get('content', '')}",
                    council_type="ad_hoc",
                )

    async def _audit_log(self, message: Message) -> None:
        """Log every message for audit purposes."""
        if self.knowledge:
            await self.knowledge.log_event(
                event_type=f"message:{message.type.value}",
                agent_id=message.sender,
                content=f"{message.sender} → {message.recipient}: {message.payload.get('content', '')[:200]}",
                metadata={
                    "message_id": message.id,
                    "thread_id": message.thread_id or "",
                    "priority": message.priority.value,
                },
            )

    # ── Status ───────────────────────────────────────

    def get_network_status(self) -> dict:
        """Get current status of the entire network."""
        agents = self.registry.get_all_agents()
        return {
            "total_agents": len(agents),
            "agents": {
                aid: {
                    "name": a.config.name,
                    "title": a.config.title,
                    "state": a.state.value,
                    "reports_to": a.config.reports_to,
                }
                for aid, a in agents.items()
            },
            "teams": self.bus.team_ids,
            "message_history_size": len(self.bus._history),
            "running": self._running,
        }

    def list_agents(self) -> list[dict]:
        """List all agents with basic info."""
        return [
            {
                "id": c.agent_id,
                "name": c.name,
                "title": c.title,
                "sigil": c.sigil,
                "reports_to": c.reports_to,
            }
            for c in self.registry.all_configs
        ]
