//! Agent lifecycle use cases (ARCHITECTURE §6, L6).
//!
//! These own the *project-agent* side (distinct from the profile side in
//! [`super::usecases`]): creating agents and their `.md` contexts under
//! `.ideai/`, listing/reading/updating them, and — the centrepiece —
//! [`LaunchAgent`], which resolves the agent's profile + context, applies the
//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and
//! spawns the CLI.
//!
//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`],
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
//! a concrete adapter or Tauri.

use std::collections::HashMap;
use std::sync::Arc;

use domain::ports::{
    AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
    FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
    ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
    SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
};
use domain::profile::{
    resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter,
    CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{
    bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
    ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
    Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
    NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, Posture, ProfileId, Project,
    ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
    SessionId, SessionKind, SessionStatus, SkillKind, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
};

use domain::live_state::WorkStatus;

use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::model_server::{
    EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
};
use crate::project::project_context_path;
use crate::skill::{AgentCapability, AssignedSkillResolver, ResolveAgentCapabilities};
use crate::terminal::{StructuredSessions, TerminalSessions};
use crate::workstate::GetLiveStateLean;

/// Directory (relative to `.ideai/`) under which agent contexts are written.
const AGENTS_SUBDIR: &str = "agents";

/// Token budget of the project-memory recall injected into the convention file at
/// agent activation (ARCHITECTURE §14.5.4). Bounds the number of index entries
/// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in
/// config**: it may later become a per-project setting without changing the
/// contract.
pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;

/// Fournit le [`HandoffStore`] **lié au project root** du lancement en cours (lot P7).
///
/// [`LaunchAgent`] est une **instance unique partagée par tous les projets** (le
/// project root arrive *par lancement* via [`LaunchAgentInput::project`]), alors que
/// le handoff conversationnel est **par project root**
/// (`<root>/.ideai/conversations/`, comme la mémoire et le log). Les adapters `Fs*`
/// fixent leur racine **à la construction** et ne portent pas le root par appel : on
/// ne peut donc pas figer un `HandoffStore` global. Ce **port** lève la tension —
/// calqué sur [`crate::RecordTurnProvider`] — en matérialisant un store ciblant le
/// **bon** dossier à chaque résolution (les adapters `Fs*` ne font que des jointures
/// de chemin, leur construction est triviale).
///
/// `None` ⇒ aucune injection de reprise (best-effort absente) : zéro régression pour
/// les call sites/tests qui ne le branchent pas. Implémenté dans `app-tauri` (seul
/// détenteur des adapters `Fs*`).
pub trait HandoffProvider: Send + Sync {
    /// Construit le [`HandoffStore`] dont la persistance cible `root`. Appelé une fois
    /// par lancement best-effort ; `None` ⇒ on saute silencieusement l'injection.
    fn handoff_store_for(&self, root: &ProjectPath) -> Option<Arc<dyn HandoffStore>>;
}

/// Fournit le [`ProviderSessionStore`] **lié au project root** du lancement en cours
/// (lot P8b). Jumeau stateless de [`HandoffProvider`] : même tension (instance
/// [`LaunchAgent`] partagée vs adapter `Fs*` à racine fixée à la construction),
/// même réponse (matérialiser le store ciblant le **bon** dossier à chaque appel).
///
/// Sert à ranger, après un lancement structuré, l'id de session **moteur**
/// (resumable du provider) sous la clé de paire IdeA dans `providers.json`. `None`
/// ⇒ aucune écriture (best-effort absente) : zéro régression pour les call
/// sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`.
pub trait ProviderSessionProvider: Send + Sync {
    /// Construit le [`ProviderSessionStore`] dont la persistance cible `root`. Appelé
    /// une fois par lancement best-effort ; `None` ⇒ on saute silencieusement
    /// l'écriture du resumable.
    fn provider_session_store_for(
        &self,
        root: &ProjectPath,
    ) -> Option<Arc<dyn ProviderSessionStore>>;
}

/// Cap on the number of live-state rows injected into the convention file at
/// activation (lot LS4). A project runs a handful of agents; `16` bounds the
/// injected section so a transient fan-out never bloats every agent's context.
pub const LIVE_STATE_INJECT_MAX: usize = 16;

/// One distilled live-state row ready to inject into the convention file's
/// `# État du projet` section (lot LS4).
///
/// Deliberately minimal: the **other** agent's display name, its coarse status and
/// a short intent — never the ticket/lastDelegation UUIDs, `progress`, or any
/// transcript. Built by [`LaunchAgent::resolve_live_state`] (which owns the manifest
/// join + ordering + self-exclusion) so [`compose_convention_file`] stays pure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InjectedLiveRow {
    /// The other agent's display name.
    pub name: String,
    /// Coarse status at a glance (rendered as its [`WorkStatus::label`]).
    pub status: WorkStatus,
    /// Short intent free-text; empty ⇒ the intent suffix is omitted.
    pub intent: String,
}

/// One assigned skill resolved for the effective context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedAssignedSkill {
    /// Agent-facing snapshot metadata.
    pub snapshot: domain::AssignedSkillSnapshot,
    /// Full Markdown body kept in the effective snapshot so authorized lazy-read
    /// paths can use the same resolution; convention-file rendering exposes only
    /// bounded affordances.
    pub content: MarkdownDoc,
}

/// Effective model context resolved once per runtime launch and shared by every
/// provider path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveAgentContext {
    /// Raw persona Markdown from the agent `.md`.
    pub persona: MarkdownDoc,
    /// Shared project context.
    pub project_context: String,
    /// Provider-agnostic orchestration capabilities exposed to the agent.
    pub capabilities: OrchestrationCapabilitySnapshot,
    /// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
    pub agent_capabilities: Vec<AgentCapability>,
    /// Resolved assigned skill bodies. These are not dumped into provider context
    /// by default; agents receive compact affordances and load details through the
    /// active IdeA surface.
    pub assigned_skills: Vec<ResolvedAssignedSkill>,
    /// Project-memory recall selected for this launch.
    pub memory: Vec<MemoryIndexEntry>,
    /// Optional conversation handoff for this launch.
    pub handoff: Option<Handoff>,
    /// Lean live-state rows for other agents.
    pub live_rows: Vec<InjectedLiveRow>,
}

/// Fournit le [`GetLiveStateLean`] **lié au project root** du lancement en cours
/// (lot LS4), pour injecter l'aperçu `# État du projet` dans le contexte composé.
///
/// Même tension/réponse que [`HandoffProvider`] : [`LaunchAgent`] est unique pour tous
/// les projets, alors que le live-state est **par project root**
/// (`<root>/.ideai/live-state.json`) et le port [`domain::ports::LiveStateStore`] fixe
/// sa racine à la construction. On matérialise donc un `GetLiveStateLean` ciblant le
/// **bon** dossier à chaque résolution (prune-on-read + snapshot lean).
///
/// `None` ⇒ aucune injection d'état (best-effort absente) : zéro régression pour les
/// call sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`.
pub trait LiveStateLeanProvider: Send + Sync {
    /// Construit le [`GetLiveStateLean`] dont le store cible `root`. Appelé une fois par
    /// lancement best-effort ; `None` ⇒ on saute silencieusement l'injection.
    fn live_state_lean_for(&self, root: &ProjectPath) -> Option<Arc<GetLiveStateLean>>;
}

// ---------------------------------------------------------------------------
// CreateAgentFromScratch
// ---------------------------------------------------------------------------

/// Input for [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentInput {
    /// The project that owns the agent.
    pub project: Project,
    /// Display name of the agent.
    pub name: String,
    /// Runtime profile the agent launches with.
    pub profile_id: ProfileId,
    /// Initial `.md` content (empty when `None`).
    pub initial_content: Option<String>,
}

/// Output of [`CreateAgentFromScratch::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAgentOutput {
    /// The freshly-created agent.
    pub agent: Agent,
}

/// Creates a project agent from scratch: mints an id, derives a unique `.md`
/// path, records the manifest entry, then writes the (possibly empty) context.
pub struct CreateAgentFromScratch {
    contexts: Arc<dyn AgentContextStore>,
    ids: Arc<dyn domain::ports::IdGenerator>,
    events: Arc<dyn EventBus>,
}

impl CreateAgentFromScratch {
    /// Builds the use case from its injected ports.
    #[must_use]
    pub fn new(
        contexts: Arc<dyn AgentContextStore>,
        ids: Arc<dyn domain::ports::IdGenerator>,
        events: Arc<dyn EventBus>,
    ) -> Self {
        Self {
            contexts,
            ids,
            events,
        }
    }

    /// Executes creation.
    ///
    /// Ordering matters: the manifest entry is persisted **before** the context
    /// is written, because [`AgentContextStore::write_context`] resolves the
    /// on-disk path from the manifest.
    ///
    /// # Errors
    /// - [`AppError::Invalid`] if the name is empty or the manifest would become
    ///   inconsistent,
    /// - [`AppError::Store`] on persistence failure.
    pub async fn execute(&self, input: CreateAgentInput) -> Result<CreateAgentOutput, AppError> {
        let manifest = self.contexts.load_manifest(&input.project).await?;

        let id = AgentId::from_uuid(self.ids.new_uuid());
        let md_path = unique_md_path(&input.name, &manifest);

        let agent = Agent::new(
            id,
            input.name,
            md_path,
            input.profile_id,
            AgentOrigin::Scratch,
            false,
        )
        .map_err(|e| AppError::Invalid(e.to_string()))?;

        // Append the entry and re-validate the whole manifest (unique md_paths).
        let mut entries = manifest.entries;
        entries.push(ManifestEntry::from_agent(&agent));
        let manifest = AgentManifest::new(manifest.version, entries)
            .map_err(|e| AppError::Invalid(e.to_string()))?;
        self.contexts
            .save_manifest(&input.project, &manifest)
            .await?;

        // Now the path resolves: write the initial context.
        let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
        self.contexts
            .write_context(&input.project, &agent.id, &md)
            .await?;

        self.events.publish(DomainEvent::LayoutChanged {
            project_id: input.project.id,
        });

        Ok(CreateAgentOutput { agent })
    }
}

// ---------------------------------------------------------------------------
// ListAgents
// ---------------------------------------------------------------------------

/// Input for [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsInput {
    /// The project whose agents to list.
    pub project: Project,
}

/// Output of [`ListAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListAgentsOutput {
    /// The project's agents (reconstructed from the manifest).
    pub agents: Vec<Agent>,
    /// Resolved discoverable capabilities per agent.
    pub capabilities: Vec<ListedAgentCapabilities>,
    /// The manifest's resolved orchestrator.
    pub effective_orchestrator: Option<AgentId>,
}

/// Resolved capabilities for one listed agent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedAgentCapabilities {
    /// Agent id.
    pub agent_id: AgentId,
    /// Compact capability list.
    pub capabilities: Vec<AgentCapability>,
}

/// Agent list item for discovery surfaces.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentDiscoveryEntry {
    /// Raw agent manifest shape, including `skills` for compatibility.
    #[serde(flatten)]
    pub agent: Agent,
    /// Resolved capability affordances.
    pub capabilities: Vec<AgentCapability>,
    /// Whether this agent is the project's current orchestrator.
    pub is_orchestrator: bool,
}

impl ListAgentsOutput {
    /// Returns the additive discovery shape used by inter-agent surfaces.
    #[must_use]
    pub fn discovery_entries(&self) -> Vec<AgentDiscoveryEntry> {
        self.agents
            .iter()
            .cloned()
            .map(|agent| {
                let capabilities = self
                    .capabilities
                    .iter()
                    .find(|entry| entry.agent_id == agent.id)
                    .map(|entry| entry.capabilities.clone())
                    .unwrap_or_default();
                AgentDiscoveryEntry {
                    is_orchestrator: self.effective_orchestrator == Some(agent.id),
                    agent,
                    capabilities,
                }
            })
            .collect()
    }
}

/// Lists a project's agents by reconstructing them from the manifest entries.
pub struct ListAgents {
    contexts: Arc<dyn AgentContextStore>,
    capabilities: Option<Arc<ResolveAgentCapabilities>>,
}

impl ListAgents {
    /// Builds the use case from the [`AgentContextStore`] port.
    #[must_use]
    pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
        Self {
            contexts,
            capabilities: None,
        }
    }

    /// Builds the use case with resolved agent capabilities enabled.
    #[must_use]
    pub fn with_capabilities(
        contexts: Arc<dyn AgentContextStore>,
        capabilities: Arc<ResolveAgentCapabilities>,
    ) -> Self {
        Self {
            contexts,
            capabilities: Some(capabilities),
        }
    }

    /// Loads the manifest and folds each entry back into an [`Agent`].
    ///
    /// # Errors
    /// - [`AppError::Store`] on persistence failure,
    /// - [`AppError::Invalid`] if a persisted entry violates an agent invariant.
    pub async fn execute(&self, input: ListAgentsInput) -> Result<ListAgentsOutput, AppError> {
        let manifest = self.contexts.load_manifest(&input.project).await?;
        let agents = manifest
            .entries
            .iter()
            .map(|e| {
                e.to_agent()
                    .map_err(|err| AppError::Invalid(err.to_string()))
            })
            .collect::<Result<Vec<_>, _>>()?;
        let capabilities = match &self.capabilities {
            Some(resolver) => {
                let mut out = Vec::with_capacity(agents.len());
                for agent in &agents {
                    out.push(ListedAgentCapabilities {
                        agent_id: agent.id,
                        capabilities: resolver
                            .execute(crate::ResolveAgentCapabilitiesInput {
                                agent: agent.clone(),
                                project_root: input.project.root.clone(),
                            })
                            .await?,
                    });
                }
                out
            }
            None => Vec::new(),
        };
        Ok(ListAgentsOutput {
            agents,
            capabilities,
            effective_orchestrator: manifest.effective_orchestrator(),
        })
    }
}

// ---------------------------------------------------------------------------
// ReadAgentContext / UpdateAgentContext
// ---------------------------------------------------------------------------

/// Input for [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextInput {
    /// The owning project.
    pub project: Project,
    /// The agent whose `.md` to read.
    pub agent_id: AgentId,
}

/// Output of [`ReadAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadAgentContextOutput {
    /// The agent's Markdown context.
    pub content: MarkdownDoc,
}

/// Reads an agent's `.md` context.
pub struct ReadAgentContext {
    contexts: Arc<dyn AgentContextStore>,
}

impl ReadAgentContext {
    /// Builds the use case.
    #[must_use]
    pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
        Self { contexts }
    }

    /// Reads the context.
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown,
    /// - [`AppError::Store`] on persistence failure.
    pub async fn execute(
        &self,
        input: ReadAgentContextInput,
    ) -> Result<ReadAgentContextOutput, AppError> {
        let content = self
            .contexts
            .read_context(&input.project, &input.agent_id)
            .await?;
        Ok(ReadAgentContextOutput { content })
    }
}

/// Input for [`UpdateAgentContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContextInput {
    /// The owning project.
    pub project: Project,
    /// The agent whose `.md` to overwrite.
    pub agent_id: AgentId,
    /// New Markdown content.
    pub content: String,
}

/// Overwrites an agent's `.md` context.
pub struct UpdateAgentContext {
    contexts: Arc<dyn AgentContextStore>,
}

impl UpdateAgentContext {
    /// Builds the use case.
    #[must_use]
    pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
        Self { contexts }
    }

    /// Writes the new context.
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if the agent is unknown,
    /// - [`AppError::Store`] on persistence failure.
    pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> {
        let md = MarkdownDoc::new(input.content);
        self.contexts
            .write_context(&input.project, &input.agent_id, &md)
            .await?;
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// UpdateAgentEffort
// ---------------------------------------------------------------------------

/// Input for [`UpdateAgentEffort::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentEffortInput {
    /// The owning project.
    pub project: Project,
    /// The agent whose effort override changes.
    pub agent_id: AgentId,
    /// `None` clears the override, falling back to the profile default.
    pub effort: Option<EffortSelection>,
}

/// Output of [`UpdateAgentEffort::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentEffortOutput {
    /// The updated agent.
    pub agent: Agent,
}

/// Sets or clears an agent's per-agent effort override in the manifest.
///
/// The change applies at the agent's next launch; live sessions are not mutated.
pub struct UpdateAgentEffort {
    contexts: Arc<dyn AgentContextStore>,
}

impl UpdateAgentEffort {
    /// Builds the use case.
    #[must_use]
    pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
        Self { contexts }
    }

    /// Executes the update.
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if the agent is unknown to the project,
    /// - [`AppError::Invalid`] if the resulting manifest is invalid,
    /// - [`AppError::Store`] on persistence failure.
    pub async fn execute(
        &self,
        input: UpdateAgentEffortInput,
    ) -> Result<UpdateAgentEffortOutput, AppError> {
        let mut manifest = self.contexts.load_manifest(&input.project).await?;
        let entry = manifest
            .entries
            .iter_mut()
            .find(|entry| entry.agent_id == input.agent_id)
            .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;

        let agent = entry
            .to_agent()
            .map_err(|err| AppError::Invalid(err.to_string()))?
            .with_effort(input.effort);
        *entry = ManifestEntry::from_agent(&agent);

        let manifest = AgentManifest::new(manifest.version, manifest.entries)
            .map_err(|err| AppError::Invalid(err.to_string()))?;
        self.contexts
            .save_manifest(&input.project, &manifest)
            .await?;
        Ok(UpdateAgentEffortOutput { agent })
    }
}

// ---------------------------------------------------------------------------
// ChangeAgentProfile
// ---------------------------------------------------------------------------

/// Input for [`ChangeAgentProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeAgentProfileInput {
    /// The owning project.
    pub project: Project,
    /// The agent whose runtime profile to hot-swap.
    pub agent_id: AgentId,
    /// The new runtime profile.
    pub profile_id: ProfileId,
    /// Terminal height in rows for a possible hot relaunch.
    pub rows: u16,
    /// Terminal width in columns for a possible hot relaunch.
    pub cols: u16,
}

/// Output of [`ChangeAgentProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeAgentProfileOutput {
    /// The mutated agent (now carrying the new profile).
    pub agent: Agent,
    /// The freshly relaunched session, when a live session was hot-swapped.
    pub relaunched: Option<TerminalSession>,
}

/// Hot-swaps an existing agent's runtime profile (ARCHITECTURE §15.1).
///
/// **Single Responsibility**: mutate the profile in the manifest, clear the now
/// foreign conversation id on every persisted layout cell hosting the agent, and
/// — if the agent is live — kill its PTY and re-sequence the session in the same
/// cell with the new engine. The relaunch is **composed**, not duplicated: this
/// use case *calls* [`LaunchAgent::execute`] rather than re-implementing the spawn.
///
/// Ports consumed (ISP — only what is needed): [`AgentContextStore`] (manifest),
/// [`ProfileStore`] (validate the target profile), [`ProjectStore`] +
/// [`FileSystem`] (clean the conversation id on persisted layouts, exactly like
/// [`crate::layout::SnapshotRunningAgents`]), [`TerminalSessions`] + [`PtyPort`]
/// (detect/kill a live session), an [`Arc<LaunchAgent>`] for the hot relaunch, and
/// [`EventBus`] to publish.
pub struct ChangeAgentProfile {
    contexts: Arc<dyn AgentContextStore>,
    profiles: Arc<dyn ProfileStore>,
    projects: Arc<dyn ProjectStore>,
    fs: Arc<dyn FileSystem>,
    sessions: Arc<TerminalSessions>,
    pty: Arc<dyn PtyPort>,
    launch: Arc<LaunchAgent>,
    events: Arc<dyn EventBus>,
    /// Registre des sessions **structurées** (§17.5), pour un « kill » polymorphe
    /// (§17.4) : si l'agent a une session structurée vivante, on la `shutdown()` au
    /// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None`
    /// ⇒ seul le registre PTY est consulté (mode legacy / tests existants).
    structured: Option<Arc<StructuredSessions>>,
    /// Registre des **projecteurs de permissions** (lot LP3-4), pour nettoyer au swap
    /// les fichiers `Replace` orphelins possédés par l'ANCIEN profil que le nouveau ne
    /// réécrira pas. Injecté via [`Self::with_permission_projectors`] (le câblage passe
    /// le **même** `Arc` que celui donné au `LaunchAgent` interne). `None` ⇒ aucun
    /// nettoyage (comportement legacy ; la re-projection du nouveau profil, elle, reste
    /// assurée par le `LaunchAgent` composé).
    projectors: Option<Arc<PermissionProjectorRegistry>>,
}

impl ChangeAgentProfile {
    /// Builds the use case from its injected ports (and the composed launcher).
    #[must_use]
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        contexts: Arc<dyn AgentContextStore>,
        profiles: Arc<dyn ProfileStore>,
        projects: Arc<dyn ProjectStore>,
        fs: Arc<dyn FileSystem>,
        sessions: Arc<TerminalSessions>,
        pty: Arc<dyn PtyPort>,
        launch: Arc<LaunchAgent>,
        events: Arc<dyn EventBus>,
    ) -> Self {
        Self {
            contexts,
            profiles,
            projects,
            fs,
            sessions,
            pty,
            launch,
            events,
            structured: None,
            projectors: None,
        }
    }

    /// Branche le registre des sessions **structurées** (§17.4) pour un kill
    /// polymorphe au hot-swap. Builder additif : signature de [`Self::new`]
    /// **inchangée** (les tests A existants restent verts), le câblage fait
    /// `ChangeAgentProfile::new(...).with_structured(registry)`.
    #[must_use]
    pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
        self.structured = Some(structured);
        self
    }

    /// Branche le **registre de projecteurs de permissions** (lot LP3-4) pour le
    /// nettoyage des fichiers orphelins au swap cross-profile. Le câblage passe le
    /// **même** `Arc<PermissionProjectorRegistry>` que celui injecté au `LaunchAgent`
    /// interne (source unique de vérité). Builder additif : signature de [`Self::new`]
    /// inchangée ; `None` ⇒ pas de nettoyage (legacy).
    #[must_use]
    pub fn with_permission_projectors(
        mut self,
        projectors: Arc<PermissionProjectorRegistry>,
    ) -> Self {
        self.projectors = Some(projectors);
        self
    }

    /// Executes the hot-swap, following the 7-step algorithm of §15.1.
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if the agent or the target profile is unknown,
    /// - [`AppError::Invalid`] on a manifest/layout invariant violation,
    /// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
    ///   the respective port failures (manifest, layouts, PTY kill, relaunch).
    pub async fn execute(
        &self,
        input: ChangeAgentProfileInput,
    ) -> Result<ChangeAgentProfileOutput, AppError> {
        // 1. Load the manifest and resolve the agent's entry (NotFound otherwise).
        let manifest = self.contexts.load_manifest(&input.project).await?;
        let entry = manifest
            .entries
            .iter()
            .find(|e| e.agent_id == input.agent_id)
            .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;

        // Capture the PREVIOUS profile id BEFORE mutation (lot LP3-4): it identifies
        // the projector whose now-orphan `Replace` files must be cleaned up at the swap.
        let previous_profile_id = entry.profile_id;

        // 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch,
        //    no event.
        if entry.profile_id == input.profile_id {
            let agent = entry
                .to_agent()
                .map_err(|e| AppError::Invalid(e.to_string()))?;
            return Ok(ChangeAgentProfileOutput {
                agent,
                relaunched: None,
            });
        }

        // 3. Validate that the target profile is a known one (ProfileStore.list).
        //    Capture both the NEW profile (validation) and the PREVIOUS profile (for
        //    the LP3-4 cleanup; absent if it was deleted ⇒ cleanup is skipped).
        let profiles = self.profiles.list().await?;
        let Some(new_profile) = profiles.iter().find(|p| p.id == input.profile_id).cloned() else {
            return Err(AppError::NotFound(format!("profile {}", input.profile_id)));
        };
        let previous_profile = profiles
            .iter()
            .find(|p| p.id == previous_profile_id)
            .cloned();

        // 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
        //    and persist.
        let mut entries = manifest.entries;
        let mut mutated_agent = None;
        for e in &mut entries {
            if e.agent_id == input.agent_id {
                e.profile_id = input.profile_id;
                let agent = e
                    .to_agent()
                    .map_err(|err| AppError::Invalid(err.to_string()))?;
                mutated_agent = Some(agent);
            }
        }
        let agent =
            mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
        let manifest = AgentManifest::new(manifest.version, entries)
            .map_err(|e| AppError::Invalid(e.to_string()))?;
        self.contexts
            .save_manifest(&input.project, &manifest)
            .await?;

        // 5. Invalidate the engine link on every persisted layout: for each leaf
        //    hosting this agent, drop the (now foreign) engine resumable cache and
        //    reset the running flag, while **preserving** the stable IdeA pair id.
        //    Mirrors `SnapshotRunningAgents`: resolve_doc → walk agent_leaves →
        //    mutate → persist_doc (if changed). Returns the preserved pair id.
        let pair_id = self
            .invalidate_engine_link(&input.project, &input.agent_id)
            .await?;

        // 5b. (lot LP3-4) Clean up the PREVIOUS profile's now-orphan permission files
        //     in the agent's (stable) run dir, BEFORE the relaunch re-projects the new
        //     profile — so we never delete a file the new profile is about to write.
        //     Only `Replace`-owned paths of the old projector that the new projector
        //     does NOT also own are removed (best-effort). `MergeToml` files are never
        //     touched. No-op without a projector registry.
        self.cleanup_swapped_out_files(
            &input.project.root,
            &input.agent_id,
            previous_profile.as_ref(),
            &new_profile,
        )
        .await;

        // 6. A live session? Kill its PTY then relaunch in the same cell with the
        //    new profile, carrying the **preserved** pair id (so the handoff is
        //    re-injected and resume routes via providers.json[new provider]).
        let relaunched = self.relaunch_if_live(&input, pair_id).await?;

        // 7. Publish the profile change and return.
        self.events.publish(DomainEvent::AgentProfileChanged {
            agent_id: input.agent_id,
            profile_id: input.profile_id,
        });

        Ok(ChangeAgentProfileOutput { agent, relaunched })
    }

    /// Invalidates the **engine link** on every persisted layout leaf hosting
    /// `agent_id` (step 5), while **preserving** the IdeA pair conversation id.
    ///
    /// Post-P8a, `LeafCell::conversation_id` is a stable, provider-independent
    /// **pair id** (User↔agent) that retrieves the work log + handoff and must
    /// survive a profile swap. Only the engine-side cache
    /// (`LeafCell::engine_session_id`, a foreign resumable for the new engine) is
    /// cleared; the source of truth for resume routing is `providers.json`. The
    /// running flag is reset too.
    ///
    /// Returns the **preserved pair id** of the agent (the first non-`None` leaf;
    /// every leaf of this agent carries the same User↔agent pair id), or `None`
    /// when no hosting leaf was found. Persists only when something actually
    /// changed (a project with no such leaf is a no-op write).
    async fn invalidate_engine_link(
        &self,
        project: &Project,
        agent_id: &AgentId,
    ) -> Result<Option<String>, AppError> {
        let project = self.projects.load_project(project.id).await?;
        let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
        let mut changed = false;
        let mut pair_id: Option<String> = None;

        for named in &mut doc.layouts {
            for (leaf_id, leaf_agent) in named.tree.agent_leaves() {
                if &leaf_agent != agent_id {
                    continue;
                }
                // Capture the preserved pair id (stable across all leaves of this
                // agent) — used to relaunch with handoff re-injection (P7).
                if pair_id.is_none() {
                    if let Some(cid) = named
                        .tree
                        .leaf(leaf_id)
                        .and_then(|l| l.conversation_id.clone())
                    {
                        pair_id = Some(cid);
                    }
                }
                // Pure ops — only NodeNotFound is possible, which cannot happen
                // since `leaf_id` came from this very tree.
                //
                // Preserve `conversation_id` (stable pair id); only drop the
                // engine-side resumable cache (foreign to the new engine).
                named.tree = named
                    .tree
                    .set_cell_engine_session(leaf_id, None)
                    .map_err(|e| AppError::Invalid(e.to_string()))?;
                named.tree = named
                    .tree
                    .set_agent_running(leaf_id, false)
                    .map_err(|e| AppError::Invalid(e.to_string()))?;
                changed = true;
            }
        }

        if changed {
            persist_doc(self.fs.as_ref(), &project, &doc).await?;
        }
        Ok(pair_id)
    }

    /// Removes the **previous** profile's now-orphan permission files at the swap
    /// (lot LP3-4), best-effort. Because the run dir is **stable per agent id**
    /// (`agent_run_dir`), the old CLI's config (e.g. `.claude/settings.local.json`)
    /// survives a swap in the very same directory; left in place it is stale.
    ///
    /// Cleanup rule (Architect-validated): delete exactly
    /// `owned_replace_paths(old) − owned_replace_paths(new)` — only the
    /// **`Replace`-owned** files of the old projector that the new projector will not
    /// re-own (and thus re-write). `MergeToml` (co-owned, e.g. `.codex/config.toml`)
    /// files are **never** deleted. Examples: Claude→Codex removes
    /// `.claude/settings.local.json`; Claude→Claude removes nothing (empty diff);
    /// Codex→Claude removes nothing on the Replace side (Codex owns no Replace file).
    ///
    /// No-op when: no registry is wired, the previous profile is unknown (deleted),
    /// or it maps to no projector. Every delete is best-effort (`remove_file` is
    /// idempotent), so a missing file never fails the swap. This does **not** touch
    /// the stable pair id or the handoff (P8d) — it only removes engine config files.
    async fn cleanup_swapped_out_files(
        &self,
        project_root: &ProjectPath,
        agent_id: &AgentId,
        previous_profile: Option<&AgentProfile>,
        new_profile: &AgentProfile,
    ) {
        let Some(registry) = &self.projectors else {
            return;
        };
        let Some(previous_profile) = previous_profile else {
            return;
        };
        let Some(old_key) = select_projector_key(previous_profile) else {
            return;
        };
        let Some(old_projector) = registry.get(old_key) else {
            return;
        };
        let old_owned = old_projector.owned_replace_paths();
        if old_owned.is_empty() {
            return;
        }
        // Paths the NEW projector will re-own (and re-write) ⇒ never delete these.
        let kept: Vec<String> = select_projector_key(new_profile)
            .and_then(|key| registry.get(key))
            .map(|p| p.owned_replace_paths())
            .unwrap_or_default();

        let run_dir = match agent_run_dir(project_root, agent_id) {
            Ok(dir) => dir,
            Err(_) => return,
        };
        for rel in old_owned {
            if kept.iter().any(|k| k == &rel) {
                continue;
            }
            let path = RemotePath::new(join(&run_dir, &rel));
            // Best-effort: a missing file / delete failure must never fail the swap.
            let _ = self.fs.remove_file(&path).await;
        }
    }

    /// Kills the agent's live PTY (if any) and relaunches it in the same cell with
    /// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
    /// relaunched session, or `None` when the agent had no live session.
    ///
    /// `pair_id` is the **preserved** IdeA pair id (User↔agent) captured at step 5;
    /// it is threaded into the relaunch so the handoff (P7) is re-injected into the
    /// new engine and resume (P8c) is routed via `providers.json[new provider]` —
    /// the old engine's resumable is **never** replayed (providers.json for the new
    /// provider is empty ⇒ `SessionPlan::None`, fidelity carried by the handoff).
    /// When step 5 found no hosting leaf (e.g. a background session without a cell),
    /// the id is **derived** deterministically via `for_pair(User, agent)`.
    async fn relaunch_if_live(
        &self,
        input: &ChangeAgentProfileInput,
        pair_id: Option<String>,
    ) -> Result<Option<TerminalSession>, AppError> {
        // Résolution **polymorphe** de la session vivante sur les deux registres
        // (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des
        // deux à la fois (invariant « 1 session/agent »).
        let killed = self
            .kill_live_session(&input.project, &input.agent_id)
            .await?;
        let Some(node_id) = killed else {
            // Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer.
            return Ok(None);
        };

        // Pair id préservé (step 5) ; en repli (session de fond sans cellule), on
        // dérive l'id de paire déterministe User↔agent — les cellules sont toujours
        // User↔agent, donc cette dérivation coïncide avec ce qu'eût porté la feuille.
        let conversation_id = Some(pair_id.unwrap_or_else(|| {
            ConversationId::for_pair(
                ConversationParty::User,
                ConversationParty::agent(input.agent_id),
            )
            .to_string()
        }));

        let output = self
            .launch
            .execute(LaunchAgentInput {
                project: input.project.clone(),
                agent_id: input.agent_id,
                rows: input.rows,
                cols: input.cols,
                node_id,
                // Pair id **préservé** (id de paire IdeA stable) : le handoff (P7) est
                // réinjecté dans le nouveau moteur, et le resume (P8c) est routé via
                // providers.json[nouveau provider] — l'ancien resumable n'est jamais
                // repassé (providers.json du nouveau provider vide ⇒ moteur neuf).
                conversation_id,
                // Internal relaunch (no app-tauri composition root in scope) ⇒ the
                // MCP runtime is not injected here; `apply_mcp_config` falls back to
                // the minimal declaration. A profile-hot-swap that needs the real
                // endpoint is re-driven through the app-tauri launch path.
                mcp_runtime: None,
                allow_structured_alongside_pty: false,
            })
            .await?;
        Ok(Some(output.session))
    }

    /// Tue la session vivante de `agent_id` de façon **polymorphe** (§17.4) :
    /// `shutdown()` si elle est structurée, kill PTY sinon. Retire la session de son
    /// registre **avant** l'arrêt, pour que la garde d'unicité du relance (sur les
    /// deux registres) ne voie plus de session vivante.
    ///
    /// Retourne `Some(node_id_hôte)` quand une session a été tuée (le node où
    /// relancer), `None` si l'agent n'avait aucune session vivante. La cellule hôte
    /// peut elle-même être absente (session de fond) ⇒ `Some(None)` est replié sur un
    /// node neuf côté relance via `LaunchAgentInput.node_id = None`.
    async fn kill_live_session(
        &self,
        project: &Project,
        agent_id: &AgentId,
    ) -> Result<Option<Option<NodeId>>, AppError> {
        // 1. Session structurée vivante ? ⇒ shutdown polymorphe.
        if let Some(structured) = &self.structured {
            if let Some(session_id) =
                structured.session_id_for_agent_in_project(project.id, agent_id)
            {
                let node_id = structured.node_for_agent_in_project(project.id, agent_id);
                if let Some(session) = structured.remove(&session_id) {
                    session
                        .shutdown()
                        .await
                        .map_err(|e| AppError::Process(e.to_string()))?;
                }
                return Ok(Some(node_id));
            }
        }

        // 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique).
        let Some(session_id) = self
            .sessions
            .session_for_agent_in_project(project.id, agent_id)
        else {
            return Ok(None);
        };
        let node_id = self
            .sessions
            .node_for_agent_in_project(project.id, agent_id);
        if let Some(handle) = self.sessions.remove(&session_id) {
            self.pty.kill(&handle).await?;
        }
        Ok(Some(node_id))
    }
}

// ---------------------------------------------------------------------------
// DeleteAgent
// ---------------------------------------------------------------------------

/// Input for [`DeleteAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAgentInput {
    /// The owning project.
    pub project: Project,
    /// The agent to remove.
    pub agent_id: AgentId,
}

/// Removes an agent from the project manifest.
///
/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no
/// delete, and keeping the file is the safe default (the user may want to recover
/// the context). Re-creating an agent with the same name reuses a fresh path.
pub struct DeleteAgent {
    contexts: Arc<dyn AgentContextStore>,
    events: Arc<dyn EventBus>,
}

impl DeleteAgent {
    /// Builds the use case.
    #[must_use]
    pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
        Self { contexts, events }
    }

    /// Drops the manifest entry for the agent.
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if the agent is not in the manifest,
    /// - [`AppError::Store`] on persistence failure.
    pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
        let manifest = self.contexts.load_manifest(&input.project).await?;
        let before = manifest.entries.len();
        let entries: Vec<ManifestEntry> = manifest
            .entries
            .into_iter()
            .filter(|e| e.agent_id != input.agent_id)
            .collect();
        if entries.len() == before {
            return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
        }
        let manifest = AgentManifest::new(manifest.version, entries)
            .map_err(|e| AppError::Invalid(e.to_string()))?;
        self.contexts
            .save_manifest(&input.project, &manifest)
            .await?;
        self.events.publish(DomainEvent::LayoutChanged {
            project_id: input.project.id,
        });
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// LaunchAgent
// ---------------------------------------------------------------------------

/// Input for [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentInput {
    /// The owning project.
    pub project: Project,
    /// The agent to launch.
    pub agent_id: AgentId,
    /// Initial terminal height in rows.
    pub rows: u16,
    /// Initial terminal width in columns.
    pub cols: u16,
    /// The layout leaf hosting the session (a fresh node when `None`).
    pub node_id: Option<NodeId>,
    /// The persistent CLI conversation id currently recorded on the hosting cell,
    /// if any. `Some` means a previous conversation exists and the launch should
    /// **resume** it; `None` means a fresh cell (the launch may *assign* a new id
    /// when the profile supports it). The caller (which owns the layout) reads this
    /// from the leaf's [`domain::layout::LeafCell::conversation_id`].
    pub conversation_id: Option<String>,
    /// Runtime facts needed to write the **real** IdeA MCP server declaration
    /// (M5d). These are **OS/runtime data** (the IdeA executable path, the
    /// project's loopback endpoint) that live in `app-tauri` — they are *injected
    /// as data* from the composition root, never computed in `application` (which
    /// must not depend on `app-tauri`, cadrage v5 §0.3 / §7).
    ///
    /// `None` ⇒ no runtime injected (launches issued from inside `application`:
    /// the orchestrator's `spawn_agent`/`ask_agent`, a profile hot-swap relaunch,
    /// or tests). In that case [`apply_mcp_config`](LaunchAgent::apply_mcp_config)
    /// still honours the profile's `McpConfigStrategy`, but falls back to a
    /// **coherent minimal** declaration (the `idea mcp-server` command without the
    /// endpoint/project/requester args) rather than a project-bound one — see
    /// [`mcp_server_declaration`].
    pub mcp_runtime: Option<McpRuntime>,
    /// Internal `ask_agent` seam: when `true`, an existing visible PTY session for
    /// the same agent must not block the structured/headless launch. This keeps the
    /// human CLI cell and the inter-agent `AgentSession` input channel separate.
    ///
    /// Ordinary UI launches keep this `false` and retain the singleton/rebind guard.
    pub allow_structured_alongside_pty: bool,
}

/// OS/runtime facts injected by the composition root (`app-tauri`) to materialise
/// the **real** IdeA MCP server declaration in an agent's `.mcp.json` (cadrage v5
/// §2). Carrying these as **plain data** on [`LaunchAgentInput`] keeps
/// `application` free of any `app-tauri` / `current_exe` / `mcp_endpoint`
/// dependency: the endpoint stays computed by the *single source of truth*
/// (`app-tauri::mcp_endpoint`) and only its **string** crosses the layer boundary.
///
/// All four fields are the exact strings the spawned bridge expects on its command
/// line (`<exe> mcp-server --endpoint <endpoint> --project <project_id> --requester
/// <requester>`); the `project_id` must already be in the **hyphen-free 32-hex
/// `simple` form** consumed by the M5c handshake guard (`serve_peer`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpRuntime {
    /// Absolute path to the IdeA executable (`std::env::current_exe()`), used as the
    /// declaration's `command` — the CLI spawns *this* binary in its `mcp-server`
    /// bridge mode.
    pub exe: String,
    /// The project's loopback endpoint string (`mcp_endpoint(project).as_cli_arg()`),
    /// passed as `--endpoint`. **Same source of truth** as the listener bound by
    /// `ensure_mcp_server` (cadrage v5 §2 coherence invariant).
    pub endpoint: String,
    /// The project id in the **hyphen-free 32-hex `simple` form** consumed by the
    /// M5c handshake guard, passed as `--project`.
    pub project_id: String,
    /// The launching agent's id, passed as `--requester` so the server tags
    /// `OrchestratorRequestProcessed.requester_id` with the real agent (cadrage v5
    /// §1.4) instead of the frozen `"mcp"` placeholder.
    pub requester: String,
}

/// Descripteur d'une session **structurée** (IA, cellule chat) démarrée par
/// [`LaunchAgent`] (ARCHITECTURE §17.4). Renvoyé en plus du snapshot
/// [`TerminalSession`] quand le profil porte un `structured_adapter` : il identifie
/// la session structurée vivante (registre [`StructuredSessions`]) sans faire fuiter
/// l'`Arc<dyn AgentSession>` à travers la frontière de sortie du use case.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuredSessionDescriptor {
    /// L'id de session IdeA de la session structurée (clé du registre).
    pub session_id: SessionId,
    /// L'agent IA pilotant cette session.
    pub agent_id: AgentId,
    /// La cellule (feuille de layout) qui héberge la vue chat.
    pub node_id: NodeId,
    /// L'id de conversation du moteur, s'il a déjà été attribué.
    pub conversation_id: Option<String>,
}

/// Output of [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentOutput {
    /// The created agent terminal session.
    ///
    /// **Toujours présent**, y compris pour un agent IA structuré (cellule chat) :
    /// dans ce cas c'est un snapshot `kind = Agent` portant l'id de la session
    /// structurée (cf. [`Self::structured`]), conformément à §17.4. Garde la sortie
    /// **non cassante** pour A/B et le câblage existant.
    pub session: TerminalSession,
    /// L'**id de paire IdeA** (pivot **logique** de la conversation, ARCHITECTURE
    /// §19.7) assigné par ce lancement, quand la cellule n'en portait pas encore. Le
    /// caller le persiste sur la feuille hôte via `set_cell_conversation` : c'est la
    /// clé qui retrouve **log + handoff** au (re)lancement (P7) et survit au swap de
    /// profil. **Ce n'est plus l'id de session moteur** (qui part désormais dans
    /// [`Self::engine_session_id`]). `None` quand rien de neuf n'a été assigné (reprise
    /// d'un id existant, mode dégradé, ou profil sans session) — rien à persister.
    pub assigned_conversation_id: Option<String>,
    /// L'**id de session moteur** (resumable du provider : id Claude `--resume`, ou
    /// l'UUID minté pour `--session-id`) exposé/attribué par ce lancement, **distinct**
    /// de l'id de paire (ARCHITECTURE §19.7, lot P8a). Le caller le range dans le
    /// **cache** `LeafCell::engine_session_id` (via `set_cell_engine_session`) — jamais
    /// sur `conversation_id`. La **source de vérité** du resumable reste `providers.json`
    /// (lot P8b, hors P8a). `None` pour le chemin PTY/legacy ou quand le moteur n'expose
    /// aucun id : rien à cacher.
    pub engine_session_id: Option<String>,
    /// Descripteur de la session **structurée**, présent **uniquement** quand ce
    /// lancement a routé vers un agent IA structuré (`profile.structured_adapter =
    /// Some(_)`, ARCHITECTURE §17.4). `None` pour le chemin PTY/terminal brut
    /// historique. Champ **additionnel et optionnel** : la sortie reste compatible
    /// avec les use cases A/B et le câblage qui ne lisent que `session` /
    /// `assigned_conversation_id`.
    pub structured: Option<StructuredSessionDescriptor>,
    /// Le **profil résolu** de l'agent lors d'un (re)lancement *effectif* (LS7,
    /// ARCHITECTURE §21.10-4). `LaunchAgent::execute` le résout déjà en interne (zéro
    /// I/O supplémentaire) ; on l'expose pour que le câblage app-tauri décide d'armer —
    /// ou non — le détecteur de limite **niveau 2** (PTY) via la règle de sélection
    /// `infrastructure::ratelimit::applies` (anti-double-détection N1/N2), sans
    /// reconstruire la frontière infra côté domaine. `None` sur une **réattache**
    /// (rebind/idempotent) où aucun profil n'est re-résolu — le câblage n'arme alors
    /// rien (best-effort, le tour est sans nouvelle session).
    pub profile: Option<AgentProfile>,
}

/// Explicit routing intent for profiles that declare a structured adapter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StructuredRoutingMode {
    /// Human-facing cells intentionally keep the native CLI/TUI in a PTY.
    HumanPtyFallback,
    /// Headless/orchestrated launchers must route structured profiles via AgentSession.
    RequireStructured,
}

/// Registry mapping each [`ProjectorKey`] to its concrete
/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the
/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete
/// projectors (Claude / Codex) live in `infrastructure` and are inserted at the
/// composition root (lot LP3-5). Absent registry ⇒ no projection (legacy
/// behaviour preserved).
#[derive(Default, Clone)]
pub struct PermissionProjectorRegistry {
    by_key: HashMap<ProjectorKey, Arc<dyn PermissionProjector>>,
}

impl PermissionProjectorRegistry {
    /// An empty registry.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Registers a projector under its own [`PermissionProjector::key`] and returns
    /// `self` (builder style), so a registry can be assembled fluently.
    #[must_use]
    pub fn with(mut self, projector: Arc<dyn PermissionProjector>) -> Self {
        self.insert(projector);
        self
    }

    /// Registers (or replaces) a projector under its own key.
    pub fn insert(&mut self, projector: Arc<dyn PermissionProjector>) {
        self.by_key.insert(projector.key(), projector);
    }

    /// Returns the projector registered for `key`, if any.
    #[must_use]
    pub fn get(&self, key: ProjectorKey) -> Option<&Arc<dyn PermissionProjector>> {
        self.by_key.get(&key)
    }
}

/// Selects the [`ProjectorKey`] for a launched agent's profile (lot LP3-3).
///
/// Primary source: the profile's explicit `projector` field. **Migration
/// fallback** for legacy profiles (e.g. an old `profiles.json` written before the
/// field existed): the historical heuristic — a `CLAUDE.md` convention file ⇒
/// Claude; a `StructuredAdapter::Codex` or a `TomlConfigHome` MCP strategy ⇒
/// Codex. Anything else ⇒ `None` (no projection).
fn select_projector_key(profile: &AgentProfile) -> Option<ProjectorKey> {
    if let Some(key) = profile.projector {
        return Some(key);
    }
    // Legacy fallback: Claude is recognised by its `CLAUDE.md` convention file.
    let is_claude = matches!(
        &profile.context_injection,
        ContextInjection::ConventionFile { target }
            if target
                .rsplit(['/', '\\'])
                .next()
                .unwrap_or(target)
                .eq_ignore_ascii_case("CLAUDE.md")
    );
    if is_claude {
        return Some(ProjectorKey::Claude);
    }
    // Legacy fallback: Codex is recognised by its structured adapter or its
    // CODEX_HOME-isolated TOML MCP strategy.
    if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
        return Some(ProjectorKey::Codex);
    }
    if let Some(mcp) = &profile.mcp {
        if matches!(mcp.config, McpConfigStrategy::TomlConfigHome { .. }) {
            return Some(ProjectorKey::Codex);
        }
    }
    None
}

fn codex_structured_sandbox_mode(permissions: Option<&EffectivePermissions>) -> &'static str {
    match permissions.map(EffectivePermissions::fallback) {
        Some(Posture::Deny) => "read-only",
        Some(Posture::Ask | Posture::Allow) | None => "workspace-write",
    }
}

fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
    matches!(network, Some(NetworkPolicy::Allow))
}

fn build_structured_launch_policy(
    profile: &AgentProfile,
    permissions: Option<&EffectivePermissions>,
    network: Option<NetworkPolicy>,
    project_root: &ProjectPath,
) -> Option<StructuredProviderLaunchPolicy> {
    if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
        return None;
    }
    if select_projector_key(profile) != Some(ProjectorKey::Codex) {
        return None;
    }

    let sandbox_mode = codex_structured_sandbox_mode(permissions).to_owned();
    let writable_roots = if sandbox_mode == "workspace-write" && !project_root.as_str().is_empty() {
        vec![project_root.as_str().to_owned()]
    } else {
        Vec::new()
    };

    Some(StructuredProviderLaunchPolicy::Codex {
        sandbox_mode,
        writable_roots,
        network_access: codex_network_access(network),
    })
}

fn projection_model(profile: &AgentProfile) -> Option<&str> {
    profile.model.as_deref()
}

fn is_codex_profile(profile: &AgentProfile) -> bool {
    matches!(profile.structured_adapter, Some(StructuredAdapter::Codex))
        || matches!(profile.projector, Some(ProjectorKey::Codex))
        || profile
            .command
            .rsplit(['/', '\\'])
            .next()
            .unwrap_or(&profile.command)
            == "codex"
}

fn append_codex_pty_model_overrides(profile: &AgentProfile, spec: &mut SpawnSpec) {
    if !is_codex_profile(profile) {
        return;
    }
    if let Some(model) = profile
        .model
        .as_deref()
        .map(str::trim)
        .filter(|model| !model.is_empty())
    {
        spec.args.push("-c".to_owned());
        spec.args
            .push(format!("model={}", toml_string_literal(model)));
    }
    if let Some(effort) = profile
        .model_reasoning_effort
        .as_deref()
        .map(str::trim)
        .filter(|effort| !effort.is_empty())
    {
        spec.args.push("-c".to_owned());
        spec.args.push(format!(
            "model_reasoning_effort={}",
            toml_string_literal(effort)
        ));
    }
}

fn toml_string_literal(value: &str) -> String {
    serde_json::to_string(value).expect("string serialization cannot fail")
}

/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
/// This is the orchestrating use case of L6 and therefore consumes several ports
/// — each only for the slice it needs (Interface Segregation): the context store
/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime
/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile`
/// context), and the PTY (spawn + optional stdin injection).
pub struct LaunchAgent {
    contexts: Arc<dyn AgentContextStore>,
    profiles: Arc<dyn ProfileStore>,
    runtime: Arc<dyn AgentRuntime>,
    fs: Arc<dyn FileSystem>,
    pty: Arc<dyn PtyPort>,
    assigned_skills: Arc<AssignedSkillResolver>,
    agent_capabilities: Arc<ResolveAgentCapabilities>,
    sessions: Arc<TerminalSessions>,
    events: Arc<dyn EventBus>,
    ids: Arc<dyn IdGenerator>,
    /// Bounded recall of the project's memory index, injected into the convention
    /// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent
    /// or empty memory yields an empty list, never blocking a launch.
    recall: Arc<dyn MemoryRecall>,
    /// Optional contextual embedder-suggestion check (LOT C3, §14.5.5), run
    /// best-effort right after the memory recall at activation — the moment an agent
    /// reads the project memory. `None` keeps the launcher independent of it (legacy
    /// wiring / tests). A failure here never affects the launch.
    embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
    /// Optional permissions store (LP1). When absent, the launcher keeps its
    /// historical hardcoded CLI permission seeds. When present and a project or
    /// agent policy is configured, the resolved policy is projected to the CLI.
    permissions: Option<Arc<dyn PermissionStore>>,
    /// Optional system permission store. Network policy is separate from
    /// filesystem/bash permissions, but it must still be projected into launch env.
    system_permissions: Option<Arc<dyn SystemPermissionStore>>,
    /// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
    /// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
    /// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
    /// tests existants, qui restent verts sans changement de signature).
    session_factory: Option<Arc<dyn AgentSessionFactory>>,
    /// Registre des sessions structurées vivantes (§17.5), jumeau de
    /// [`TerminalSessions`]. Peuplé quand un agent IA structuré est lancé. `None` en
    /// même temps que [`Self::session_factory`].
    structured: Option<Arc<StructuredSessions>>,
    /// Provider du [`HandoffStore`] **par project root** (lot P7), pour réinjecter
    /// **best-effort** le résumé de reprise (`summary_md` + objectif) de la
    /// conversation de la cellule dans le convention file au (re)lancement. Injecté au
    /// câblage via [`Self::with_handoff_provider`] ; `None` ⇒ aucune injection de
    /// reprise (zéro régression pour les call sites/tests legacy). Un handoff
    /// absent/illisible ⇒ lancement normal, jamais d'échec.
    handoffs: Option<Arc<dyn HandoffProvider>>,
    /// Provider du [`ProviderSessionStore`] **par project root** (lot P8b), pour ranger
    /// **best-effort** l'id de session moteur (resumable du provider) sous la clé de
    /// paire IdeA dans `providers.json` après un lancement structuré. Injecté au câblage
    /// via [`Self::with_provider_session_provider`] ; `None` ⇒ aucune écriture (zéro
    /// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement
    /// normal, jamais d'échec.
    provider_sessions: Option<Arc<dyn ProviderSessionProvider>>,
    /// Registre des **projecteurs de permissions** par CLI (lot LP3-3). Injecté au
    /// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
    /// (comportement historique préservé pour les call sites/tests legacy).
    projectors: Option<Arc<PermissionProjectorRegistry>>,
    /// Provider du [`GetLiveStateLean`] **par project root** (lot LS4), pour injecter
    /// l'aperçu `# État du projet` (que font les autres agents) dans le convention file
    /// au lancement. Injecté via [`Self::with_live_state_lean`] ; `None` ⇒ aucune
    /// injection (zéro régression). Best-effort strict : provider absent / erreur /
    /// parse ⇒ section omise, jamais d'échec de lancement.
    live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>,
    /// Ensure local model servers referenced by OpenCode profiles before writing
    /// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
    /// when `OpenCodeConfig.localModelServerId` is set.
    local_model_server: Option<Arc<EnsureLocalModelServer>>,
    /// Resolves the literal API key of an [`domain::profile::OpenCodeProviderConfig`]
    /// (ticket #92, lot B3) just before writing `opencode.json`. Optional for legacy
    /// tests/wiring; required at runtime when `profile.opencode_provider` is set — a
    /// missing store or a missing/undecryptable secret **fails the launch**
    /// (never spawns with a dead/absent key).
    secret_store: Option<Arc<dyn SecretStore>>,
    /// Explicit intent for structured profiles when structured ports are absent.
    structured_routing_mode: StructuredRoutingMode,
}

impl LaunchAgent {
    /// Builds the use case from its injected ports.
    #[must_use]
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        contexts: Arc<dyn AgentContextStore>,
        profiles: Arc<dyn ProfileStore>,
        runtime: Arc<dyn AgentRuntime>,
        fs: Arc<dyn FileSystem>,
        pty: Arc<dyn PtyPort>,
        skills: Arc<dyn SkillStore>,
        sessions: Arc<TerminalSessions>,
        events: Arc<dyn EventBus>,
        ids: Arc<dyn IdGenerator>,
        recall: Arc<dyn MemoryRecall>,
        embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
    ) -> Self {
        let assigned_skills = Arc::new(AssignedSkillResolver::new(skills));
        let agent_capabilities = Arc::new(ResolveAgentCapabilities::with_resolver(Arc::clone(
            &assigned_skills,
        )));
        Self {
            contexts,
            profiles,
            runtime,
            fs,
            pty,
            assigned_skills,
            agent_capabilities,
            sessions,
            events,
            ids,
            recall,
            embedder_suggestion,
            permissions: None,
            system_permissions: None,
            session_factory: None,
            structured: None,
            handoffs: None,
            provider_sessions: None,
            projectors: None,
            live_state_lean: None,
            local_model_server: None,
            secret_store: None,
            structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
        }
    }

    /// Sets the explicit routing intent for profiles carrying a structured adapter.
    #[must_use]
    pub const fn with_structured_routing_mode(mut self, mode: StructuredRoutingMode) -> Self {
        self.structured_routing_mode = mode;
        self
    }

    /// Injects the local model-server ensure use case used by OpenCode profiles.
    #[must_use]
    pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
        self.local_model_server = Some(ensure);
        self
    }

    /// Injects the [`SecretStore`] used to resolve an
    /// [`domain::profile::OpenCodeProviderConfig`]'s literal API key at launch
    /// (ticket #92, lot B3). Without this call (legacy call sites / tests), a
    /// profile carrying `opencode_provider` fails its launch instead of silently
    /// writing an unauthenticated `opencode.json`.
    #[must_use]
    pub fn with_secret_store(mut self, secret_store: Arc<dyn SecretStore>) -> Self {
        self.secret_store = Some(secret_store);
        self
    }

    /// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
    /// `# État du projet` (status + intent des autres agents) est injecté dans le
    /// convention file. Sans cet appel (cas legacy / tests existants), aucune section
    /// n'est injectée — signature de [`Self::new`] **inchangée**. Builder additif.
    #[must_use]
    pub fn with_live_state_lean(mut self, provider: Arc<dyn LiveStateLeanProvider>) -> Self {
        self.live_state_lean = Some(provider);
        self
    }

    /// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
    /// translate the resolved [`EffectivePermissions`] into the launched CLI's
    /// concrete permission config. Without this call (legacy call sites / tests),
    /// no projection happens — signature of [`Self::new`] unchanged.
    #[must_use]
    pub fn with_permission_projectors(
        mut self,
        projectors: Arc<PermissionProjectorRegistry>,
    ) -> Self {
        self.projectors = Some(projectors);
        self
    }

    /// Injects the project permission store used to resolve effective agent
    /// permissions at launch time.
    #[must_use]
    pub fn with_permission_store(mut self, permissions: Arc<dyn PermissionStore>) -> Self {
        self.permissions = Some(permissions);
        self
    }

    /// Injects the project system permission store used to resolve network policy
    /// at launch time.
    #[must_use]
    pub fn with_system_permission_store(
        mut self,
        permissions: Arc<dyn SystemPermissionStore>,
    ) -> Self {
        self.system_permissions = Some(permissions);
        self
    }

    /// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
    /// après un lancement structuré exposant un id de session moteur, range
    /// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
    /// du projet. Sans cet appel (cas legacy / tests existants), aucune écriture —
    /// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse.
    ///
    /// Builder additif (consomme `self`, retourne `Self`) : le câblage fait
    /// `LaunchAgent::new(...).with_provider_session_provider(provider)`.
    #[must_use]
    pub fn with_provider_session_provider(
        mut self,
        provider_sessions: Arc<dyn ProviderSessionProvider>,
    ) -> Self {
        self.provider_sessions = Some(provider_sessions);
        self
    }

    /// Branche le provider de **handoff de reprise (lot P7)** sur ce launcher : au
    /// (re)lancement, si la cellule porte une `conversation_id` et qu'un [`Handoff`]
    /// existe pour elle, son `summary_md` (et son objectif) sont injectés comme section
    /// de contexte dans le convention file, à côté de la mémoire projet. Sans cet appel
    /// (cas legacy / tests existants), aucune section de reprise n'est injectée —
    /// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse.
    ///
    /// Builder additif (consomme `self`, retourne `Self`) : le câblage fait
    /// `LaunchAgent::new(...).with_handoff_provider(provider)`.
    #[must_use]
    pub fn with_handoff_provider(mut self, handoffs: Arc<dyn HandoffProvider>) -> Self {
        self.handoffs = Some(handoffs);
        self
    }

    /// Branche le **routage structuré (§17.4)** sur ce launcher : fournit la fabrique
    /// [`AgentSessionFactory`] et le registre [`StructuredSessions`] injectés au
    /// composition root. Sans cet appel (cas legacy / tests existants), `execute`
    /// route **tout** vers le PTY — signature de [`Self::new`] **inchangée**, donc
    /// aucun appelant existant ne casse.
    ///
    /// Builder (consomme `self`, retourne `Self`) pour rester additif : le câblage
    /// fait `LaunchAgent::new(...).with_structured(factory, registry)`.
    #[must_use]
    pub fn with_structured(
        mut self,
        session_factory: Arc<dyn AgentSessionFactory>,
        structured: Arc<StructuredSessions>,
    ) -> Self {
        self.session_factory = Some(session_factory);
        self.structured = Some(structured);
        self
    }

    /// Builds the effective runtime context once, before the provider split, so
    /// PTY, structured/headless, hot relaunch and handoff rebuild paths consume the
    /// same capability snapshot.
    async fn build_effective_context(
        &self,
        project: &Project,
        manifest: &AgentManifest,
        agent: &Agent,
        persona: MarkdownDoc,
        cell_conversation_id: Option<&str>,
    ) -> Result<EffectiveAgentContext, AppError> {
        let assigned_skills: Vec<ResolvedAssignedSkill> = self
            .assigned_skills
            .resolve_for_agent_with_content(agent, &project.root)
            .await?
            .into_iter()
            .map(|(snapshot, content)| ResolvedAssignedSkill { snapshot, content })
            .collect();
        let capabilities = OrchestrationCapabilitySnapshot::new(
            assigned_skills.iter().map(|s| s.snapshot.clone()).collect(),
        );
        let agent_capabilities = self
            .agent_capabilities
            .execute(crate::ResolveAgentCapabilitiesInput {
                agent: agent.clone(),
                project_root: project.root.clone(),
            })
            .await?;
        let project_context = self.resolve_project_context(project).await?;
        let memory = self.resolve_memory(&project.root, persona.as_str()).await;
        let handoff = self
            .resolve_handoff(&project.root, cell_conversation_id)
            .await;
        let live_rows = self
            .resolve_live_state(&project.root, manifest, agent.id)
            .await;
        Ok(EffectiveAgentContext {
            persona,
            project_context,
            capabilities,
            agent_capabilities,
            assigned_skills,
            memory,
            handoff,
            live_rows,
        })
    }

    /// Resolves the project's memory recall (index/hooks) to inject into the
    /// convention file at activation (ARCHITECTURE §14.5.4), mirroring
    /// [`Self::resolve_skills`]. The query text is the agent's persona `.md`
    /// (irrelevant to the naïve adapter, but already the right query for the future
    /// semantic recall — zero refactor at étage 2), bounded by
    /// [`AGENT_MEMORY_RECALL_BUDGET`].
    ///
    /// **Best-effort, never blocking**: an absent or empty memory yields an empty
    /// list by the [`MemoryRecall`] contract, and any unexpected error degrades to
    /// an empty list rather than failing the launch (exactly like a dangling skill).
    async fn resolve_memory(&self, root: &ProjectPath, persona: &str) -> Vec<MemoryIndexEntry> {
        let query = MemoryQuery {
            text: persona.to_owned(),
            token_budget: AGENT_MEMORY_RECALL_BUDGET,
        };
        self.recall.recall(root, &query).await.unwrap_or_default()
    }

    /// Résout **best-effort** le handoff de reprise de la cellule (lot P7), calqué sur
    /// [`Self::resolve_memory`]. L'injection n'a lieu que si :
    /// - la cellule porte une `conversation_id` ([`LaunchAgentInput::conversation_id`]),
    /// - le provider de handoff est câblé ([`Self::with_handoff_provider`]),
    /// - cette chaîne se parse en [`ConversationId`] (UUID), et
    /// - un [`Handoff`] existe effectivement pour cette conversation.
    ///
    /// **Jamais bloquant** : un parse en échec, l'absence de provider/handoff, ou
    /// toute erreur du store dégradent vers `None` (pas de section de reprise), exactly
    /// comme une mémoire absente — un lancement n'est jamais cassé par la reprise.
    async fn resolve_handoff(
        &self,
        root: &ProjectPath,
        cell_conversation_id: Option<&str>,
    ) -> Option<Handoff> {
        let provider = self.handoffs.as_ref()?;
        let raw = cell_conversation_id?;
        // Le `conversation_id` d'une cellule est un identifiant de paire (UUID, §C3).
        // Un id non-UUID (ancien id CLI, donnée corrompue) ⇒ pas d'injection.
        let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?);
        let store = provider.handoff_store_for(root)?;
        // Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort).
        // Borne défensive LS5 : un `handoff.md` legacy non borné (écrit avant LS5) est
        // ramené sous `HANDOFF_SUMMARY_MAX_CHARS` **avant** d'entrer dans le contexte
        // composé — sans migration de données, idempotent et best-effort.
        store
            .load(conversation)
            .await
            .ok()
            .flatten()
            .map(|h| bound_handoff_summary(h, HANDOFF_SUMMARY_MAX_CHARS))
    }

    /// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à
    /// injecter dans la section `# État du projet`, calqué sur [`Self::resolve_handoff`].
    ///
    /// Ordre **du manifeste** (déterministe). L'agent lancé (`self_agent_id`) est
    /// **exclu** (il ne se voit pas lui-même). Les lignes du live-state qui ne
    /// correspondent à **aucun** agent du manifeste sont droppées (la frontière du
    /// manifeste prime). Le résultat est borné à [`LIVE_STATE_INJECT_MAX`].
    ///
    /// **Jamais bloquant** : provider absent, erreur du store, ou parse en échec
    /// dégradent vers un `Vec` vide (section omise), exactement comme une mémoire/reprise
    /// absente — un lancement n'est jamais cassé par cet aperçu.
    async fn resolve_live_state(
        &self,
        root: &ProjectPath,
        manifest: &AgentManifest,
        self_agent_id: AgentId,
    ) -> Vec<InjectedLiveRow> {
        let Some(provider) = self.live_state_lean.as_ref() else {
            return Vec::new();
        };
        let Some(getter) = provider.live_state_lean_for(root) else {
            return Vec::new();
        };
        // Toute erreur de lecture/prune ⇒ pas d'aperçu (best-effort).
        let Ok(lean) = getter.execute().await else {
            return Vec::new();
        };
        let by_agent: HashMap<AgentId, &crate::workstate::LeanLiveEntry> =
            lean.entries.iter().map(|e| (e.agent_id, e)).collect();

        let mut rows = Vec::new();
        for entry in &manifest.entries {
            if rows.len() >= LIVE_STATE_INJECT_MAX {
                break;
            }
            // Exclure la ligne de l'agent lancé : il ne s'observe pas lui-même.
            if entry.agent_id == self_agent_id {
                continue;
            }
            // Droppe les lignes live-state hors manifeste (on n'itère que le manifeste).
            let Some(live) = by_agent.get(&entry.agent_id) else {
                continue;
            };
            rows.push(InjectedLiveRow {
                name: entry.name.clone(),
                status: live.status,
                intent: live.intent.clone(),
            });
        }
        rows
    }

    /// Reads the shared project context from `.ideai/CONTEXT.md`.
    ///
    /// A missing file is normal for existing projects and simply omits the
    /// project-context section from the generated model context.
    async fn resolve_project_context(&self, project: &Project) -> Result<String, AppError> {
        match self.fs.read(&project_context_path(project)).await {
            Ok(bytes) => String::from_utf8(bytes)
                .map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}"))),
            Err(FsError::NotFound(_)) => Ok(String::new()),
            Err(e) => Err(AppError::FileSystem(e.to_string())),
        }
    }

    /// Executes the launch.
    ///
    /// Step order is contractually significant (and unit-tested): resolve the
    /// agent + context, **`prepare_invocation`**, **apply the injection plan**
    /// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the
    /// resolved `cwd`, and finally pipe the context on stdin for the `Stdin`
    /// strategy.
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if the agent or its profile is unknown,
    /// - [`AppError::Invalid`] for a zero-sized terminal,
    /// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
    ///   the respective port failures.
    pub async fn execute(&self, input: LaunchAgentInput) -> Result<LaunchAgentOutput, AppError> {
        let size =
            PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;

        // 1. Resolve the agent from the manifest (name + profile + md_path).
        let manifest = self.contexts.load_manifest(&input.project).await?;
        let entry = manifest
            .entries
            .iter()
            .find(|e| e.agent_id == input.agent_id)
            .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
        let agent = entry
            .to_agent()
            .map_err(|e| AppError::Invalid(e.to_string()))?;

        // 1b. Enforce the "one live session per agent" invariant (decision: an
        //     agent is a singleton that runs in a single cell at a time). This
        //     runs AFTER the NotFound resolution above (so an unknown agent still
        //     errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the
        //     agent already owns a live session:
        //     - with a requested node → rebind the live session to that cell and
        //       return it without respawning;
        //     - without a requested node → idempotent background/no-op launch:
        //       return the existing session without respawning.
        //     The resume path (agent dead ⇒ no live session) is unaffected.
        //
        //     Invariant « 1 session vivante/agent » **généralisé aux deux registres**
        //     (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée.
        //     On consulte d'abord le registre PTY (chemin historique), puis le
        //     registre structuré (le cas échéant).
        //
        //     R0a — discrimination réattache-de-vue vs second lancement (cadrage v5
        //     §3.2, Trou A). Un agent est un **singleton** : une seule session vivante.
        //     Quand l'agent est déjà vivant, on distingue (cf. [`Self::reattach_decision`]) :
        //       - **réattache de vue** (rebind sans respawn) : le `node_id` demandé est
        //         le node hôte vivant, OU la cellule porte une `conversation_id`
        //         (réattache explicite — la cellule sait que l'agent tournait) ;
        //       - **lancement background/idempotent** : ni node, ni conversation ⇒ no-op
        //         (rend la session existante, pas de respawn) ;
        //       - **second lancement neuf** : on vise un **autre** node, sans signal de
        //         réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté).
        let existing_pty = self
            .sessions
            .session_for_agent_in_project(input.project.id, &input.agent_id);
        if let Some(existing_id) = existing_pty {
            let host_node = self
                .sessions
                .node_for_agent_in_project(input.project.id, &input.agent_id);
            if input.allow_structured_alongside_pty && input.node_id.is_none() {
                crate::diag!(
                    "[launch] existing PTY kept while structured launch proceeds: agent={} \
                     pty_session={existing_id}",
                    input.agent_id
                );
            } else {
                match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref())
                {
                    ReattachDecision::Rebind { node_id } => {
                        if let Some(session) = self.sessions.rebind_agent_node_in_project(
                            input.project.id,
                            &input.agent_id,
                            node_id,
                        ) {
                            return Ok(LaunchAgentOutput {
                                session,
                                assigned_conversation_id: None,
                                engine_session_id: None,
                                structured: None,
                                // Réattache (rebind de vue) : aucun profil re-résolu.
                                profile: None,
                            });
                        }
                    }
                    ReattachDecision::Idempotent => {}
                    ReattachDecision::Refuse { node_id } => {
                        return Err(AppError::AgentAlreadyRunning {
                            agent_id: input.agent_id,
                            node_id,
                        });
                    }
                }
                // Idempotent (or a rebind that found no entry to move) — hand back the
                // already-registered session, no respawn, nothing new to persist.
                if let Some(session) = self.sessions.session(&existing_id) {
                    return Ok(LaunchAgentOutput {
                        session,
                        assigned_conversation_id: None,
                        engine_session_id: None,
                        structured: None,
                        // Idempotent (pas de respawn) : aucun profil re-résolu.
                        profile: None,
                    });
                }
            }
        }
        // Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de
        // façon identique — rebind de la cellule-vue pour une réattache légitime,
        // idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
        if let Some(structured) = &self.structured {
            if let Some(existing) =
                structured.session_for_agent_in_project(input.project.id, &input.agent_id)
            {
                let host_node =
                    structured.node_for_agent_in_project(input.project.id, &input.agent_id);
                let node_id = match reattach_decision(
                    input.node_id,
                    host_node,
                    input.conversation_id.as_deref(),
                ) {
                    ReattachDecision::Rebind { node_id } => {
                        let _ = structured.rebind_agent_node_in_project(
                            input.project.id,
                            &input.agent_id,
                            node_id,
                        );
                        node_id
                    }
                    // Idempotent — garder le node hôte courant, sinon un node neuf.
                    ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random),
                    ReattachDecision::Refuse { node_id } => {
                        return Err(AppError::AgentAlreadyRunning {
                            agent_id: input.agent_id,
                            node_id,
                        });
                    }
                };
                return Ok(LaunchAgentOutput {
                    session: structured_snapshot(&existing, input.agent_id, node_id, size),
                    assigned_conversation_id: None,
                    // Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher.
                    engine_session_id: None,
                    structured: Some(StructuredSessionDescriptor {
                        session_id: existing.id(),
                        agent_id: input.agent_id,
                        node_id,
                        conversation_id: existing.conversation_id(),
                    }),
                    // Réattache structurée (rebind de vue) : aucun profil re-résolu.
                    profile: None,
                });
            }
        }

        // 2. Read its context and resolve its profile.
        let content = self
            .contexts
            .read_context(&input.project, &agent.id)
            .await?;
        let mut profile = self
            .profiles
            .list()
            .await?
            .into_iter()
            .find(|p| p.id == agent.profile_id)
            .ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
        if input.allow_structured_alongside_pty && profile.structured_adapter.is_none() {
            return Err(AppError::Invalid(format!(
                "agent {}: le lancement headless structuré a été demandé, mais le profil {} \
                 ne déclare pas d'adaptateur structured/headless",
                input.agent_id, profile.id
            )));
        }

        // 3. Compute and create the agent's isolated run directory
        //    `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is
        //    *never* the project root: each agent gets its own directory so that N
        //    instances of the same profile never collide on a single conventional
        //    file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the
        //    runtime's `prepare_invocation` stays pure.
        let run_dir = agent_run_dir(&input.project.root, &agent.id)
            .map_err(|e| AppError::Invalid(e.to_string()))?;
        self.fs
            .create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
            .await?;

        let effective_permissions = self
            .resolve_effective_permissions(&input.project, agent.id)
            .await?;
        let network_permission = self
            .resolve_launch_network_permission(&input.project, agent.id)
            .await?;

        // 3b. (Permission projection moved to step 5c, after the convention file and
        //     the MCP config, so both the structured and PTY paths inherit it — see
        //     `apply_permission_projection`.)

        // 4. Prepare the invocation (pure): command + args + injection plan + cwd.
        //    The run dir is passed as the cwd base; the profile's `{agentRunDir}`
        //    placeholder resolves against it.
        let prepared = PreparedContext {
            content: content.clone(),
            relative_path: agent.context_path.clone(),
            project_root: input.project.root.as_str().to_owned(),
        };
        // 4a. Resolve the session intention (T4). The conversation id is a property
        //     of the *cell*, not the PTY: the caller (which owns the layout) passes
        //     the cell's current `conversation_id`. Any id this launch *assigns* is
        //     returned in the output so the caller persists it on the leaf.
        let (session_plan, assigned_conversation_id) = self
            .resolve_session_plan(&profile, input.conversation_id.clone(), &input.project.root)
            .await;
        let mut spec =
            self.runtime
                .prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;

        // 5. Build the effective context once, then apply the injection side
        //    effects before spawning. The capability snapshot inside this context
        //    is also the authorization source for `idea_skill_read`.
        let effective_context = self
            .build_effective_context(
                &input.project,
                &manifest,
                &agent,
                content.clone(),
                input.conversation_id.as_deref(),
            )
            .await?;
        // Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
        // has just read the project memory, so this is the moment to check whether a
        // semantic embedder would now help. Fully isolated from the launch outcome —
        // an error or absence of the check never affects activation.
        if let Some(check) = &self.embedder_suggestion {
            let _ = check
                .execute(crate::embedder::CheckEmbedderSuggestionInput {
                    project_id: input.project.id,
                    project_root: input.project.root.clone(),
                })
                .await;
        }
        self.apply_injection(
            &input.project,
            &agent.context_path,
            &effective_context,
            profile.mcp.is_some(),
            &mut spec,
        )
        .await?;

        let local_model_server_guard = self
            .ensure_local_model_server_for_opencode(&input.project, &agent, &mut profile)
            .await?;

        // 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
        //     Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
        //     `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
        //     IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même**
        //     run dir isolé que le convention file et le seed de permissions. `None`
        //     ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
        self.apply_mcp_config(
            &profile,
            &run_dir,
            &input.project.root,
            input.mcp_runtime.as_ref(),
            effective_permissions.as_ref(),
            &mut spec,
        )
        .await?;

        // 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
        //     Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
        //     AVANT le split structuré/PTY (5b) et le spawn : les DEUX chemins
        //     héritent de la projection (fichiers du plan écrits dans le run dir,
        //     `args`/`env` foldés dans `spec`). `None` registre / profil non
        //     projetable / `eff == None` ⇒ no-op (prompting natif conservé).
        self.apply_permission_projection(
            &profile,
            &run_dir,
            &input.project.root,
            effective_permissions.as_ref(),
            network_permission,
            &mut spec,
        )
        .await?;

        // 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ──
        //     Strictement APRÈS la résolution des permissions (3) et la projection
        //     advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan`
        //     est **pur** (domaine) — le launch path ne fait qu'orchestrer
        //     `EffectivePermissions` (déjà lue du store en 3) → `SandboxPlan` et
        //     remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
        //     comportement natif conservé (invariant produit). L'enforcer concret
        //     (Landlock/Noop) est injecté côté PTY au composition root.
        spec.sandbox = compile_sandbox_plan(
            effective_permissions.as_ref(),
            &SandboxContext {
                project_root: input.project.root.as_str(),
                run_dir: run_dir.as_str(),
            },
        );
        let structured_policy = build_structured_launch_policy(
            &profile,
            effective_permissions.as_ref(),
            network_permission,
            &input.project.root,
        );
        let resolved_effort = resolve_effort(
            profile.model_reasoning_effort.as_deref(),
            agent.effort.as_ref(),
        );
        let mut launch_profile = profile.clone();
        launch_profile.model_reasoning_effort = resolved_effort;

        // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
        //     L'intention est explicite sur le launcher : les cellules humaines peuvent
        //     garder une CLI/TUI native en PTY, les launchers headless/orchestrés doivent
        //     échouer fort si le routage structuré est mal câblé.
        match (
            profile.structured_adapter.is_some(),
            self.session_factory.as_ref(),
            self.structured.as_ref(),
            self.structured_routing_mode,
        ) {
            (false, _, _, _) => {}
            (true, Some(factory), Some(structured), _) => {
                // ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7,
                //    lot P8a) ──
                //  - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué
                //    qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire,
                //    on le conserve tel quel ;
                //  - cellule structurée **neuve** (lancement direct utilisateur, aucun
                //    requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine
                //    pure partagée avec `resolve_conversation` (aucun couplage à
                //    l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
                //    qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
                let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
                    ConversationId::for_project_pair(
                        input.project.id,
                        ConversationParty::User,
                        ConversationParty::agent(agent.id),
                    )
                    .to_string()
                });
                return self
                    .launch_structured(
                        factory.as_ref(),
                        structured,
                        &agent,
                        &launch_profile,
                        &prepared,
                        &run_dir,
                        &session_plan,
                        pair_conversation_id,
                        input.project.id,
                        &input.project.root,
                        input.node_id,
                        size,
                        &spec.env,
                        spec.sandbox.as_ref(),
                        structured_policy.as_ref(),
                        local_model_server_guard,
                    )
                    .await;
            }
            (true, _, _, StructuredRoutingMode::HumanPtyFallback) => {
                // Fallback humain intentionnel : cellule interactive native en PTY.
            }
            (true, _, _, StructuredRoutingMode::RequireStructured) => {
                return Err(AppError::Process(
                    "structured profile requires structured session factory".to_owned(),
                ));
            }
        }

        // PTY/interactif Codex : le TUI conserve un état `model = ...` dans son
        // CODEX_HOME isolé. Passer le modèle du profil sur l'argv garantit que le
        // lancement interactif respecte l'édition IdeA, comme le chemin structuré
        // le fait déjà dans `CodexExecSession`.
        append_codex_pty_model_overrides(&launch_profile, &mut spec);

        // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
        let handle = self.pty.spawn(spec.clone(), size).await?;
        let session_id = handle.session_id;

        // 7. For the Stdin strategy, pipe the context once the PTY is live.
        if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
            if let Err(err) = self.pty.write(&handle, content.as_str().as_bytes()) {
                let _ = self.pty.kill(&handle).await;
                return Err(err.into());
            }
        }

        let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
        let mut session = TerminalSession::starting(
            session_id,
            node_id,
            spec.cwd.clone(),
            SessionKind::Agent { agent_id: agent.id },
            size,
        );
        session.status = SessionStatus::Running;
        self.sessions.insert_in_project_with_model_server_guard(
            input.project.id,
            handle,
            session.clone(),
            local_model_server_guard,
        );

        self.events.publish(DomainEvent::AgentLaunched {
            agent_id: agent.id,
            session_id,
        });

        Ok(LaunchAgentOutput {
            session,
            assigned_conversation_id,
            // Chemin PTY/terminal brut : pas de session moteur structurée à cacher.
            engine_session_id: None,
            structured: None,
            // Lancement effectif : profil résolu exposé pour la sélection du détecteur
            // de limite niveau 2 (§21.10-4) côté câblage.
            profile: Some(profile.clone()),
        })
    }

    /// Démarre un agent IA **structuré** (§17.4) : crée une [`AgentSession`] via la
    /// fabrique, l'enregistre dans [`StructuredSessions`] avec son `agent_id`/`node_id`
    /// et publie [`DomainEvent::AgentLaunched`]. **Aucun `pty.spawn`** — la cellule est
    /// de type chat.
    ///
    /// L'`assigned_conversation_id` calculé en amont (`resolve_session_plan`) est
    /// préservé, mais l'id réellement attribué par le moteur (s'il diffère ou s'il
    /// apparaît seulement après le démarrage) prime quand il est disponible : c'est
    /// `session.conversation_id()` qui fait foi pour la persistance sur la cellule.
    #[allow(clippy::too_many_arguments)]
    async fn launch_structured(
        &self,
        factory: &dyn AgentSessionFactory,
        structured: &Arc<StructuredSessions>,
        agent: &Agent,
        profile: &AgentProfile,
        prepared: &PreparedContext,
        run_dir: &ProjectPath,
        session_plan: &SessionPlan,
        pair_conversation_id: String,
        project_id: domain::ProjectId,
        root: &ProjectPath,
        node_id: Option<NodeId>,
        size: PtySize,
        env: &[(String, String)],
        sandbox: Option<&SandboxPlan>,
        structured_policy: Option<&StructuredProviderLaunchPolicy>,
        model_server_guard: Option<ModelServerUseGuard>,
    ) -> Result<LaunchAgentOutput, AppError> {
        // Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`,
        // déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée.
        let session = factory
            .start(
                profile,
                prepared,
                run_dir,
                session_plan,
                Some(&agent.id.to_string()),
                env,
                sandbox,
                structured_policy,
            )
            .await
            .map_err(|e| AppError::Process(e.to_string()))?;

        let session_id = session.id();
        let node_id = node_id.unwrap_or_else(NodeId::new_random);

        // Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en
        // amont sur les deux registres).
        structured.insert_in_project_with_model_server_guard(
            project_id,
            Arc::clone(&session),
            agent.id,
            node_id,
            model_server_guard,
        );

        // ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
        //  - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
        //    `LeafCell.conversation_id`. C'est lui qui retrouve log + handoff (P7) et
        //    survit au swap. Stable, indépendant du provider.
        //  - **id de session moteur** (`session.conversation_id()`) : resumable du
        //    provider (Claude/Codex). Rangé **séparément** dans le cache
        //    `LeafCell.engine_session_id` (via `set_cell_engine_session`) — **jamais**
        //    sur `conversation_id`. `None` si le moteur n'expose encore aucun id.
        let engine_session_id = session.conversation_id();

        // ── P8b — range le resumable moteur par provider dans `providers.json`
        //    (best-effort, jamais bloquant) ──
        //  On persiste `(pair_conversation_id, provider_key) → engine_session_id`
        //  **uniquement** quand le moteur expose un id ET que le provider est câblé.
        //  La clé provider est dérivée de la **famille de moteur** (`structured_adapter`),
        //  pas de l'uuid d'instance du profil : un swap vers la même famille réutilise la
        //  même entrée. Toute défaillance (provider absent, pair_conversation_id non-UUID,
        //  erreur du store) dégrade silencieusement — un lancement n'est **jamais** cassé
        //  par cette persistance (lot P8c lira ce store pour `--resume`).
        if let Some(engine_id) = engine_session_id
            .as_deref()
            .map(str::trim)
            .filter(|id| !id.is_empty())
        {
            self.persist_provider_session(root, profile, &pair_conversation_id, engine_id)
                .await;
        }

        let snapshot = structured_snapshot(&session, agent.id, node_id, size);

        self.events.publish(DomainEvent::AgentLaunched {
            agent_id: agent.id,
            session_id,
        });

        Ok(LaunchAgentOutput {
            session: snapshot,
            // Cellule ⇐ id de **paire** (pivot logique de reprise §15.2 / §19.7).
            assigned_conversation_id: Some(pair_conversation_id),
            // Cache moteur ⇐ id resumable du provider (distinct de la paire).
            engine_session_id: engine_session_id.clone(),
            structured: Some(StructuredSessionDescriptor {
                session_id,
                agent_id: agent.id,
                node_id,
                conversation_id: engine_session_id,
            }),
            // Lancement structuré effectif : profil résolu exposé (le tap niveau 2 PTY ne
            // l'arme pas — `applies` est faux pour un profil structuré, §21.10-4).
            profile: Some(profile.clone()),
        })
    }

    /// Range **best-effort** (lot P8b) le resumable moteur `engine_id` sous la clé
    /// `(pair_conversation_id, provider_key)` dans `providers.json` du projet.
    ///
    /// `provider_key` est dérivée de la **famille de moteur** du profil
    /// ([`StructuredAdapter::provider_key`]) ; un profil sans `structured_adapter` (ne
    /// devrait pas arriver sur ce chemin structuré) ⇒ skip. `pair_conversation_id` est
    /// une chaîne : un id non-UUID (donnée legacy/corrompue) ⇒ skip. Le provider absent
    /// ([`Self::with_provider_session_provider`] non appelé) ⇒ skip. Toute erreur du
    /// store est tracée et avalée : **jamais** d'échec ni de panique — le lancement a
    /// déjà réussi à ce stade.
    async fn persist_provider_session(
        &self,
        root: &ProjectPath,
        profile: &AgentProfile,
        pair_conversation_id: &str,
        engine_id: &str,
    ) {
        let Some(provider) = self.provider_sessions.as_ref() else {
            return;
        };
        let Some(adapter) = profile.structured_adapter else {
            return;
        };
        // Le `conversation_id` d'une cellule est un id de paire (UUID, §C3).
        let Ok(uuid) = uuid::Uuid::parse_str(pair_conversation_id) else {
            return;
        };
        let conversation = ConversationId::from_uuid(uuid);
        let Some(store) = provider.provider_session_store_for(root) else {
            return;
        };
        // Best-effort : toute erreur du store est avalée — le lancement a déjà réussi,
        // la persistance du resumable ne doit jamais le casser (lot P8c lira ce store).
        let _ = store
            .set(conversation, adapter.provider_key(), engine_id)
            .await;
    }

    /// Resolves the [`SessionPlan`] for a launch from the profile's session
    /// strategy and the cell's current `conversation_id` (T4).
    ///
    /// Returns the plan *and* — when this launch mints a fresh id — that id, so the
    /// caller can persist it on the hosting leaf. The id is only generated for an
    /// `Assign` (profile has a `session` block with an `assign_flag`, and the cell
    /// had no id yet); every other branch returns `None` (nothing to persist).
    ///
    /// Branches:
    /// - cell already has an id ⇒ [`SessionPlan::Resume`] (reopen) — no new id;
    /// - no id, profile has `session.assign_flag` ⇒ mint a UUID, [`SessionPlan::Assign`];
    /// - no id, profile has `session` but no `assign_flag` (degraded) ⇒
    ///   [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
    ///   uses the bare resume flag only on later reopens);
    /// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
    async fn resolve_session_plan(
        &self,
        profile: &AgentProfile,
        cell_conversation_id: Option<String>,
        root: &ProjectPath,
    ) -> (SessionPlan, Option<String>) {
        // Profil **structuré** (§17, lot P8c) : le `conversation_id` de la cellule est
        // un **id de paire IdeA**, jamais le resumable du moteur. Le passer en `--resume`
        // serait invalide (Claude/Codex attendent *leur* id). On route donc le resume
        // moteur via `providers.json`, keyé par `(id de paire, provider_key)` (écrit en
        // P8b). Rien à pré-attribuer ici : l'id de paire est géré en aval (P8a), le
        // resumable moteur est capté/rapporté par P8b au fil de l'exécution.
        if let Some(adapter) = profile.structured_adapter {
            // Store câblé : on lit le resumable moteur rangé pour cette paire+provider.
            if let Some(provider) = self.provider_sessions.as_ref() {
                let engine = match &cell_conversation_id {
                    // L'id de paire (UUID) → resumable moteur via providers.json.
                    Some(raw) => match uuid::Uuid::parse_str(raw) {
                        Ok(uuid) => match provider.provider_session_store_for(root) {
                            Some(store) => match store
                                .get(ConversationId::from_uuid(uuid), adapter.provider_key())
                                .await
                            {
                                Ok(Some(engine_id)) => {
                                    let engine_id = engine_id.trim().to_owned();
                                    if engine_id.is_empty() {
                                        None
                                    } else {
                                        Some(engine_id)
                                    }
                                }
                                Ok(None) | Err(_) => None,
                            },
                            None => None,
                        },
                        Err(_) => None,
                    },
                    None => None,
                };
                return match engine {
                    // Vrai resumable moteur connu ⇒ resume propre du moteur.
                    Some(engine_id) => (
                        SessionPlan::Resume {
                            conversation_id: engine_id,
                        },
                        None,
                    ),
                    // Aucun resumable (None / parse KO / store absent / pas d'id de
                    // cellule) ⇒ premier lancement propre. Le moteur attribuera/rapportera
                    // son id (capté par P8b) ; la continuité du travail est assurée par le
                    // handoff (P7). On ne passe **jamais** l'id de paire en `--resume`.
                    None => (SessionPlan::None, None),
                };
            }
            // Store **non câblé** (tests/legacy) : repli gracieux sur l'ancien
            // comportement — une conversation sur la cellule ⇒ `Resume`, sinon `None`.
            // Garantit zéro régression des tests qui ne câblent pas le store.
            return match cell_conversation_id {
                Some(conversation_id) => (SessionPlan::Resume { conversation_id }, None),
                None => (SessionPlan::None, None),
            };
        }

        // Profils **non structurés** (PTY/TUI) : la cellule porte toujours l'id moteur.
        // Comportement **inchangé** depuis T4.
        let Some(session) = &profile.session else {
            return (SessionPlan::None, None);
        };

        // The cell already carries a conversation: resume it (no new id minted).
        if let Some(conversation_id) = cell_conversation_id {
            return (SessionPlan::Resume { conversation_id }, None);
        }

        // Fresh cell. Only mint+assign an id when the profile can assign one;
        // otherwise (degraded mode) the first launch has nothing to resume.
        if session.assign_flag.is_some() {
            let conversation_id = self.ids.new_uuid().to_string();
            (
                SessionPlan::Assign {
                    conversation_id: conversation_id.clone(),
                },
                Some(conversation_id),
            )
        } else {
            (SessionPlan::None, None)
        }
    }

    /// Projects the resolved [`EffectivePermissions`] into the launched CLI's
    /// concrete permission config (lot LP3-3), replacing the historical per-CLI
    /// seeds. Selects the profile's [`PermissionProjector`] (cf.
    /// [`select_projector_key`]), asks it for a pure [`domain::permission::PermissionProjection`]
    /// (a plan), then **applies** that plan: materialises its files in the run dir
    /// and folds its `args`/`env` into `spec`.
    ///
    /// File ownership drives the write regime:
    /// - [`ProjectedFile::Replace`] ⇒ **clobber** (always overwrite). Unlike the old
    ///   non-clobbering seed, this lets a re-projection (e.g. after a profile swap)
    ///   refresh an IdeA-owned file.
    /// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into
    ///   any existing file (via the shared TOML helpers), preserving everything else.
    ///
    /// No-op when: no registry is wired, or the profile has no matching projector.
    /// `permissions == None` only means no filesystem/bash policy is projected; the
    /// projector may still apply orthogonal system policy such as network env.
    ///
    /// # Errors
    /// [`AppError::FileSystem`] if a plan file cannot be written.
    async fn apply_permission_projection(
        &self,
        profile: &AgentProfile,
        run_dir: &ProjectPath,
        project_root: &ProjectPath,
        permissions: Option<&EffectivePermissions>,
        network: Option<NetworkPolicy>,
        spec: &mut SpawnSpec,
    ) -> Result<(), AppError> {
        let Some(registry) = &self.projectors else {
            return Ok(());
        };
        let Some(key) = select_projector_key(profile) else {
            return Ok(());
        };
        let Some(projector) = registry.get(key) else {
            return Ok(());
        };

        let ctx = ProjectionContext {
            project_root: project_root.as_str(),
            run_dir: run_dir.as_str(),
            model: projection_model(profile),
        };
        let projection = projector.project(permissions, network, &ctx);

        for file in &projection.files {
            match file {
                ProjectedFile::Replace { rel_path, contents } => {
                    self.ensure_run_dir_parent(run_dir, rel_path).await?;
                    let path = RemotePath::new(join(run_dir, rel_path));
                    // Clobber: IdeA owns this file; overwriting refreshes it on re-projection.
                    self.fs.write(&path, contents.as_bytes()).await?;
                }
                ProjectedFile::MergeToml {
                    rel_path,
                    managed_tables,
                    managed_keys,
                    contents,
                } => {
                    self.ensure_run_dir_parent(run_dir, rel_path).await?;
                    let path = RemotePath::new(join(run_dir, rel_path));
                    let existing = match self.fs.read(&path).await {
                        Ok(bytes) => String::from_utf8(bytes).unwrap_or_default(),
                        Err(_) => String::new(),
                    };
                    let merged =
                        merge_managed_toml(&existing, managed_tables, managed_keys, contents);
                    self.fs.write(&path, merged.as_bytes()).await?;
                }
            }
        }

        // Fold the plan's launch args/env into the spec, before the structured/PTY
        // split so both inherit them. Env is upserted so stale runtime/parent values
        // cannot shadow the project policy for keys such as Codex network.
        spec.args.extend(projection.args.iter().cloned());
        for (key, value) in projection.env.iter() {
            upsert_env(&mut spec.env, key, value);
        }
        Ok(())
    }

    /// Ensures the parent directory of `<run_dir>/<rel_path>` exists (e.g. the
    /// `.claude/` or `.codex/` subdir), so a projected file write never fails on a
    /// missing directory. A `rel_path` with no separator needs no extra dir.
    async fn ensure_run_dir_parent(
        &self,
        run_dir: &ProjectPath,
        rel_path: &str,
    ) -> Result<(), AppError> {
        if let Some((parent, _)) = rel_path.rsplit_once(['/', '\\']) {
            if !parent.is_empty() {
                self.fs
                    .create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
                    .await?;
            }
        }
        Ok(())
    }

    async fn resolve_effective_permissions(
        &self,
        project: &Project,
        agent_id: AgentId,
    ) -> Result<Option<EffectivePermissions>, AppError> {
        let Some(store) = &self.permissions else {
            return Ok(None);
        };
        let doc = store.load_permissions(project).await?;
        Ok(doc.resolve_for(agent_id))
    }

    async fn resolve_launch_network_permission(
        &self,
        project: &Project,
        agent_id: AgentId,
    ) -> Result<Option<NetworkPolicy>, AppError> {
        let Some(store) = &self.system_permissions else {
            return Ok(None);
        };
        let doc = store.load_system_permissions(project).await?;
        Ok(doc.wanted_network_for(agent_id))
    }

    /// Applies the context-injection plan that must happen *before* spawn:
    /// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
    /// or attaching the on-disk context path to an environment variable. `Args` is
    /// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
    #[allow(clippy::too_many_arguments)]
    async fn apply_injection(
        &self,
        project: &Project,
        context_rel_path: &str,
        effective: &EffectiveAgentContext,
        mcp_enabled: bool,
        spec: &mut SpawnSpec,
    ) -> Result<(), AppError> {
        match spec.context_plan.clone() {
            Some(ContextInjectionPlan::File { target }) => {
                // conventionFile (ARCHITECTURE §14.1): IdeA *generates* the
                // conventional file (e.g. CLAUDE.md) inside the agent's isolated
                // run directory — `spec.cwd` is that run dir, never the project
                // root, so there is zero collision between agents. The document is
                // composed: an absolute project-root header (so the agent knows
                // where to operate, since its cwd is *not* the root), the agent's
                // persona `.md`, then the bodies of its assigned skills (§14.2).
                let document =
                    compose_convention_file(project.root.as_str(), effective, mcp_enabled);
                let path = RemotePath::new(join(&spec.cwd, &target));
                self.fs.write(&path, document.as_bytes()).await?;
            }
            Some(ContextInjectionPlan::Env { var }) => {
                // Hand the CLI the absolute path of the agent's `.md` (which lives at
                // `<root>/.ideai/<context_rel_path>`) via the environment variable.
                let abspath = join(&project.root, &format!(".ideai/{context_rel_path}"));
                spec.env.push((var, abspath));
            }
            // Args were folded into spec.args by prepare_invocation; Stdin is
            // applied after the PTY is live.
            Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {}
        }
        Ok(())
    }

    /// Materialises the IdeA MCP server config for **this** CLI when the profile
    /// carries an [`McpCapability`] (cadrage v3, Décision 3). Runs **after** the
    /// convention file (`apply_injection`) and **before** the spawn / `factory.start`,
    /// in the **same** isolated run dir as the convention file and the permission seed.
    ///
    /// Dispatch by [`McpConfigStrategy`]:
    /// - `ConfigFile { target }` → write `<run_dir>/<target>` (e.g. `.mcp.json`) with
    ///   the IdeA MCP server declaration. **Best-effort** (any write/exists failure is
    ///   swallowed so it **never** fails the launch), with two clobber regimes:
    ///   * with an injected [`McpRuntime`] (real app-tauri launch) the file is
    ///     **regenerated and clobbered on every (re)launch**, exactly like the
    ///     convention file. The declaration's `command` carries the **stable** IdeA
    ///     exe path (`$APPIMAGE`) and the project's live endpoint — both drift between
    ///     runs (the AppImage mount path changes at each remount/reboot), so a stale
    ///     `.mcp.json` would point the bridge at a dead binary/socket. `.mcp.json` is
    ///     IdeA-managed (not user-edited), so clobbering is safe;
    ///   * without a runtime (orchestrator / hot-swap / tests) only a degraded minimal
    ///     declaration is available, so the write stays **non-clobbering** (mirrors
    ///     [`Self::seed_cli_permissions`]) — never overwriting a real declaration.
    /// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`].
    /// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`].
    ///
    /// `profile.mcp == None` ⇒ no-op: no write, no flag, no env (current path, zero
    /// regression).
    ///
    /// MCP runtime (M5d): when the composition root injects a [`McpRuntime`], the
    /// declaration written for a `ConfigFile` strategy is the **real** one — it points
    /// the spawned `idea mcp-server` bridge at the project's exact loopback endpoint
    /// (same source of truth as `ensure_mcp_server`, cadrage v5 §2). When no runtime is
    /// injected (launches issued from inside `application`), a coherent **minimal**
    /// declaration is written instead (see [`mcp_server_declaration`]). `Flag`/`Env`
    /// are unaffected by the runtime: they keep passing the run-dir config path.
    async fn apply_mcp_config(
        &self,
        profile: &AgentProfile,
        run_dir: &ProjectPath,
        project_root: &ProjectPath,
        runtime: Option<&McpRuntime>,
        eff: Option<&EffectivePermissions>,
        spec: &mut SpawnSpec,
    ) -> Result<(), AppError> {
        let Some(mcp) = &profile.mcp else {
            return Ok(());
        };
        match &mcp.config {
            domain::profile::McpConfigStrategy::ConfigFile { target } => {
                let path = RemotePath::new(join(run_dir, target));
                let declaration = mcp_server_declaration(mcp.transport, runtime);
                match runtime {
                    // Real launch (app-tauri injected the runtime): the declaration
                    // carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
                    // by `idea_exe_path`) and the project's live loopback endpoint. Both
                    // can drift between runs — the AppImage internal mount path changes
                    // at every remount/reboot — so the file MUST be regenerated and
                    // **clobbered** on every (re)launch, exactly like the convention file
                    // (`apply_injection`). `.mcp.json` is IdeA-managed, not user-edited,
                    // so there is nothing to preserve. Best-effort: a write failure must
                    // never fail the launch.
                    Some(_) => {
                        let _ = self.fs.write(&path, declaration.as_bytes()).await;
                    }
                    // Internal launch (orchestrator / hot-swap / tests): we only have a
                    // degraded **minimal** declaration (no endpoint/project/requester).
                    // Stay non-clobbering — mirrors `seed_cli_permissions` — so we never
                    // overwrite a real declaration previously written by an app-tauri
                    // launch with the minimal one.
                    None => match self.fs.exists(&path).await {
                        Ok(true) => {}
                        Ok(false) => {
                            let _ = self.fs.write(&path, declaration.as_bytes()).await;
                        }
                        Err(_) => {}
                    },
                }
            }
            domain::profile::McpConfigStrategy::TomlConfigHome { target, home_env } => {
                // Pendant Codex de `ConfigFile` : on écrit un `config.toml` (table
                // `[mcp_servers.idea]`, via l'encodeur TOML partagé D2) au chemin
                // `<run_dir>/<target>`, et on pousse `home_env` (ex. `CODEX_HOME`) vers
                // le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
                // dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
                let path = RemotePath::new(join(run_dir, target));
                let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml();
                // Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel
                // (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
                // `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
                // (orchestrateur / hot-swap / tests) on reste non-clobbering pour ne pas
                // écraser une déclaration réelle par la minimale.
                match runtime {
                    Some(_) => {
                        let existing = match self.fs.read(&path).await {
                            Ok(bytes) => String::from_utf8(bytes).ok(),
                            Err(_) => None,
                        };
                        let rendered = codex_config_toml(
                            existing.as_deref(),
                            &declaration,
                            run_dir.as_str(),
                            project_root.as_str(),
                        );
                        let _ = self.fs.write(&path, rendered.as_bytes()).await;
                    }
                    None => match self.fs.exists(&path).await {
                        Ok(true) => {}
                        Ok(false) => {
                            let rendered = codex_config_toml(
                                None,
                                &declaration,
                                run_dir.as_str(),
                                project_root.as_str(),
                            );
                            let _ = self.fs.write(&path, rendered.as_bytes()).await;
                        }
                        Err(_) => {}
                    },
                }
                // Permission projection (sandbox_mode/approval_policy + --sandbox/
                // --ask-for-approval) is NO LONGER done here — it is decoupled into
                // `apply_permission_projection` (lot LP3-3), so a Codex profile gets its
                // sandbox even without an MCP capability. `apply_mcp_config` is now
                // MCP-only (mcp_servers.idea table + projects trust).
                // `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
                // `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
                let home_dir = parent_dir(run_dir, target);
                upsert_env(&mut spec.env, home_env, &home_dir);
            }
            domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
                if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
                    return Ok(());
                }
                let body = if let Some(opencode) = profile.opencode.as_ref() {
                    opencode_config_json(opencode, project_root.as_str(), runtime, eff).to_string()
                } else if let Some(provider) = profile.opencode_provider.as_ref() {
                    let api_key = self.resolve_opencode_provider_api_key(provider).await?;
                    opencode_provider_config_json(
                        provider,
                        &api_key,
                        project_root.as_str(),
                        runtime,
                        eff,
                    )
                    .to_string()
                } else {
                    return Ok(());
                };
                let config_path = join(run_dir, target);
                let opencode_home = join(run_dir, ".opencode");
                let xdg_config = format!("{opencode_home}/config");
                let xdg_data = format!("{opencode_home}/data");
                let xdg_cache = format!("{opencode_home}/cache");
                for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
                    let _ = self.fs.create_dir_all(&RemotePath::new(dir.clone())).await;
                }
                if let Some(parent) = parent_rel(target) {
                    let _ = self
                        .fs
                        .create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
                        .await;
                }
                let _ = self
                    .fs
                    .write(&RemotePath::new(config_path.clone()), body.as_bytes())
                    .await;
                spec.env.extend([
                    ("OPENCODE_CONFIG".to_owned(), config_path),
                    ("HOME".to_owned(), opencode_home),
                    ("XDG_CONFIG_HOME".to_owned(), xdg_config),
                    ("XDG_DATA_HOME".to_owned(), xdg_data),
                    ("XDG_CACHE_HOME".to_owned(), xdg_cache),
                    ("OPENCODE_DISABLE_AUTOUPDATE".to_owned(), "1".to_owned()),
                ]);
            }
            domain::profile::McpConfigStrategy::Flag { flag } => {
                // Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
                // config path is the run dir itself (the CLI's cwd), where the server
                // declaration / connection is anchored.
                spec.args.push(flag.clone());
                spec.args.push(run_dir.as_str().to_owned());
            }
            domain::profile::McpConfigStrategy::Env { var } => {
                spec.env.push((var.clone(), run_dir.as_str().to_owned()));
            }
        }
        Ok(())
    }

    /// Resolves the literal API key of an [`OpenCodeProviderConfig`] through the
    /// injected [`SecretStore`] (ticket #92, lot B3). Unlike the rest of
    /// [`Self::apply_mcp_config`] (best-effort, never fails a launch), this fails
    /// **hard**: an OpenCode cloud profile with no resolvable key must never spawn
    /// with a dead/absent key, silently falling back to the CLI's native provider
    /// prompting.
    ///
    /// # Errors
    /// [`AppError::Invalid`] if no [`SecretStore`] was injected
    /// ([`Self::with_secret_store`]) or the secret is absent (corrupted/deleted
    /// out from under the profile); [`AppError::Store`] on store failure.
    async fn resolve_opencode_provider_api_key(
        &self,
        provider: &OpenCodeProviderConfig,
    ) -> Result<String, AppError> {
        let secret_store = self.secret_store.as_ref().ok_or_else(|| {
            AppError::Invalid(
                "OpenCode cloud provider profile requires a SecretStore, none injected".into(),
            )
        })?;
        secret_store
            .get(&provider.api_key_ref)
            .await?
            .ok_or_else(|| {
                AppError::Invalid(format!(
                    "no secret found for OpenCode provider `{}` (secret ref `{}`)",
                    provider.provider_id,
                    provider.api_key_ref.as_str()
                ))
            })
    }

    async fn ensure_local_model_server_for_opencode(
        &self,
        _project: &Project,
        agent: &Agent,
        profile: &mut AgentProfile,
    ) -> Result<Option<ModelServerUseGuard>, AppError> {
        if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
            return Ok(None);
        }
        let Some(opencode) = profile.opencode.as_mut() else {
            return Ok(None);
        };
        let Some(server_id) = opencode.local_model_server_id else {
            return Ok(None);
        };
        let Some(ensure) = self.local_model_server.as_ref() else {
            let err = AppError::ModelServer {
                code: "not_wired".to_owned(),
                message:
                    "OpenCode profile references localModelServerId but no model server service is wired"
                        .to_owned(),
            };
            self.publish_agent_launch_failed(agent.id, &err);
            return Err(err);
        };
        let guard = match ensure.acquire_use(server_id) {
            Ok(guard) => guard,
            Err(err) => {
                self.publish_agent_launch_failed(agent.id, &err);
                return Err(err);
            }
        };

        match ensure
            .execute(EnsureLocalModelServerInput { server_id })
            .await
        {
            Ok(output) => {
                opencode.base_url = output.ready.base_url;
                opencode.model = output.ready.model;
                Ok(Some(guard))
            }
            Err(err) => {
                self.publish_agent_launch_failed(agent.id, &err);
                Err(err)
            }
        }
    }

    fn publish_agent_launch_failed(&self, agent_id: AgentId, err: &AppError) {
        let (cause, code) = match err {
            AppError::ModelServer { code, .. } => ("model_server", code.as_str()),
            _ => ("launch", err.code()),
        };
        self.events.publish(DomainEvent::AgentLaunchFailed {
            agent_id,
            cause: cause.to_owned(),
            code: code.to_owned(),
            message: err.to_string(),
        });
    }
}

/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
/// **fresh second launch** of an already-live agent (cadrage v5 §3.2, Trou A).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReattachDecision {
    /// Rebind the live session's view to `node_id` without respawning (the request
    /// targets the live host node, or carries an explicit reattach signal).
    Rebind { node_id: NodeId },
    /// Background / idempotent no-op: hand back the existing session unchanged
    /// (neither a node nor a conversation id was supplied).
    Idempotent,
    /// Refuse the launch — a genuine second launch of a singleton agent already
    /// live on `node_id` (the host node, reported in `AgentAlreadyRunning`).
    Refuse { node_id: NodeId },
}

impl ReattachDecision {
    /// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`].
    #[must_use]
    pub(crate) fn resolve(
        requested_node: Option<NodeId>,
        host_node: Option<NodeId>,
        conversation_id: Option<&str>,
    ) -> Self {
        reattach_decision(requested_node, host_node, conversation_id)
    }
}

/// Decides whether a launch hitting an already-live agent is a legitimate view
/// **reattach** (rebind), a **background/idempotent** no-op, or a **refused** second
/// launch (cadrage v5 §3.2, Trou A). Pure (no I/O), unit-testable.
///
/// Signals (both already present in the launch flow):
/// - `requested_node` — the hosting leaf the caller targets (`None` ⇒ no cell, e.g.
///   a background launch);
/// - `host_node` — the node currently hosting the agent's live session, if known;
/// - `conversation_id` — the conversation id recorded on the hosting cell. Its
///   presence means the **cell knows the agent was running** ⇒ it is a reattach of a
///   view onto an in-progress session, not a brand-new launch.
///
/// Rules:
/// - requested node **is** the host node ⇒ `Rebind` (re-open of the same cell);
/// - a `conversation_id` is present ⇒ `Rebind` (explicit reattach, even on another
///   node — the cell is a rebindable view, §17.6);
/// - no node **and** no conversation ⇒ `Idempotent` (background/no-op relaunch);
/// - a different node, no reattach signal ⇒ `Refuse` (genuine second launch).
fn reattach_decision(
    requested_node: Option<NodeId>,
    host_node: Option<NodeId>,
    conversation_id: Option<&str>,
) -> ReattachDecision {
    // Explicit reattach: the cell carries a conversation id, so it is re-binding a
    // view onto an already-running session. Rebind to the requested node, or keep the
    // current host node when none was supplied.
    if conversation_id.is_some() {
        if let Some(node_id) = requested_node.or(host_node) {
            return ReattachDecision::Rebind { node_id };
        }
        return ReattachDecision::Idempotent;
    }

    match requested_node {
        // Same cell re-opened ⇒ idempotent rebind (no respawn).
        Some(node) if host_node == Some(node) => ReattachDecision::Rebind { node_id: node },
        // A different cell with no reattach signal ⇒ genuine second launch: refuse,
        // reporting the live host node (falling back to the requested node only if the
        // host is somehow unknown, so the error always carries a meaningful cell).
        Some(node) => ReattachDecision::Refuse {
            node_id: host_node.unwrap_or(node),
        },
        // No node and no reattach signal ⇒ background/idempotent no-op.
        None => ReattachDecision::Idempotent,
    }
}

/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP
/// config (cadrage v3 D3 ; cadrage v5 §2). Kept pure (no I/O) so it is unit-testable.
///
/// Two shapes, by whether the composition root injected a [`McpRuntime`]:
///
/// - **`Some(runtime)` — the real declaration (M5d, end of the placeholder)**:
///   `command = runtime.exe` (the IdeA executable), and `args` carry the
///   `mcp-server` subcommand plus the project's exact loopback `--endpoint`, its
///   `--project` (hyphen-free 32-hex form, consumed by the M5c guard) and the
///   launching agent as `--requester`. The endpoint string comes verbatim from the
///   **single source of truth** (`app-tauri::mcp_endpoint`), so the bridge connects
///   to exactly what `ensure_mcp_server` listens on.
///
/// - **`None` — coherent minimal declaration**: launches issued from inside
///   `application` (orchestrator/hot-swap/tests) have no OS/runtime facts to inject;
///   rather than fabricate a project-bound endpoint, we write the `idea mcp-server`
///   command without the endpoint/project/requester args. It stays a valid,
///   self-consistent `mcpServers/idea` entry (the bridge would resolve the endpoint
///   from its own project context), surfacing `transport` for the future socket path.
#[must_use]
fn mcp_server_declaration(
    transport: domain::profile::McpTransport,
    runtime: Option<&McpRuntime>,
) -> String {
    mcp_server_wiring(transport, runtime).to_mcp_json()
}

/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
/// every materialisation format (cadrage Codex D2). It is the **single source of
/// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json`
/// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain
/// encoders [`domain::McpServerWiring::to_mcp_json`] /
/// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift.
///
/// `Some(runtime)` ⇒ the **real** wiring (this IdeA exe + the project's loopback
/// endpoint/project/requester); `None` ⇒ the coherent **minimal** `idea mcp-server`
/// fallback (orchestrator / hot-swap / tests).
#[must_use]
fn mcp_server_wiring(
    transport: domain::profile::McpTransport,
    runtime: Option<&McpRuntime>,
) -> domain::McpServerWiring {
    let (command, args) = match runtime {
        Some(rt) => (
            rt.exe.clone(),
            vec![
                "mcp-server".to_owned(),
                "--endpoint".to_owned(),
                rt.endpoint.clone(),
                "--project".to_owned(),
                rt.project_id.clone(),
                "--requester".to_owned(),
                rt.requester.clone(),
            ],
        ),
        None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
    };
    domain::McpServerWiring::new(command, args, transport)
}

/// Renders the OpenCode config managed by IdeA in the agent run dir.
///
/// The MCP server is named `idea`; OpenCode therefore exposes IdeA tools as
/// `idea_idea_*` (`<serverName>_<toolName>`), matching the observed contract.
/// Default MCP request timeout (milliseconds) injected into the OpenCode agent
/// config (`opencode.json` → `mcp.idea.timeout`). OpenCode applies this **per
/// `tools/call`** to its MCP servers; the original hardcoded `15_000` (15 s) was
/// far too short for `idea_ask_agent`, which blocks while the target agent works
/// (potentially minutes). Aligning on the rendezvous ceiling (4 h) ensures
/// OpenCode's client never kills an in-flight delegation before the server-side
/// watchdog resolves.
///
/// **Must stay ≥ `DEFAULT_RENDEZVOUS_CEILING`** so the MCP client timeout and the
/// server-side absolute ceiling expire together — never the client first.
/// Overridable via `IDEA_OPENCODE_MCP_TIMEOUT_MS`.
pub const DEFAULT_OPENCODE_MCP_TIMEOUT_MS: u64 = 4 * 60 * 60 * 1000;

/// Resolves the effective OpenCode MCP request timeout (ms) from the
/// `IDEA_OPENCODE_MCP_TIMEOUT_MS` env var, falling back to
/// [`DEFAULT_OPENCODE_MCP_TIMEOUT_MS`]. Parse failure or `0` ⇒ default, so a
/// misconfigured value never collapses the timeout to an instant kill.
pub fn resolve_opencode_mcp_timeout_ms() -> u64 {
    std::env::var("IDEA_OPENCODE_MCP_TIMEOUT_MS")
        .ok()
        .and_then(|v| v.trim().parse::<u64>().ok())
        .filter(|&ms| ms > 0)
        .unwrap_or(DEFAULT_OPENCODE_MCP_TIMEOUT_MS)
}

fn opencode_config_json(
    config: &domain::profile::OpenCodeConfig,
    project_root: &str,
    runtime: Option<&McpRuntime>,
    eff: Option<&EffectivePermissions>,
) -> serde_json::Value {
    let model = config.model.as_str();
    let opencode_model = format!("llamacpp/{model}");
    let mut root = serde_json::Map::new();
    root.insert(
        "$schema".to_owned(),
        serde_json::Value::String("https://opencode.ai/config.json".to_owned()),
    );
    root.insert(
        "model".to_owned(),
        serde_json::Value::String(opencode_model.clone()),
    );

    let mut options = serde_json::Map::new();
    options.insert(
        "baseURL".to_owned(),
        serde_json::Value::String(config.base_url.clone()),
    );
    if let Some(api_key) = config.api_key.as_ref() {
        options.insert(
            "apiKey".to_owned(),
            serde_json::Value::String(api_key.clone()),
        );
    }

    let mut models = serde_json::Map::new();
    models.insert(
        model.to_owned(),
        serde_json::json!({
            "name": opencode_model,
            "tool_call": true,
            "reasoning": config.reasoning_enabled(),
            "attachment": config.attachment_enabled()
        }),
    );

    root.insert(
        "provider".to_owned(),
        serde_json::json!({
            "llamacpp": {
                "npm": "@ai-sdk/openai-compatible",
                "name": "llama.cpp",
                "options": options,
                "models": models
            }
        }),
    );

    let (command, args) = match runtime {
        Some(rt) => (
            rt.exe.clone(),
            vec![
                "mcp-server".to_owned(),
                "--endpoint".to_owned(),
                rt.endpoint.clone(),
                "--project".to_owned(),
                rt.project_id.clone(),
                "--requester".to_owned(),
                rt.requester.clone(),
            ],
        ),
        None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
    };
    let command_array = std::iter::once(command)
        .chain(args)
        .map(serde_json::Value::String)
        .collect::<Vec<_>>();
    root.insert(
        "mcp".to_owned(),
        serde_json::json!({
            "idea": {
                "type": "local",
                "command": command_array,
                "cwd": project_root,
                "enabled": true,
                "timeout": resolve_opencode_mcp_timeout_ms()
            }
        }),
    );
    if let Some(permission) = domain::opencode_permission_block(eff) {
        root.insert("permission".to_owned(), permission);
    }
    root.insert(
        "disabled_providers".to_owned(),
        serde_json::json!(["anthropic", "openai", "gemini", "ollama"]),
    );
    serde_json::Value::Object(root)
}

/// Renders the OpenCode config for a **cloud** provider profile (ticket #92, lot
/// B3), mirroring [`opencode_config_json`]. Unlike the `llamacpp` custom-provider
/// shape, `provider.provider_id` is a BUILT-IN OpenCode provider (`anthropic`,
/// `openrouter`, …): it needs only the resolved `apiKey` override, no
/// `npm`/`name`/`models` block, and it must NOT appear in `disabled_providers`
/// (that list exists to keep the local llamacpp profile from picking up a
/// built-in provider by accident — the opposite of what a cloud profile wants).
fn opencode_provider_config_json(
    config: &OpenCodeProviderConfig,
    api_key: &str,
    project_root: &str,
    runtime: Option<&McpRuntime>,
    eff: Option<&EffectivePermissions>,
) -> serde_json::Value {
    let mut root = serde_json::Map::new();
    root.insert(
        "$schema".to_owned(),
        serde_json::Value::String("https://opencode.ai/config.json".to_owned()),
    );
    root.insert(
        "model".to_owned(),
        serde_json::Value::String(format!("{}/{}", config.provider_id, config.model)),
    );
    let mut options = serde_json::Map::new();
    options.insert(
        "apiKey".to_owned(),
        serde_json::Value::String(api_key.to_owned()),
    );
    let mut provider_entry = serde_json::Map::new();
    if let Some(custom) = config.custom.as_ref() {
        // Provider outside the OpenCode registry: it needs the AI SDK package
        // (`npm`), the endpoint (`options.baseURL`), and a `models` block —
        // OpenCode has no built-in knowledge of this provider otherwise.
        options.insert(
            "baseURL".to_owned(),
            serde_json::Value::String(custom.base_url.clone()),
        );
        provider_entry.insert(
            "npm".to_owned(),
            serde_json::Value::String(custom.npm.clone()),
        );
        let model_label = custom
            .display_name
            .clone()
            .unwrap_or_else(|| config.model.clone());
        provider_entry.insert(
            "models".to_owned(),
            serde_json::json!({ config.model.as_str(): { "name": model_label } }),
        );
    }
    provider_entry.insert("options".to_owned(), serde_json::Value::Object(options));
    root.insert(
        "provider".to_owned(),
        serde_json::json!({ config.provider_id.as_str(): provider_entry }),
    );

    let wiring = mcp_server_wiring(domain::profile::McpTransport::Stdio, runtime);
    let command_array = std::iter::once(wiring.command)
        .chain(wiring.args)
        .map(serde_json::Value::String)
        .collect::<Vec<_>>();
    root.insert(
        "mcp".to_owned(),
        serde_json::json!({
            "idea": {
                "type": "local",
                "command": command_array,
                "cwd": project_root,
                "enabled": true,
                "timeout": resolve_opencode_mcp_timeout_ms()
            }
        }),
    );
    if let Some(permission) = domain::opencode_permission_block(eff) {
        root.insert("permission".to_owned(), permission);
    }
    serde_json::Value::Object(root)
}

/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String {
    let b = base.as_str().trim_end_matches(['/', '\\']);
    format!("{b}/{rel}")
}

fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
    if let Some((_, existing)) = env.iter_mut().find(|(candidate, _)| candidate == key) {
        *existing = value.to_owned();
    } else {
        env.push((key.to_owned(), value.to_owned()));
    }
}

fn parent_rel(rel: &str) -> Option<&str> {
    rel.rsplit_once(['/', '\\']).map(|(parent, _)| parent)
}

/// Resolves the **parent directory** (absolute) of `<base>/<rel>` — used to point a
/// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised
/// config file (`config.toml`), not the file itself. When `rel` has no separator
/// (file directly in the run dir), the parent is the run dir.
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
    let full = join(base, rel);
    match full.rsplit_once(['/', '\\']) {
        Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
        _ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
    }
}

/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
/// root — guaranteeing that two distinct agents on the same project root get two
/// distinct cwd (the anti-collision contract).
///
/// # Errors
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
pub(crate) fn agent_run_dir(
    root: &ProjectPath,
    agent_id: &AgentId,
) -> Result<ProjectPath, domain::error::DomainError> {
    ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
}

/// Construit le snapshot [`TerminalSession`] qui représente une session
/// **structurée** (cellule chat) dans la sortie de [`LaunchAgent`] (§17.4).
///
/// Le modèle de sortie reste le même que pour le PTY (non cassant pour A/B) : un
/// snapshot `kind = Agent` portant l'id de la session structurée. Le `cwd` n'a pas
/// de sens process ici (la cellule chat n'a pas de PTY) ; on réutilise le run dir
/// au câblage si besoin, mais le snapshot ne porte qu'un placeholder cohérent.
fn structured_snapshot(
    session: &Arc<dyn domain::ports::AgentSession>,
    agent_id: AgentId,
    node_id: NodeId,
    size: PtySize,
) -> TerminalSession {
    // La cellule chat n'a pas de cwd PTY ; un chemin racine neutre suffit au snapshot
    // (le run dir réel reste connu de l'adapter). `ProjectPath::new("/")` ne peut pas
    // échouer (chemin absolu trivial).
    let cwd = ProjectPath::new("/").expect("/ est un ProjectPath absolu valide");
    let mut snapshot = TerminalSession::starting(
        session.id(),
        node_id,
        cwd,
        SessionKind::Agent { agent_id },
        size,
    );
    snapshot.status = SessionStatus::Running;
    snapshot
}

/// Minimal JSON string escaper used by [`toml_string`] (and historically by the
/// Claude seed, now extracted to the infra projector). Handles the characters that
/// actually occur in paths: backslash, quote, control chars.
fn json_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out
}

fn toml_string(s: &str) -> String {
    format!("\"{}\"", json_escape(s))
}

/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges
/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust
/// entries. The permission part (`sandbox_mode` / `approval_policy` + the
/// `--sandbox` / `--ask-for-approval` args) now lives in the Codex permission
/// projector (`apply_permission_projection`), so this function is MCP-only and a
/// Codex profile receives its sandbox even without an MCP capability.
fn codex_config_toml(
    existing: Option<&str>,
    mcp_declaration: &str,
    run_dir: &str,
    project_root: &str,
) -> String {
    let mut text = existing.unwrap_or_default().to_owned();
    text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
    text = replace_toml_table(
        &text,
        CODEX_CODE_MODE_FEATURES_TABLE,
        CODEX_CODE_MODE_FEATURES_TOML.trim_end(),
    );
    text = ensure_codex_trust(&text, run_dir);
    text = ensure_codex_trust(&text, project_root);
    if !text.ends_with('\n') {
        text.push('\n');
    }
    text
}

/// Merges a projector's **managed** TOML fragment into an existing `config.toml`
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
/// `managed_keys` are touched; every other line of `existing` is preserved.
///
/// - each managed table is replaced wholesale by its block from `fragment`
///   ([`replace_toml_table`]);
/// - each managed top-level key takes the value found for it in `fragment`
///   ([`set_top_level_toml_line`]).
///
/// A managed table/key absent from `fragment` leaves `existing` untouched for it.
fn merge_managed_toml(
    existing: &str,
    managed_tables: &[String],
    managed_keys: &[String],
    fragment: &str,
) -> String {
    let mut text = existing.to_owned();
    for table in managed_tables {
        if let Some(block) = extract_toml_table(fragment, table) {
            text = replace_toml_table(&text, table, block.trim_end());
        }
    }
    for key in managed_keys {
        if let Some(line) = extract_top_level_toml_line(fragment, key) {
            text = set_top_level_toml_line(&text, key, line);
        }
    }
    if !text.ends_with('\n') {
        text.push('\n');
    }
    text
}

/// Returns the full `key = …` line for `key` found at top level in `fragment`
/// (before any `[table]` header), or `None` if absent.
fn extract_top_level_toml_line<'a>(fragment: &'a str, key: &str) -> Option<&'a str> {
    let needle = format!("{key} =");
    for line in fragment.lines() {
        let trimmed = line.trim_start();
        if trimmed.starts_with('[') {
            break;
        }
        if trimmed.starts_with(&needle) {
            return Some(line);
        }
    }
    None
}

/// Returns the `[table]` block (header + body up to the next header / EOF) for
/// `table` in `fragment`, or `None` if the table is absent.
fn extract_toml_table(fragment: &str, table: &str) -> Option<String> {
    let header = format!("[{table}]");
    let mut block: Vec<&str> = Vec::new();
    let mut capturing = false;
    for line in fragment.lines() {
        let trimmed = line.trim();
        if trimmed == header {
            capturing = true;
            block.push(line);
            continue;
        }
        if capturing {
            if trimmed.starts_with('[') {
                break;
            }
            block.push(line);
        }
    }
    if capturing {
        Some(block.join("\n"))
    } else {
        None
    }
}

fn ensure_codex_trust(input: &str, path: &str) -> String {
    let header = format!("[projects.{}]", toml_string(path));
    if input.lines().any(|line| line.trim() == header) {
        return input.to_owned();
    }
    append_block(input, &format!("{header}\ntrust_level = \"trusted\""))
}

fn replace_toml_table(input: &str, table: &str, block: &str) -> String {
    let header = format!("[{table}]");
    let mut out = Vec::new();
    let mut skipping = false;
    for line in input.lines() {
        let trimmed = line.trim();
        if trimmed == header {
            skipping = true;
            continue;
        }
        if skipping && trimmed.starts_with('[') {
            skipping = false;
        }
        if !skipping {
            out.push(line.to_owned());
        }
    }
    append_block(&out.join("\n"), block)
}

/// Upserts a top-level `key` with a pre-formatted `replacement` line (already
/// `key = <toml value>`): replaces the existing top-level occurrence if present,
/// otherwise prepends it. The line-level core shared by [`merge_managed_toml`].
fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String {
    let mut out = Vec::new();
    let mut replaced = false;
    let mut in_top_level = true;
    for line in input.lines() {
        let trimmed = line.trim_start();
        if trimmed.starts_with('[') {
            in_top_level = false;
        }
        if in_top_level && !replaced && trimmed.starts_with(&format!("{key} =")) {
            out.push(replacement.to_owned());
            replaced = true;
        } else {
            out.push(line.to_owned());
        }
    }
    let text = out.join("\n");
    if replaced {
        text
    } else {
        prepend_line(&text, replacement)
    }
}

fn prepend_line(input: &str, line: &str) -> String {
    if input.trim().is_empty() {
        format!("{line}\n")
    } else {
        format!("{line}\n{}", input.trim_start_matches('\n'))
    }
}

fn append_block(input: &str, block: &str) -> String {
    let trimmed = input.trim_end();
    if trimmed.is_empty() {
        format!("{}\n", block.trim_end())
    } else {
        format!("{trimmed}\n\n{}\n", block.trim_end())
    }
}

/// Composes the convention file IdeA writes into an agent's run directory: an
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
/// so it must be told where to work), the IdeA orchestration contract, the
/// agent's persona `.md` (ARCHITECTURE §14.2).
///
/// A short skill-awareness paragraph is always injected in the orchestration
/// block (followed by the auto-memory harvest directive, Lot E1): it explains that
/// assigned skills are operational workflow context, not magic commands or provider
/// subagents, and points reusable workflow creation to the active IdeA orchestration
/// surface (`idea_create_skill` for MCP profiles, `skill.create` for the file
/// protocol). This awareness deliberately does not inject unassigned skill bodies;
/// assignment remains the context boundary.
///
/// On top of that awareness, assigned skills surface as a high-altitude
/// `# Skills disponibles` section right after the orchestration block, listing
/// each as `**<name>** — <effective description> (<kind>)` (affordances only,
/// *no body*). This bounded surface is shared by MCP and non-MCP profiles; only
/// the instruction for loading details is adapted to the active runtime surface.
/// When `skills` is empty the section is omitted entirely.
///
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
/// entry, in the order given. When `memory` is empty the section is omitted
/// entirely, so an agent with no memory gets exactly the previous document.
///
/// The conversation `handoff` (lot P7, ARCHITECTURE §19) is appended **last**, as a
/// `# Reprise de la conversation` section carrying the optional objective and the
/// cumulative `summary_md`. Placed after the project memory (the most situational,
/// "where we left off" context an agent reads), it is omitted entirely when `handoff`
/// is `None`, so an agent with no handoff gets exactly the previous document.
///
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
///
/// The orchestration prose adapts to the agent's **surface** (cadrage v3, Décision
/// 3): when `mcp_enabled` is `true` (`profile.mcp.is_some()`), the agent sees the
/// native `idea_*` tools, so the prose points to those instead of the file
/// protocol — while keeping the **same** ban on the provider's native subagents.
/// When `false` (`mcp == None`) the prose is the **current `.ideai/requests`
/// wording, unchanged** (zero regression).
#[must_use]
pub(crate) fn compose_convention_file(
    project_root: &str,
    effective: &EffectiveAgentContext,
    mcp_enabled: bool,
) -> String {
    let mut out = String::new();
    out.push_str("# Project root\n\n");
    out.push_str(project_root);
    out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). ");
    out.push_str(
        "Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run/<agent>/`) ; \
         opère sur le project root, pas sur ce dossier.\n\n",
    );
    out.push_str("---\n\n");
    out.push_str("# Orchestration IdeA\n\n");
    if mcp_enabled {
        // Surface MCP : outils IdeA natifs. `idea_ask_agent` est une porte d'entrée
        // ergonomique vers l'orchestrateur headless, pas un protocole ask/reply exposé
        // aux modèles.
        out.push_str(
            "La conversation inter-agent est gérée par IdeA en headless. Pour contacter \
             un autre agent, utilise l'outil MCP IdeA `idea_ask_agent(target, task)` quand \
             il est disponible : IdeA lance ou réattache la cible via son profil \
             structured/headless, capture sa réponse finale et te la renvoie inline. \
             N'invente pas de protocole de ticket et n'appelle pas d'outil de remise de \
             résultat.\n\n",
        );
        // Conversation inter-agent : le transport est géré par IdeA en headless. Les
        // agents n'ont plus à connaître un protocole de reply ni à corréler des tickets.
        out.push_str(
            "Quand IdeA te sollicite via une conversation inter-agent headless, traite \
             simplement la demande et termine ton tour avec ta réponse normale. N'ajoute \
             pas de protocole de ticket, n'appelle pas d'outil de remise de résultat : IdeA \
             capture automatiquement la réponse finale du modèle et la transmet à l'agent \
             demandeur.\n\n",
        );
        // Capacités IdeA (feature skill-awareness) : briefing à HAUTE ALTITUDE,
        // télégraphique, injecté à CHAQUE lancement (coût token → pas d'exemples ni de
        // schémas). On expose la CAPACITÉ (jamais le contenu) : contexte projet géré par
        // IdeA, mémoire durable partagée, skills. But : un agent neuf ne réinvente pas son
        // propre contexte/mémoire/workflow alors qu'IdeA les fournit déjà.
        out.push_str(
            "**Contexte projet** : le contexte projet d'IdeA EST le contexte du projet — \
             n'improvise JAMAIS ton propre fichier de contexte/config. Lis-le avec \
             `idea_context_read` (sans `target`). Pour le faire évoluer, soumets une \
             proposition via `idea_context_propose` : le contexte projet global est \
             single-writer (réservé à l'orchestrateur), donc une proposition sans `target` \
             n'est PAS appliquée directement, elle est enregistrée pour validation. Pour \
             écrire le `.md` d'un agent précis : `idea_update_context` (ou \
             `idea_context_propose` avec `target`).\n\n",
        );
        out.push_str(
            "**Mémoire durable projet** : partagée entre TOUS les agents du projet. Lis-la \
             avec `idea_memory_read` (sans `slug` = index agrégé, ou un slug pour une note), \
             écris une note stable avec `idea_memory_write`. N'invente pas ton propre \
             stockage de mémoire.\n\n",
        );
        out.push_str(
            "**Skills** : workflows réutilisables. Ceux qui te sont assignés te sont listés \
             plus haut dans ce fichier ; charge le détail d'un skill avec \
             `idea_skill_read(name=…)` ; crée-en un avec `idea_create_skill`. Ne réinvente \
             pas un workflow déjà couvert par un skill.\n\n",
        );
    } else {
        out.push_str(
            "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
             natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
             `.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
             avec son propre AI Profile, son contexte et sa mémoire.\n\n",
        );
        // Capacités IdeA, surface SANS MCP : mêmes concepts décrits UNIQUEMENT via les
        // fichiers/dossiers `.ideai/` — aucun nom d'outil `idea_*` ici (cloisonnement
        // strict des deux surfaces). Télégraphique, capacité (jamais contenu).
        out.push_str(
            "Le contexte projet partagé vit dans `.ideai/CONTEXT.md` (géré par IdeA) — ne \
             crée pas ton propre fichier de contexte/config, c'est CELUI-LÀ le contexte du \
             projet.\n\n",
        );
        out.push_str(
            "La mémoire durable partagée entre tous les agents vit dans `.ideai/memory/` \
             (notes `.md` + index `MEMORY.md`). N'invente pas ton propre stockage de \
             mémoire.\n\n",
        );
        out.push_str(
            "Les skills sont des workflows réutilisables, stockés comme des `.md` gérés par \
             IdeA. Ne réinvente pas un workflow déjà couvert par un skill.\n\n",
        );
    }
    out.push_str(skill_awareness(mcp_enabled));
    out.push_str(memory_awareness());
    out.push_str("---\n\n");

    // Skills « capability-first » : à HAUTE ALTITUDE, juste après le bloc
    // d'orchestration. On expose les skills assignés comme des affordances
    // nommées+décrites (et NON leur corps complet), à la manière des outils MCP.
    if !effective.agent_capabilities.is_empty() {
        out.push_str("# Skills disponibles\n\n");
        out.push_str("Snapshot version: ");
        out.push_str(&effective.capabilities.version.to_string());
        out.push_str("\n\n");
        if mcp_enabled {
            out.push_str(
                "Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
                 détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
                 workflow déjà couvert par un skill, charge-le.\n\n",
            );
        } else {
            out.push_str(
                "Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
                 détail, lis le fichier `.ideai/skills/md/<skill-id>.md` correspondant au \
                 `skillId` assigné dans le manifeste — n'improvise pas un workflow déjà \
                 couvert par un skill, charge-le.\n\n",
            );
        }
        for skill in &effective.agent_capabilities {
            out.push_str("**");
            out.push_str(&skill.name);
            out.push_str("** — ");
            out.push_str(&skill.description);
            out.push_str(" (");
            out.push_str(match skill.kind {
                SkillKind::Workflow => "workflow",
                SkillKind::Reference => "reference",
            });
            out.push(')');
            out.push('\n');
        }
        out.push_str("\n---\n\n");
    }

    if !effective.project_context.trim().is_empty() {
        out.push_str("# Contexte projet\n\n");
        out.push_str(effective.project_context.trim());
        out.push_str("\n\n---\n\n");
    }

    out.push_str(effective.persona.as_str());

    if !effective.memory.is_empty() {
        out.push_str("\n\n---\n\n# Mémoire projet\n\n");
        for entry in &effective.memory {
            out.push_str("- [");
            out.push_str(&entry.title);
            out.push_str("](");
            out.push_str(".ideai/memory/");
            out.push_str(entry.slug.as_str());
            out.push_str(".md) — ");
            out.push_str(&entry.hook);
            out.push_str(" (");
            out.push_str(memory_type_label(entry.r#type));
            out.push_str(")\n");
        }
    }

    // État du projet (lot LS4) : aperçu lean de ce que font les autres agents en ce
    // moment, placé APRÈS la mémoire projet et AVANT la reprise de conversation. Une
    // ligne par agent (`- **Nom** — status · intent`), intent omis si vide. Jamais
    // d'UUID (ticket/lastDelegation), de progress ni de transcript. `live_rows` vide ⇒
    // section entièrement omise (document octet-identique à sans-section).
    if !effective.live_rows.is_empty() {
        out.push_str("\n\n---\n\n# État du projet\n\n");
        out.push_str(
            "Aperçu de ce que font les autres agents en ce moment (last-writer-wins, \
             non temps-réel). Pour le détail ou pour publier ton propre statut, utilise \
             `idea_workstate_read` / `idea_workstate_set`.\n\n",
        );
        for row in &effective.live_rows {
            out.push_str("- **");
            out.push_str(&row.name);
            out.push_str("** — ");
            out.push_str(row.status.label());
            if !row.intent.trim().is_empty() {
                out.push_str(" · ");
                out.push_str(row.intent.trim());
            }
            out.push('\n');
        }
    }

    // Reprise conversationnelle (lot P7) : section finale, la plus situationnelle
    // (« où on en était »), placée après la mémoire projet. Omise sans handoff.
    if let Some(handoff) = effective.handoff.as_ref() {
        out.push_str("\n\n---\n\n# Reprise de la conversation\n\n");
        if let Some(objective) = &handoff.objective {
            if !objective.trim().is_empty() {
                out.push_str("**Objectif :** ");
                out.push_str(objective.trim());
                out.push_str("\n\n");
            }
        }
        out.push_str(handoff.summary_md.as_str());
        out.push('\n');
    }

    out
}

fn skill_awareness(mcp_enabled: bool) -> &'static str {
    if mcp_enabled {
        "**Skills IdeA** : les workflows assignés apparaissent plus bas dans ce fichier sous la \
         section Skills quand il y en a. Un skill assigné est du contexte opérationnel utilisable \
         quand pertinent : ce n'est ni une commande magique, ni une sous-tâche fournisseur. Si tu \
         identifies un workflow réutilisable à capitaliser, passe par l'orchestration IdeA avec \
         `idea_create_skill`. Les skills non assignés ne sont pas injectés intégralement à tous les \
         agents ; l'assignation reste la frontière.\n\n"
    } else {
        "**Skills IdeA** : les workflows assignés apparaissent plus bas dans ce fichier sous la \
         section Skills quand il y en a. Un skill assigné est du contexte opérationnel utilisable \
         quand pertinent : ce n'est ni une commande magique, ni une sous-tâche fournisseur. Si tu \
         identifies un workflow réutilisable à capitaliser, passe par l'orchestration IdeA avec le \
         protocole fichier `skill.create`. Les skills non assignés ne sont pas injectés \
         intégralement à tous les agents ; l'assignation reste la frontière.\n\n"
    }
}

/// The auto-memory harvest directive (Lot E1) injected in the `# Orchestration
/// IdeA` block, right after [`skill_awareness`]. Independent of MCP: the agent emits
/// the directive in its final reply text either way (IdeA harvests it from the
/// recorded `Response`, not via a tool call), so a single wording serves both modes.
fn memory_awareness() -> &'static str {
    "**Mémoire IdeA** : si une décision, convention, préférence utilisateur ou fait projet \
     durable mérite d'être capitalisé, ajoute à ta réponse finale un bloc fenced `idea-memory` \
     avec `slug`, `title`, `type`, `description`, puis `---` et le corps Markdown. N'en écris pas \
     pour les tâches temporaires, logs, tickets, résultats transitoires ou données sensibles ; \
     réutilise le même `slug` pour remplacer une note existante.\n\n"
}

/// Renders a [`MemoryType`] as its stable lowercase label for the convention-file
/// memory section (`user`/`feedback`/`project`/`reference`).
#[must_use]
fn memory_type_label(kind: MemoryType) -> &'static str {
    match kind {
        MemoryType::User => "user",
        MemoryType::Feedback => "feedback",
        MemoryType::Project => "project",
        MemoryType::Reference => "reference",
    }
}

/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
/// agent, disambiguating against the manifest's existing paths with a numeric
/// suffix when needed. Shared with the template-driven agent creation (L7).
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
    let slug = slugify(name);
    let base = if slug.is_empty() {
        "agent".to_owned()
    } else {
        slug
    };
    let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
    let mut n = 2;
    while manifest.entries.iter().any(|e| e.md_path == candidate) {
        candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md");
        n += 1;
    }
    candidate
}

/// Lowercases and slugifies a display name into a safe file stem
/// (`[a-z0-9-]`), collapsing runs of separators.
fn slugify(name: &str) -> String {
    let mut out = String::with_capacity(name.len());
    let mut prev_dash = false;
    for ch in name.trim().chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_owned()
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::Skill;

    #[allow(clippy::too_many_arguments)]
    fn compose_convention_file(
        project_root: &str,
        project_context: &str,
        agent_md: &str,
        skills: &[domain::Skill],
        memory: &[MemoryIndexEntry],
        handoff: Option<&Handoff>,
        live_rows: &[InjectedLiveRow],
        mcp_enabled: bool,
    ) -> String {
        let assigned_skills: Vec<ResolvedAssignedSkill> = skills
            .iter()
            .enumerate()
            .map(|(index, skill)| ResolvedAssignedSkill {
                snapshot: domain::AssignedSkillSnapshot {
                    skill_id: skill.id,
                    scope: skill.scope,
                    name: skill.name.clone(),
                    description: skill.effective_description(),
                    kind: skill.kind,
                    assignment_index: index as u32,
                },
                content: skill.content_md.clone(),
            })
            .collect();
        let agent_capabilities = assigned_skills
            .iter()
            .map(|skill| AgentCapability::from(&skill.snapshot))
            .collect();
        let effective = EffectiveAgentContext {
            persona: MarkdownDoc::new(agent_md),
            project_context: project_context.to_owned(),
            capabilities: OrchestrationCapabilitySnapshot::new(
                assigned_skills.iter().map(|s| s.snapshot.clone()).collect(),
            ),
            agent_capabilities,
            assigned_skills,
            memory: memory.to_vec(),
            handoff: handoff.cloned(),
            live_rows: live_rows.to_vec(),
        };
        super::compose_convention_file(project_root, &effective, mcp_enabled)
    }

    #[test]
    fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
        let root = ProjectPath::new("/home/me/proj").unwrap();
        let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
        let b = AgentId::from_uuid(uuid::Uuid::from_u128(2));

        let dir_a = agent_run_dir(&root, &a).unwrap();
        let dir_b = agent_run_dir(&root, &b).unwrap();

        assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}"));
        assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs");
        // Never the project root.
        assert_ne!(dir_a.as_str(), "/home/me/proj");
    }

    #[test]
    fn compose_convention_file_carries_root_then_persona() {
        let doc = compose_convention_file(
            "/abs/project/root",
            "",
            "# Persona\n\nDo things.",
            &[],
            &[],
            None,
            &[],
            false,
        );

        // Absolute project root present.
        assert!(doc.contains("/abs/project/root"));
        // Persona present.
        assert!(doc.contains("# Persona"));
        assert!(doc.contains("Do things."));
        // Root header precedes the persona body (ordering of the composition).
        let root_at = doc.find("/abs/project/root").unwrap();
        let persona_at = doc.find("# Persona").unwrap();
        assert!(root_at < persona_at, "root header must precede the persona");
        // No skills ⇒ awareness is present, but no Skills section is opened.
        assert!(doc.contains("**Skills IdeA**"));
        assert!(
            !doc.contains("\n# Skills\n"),
            "no assigned skills ⇒ no Skills section: {doc}"
        );
        assert!(doc.contains("skill.create"));
    }

    #[test]
    fn compose_convention_file_includes_project_context_before_persona() {
        let doc = compose_convention_file(
            "/root",
            "# Shared project context\n\nUse pnpm.",
            "# Persona\n\nDo X.",
            &[],
            &[],
            None,
            &[],
            false,
        );

        assert!(doc.contains("# Contexte projet"));
        assert!(doc.contains("Use pnpm."));
        let project_context_at = doc.find("# Contexte projet").unwrap();
        let persona_at = doc.find("# Persona").unwrap();
        assert!(
            project_context_at < persona_at,
            "shared project context must precede agent persona"
        );
    }

    #[test]
    fn compose_convention_file_appends_assigned_skill_affordances_in_order() {
        let s = |n: u128, name: &str, body: &str| {
            Skill::new(
                domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
                name,
                MarkdownDoc::new(body),
                domain::SkillScope::Global,
            )
            .unwrap()
        };
        let doc = compose_convention_file(
            "/root",
            "",
            "# Persona",
            &[
                s(1, "refactor", "REFAC_BODY"),
                s(2, "review", "REVIEW_BODY"),
            ],
            &[],
            None,
            &[],
            false,
        );

        // Both skill affordances are present, but bodies are not dumped.
        assert!(doc.contains("**refactor** — REFAC_BODY (workflow)"));
        assert!(doc.contains("**review** — REVIEW_BODY (workflow)"));
        assert!(!doc.contains("\n\nREFAC_BODY"));
        assert!(!doc.contains("\n\nREVIEW_BODY"));
        let awareness_at = doc.find("**Skills IdeA**").unwrap();
        let skills_at = doc.find("# Skills disponibles").unwrap();
        let persona_at = doc.find("# Persona").unwrap();
        let refac_at = doc.find("**refactor**").unwrap();
        let review_at = doc.find("**review**").unwrap();
        assert!(
            awareness_at < persona_at,
            "skill awareness belongs to orchestration, before persona"
        );
        assert!(
            awareness_at < skills_at && skills_at < persona_at,
            "assigned skill affordances come before persona"
        );
        // Deterministic order: first assigned skill precedes the second.
        assert!(refac_at < review_at, "skills emitted in the given order");
        assert!(!doc.contains("## refactor"));
        assert!(!doc.contains("## review"));
    }

    #[test]
    fn compose_convention_file_exposes_assigned_skill_snapshot_version() {
        let skill = Skill::new(
            domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)),
            "review",
            MarkdownDoc::new("REVIEW_BODY"),
            domain::SkillScope::Global,
        )
        .unwrap();

        for mcp_enabled in [true, false] {
            let doc = compose_convention_file(
                "/root",
                "",
                "# Persona",
                std::slice::from_ref(&skill),
                &[],
                None,
                &[],
                mcp_enabled,
            );
            assert!(
                doc.contains(&format!(
                    "Snapshot version: {}",
                    domain::ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION
                )),
                "assigned skill snapshot version must be visible (mcp_enabled={mcp_enabled})"
            );
        }
    }

    #[test]
    fn compose_convention_file_skill_awareness_precedes_project_context() {
        let doc = compose_convention_file(
            "/root",
            "# Shared project context\n\nUse pnpm.",
            "# Persona\n\nDo X.",
            &[],
            &[],
            None,
            &[],
            false,
        );

        let awareness_at = doc.find("**Skills IdeA**").unwrap();
        let context_at = doc.find("# Contexte projet").unwrap();
        let persona_at = doc.find("# Persona").unwrap();
        assert!(
            awareness_at < context_at && context_at < persona_at,
            "order must stay root -> orchestration/awareness -> project context -> persona"
        );
    }

    #[test]
    fn compose_convention_file_carries_memory_harvest_directive_both_modes() {
        // The auto-memory harvest directive (Lot E1) is present in both MCP and
        // file-protocol modes, positioned right after the Skills awareness and
        // before the orchestration block's closing separator / project context.
        for mcp_enabled in [true, false] {
            let doc = compose_convention_file(
                "/root",
                "ctx",
                "# Persona",
                &[],
                &[],
                None,
                &[],
                mcp_enabled,
            );
            assert!(
                doc.contains("**Mémoire IdeA**"),
                "memory harvest directive present (mcp_enabled={mcp_enabled})"
            );
            assert!(
                doc.contains("bloc fenced `idea-memory`")
                    && doc.contains("réutilise le même `slug`"),
                "directive carries the idea-memory wording (mcp_enabled={mcp_enabled})"
            );
            // Position: after Skills awareness, before the project-context section.
            let skills_at = doc.find("**Skills IdeA**").unwrap();
            let memory_at = doc.find("**Mémoire IdeA**").unwrap();
            let context_at = doc.find("# Contexte projet").unwrap();
            assert!(
                skills_at < memory_at && memory_at < context_at,
                "order: skills awareness -> memory directive -> project context \
                 (mcp_enabled={mcp_enabled})"
            );
        }
    }

    #[test]
    fn compose_convention_file_skill_awareness_uses_mcp_or_file_creation_path() {
        let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
        assert!(mcp_doc.contains("idea_create_skill"));
        assert!(
            !mcp_doc.contains("skill.create"),
            "MCP awareness must point to the native IdeA tool"
        );

        let file_doc =
            compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
        assert!(file_doc.contains("skill.create"));
        assert!(
            !file_doc.contains("idea_create_skill"),
            "file-protocol awareness must point to skill.create"
        );
    }

    #[test]
    fn compose_convention_file_exposes_skill_affordances_not_bodies() {
        // Both surfaces: a high-altitude `# Skills disponibles`
        // section after the Orchestration block, listing `**name** — description`
        // affordances, WITHOUT dumping the bodies.
        let s = |n: u128, name: &str, desc: Option<&str>, body: &str| {
            Skill::new(
                domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
                name,
                MarkdownDoc::new(body),
                domain::SkillScope::Global,
            )
            .unwrap()
            .with_description(desc.map(str::to_owned))
        };
        for mcp_enabled in [true, false] {
            let doc = compose_convention_file(
                "/root",
                "",
                "# Persona",
                &[
                    s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
                    // No explicit description ⇒ effective_description falls back to the
                    // body's first line (heading marker stripped).
                    s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
                ],
                &[],
                None,
                &[],
                mcp_enabled,
            );

            assert!(
                doc.contains("# Skills disponibles"),
                "skills section present (mcp_enabled={mcp_enabled})"
            );
            if mcp_enabled {
                assert!(doc.contains("idea_skill_read"), "points to the read tool");
            } else {
                assert!(
                    doc.contains(".ideai/skills/md/<skill-id>.md"),
                    "points to the file-protocol read path"
                );
            }
            assert!(doc.contains("**refactor** — Refactors code (workflow)"));
            assert!(doc.contains("**review** — Review skill (workflow)"));
            assert!(!doc.contains("REFAC_BODY"), "no full body is injected");
            assert!(!doc.contains("REVIEW_BODY"), "no full body is injected");
            assert!(!doc.contains("## refactor"));

            let orch_at = doc.find("# Orchestration IdeA").unwrap();
            let skills_at = doc.find("# Skills disponibles").unwrap();
            let persona_at = doc.find("# Persona").unwrap();
            assert!(orch_at < skills_at, "skills come after orchestration");
            assert!(skills_at < persona_at, "skills come before the persona");
        }
    }

    #[test]
    fn compose_convention_file_mcp_mode_without_skills_omits_section() {
        // MCP mode + zero skills ⇒ no `# Skills disponibles` affordance section at all.
        // NB : l'outil `idea_skill_read` reste évoqué par le briefing « capacités IdeA »
        // (toujours présent), donc on n'asserte plus son absence — seulement celle de la
        // section d'affordances, qui, elle, dépend bien des skills assignés.
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
        assert!(!doc.contains("# Skills disponibles"));
    }

    /// Builds a memory index entry for the convention-file composition tests.
    fn mem(slug_str: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry {
        MemoryIndexEntry {
            slug: domain::MemorySlug::new(slug_str).unwrap(),
            title: title.to_owned(),
            hook: hook.to_owned(),
            r#type: kind,
        }
    }

    #[test]
    fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
        // An empty `memory` must yield exactly the previous document: no section,
        // byte-for-byte identical to the no-skills/no-memory composition.
        let with_empty = compose_convention_file(
            "/root",
            "",
            "# Persona\n\nDo X.",
            &[],
            &[],
            None,
            &[],
            false,
        );
        assert!(
            !with_empty.contains("# Mémoire projet"),
            "no memory ⇒ no memory section"
        );
        // Same document whether or not we thread an empty slice (it already is the
        // 4-arg call; this pins the omission contract explicitly).
        assert_eq!(
            with_empty,
            compose_convention_file(
                "/root",
                "",
                "# Persona\n\nDo X.",
                &[],
                &[],
                None,
                &[],
                false
            )
        );
    }

    #[test]
    fn compose_convention_file_appends_memory_entries_in_order() {
        let doc = compose_convention_file(
            "/root",
            "",
            "# Persona",
            &[],
            &[
                mem("alpha-note", "Alpha", "the first hook", MemoryType::User),
                mem(
                    "beta-note",
                    "Beta",
                    "the second hook",
                    MemoryType::Reference,
                ),
            ],
            None,
            &[],
            false,
        );

        // Section present, after the persona.
        assert!(doc.contains("# Mémoire projet"));
        let persona_at = doc.find("# Persona").unwrap();
        let section_at = doc.find("# Mémoire projet").unwrap();
        assert!(persona_at < section_at, "memory comes after the persona");

        // Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
        assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
        assert!(doc.contains("- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"));

        // Deterministic order: first entry precedes the second.
        let alpha_at = doc.find("[Alpha]").unwrap();
        let beta_at = doc.find("[Beta]").unwrap();
        assert!(
            alpha_at < beta_at,
            "memory entries emitted in the given order"
        );
    }

    #[test]
    fn compose_convention_file_memory_and_skills_coexist() {
        let skill = Skill::new(
            domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)),
            "refactor",
            MarkdownDoc::new("REFAC_BODY"),
            domain::SkillScope::Global,
        )
        .unwrap();
        let doc = compose_convention_file(
            "/root",
            "",
            "# Persona",
            std::slice::from_ref(&skill),
            &[mem("note", "Note", "a hook", MemoryType::Project)],
            None,
            &[],
            false,
        );

        // Both sections present.
        assert!(doc.contains("# Skills"));
        assert!(doc.contains("REFAC_BODY"));
        assert!(doc.contains("# Mémoire projet"));
        assert!(doc.contains("- [Note](.ideai/memory/note.md) — a hook (project)"));

        // Skills section precedes the memory section (persona → skills → memory).
        let skills_at = doc.find("# Skills").unwrap();
        let memory_at = doc.find("# Mémoire projet").unwrap();
        assert!(skills_at < memory_at, "skills come before memory");
    }

    #[test]
    fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() {
        // mcp_enabled = true ⇒ prose exposes the remaining native `idea_*` tools while
        // keeping the ban on the provider's native subagents (cadrage v3, Décision 3).
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);

        // Native IdeA orchestration tools surfaced.
        assert!(
            doc.contains("idea_context_read"),
            "MCP prose must mention remaining IdeA tools"
        );
        assert!(doc.contains("idea_memory_read"));
        assert!(doc.contains("idea_ask_agent"));
        assert!(!doc.contains("idea_reply"));
        // It does NOT fall back to the file protocol wording.
        assert!(
            !doc.contains(".ideai/requests"),
            "MCP prose must not point to the file protocol"
        );
    }

    #[test]
    fn compose_convention_file_mcp_prose_does_not_carry_idea_reply_protocol() {
        // Inter-agent conversation is headless: the model's final answer is captured by
        // IdeA. Agents must not be instructed to call a reply tool or echo tickets.
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
        assert!(
            !doc.contains("idea_reply"),
            "MCP prose must not instruct answering via idea_reply"
        );
        assert!(
            doc.contains("capture automatiquement la réponse finale")
                || doc.contains("capture sa réponse finale"),
            "MCP prose must describe final-answer capture"
        );
        assert!(
            !doc.contains("[IdeA · tâche"),
            "MCP prose must not describe the old delegated-task prefix"
        );
        let file_doc =
            compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
        assert!(!file_doc.contains("idea_reply"));
    }

    #[test]
    fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
        // mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
        // and crucially WITHOUT the `idea_*` tools (zero regression).
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);

        assert!(
            doc.contains(".ideai/requests"),
            "non-MCP prose must point to the file protocol"
        );
        assert!(
            doc.contains("n'utilise jamais les subagents"),
            "non-MCP prose keeps the native-subagent ban"
        );
        // No native MCP tools leaked into the file-protocol prose.
        assert!(
            !doc.contains("idea_ask_agent"),
            "non-MCP prose must not mention the idea_* tools"
        );
    }

    #[test]
    fn compose_convention_file_mcp_briefs_all_three_capabilities_even_when_empty() {
        // Cœur du besoin (feature skill-awareness) : le briefing « capacités IdeA »
        // est INCONDITIONNEL. Projet/agent neuf et vide (zéro skill, zéro mémoire,
        // contexte vide) ⇒ les 3 capacités (contexte, mémoire, skills) DOIVENT quand
        // même être décrites, avec les noms des outils MCP correspondants.
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);

        // Capacité contexte projet.
        assert!(
            doc.contains("idea_context_read"),
            "brief must surface idea_context_read even with empty content"
        );
        assert!(doc.contains("idea_context_propose"));
        assert!(
            doc.contains("idea_update_context"),
            "brief must surface idea_update_context for per-agent .md writes"
        );
        // Capacité mémoire durable partagée.
        assert!(doc.contains("idea_memory_read"));
        assert!(doc.contains("idea_memory_write"));
        // Capacité skills.
        assert!(doc.contains("idea_skill_read"));
        assert!(doc.contains("idea_create_skill"));
    }

    #[test]
    fn compose_convention_file_mcp_context_brief_is_single_writer_honest() {
        // La prose contexte ne doit PAS laisser croire qu'une proposition globale est
        // appliquée directement. On asserte sur la nuance réellement écrite par le dev :
        // « single-writer » + le fait qu'une proposition sans `target` est enregistrée
        // « pour validation » (et donc « n'est PAS appliquée directement »).
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);

        assert!(
            doc.contains("single-writer"),
            "context brief must name the single-writer constraint"
        );
        assert!(
            doc.contains("validation"),
            "context brief must say a proposal is recorded for validation"
        );
        assert!(
            doc.contains("n'est PAS appliquée directement"),
            "context brief must be honest that a global proposal isn't auto-applied"
        );
    }

    #[test]
    fn compose_convention_file_non_mcp_briefs_capabilities_via_ideai_files_no_tools() {
        // Surface SANS MCP : mêmes 3 capacités décrites UNIQUEMENT via les fichiers
        // `.ideai/` — et AUCUN nom d'outil `idea_*` (cloisonnement strict des surfaces).
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);

        // Capacités décrites par leurs fichiers/dossiers.
        assert!(
            doc.contains(".ideai/CONTEXT.md"),
            "non-MCP brief must point to the .ideai/CONTEXT.md context file"
        );
        assert!(
            doc.contains(".ideai/memory/"),
            "non-MCP brief must point to the .ideai/memory/ store"
        );
        assert!(
            doc.contains("MEMORY.md"),
            "non-MCP brief must name the MEMORY.md index"
        );

        // AUCUN nom d'outil `idea_*` ne fuit (asserts forts sur les noms d'OUTILS, pas
        // sur la sous-chaîne brute `idea` qui matcherait `.ideai/`).
        assert!(
            !doc.contains("idea_context"),
            "no idea_context* tool in file surface"
        );
        assert!(
            !doc.contains("idea_memory"),
            "no idea_memory* tool in file surface"
        );
        assert!(
            !doc.contains("idea_skill"),
            "no idea_skill* tool in file surface"
        );
        assert!(
            !doc.contains("idea_create"),
            "no idea_create* tool in file surface"
        );
        assert!(
            !doc.contains("idea_ask_agent"),
            "no idea_ask_agent in file surface"
        );
        assert!(
            !doc.contains("idea_update_context"),
            "no idea_update_context in file surface"
        );
    }

    #[test]
    fn compose_convention_file_surfaces_stay_cloistered_on_capabilities() {
        // Cloisonnement : la prose MCP ne mentionne pas le protocole fichier
        // `.ideai/requests`, et la prose fichier ne mentionne aucun nom d'outil.
        let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
        let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);

        assert!(
            !mcp.contains(".ideai/requests"),
            "MCP capability brief must not leak the file delegation protocol"
        );
        // Aucun nom d'outil `idea_*` dans la surface fichier (recoupe le test dédié,
        // gardé vert ici sur l'ensemble des outils des 3 capacités + délégation).
        for tool in [
            "idea_context_read",
            "idea_context_propose",
            "idea_update_context",
            "idea_memory_read",
            "idea_memory_write",
            "idea_skill_read",
            "idea_create_skill",
            "idea_ask_agent",
            "idea_reply",
        ] {
            assert!(
                !file.contains(tool),
                "non-MCP surface must not mention the tool {tool}"
            );
        }
    }

    #[test]
    fn compose_convention_file_capability_brief_precedes_the_persona_in_both_modes() {
        // Ordre : le briefing capacités apparaît AVANT le persona dans les 2 modes.
        // On ancre le brief sur un marqueur stable propre à chaque surface.
        let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
        let mcp_brief_at = mcp
            .find("idea_context_read")
            .expect("MCP capability brief present");
        let mcp_persona_at = mcp.find("# Persona").expect("persona present");
        assert!(
            mcp_brief_at < mcp_persona_at,
            "MCP capability brief must come before the persona"
        );

        let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
        let file_brief_at = file
            .find(".ideai/CONTEXT.md")
            .expect("file capability brief present");
        let file_persona_at = file.find("# Persona").expect("persona present");
        assert!(
            file_brief_at < file_persona_at,
            "file capability brief must come before the persona"
        );
    }

    #[test]
    fn mcp_declaration_with_runtime_points_the_bridge_at_the_real_endpoint() {
        // B-0 — a PTY-launched MCP-capable CLI (Claude/Codex REPL) auto-discovers the
        // `.mcp.json` in its cwd (the run dir). When the composition root injects the
        // real `McpRuntime`, the declaration written there must spawn *this* IdeA exe
        // in `mcp-server` mode and dial the project's exact loopback endpoint — the
        // same source of truth `ensure_mcp_server` binds — so the CLI can call
        // `idea_list_agents` and get a reply end-to-end.
        let rt = McpRuntime {
            exe: "/opt/idea/idea".to_owned(),
            endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
            project_id: "0123456789abcdef0123456789abcdef".to_owned(),
            requester: "11112222-3333-4444-5555-666677778888".to_owned(),
        };
        let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt));

        // It is valid JSON with the IdeA server under `mcpServers/idea`.
        let parsed: serde_json::Value =
            serde_json::from_str(&decl).expect("declaration is valid JSON");
        let idea = &parsed["mcpServers"]["idea"];
        assert_eq!(idea["command"], "/opt/idea/idea");
        let args = idea["args"].as_array().expect("args is an array");
        let args: Vec<&str> = args.iter().filter_map(serde_json::Value::as_str).collect();
        assert_eq!(args[0], "mcp-server");
        // The real endpoint / project / requester are all threaded through.
        assert!(args.contains(&"--endpoint"));
        assert!(args.contains(&"/run/user/1000/idea-mcp/proj.sock"));
        assert!(args.contains(&"--project"));
        assert!(args.contains(&"0123456789abcdef0123456789abcdef"));
        assert!(args.contains(&"--requester"));
        assert_eq!(idea["transport"], "stdio");
    }

    #[test]
    fn mcp_declaration_without_runtime_is_a_coherent_minimal_fallback() {
        // Launches issued from inside `application` (orchestrator/hot-swap/tests) have
        // no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
        // command — still a valid, self-consistent `mcpServers/idea` entry.
        let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None);
        let parsed: serde_json::Value =
            serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
        let idea = &parsed["mcpServers"]["idea"];
        assert_eq!(idea["command"], "idea");
        let args: Vec<&str> = idea["args"]
            .as_array()
            .expect("args array")
            .iter()
            .filter_map(serde_json::Value::as_str)
            .collect();
        assert_eq!(args, vec!["mcp-server"]);
        // No endpoint/project/requester when no runtime was injected.
        assert!(!args.contains(&"--endpoint"));
    }

    /// Builds a [`Handoff`] for the pure-section tests (lot P7).
    fn handoff(summary: &str, objective: Option<&str>) -> Handoff {
        Handoff::new(
            summary,
            domain::TurnId::from_uuid(uuid::Uuid::from_u128(1)),
            objective.map(ToOwned::to_owned),
        )
    }

    #[test]
    fn compose_convention_file_appends_handoff_section_after_memory_with_objective() {
        // lot P7 — a handoff with an objective ⇒ a final `# Reprise de la conversation`
        // section carrying the `**Objectif :**` line and the cumulative summary, placed
        // AFTER the `# Mémoire projet` section.
        let memory = [mem(
            "git-optional",
            "Git optionnel",
            "git reste un simple tool",
            MemoryType::Project,
        )];
        let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7"));
        let doc =
            compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), &[], false);

        assert!(
            doc.contains("# Reprise de la conversation"),
            "resume section present: {doc}"
        );
        assert!(
            doc.contains("**Objectif :** Livrer le lot P7"),
            "objective line present: {doc}"
        );
        assert!(
            doc.contains("Résumé : on a fini l'étape 2."),
            "summary present: {doc}"
        );

        // Ordering: the resume section comes AFTER the project memory.
        let memory_at = doc.find("# Mémoire projet").unwrap();
        let resume_at = doc.find("# Reprise de la conversation").unwrap();
        assert!(
            memory_at < resume_at,
            "resume must follow the project memory: {doc}"
        );
        // And the objective line precedes the summary inside the section.
        let objective_at = doc.find("**Objectif :**").unwrap();
        let summary_at = doc.find("Résumé : on a fini").unwrap();
        assert!(
            resume_at < objective_at && objective_at < summary_at,
            "objective then summary inside the section: {doc}"
        );
    }

    #[test]
    fn compose_convention_file_handoff_without_objective_omits_objective_line() {
        // objective = None ⇒ the section and the summary are present, but NO
        // `**Objectif :**` line.
        let h = handoff("Juste le résumé.", None);
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false);

        assert!(
            doc.contains("# Reprise de la conversation"),
            "section present even without objective: {doc}"
        );
        assert!(doc.contains("Juste le résumé."), "summary present: {doc}");
        assert!(
            !doc.contains("**Objectif :**"),
            "no objective line when objective is None: {doc}"
        );
    }

    #[test]
    fn compose_convention_file_handoff_blank_objective_omits_objective_line() {
        // objective = Some("   ") (whitespace only) ⇒ trimmed to empty ⇒ no objective
        // line, but the section + summary still render.
        let h = handoff("Le résumé.", Some("   "));
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false);

        assert!(doc.contains("# Reprise de la conversation"));
        assert!(doc.contains("Le résumé."));
        assert!(
            !doc.contains("**Objectif :**"),
            "blank objective is omitted: {doc}"
        );
    }

    #[test]
    fn compose_convention_file_without_handoff_omits_resume_section() {
        // handoff = None ⇒ no `# Reprise de la conversation` section at all
        // (zero regression for launches with no resume).
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
        assert!(
            !doc.contains("# Reprise de la conversation"),
            "no handoff ⇒ no resume section: {doc}"
        );
        assert!(
            !doc.contains("**Objectif :**"),
            "no handoff ⇒ no objective line: {doc}"
        );
    }

    #[test]
    fn compose_with_normal_handoff_is_byte_identical_pre_and_post_ls5_bound() {
        // ZÉRO RÉGRESSION (LS5) : pour un handoff sous le plafond, le seul changement
        // LS5 du chemin d'injection — `bound_handoff_summary` dans `resolve_handoff` —
        // est l'identité (cf. domaine `bound_under_limit_is_unchanged`). Le document
        // composé est donc OCTET-IDENTIQUE avant/après LS5. Assertion stricte.
        let memory = [mem(
            "git-optional",
            "Git optionnel",
            "git reste un simple tool",
            MemoryType::Project,
        )];
        let normal = handoff(
            "**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b",
            Some("livrer LS5"),
        );
        assert!(
            normal.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
            "pré-condition : handoff sous le plafond"
        );

        // « Avant LS5 » : le composeur reçoit le handoff tel quel (aucune borne).
        let pre = compose_convention_file(
            "/root",
            "",
            "# Persona",
            &[],
            &memory,
            Some(&normal),
            &[],
            true,
        );
        // « Après LS5 » : le handoff passe d'abord par la borne universelle.
        let bounded = bound_handoff_summary(normal.clone(), HANDOFF_SUMMARY_MAX_CHARS);
        let post = compose_convention_file(
            "/root",
            "",
            "# Persona",
            &[],
            &memory,
            Some(&bounded),
            &[],
            true,
        );
        assert_eq!(
            pre, post,
            "handoff normal ⇒ document composé octet-identique"
        );
    }

    // -----------------------------------------------------------------------
    // État du projet (lot LS4) — live-state preview section in the composer.
    // The composer is pure: it renders the already-distilled `InjectedLiveRow`s
    // (self-exclusion/cap/ordering are `resolve_live_state`'s job, covered by the
    // launch-level test). Here we pin the rendering contract.
    // -----------------------------------------------------------------------

    fn live_row(name: &str, status: WorkStatus, intent: &str) -> InjectedLiveRow {
        InjectedLiveRow {
            name: name.to_owned(),
            status,
            intent: intent.to_owned(),
        }
    }

    #[test]
    fn compose_convention_file_renders_live_state_rows_in_order_with_labels() {
        let rows = [
            live_row("Architect", WorkStatus::Working, "cadrage LS4"),
            live_row("DevBackend", WorkStatus::Blocked, "attend la review"),
            // Empty intent ⇒ the ` · intent` suffix is omitted (status only).
            live_row("QA", WorkStatus::Idle, ""),
        ];
        let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &rows, true);

        // Section present, after the persona.
        assert!(doc.contains("# État du projet"), "section present: {doc}");
        let persona_at = doc.find("# Persona").unwrap();
        let section_at = doc.find("# État du projet").unwrap();
        assert!(
            persona_at < section_at,
            "live-state comes after the persona"
        );

        // Exact line format `- **Name** — status[ · intent]`, status = WorkStatus::label.
        assert!(
            doc.contains("- **Architect** — working · cadrage LS4"),
            "working row with intent: {doc}"
        );
        assert!(
            doc.contains("- **DevBackend** — blocked · attend la review"),
            "blocked row with intent: {doc}"
        );
        // QA has an empty intent ⇒ no ` · ` suffix, no trailing separator.
        assert!(
            doc.contains("- **QA** — idle\n"),
            "idle row, intent omitted: {doc}"
        );
        assert!(
            !doc.contains("**QA** — idle ·"),
            "empty intent must not render a ` · ` suffix: {doc}"
        );

        // Manifest order is preserved: Architect before DevBackend before QA.
        let a = doc.find("**Architect**").unwrap();
        let b = doc.find("**DevBackend**").unwrap();
        let c = doc.find("**QA**").unwrap();
        assert!(a < b && b < c, "rows rendered in the given order: {doc}");

        // No leakage: the section never carries UUIDs/progress/transcripts (none in the
        // rows ⇒ none in the output).
        assert!(!doc.contains("progress"), "progress never surfaced: {doc}");
    }

    #[test]
    fn compose_convention_file_empty_live_state_is_identical_to_no_section() {
        // No live rows ⇒ the `# État du projet` section is omitted entirely, and the
        // document is byte-for-byte identical to a composition that never had one.
        let baseline =
            compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true);
        assert!(
            !baseline.contains("# État du projet"),
            "no live rows ⇒ no section"
        );
        // Strict byte equality: threading an empty slice changes nothing.
        let empty =
            compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true);
        assert_eq!(baseline, empty, "empty live-state ⇒ identical document");
    }

    #[test]
    fn compose_convention_file_live_state_placed_after_memory_before_resume() {
        // Placement contract: État du projet sits AFTER `# Mémoire projet` and BEFORE
        // `# Reprise de la conversation`.
        let memory = [mem(
            "git-optional",
            "Git optionnel",
            "git reste un simple tool",
            MemoryType::Project,
        )];
        let h = handoff("Résumé : étape 2 finie.", Some("Livrer LS4"));
        let rows = [live_row("Architect", WorkStatus::Waiting, "revue")];
        let doc = compose_convention_file(
            "/root",
            "",
            "# Persona",
            &[],
            &memory,
            Some(&h),
            &rows,
            true,
        );

        let memory_at = doc.find("# Mémoire projet").unwrap();
        let live_at = doc.find("# État du projet").unwrap();
        let resume_at = doc.find("# Reprise de la conversation").unwrap();
        assert!(
            memory_at < live_at && live_at < resume_at,
            "order must be Mémoire → État du projet → Reprise: {doc}"
        );
    }

    // NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed —
    // that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
    // and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval
    // derivation moved to `CodexPermissionProjector`; `codex_config_toml` here is now
    // **MCP-only**, so the Codex tests below assert only the MCP/trust merge.

    #[test]
    fn codex_config_trusts_run_and_project_and_replaces_only_idea_mcp() {
        let existing = r#"[projects."/home/me/proj"]
trust_level = "trusted"
approval_policy = "nested"

[mcp_servers.idea]
command = "old"

[mcp_servers.other]
command = "other"
"#;
        // No `permissions` argument anymore: the permission projection is decoupled
        // (lot LP3-3) into `CodexPermissionProjector`. This function only merges MCP.
        let rendered = codex_config_toml(
            Some(existing),
            "[mcp_servers.idea]\ncommand = \"new\"",
            "/home/me/proj/.ideai/run/a",
            "/home/me/proj",
        );

        // MCP table + trust entries are the only things this function touches.
        assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
        assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
        assert!(rendered.contains("approval_policy = \"nested\""));
        assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\""));
        assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\""));
        assert!(!rendered.contains("model = \"gpt-5-codex\""));
        assert!(!rendered.contains("command = \"old\""));
        assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1);
        assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1);
        // Permission keys are NOT added by this MCP-only function.
        assert!(!rendered.contains("sandbox_mode ="));
    }

    #[test]
    fn codex_config_is_mcp_only_and_adds_no_permission_keys() {
        let rendered = codex_config_toml(
            None,
            "[mcp_servers.idea]\ncommand = \"idea-mcp\"",
            "/home/me/proj/.ideai/run/a",
            "/home/me/proj",
        );

        assert!(!rendered.contains("model ="));
        assert!(!rendered.contains("approval_policy ="));
        assert!(!rendered.contains("sandbox_mode ="));
        assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
        assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
    }

    #[test]
    fn merge_managed_toml_upserts_only_managed_keys() {
        // Existing config (e.g. written by `apply_mcp_config`): MCP + trust + a user key.
        let existing = r#"user_key = "keep-me"

[mcp_servers.idea]
command = "idea-mcp"
"#;
        // Codex projector fragment: the two managed permission keys.
        let fragment = "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n";
        let merged = merge_managed_toml(
            existing,
            &[],
            &["sandbox_mode".to_owned(), "approval_policy".to_owned()],
            fragment,
        );

        // Managed keys are spliced in…
        assert!(merged.contains("sandbox_mode = \"workspace-write\""));
        assert!(merged.contains("approval_policy = \"never\""));
        // …without disturbing the rest of the file.
        assert!(merged.contains("user_key = \"keep-me\""));
        assert!(merged.contains("[mcp_servers.idea]\ncommand = \"idea-mcp\""));
    }

    #[test]
    fn opencode_provider_config_json_known_provider_emits_only_the_api_key_option() {
        let config = OpenCodeProviderConfig::new(
            "anthropic",
            "claude-sonnet-5",
            domain::ports::SecretRef::new("secret-ref"),
        )
        .unwrap();
        let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
        let provider = &body["provider"]["anthropic"];
        assert_eq!(provider["options"]["apiKey"], "sk-live");
        assert!(provider["options"].get("baseURL").is_none());
        assert!(provider.get("npm").is_none());
        assert!(provider.get("models").is_none());
    }

    #[test]
    fn opencode_provider_config_json_custom_provider_emits_npm_base_url_and_models() {
        let custom = domain::profile::CustomProviderConfig::new(
            "@ai-sdk/openai-compatible",
            "https://my-endpoint/v1",
            None,
        )
        .unwrap();
        let config = OpenCodeProviderConfig::new(
            "my-custom",
            "my-model",
            domain::ports::SecretRef::new("secret-ref"),
        )
        .unwrap()
        .with_custom(custom);

        let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
        let provider = &body["provider"]["my-custom"];
        assert_eq!(provider["options"]["apiKey"], "sk-live");
        assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1");
        assert_eq!(provider["npm"], "@ai-sdk/openai-compatible");
        assert_eq!(provider["models"]["my-model"]["name"], "my-model");
    }

    #[test]
    fn opencode_provider_config_json_custom_provider_model_name_prefers_display_name() {
        let custom = domain::profile::CustomProviderConfig::new(
            "@ai-sdk/openai-compatible",
            "https://my-endpoint/v1",
            Some("My Model".to_owned()),
        )
        .unwrap();
        let config = OpenCodeProviderConfig::new(
            "my-custom",
            "my-model",
            domain::ports::SecretRef::new("ref"),
        )
        .unwrap()
        .with_custom(custom);

        let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
        assert_eq!(
            body["provider"]["my-custom"]["models"]["my-model"]["name"],
            "My Model"
        );
    }

    #[test]
    fn opencode_provider_config_json_zai_is_cache_independent() {
        let custom = domain::profile::CustomProviderConfig::new(
            "@ai-sdk/openai-compatible",
            "https://api.z.ai/api/paas/v4",
            Some("GLM-5.1".to_owned()),
        )
        .unwrap();
        let config =
            OpenCodeProviderConfig::new("zai", "glm-5.1", domain::ports::SecretRef::new("ref"))
                .unwrap()
                .with_custom(custom);
        let body = opencode_provider_config_json(&config, "secret", "/project", None, None);
        assert_eq!(body["model"], "zai/glm-5.1");
        assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible");
        assert_eq!(
            body["provider"]["zai"]["options"]["baseURL"],
            "https://api.z.ai/api/paas/v4"
        );
        assert_eq!(
            body["provider"]["zai"]["models"]["glm-5.1"]["name"],
            "GLM-5.1"
        );
    }

    // ---- permission projection wiring (ticket #94) -----------------------

    /// Builds an [`EffectivePermissions`] with the given fallback posture (only
    /// the fallback drives the blanket bash/edit verdicts checked here).
    fn eff(fallback: domain::permission::Posture) -> EffectivePermissions {
        domain::permission::resolve(
            Some(&domain::permission::PermissionSet::new(vec![], fallback)),
            None,
        )
        .unwrap()
    }

    #[test]
    fn opencode_config_json_omits_permission_key_when_eff_is_none() {
        let config =
            domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
                .unwrap();
        let body = opencode_config_json(&config, "/project", None, None);
        assert!(body.get("permission").is_none());
    }

    #[test]
    fn opencode_config_json_projects_allow_and_deny() {
        let config =
            domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
                .unwrap();

        use domain::permission::Posture;
        let allowed = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow)));
        assert_eq!(allowed["permission"]["bash"], "allow");
        assert_eq!(allowed["permission"]["edit"], "allow");

        let denied = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Deny)));
        assert_eq!(denied["permission"]["bash"], "deny");
        assert_eq!(denied["permission"]["edit"], "deny");
    }

    #[test]
    fn opencode_provider_config_json_omits_permission_key_when_eff_is_none() {
        let config = OpenCodeProviderConfig::new(
            "anthropic",
            "claude-sonnet-5",
            domain::ports::SecretRef::new("secret-ref"),
        )
        .unwrap();
        let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
        assert!(body.get("permission").is_none());
    }

    #[test]
    fn opencode_provider_config_json_projects_allow_and_deny() {
        let config = OpenCodeProviderConfig::new(
            "anthropic",
            "claude-sonnet-5",
            domain::ports::SecretRef::new("secret-ref"),
        )
        .unwrap();

        let allowed = opencode_provider_config_json(
            &config,
            "sk-live",
            "/project",
            None,
            Some(&eff(domain::permission::Posture::Allow)),
        );
        assert_eq!(allowed["permission"]["bash"], "allow");
        assert_eq!(allowed["permission"]["edit"], "allow");

        let denied = opencode_provider_config_json(
            &config,
            "sk-live",
            "/project",
            None,
            Some(&eff(domain::permission::Posture::Deny)),
        );
        assert_eq!(denied["permission"]["bash"], "deny");
        assert_eq!(denied["permission"]["edit"], "deny");
    }
}
