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

@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::{AgentId, ProfileId, TemplateId};
use crate::profile::EffortSelection;
use crate::skill::SkillRef;
use crate::template::TemplateVersion;
@ -70,6 +71,10 @@ pub struct Agent {
/// activation (ARCHITECTURE §14.2). Empty by default.
#[serde(default)]
pub skills: Vec<SkillRef>,
/// Per-agent effort selection. `None` falls back to the profile's
/// `model_reasoning_effort` at launch.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<EffortSelection>,
}
impl Agent {
@ -104,6 +109,7 @@ impl Agent {
origin,
synchronized,
skills: Vec::new(),
effort: None,
})
}
@ -128,6 +134,13 @@ impl Agent {
self
}
/// Returns a copy of this agent carrying the given per-agent effort override.
#[must_use]
pub fn with_effort(mut self, effort: Option<EffortSelection>) -> Self {
self.effort = effort;
self
}
/// Assigns a skill to this agent. Idempotent: re-assigning the same
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
@ -183,6 +196,9 @@ pub struct ManifestEntry {
/// backward-compatible deserialisation of pre-L12 manifests.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<SkillRef>,
/// Per-agent effort selection. Missing in older manifests means no override.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<EffortSelection>,
}
impl ManifestEntry {
@ -221,6 +237,7 @@ impl ManifestEntry {
synchronized,
synced_template_version,
skills: Vec::new(),
effort: None,
})
}
@ -246,6 +263,7 @@ impl ManifestEntry {
synchronized: agent.synchronized,
synced_template_version,
skills: agent.skills.clone(),
effort: agent.effort.clone(),
}
}
@ -271,7 +289,8 @@ impl ManifestEntry {
origin,
self.synchronized,
)?
.with_skills(self.skills.clone()))
.with_skills(self.skills.clone())
.with_effort(self.effort.clone()))
}
}
@ -530,4 +549,41 @@ mod orchestrator_tests {
&d,
));
}
#[test]
fn agent_with_effort_round_trips_through_manifest_entry() {
let agent = Agent::new(
agent_id(1),
"agent-1",
"agents/agent-1.md",
ProfileId::from_uuid(uuid::Uuid::from_u128(1001)),
AgentOrigin::Scratch,
false,
)
.unwrap()
.with_effort(Some(EffortSelection::Preset("medium".to_owned())));
let entry = ManifestEntry::from_agent(&agent);
let back = entry.to_agent().unwrap();
assert_eq!(
back.effort,
Some(EffortSelection::Preset("medium".to_owned()))
);
}
#[test]
fn manifest_entry_effort_absent_deserialises_as_none() {
let json = r#"{
"agentId":"00000000-0000-0000-0000-000000000001",
"name":"agent-1",
"mdPath":"agents/agent-1.md",
"profileId":"00000000-0000-0000-0000-0000000003e9",
"synchronized":false
}"#;
let entry: ManifestEntry = serde_json::from_str(json).expect("legacy entry deserialises");
assert_eq!(entry.effort, None);
assert_eq!(entry.to_agent().unwrap().effort, None);
}
}