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

@ -194,6 +194,9 @@ pub struct OrchestratorRequest {
/// `memory.write`, cadrage C7). Required by those actions, ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Optional optimistic-concurrency version for `context.update`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub if_match: Option<String>,
/// Target memory note slug for the memory tools (`memory.read`/`memory.write`,
/// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒
/// the aggregated index). Ignored by the other actions.
@ -342,6 +345,16 @@ pub enum OrchestratorCommand {
/// The proposing party (handshake identity).
requester: ConversationParty,
},
/// Directly update the global project context. Strict, orchestrator-only
/// counterpart to [`Self::ProposeContext`].
UpdateProjectContext {
/// The new Markdown body.
content: String,
/// Optional expected current version.
if_match: Option<String>,
/// The writing party (handshake identity).
requester: ConversationParty,
},
/// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
/// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note.
ReadMemory {
@ -500,6 +513,11 @@ impl OrchestratorRequest {
content: self.require("content", action, self.content.as_deref())?,
requester: self.requester_party(),
}),
"context.update" => Ok(OrchestratorCommand::UpdateProjectContext {
content: self.require("content", action, self.content.as_deref())?,
if_match: self.if_match.clone(),
requester: self.requester_party(),
}),
"memory.read" => Ok(OrchestratorCommand::ReadMemory {
slug: self.optional_slug(),
requester: self.requester_party(),
@ -1089,6 +1107,34 @@ mod tests {
);
}
#[test]
fn context_update_command_parses_content_and_if_match() {
let uid = uuid::Uuid::from_u128(42);
let r = req(&format!(
r##"{{ "type":"context.update", "requestedBy":"{uid}", "content":"# body", "ifMatch":"abc123" }}"##
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::UpdateProjectContext {
content: "# body".to_owned(),
if_match: Some("abc123".to_owned()),
requester: ConversationParty::agent(AgentId::from_uuid(uid)),
}
);
}
#[test]
fn context_update_requires_content() {
let missing = req(r#"{ "type":"context.update", "ifMatch":"abc123" }"#);
assert_eq!(
missing.validate(),
Err(OrchestratorError::MissingField {
action: "context.update".to_owned(),
field: "content".to_owned(),
})
);
}
#[test]
fn memory_read_optional_slug() {
assert_eq!(