feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139

État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -21,8 +21,8 @@ use domain::ports::{
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
};
use domain::profile::{
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
CODEX_CODE_MODE_FEATURES_TOML,
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::{
@ -123,8 +123,9 @@ pub struct InjectedLiveRow {
pub struct ResolvedAssignedSkill {
/// Agent-facing snapshot metadata.
pub snapshot: domain::AssignedSkillSnapshot,
/// Full Markdown body, used only on non-MCP profiles that cannot call
/// `idea_skill_read`.
/// 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,
}
@ -140,7 +141,9 @@ pub struct EffectiveAgentContext {
pub capabilities: OrchestrationCapabilitySnapshot,
/// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
pub agent_capabilities: Vec<AgentCapability>,
/// Resolved assigned skill bodies for fallback non-MCP injection.
/// 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>,
@ -281,6 +284,8 @@ pub struct ListAgentsOutput {
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.
@ -301,6 +306,8 @@ pub struct AgentDiscoveryEntry {
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 {
@ -318,6 +325,7 @@ impl ListAgentsOutput {
.map(|entry| entry.capabilities.clone())
.unwrap_or_default();
AgentDiscoveryEntry {
is_orchestrator: self.effective_orchestrator == Some(agent.id),
agent,
capabilities,
}
@ -390,6 +398,7 @@ impl ListAgents {
Ok(ListAgentsOutput {
agents,
capabilities,
effective_orchestrator: manifest.effective_orchestrator(),
})
}
}
@ -480,6 +489,74 @@ impl UpdateAgentContext {
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
@ -2017,6 +2094,12 @@ impl LaunchAgent {
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
@ -2053,7 +2136,7 @@ impl LaunchAgent {
factory.as_ref(),
structured,
&agent,
&profile,
&launch_profile,
&prepared,
&run_dir,
&session_plan,
@ -2083,7 +2166,7 @@ impl LaunchAgent {
// 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(&profile, &mut spec);
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?;
@ -3440,8 +3523,7 @@ fn append_block(input: &str, block: &str) -> String {
/// 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`, then the bodies of its assigned `skills` under a
/// `# Skills` section (ARCHITECTURE §14.2).
/// 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
@ -3451,18 +3533,12 @@ fn append_block(input: &str, block: &str) -> String {
/// protocol). This awareness deliberately does not inject unassigned skill bodies;
/// assignment remains the context boundary.
///
/// On top of that awareness, the assigned skills surface in one of two ways
/// depending on the agent's **surface** (feature « skills à la MCP »), always in
/// the given (manifest) order — making the output deterministic:
/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section,
/// right after the orchestration block, listing each as
/// `**<name>** — <effective description> (<kind>)` (affordances only, *no body*), with
/// prose pointing to `idea_skill_read` to load a body on demand. Respects the
/// altitude: the capability is exposed, never the skill content.
/// - **Non-MCP mode**: the legacy `# Skills` section dumping each body in full
/// under a `##` header carrying its name (unchanged — zero regression).
/// When `skills` is empty both sections are omitted entirely, so an agent with no
/// skills gets exactly the previous document.
/// 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
@ -3576,22 +3652,28 @@ pub(crate) fn compose_convention_file(
out.push_str(memory_awareness());
out.push_str("---\n\n");
// Skills « à la MCP » (feature skill-awareness) : à 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,
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
if mcp_enabled && !effective.agent_capabilities.is_empty() {
// 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");
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",
);
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);
@ -3616,24 +3698,6 @@ pub(crate) fn compose_convention_file(
out.push_str(effective.persona.as_str());
// MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve
// l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le
// corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`,
// cf. la section « # Skills disponibles » à haute altitude plus haut).
if !effective.assigned_skills.is_empty() && !mcp_enabled {
out.push_str("\n\n---\n\n# Skills\n");
out.push_str("\nSnapshot version: ");
out.push_str(&effective.capabilities.version.to_string());
out.push('\n');
for skill in &effective.assigned_skills {
out.push_str("\n## ");
out.push_str(&skill.snapshot.name);
out.push_str("\n\n");
out.push_str(skill.content.as_str());
out.push('\n');
}
}
if !effective.memory.is_empty() {
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
for entry in &effective.memory {
@ -3891,7 +3955,7 @@ mod tests {
}
#[test]
fn compose_convention_file_appends_assigned_skills_in_order() {
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)),
@ -3915,28 +3979,28 @@ mod tests {
false,
);
// Both skill bodies present, after the persona.
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("REVIEW_BODY"));
// 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 skills_at = doc.find("\n# Skills\n").unwrap();
let refac_at = doc.find("REFAC_BODY").unwrap();
let review_at = doc.find("REVIEW_BODY").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!(
persona_at < skills_at && skills_at < refac_at,
"assigned skill bodies come under the Skills section after persona"
awareness_at < skills_at && skills_at < persona_at,
"assigned skill affordances come before persona"
);
assert!(persona_at < refac_at, "skills come after the persona");
// Deterministic order: first assigned skill precedes the second.
assert!(refac_at < review_at, "skills emitted in the given order");
// Skill names surface as sub-headers.
assert!(doc.contains("## refactor"));
assert!(doc.contains("## review"));
assert!(!doc.contains("## refactor"));
assert!(!doc.contains("## review"));
}
#[test]
@ -4048,10 +4112,10 @@ mod tests {
}
#[test]
fn compose_convention_file_mcp_mode_exposes_skill_affordances_not_bodies() {
// MCP mode (feature « skills à la MCP »): a high-altitude `# Skills disponibles`
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 and pointing to `idea_skill_read`, WITHOUT dumping the bodies.
// 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)),
@ -4062,44 +4126,47 @@ mod tests {
.unwrap()
.with_description(desc.map(str::to_owned))
};
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,
&[],
true, // mcp_enabled
);
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,
);
// The affordance section is present.
assert!(
doc.contains("# Skills disponibles"),
"MCP skills section present"
);
assert!(doc.contains("idea_skill_read"), "points to the read tool");
// Affordance lines: `**name** — <effective description> (<kind>)`.
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
assert!(doc.contains("**review** — Review skill (workflow)"));
// The full bodies are NOT injected in MCP mode (loaded on demand instead).
assert!(!doc.contains("REFAC_BODY"), "no full body in MCP mode");
assert!(!doc.contains("REVIEW_BODY"), "no full body in MCP mode");
// The legacy `## <name>` body dump headers are absent too.
assert!(!doc.contains("## refactor"));
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"));
// The section sits at high altitude: after the Orchestration block, before
// the persona.
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");
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]

View File

@ -37,8 +37,9 @@ pub use lifecycle::{
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};
pub use model_catalogue::{
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,