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

@ -68,6 +68,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_context_update",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
@ -166,7 +167,7 @@ pub fn catalogue() -> Vec<ToolDef> {
name: "idea_list_agents",
description: "List the IdeA agents declared in the project's manifest. Returns the \
agents inline as a JSON array (id, name, profile, origin, raw skills, \
and resolved capabilities).",
resolved capabilities, and isOrchestrator).",
input_schema: json!({
"type": "object",
"properties": {},
@ -308,6 +309,23 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_context_update",
description: "Directly update the global project context. Strict, orchestrator-only \
counterpart to idea_context_propose: if you are not the project's \
current orchestrator (see isOrchestrator on idea_list_agents), this call \
fails and never silently files a proposal. Pass ifMatch from a prior \
idea_context_read to reject the write if the context changed.",
input_schema: json!({
"type": "object",
"properties": {
"content": { "type": "string", "description": "The new Markdown body." },
"ifMatch": { "type": "string", "description": "Optional version from a prior idea_context_read; rejected if stale." }
},
"required": ["content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_memory_read",
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
@ -485,6 +503,16 @@ pub fn map_tool_call(
content: s("content"),
..base()
},
"idea_context_update" => {
reject_unknown_keys(name, args, &["content", "ifMatch"])?;
OrchestratorRequest {
request_type: Some("context.update".to_owned()),
requested_by: Some(requester.to_owned()),
content: s("content"),
if_match: s("ifMatch"),
..base()
}
}
"idea_memory_read" => OrchestratorRequest {
request_type: Some("memory.read".to_owned()),
requested_by: Some(requester.to_owned()),
@ -556,6 +584,7 @@ fn base() -> OrchestratorRequest {
result: None,
ticket: None,
content: None,
if_match: None,
slug: None,
status: None,
intent: None,
@ -569,6 +598,20 @@ fn base() -> OrchestratorRequest {
}
}
fn reject_unknown_keys(
tool: &str,
args: &serde_json::Map<String, Value>,
allowed: &[&str],
) -> Result<(), ToolMapError> {
if args
.keys()
.any(|key| !allowed.iter().any(|allowed_key| allowed_key == key))
{
return Err(ToolMapError::BadArguments(tool.to_owned()));
}
Ok(())
}
/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
/// dropping a malformed/absent one (validation then rejects a `visible` launch
/// missing its node, with a precise field error).
@ -880,6 +923,51 @@ mod tests {
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn idea_context_update_tool_registered_and_maps_to_context_update_command() {
assert_eq!(
tool_access("idea_context_update"),
Some(McpToolAccess::WriteAction)
);
assert!(catalogue()
.into_iter()
.any(|tool| tool.name == "idea_context_update"));
let cmd = map_tool_call(
"idea_context_update",
&json!({ "content": "# body", "ifMatch": "abc123" }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::UpdateProjectContext {
content: "# body".to_owned(),
if_match: Some("abc123".to_owned()),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
}
#[test]
fn idea_context_update_requires_content_and_rejects_unknown_fields() {
let missing = map_tool_call("idea_context_update", &json!({ "ifMatch": "abc123" }), REQ);
assert!(matches!(missing, Err(ToolMapError::Invalid(_))));
let unknown = map_tool_call(
"idea_context_update",
&json!({ "content": "# body", "target": "Dev" }),
REQ,
);
assert_eq!(
unknown,
Err(ToolMapError::BadArguments("idea_context_update".to_owned()))
);
}
#[test]
fn memory_read_and_write_map_to_their_commands() {
assert_eq!(