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

@ -29,8 +29,9 @@ use domain::conversation::ConversationParty;
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
use domain::{AgentId, Project};
use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath};
use domain::{AgentId, DomainEvent, Project};
use sha2::{Digest, Sha256};
use crate::error::AppError;
@ -45,6 +46,16 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
RemotePath::new(format!("{base}/{rel}"))
}
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex_encode(&hasher.finalize())
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// Resolves an agent display name to its [`AgentId`] via the project manifest
/// (case-insensitive), or [`AppError::NotFound`].
async fn resolve_agent(
@ -80,6 +91,14 @@ pub struct ReadContextInput {
pub requester: ConversationParty,
}
/// Output of [`ReadContext`].
pub struct ReadContextOutput {
/// The context Markdown.
pub content: MarkdownDoc,
/// sha256 hex digest of `content`, only for the global project context.
pub version: Option<String>,
}
impl ReadContext {
/// Builds the use case from its ports.
#[must_use]
@ -99,7 +118,7 @@ impl ReadContext {
///
/// # Errors
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
pub async fn execute(&self, input: ReadContextInput) -> Result<ReadContextOutput, AppError> {
let ReadContextInput {
project,
target,
@ -115,9 +134,13 @@ impl ReadContext {
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let bytes = self.fs.read(&path).await?;
let version = hex_sha256(&bytes);
let text =
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
Ok(MarkdownDoc::new(text))
Ok(ReadContextOutput {
content: MarkdownDoc::new(text),
version: Some(version),
})
}
Some(name) => {
let agent = resolve_agent(&self.contexts, &project, &name).await?;
@ -126,12 +149,114 @@ impl ReadContext {
.acquire_read(requester, GuardedResource::AgentContext(agent))
.await
.map_err(map_guard_err)?;
Ok(self.contexts.read_context(&project, &agent).await?)
Ok(ReadContextOutput {
content: self.contexts.read_context(&project, &agent).await?,
version: None,
})
}
}
}
}
/// Directly updates the global project context. This is the strict, fail-loud
/// counterpart to [`ProposeContext`]'s soft-degrading global branch.
pub struct UpdateProjectContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
clock: Arc<dyn Clock>,
}
/// Input for [`UpdateProjectContext`].
pub struct UpdateProjectContextInput {
/// The project to write within.
pub project: Project,
/// New global project context Markdown.
pub content: String,
/// Optional expected current version.
pub if_match: Option<String>,
/// The writing party.
pub requester: ConversationParty,
}
/// Output of [`UpdateProjectContext`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectContextOutput {
/// sha256 hex digest of the newly written content.
pub new_version: String,
}
impl UpdateProjectContext {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
clock: Arc<dyn Clock>,
) -> Self {
Self {
guard,
contexts,
fs,
events,
clock,
}
}
/// Executes the direct global-context update.
///
/// # Errors
/// - [`AppError::Invalid`] when the requester is not allowed to write directly,
/// - [`AppError::Conflict`] when `if_match` does not match current content,
/// - [`AppError`] on store/fs failure.
pub async fn execute(
&self,
input: UpdateProjectContextInput,
) -> Result<UpdateProjectContextOutput, AppError> {
let UpdateProjectContextInput {
project,
content,
if_match,
requester,
} = input;
let manifest = self.contexts.load_manifest(&project).await?;
let designation = manifest.orchestrator_designation();
let resource = GuardedResource::ProjectContext;
if !may_write_directly(requester, &resource, &designation) {
return Err(map_guard_err(GuardError::Forbidden));
}
let _lease = self
.guard
.acquire_write(requester, resource)
.await
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let current_bytes = self.fs.read(&path).await?;
let current_version = hex_sha256(&current_bytes);
if let Some(expected) = if_match {
if expected != current_version {
return Err(AppError::Conflict(current_version));
}
}
self.fs.write(&path, content.as_bytes()).await?;
let new_version = hex_sha256(content.as_bytes());
self.events.publish(DomainEvent::ProjectContextUpdated {
project_id: project.id,
by: requester,
at_ms: self.clock.now_millis(),
});
Ok(UpdateProjectContextOutput { new_version })
}
}
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
///
/// For an **agent** context: a direct write under an exclusive write-lease. For the
@ -393,7 +518,7 @@ mod tests {
use domain::agent::{AgentManifest, ManifestEntry};
use domain::conversation::ConversationParty;
use domain::fileguard::{ReadLease, WriteLease};
use domain::ports::{FsError, MemoryError, StoreError};
use domain::ports::{EventStream, FsError, MemoryError, StoreError};
use domain::project::ProjectPath;
use domain::{ProfileId, ProjectId, RemoteRef};
use std::collections::HashMap;
@ -599,6 +724,25 @@ mod tests {
}
}
#[derive(Default)]
struct SpyBus(Mutex<Vec<DomainEvent>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
self.0.lock().unwrap().clone()
}
}
impl EventBus for SpyBus {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
fn guard() -> Arc<dyn FileGuard> {
Arc::new(TestGuard::default())
}
@ -619,6 +763,7 @@ mod tests {
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
effort: None,
}],
},
contexts: Mutex::new(contexts),
@ -635,7 +780,7 @@ mod tests {
contexts_with("Dev", agent, "# hello"),
Arc::new(FakeFs::default()),
);
let md = uc
let out = uc
.execute(ReadContextInput {
project: project(),
target: Some("dev".to_owned()), // case-insensitive
@ -643,7 +788,8 @@ mod tests {
})
.await
.unwrap();
assert_eq!(md.as_str(), "# hello");
assert_eq!(out.content.as_str(), "# hello");
assert_eq!(out.version, None);
}
#[tokio::test]
@ -658,7 +804,7 @@ mod tests {
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
fs,
);
let md = uc
let out = uc
.execute(ReadContextInput {
project: project(),
target: None,
@ -666,7 +812,8 @@ mod tests {
})
.await
.unwrap();
assert_eq!(md.as_str(), "# project");
assert_eq!(out.content.as_str(), "# project");
assert_eq!(out.version, Some(hex_sha256(b"# project")));
}
#[tokio::test]
@ -749,6 +896,262 @@ mod tests {
);
}
#[tokio::test]
async fn update_project_context_orchestrator_writes_directly_and_returns_new_version() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
let bus = Arc::new(SpyBus::default());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::clone(&bus) as Arc<dyn EventBus>,
Arc::new(FixedClock),
);
let out = uc
.execute(UpdateProjectContextInput {
project: project(),
content: "# new".to_owned(),
if_match: None,
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
assert_eq!(out.new_version, hex_sha256(b"# new"));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# new"
);
}
#[tokio::test]
async fn update_project_context_non_orchestrator_fails_loud_no_proposal_filed() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
let err = uc
.execute(UpdateProjectContextInput {
project: project(),
content: "# rejected".to_owned(),
if_match: None,
requester: agent_party(8),
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
let files = fs.files.lock().unwrap();
assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old");
assert!(
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
"strict update must fail loud, not file a proposal"
);
}
#[tokio::test]
async fn update_project_context_if_match_mismatch_returns_conflict_with_current_version() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
let err = uc
.execute(UpdateProjectContextInput {
project: project(),
content: "# new".to_owned(),
if_match: Some("stale".to_owned()),
requester: ConversationParty::agent(agent),
})
.await
.unwrap_err();
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# current"
);
}
#[tokio::test]
async fn update_project_context_if_match_matching_succeeds() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
let out = uc
.execute(UpdateProjectContextInput {
project: project(),
content: "# new".to_owned(),
if_match: Some(hex_sha256(b"# old")),
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
assert_eq!(out.new_version, hex_sha256(b"# new"));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# new"
);
}
#[tokio::test]
async fn update_project_context_no_if_match_is_last_write_wins() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
uc.execute(UpdateProjectContextInput {
project: project(),
content: "# latest".to_owned(),
if_match: None,
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# latest"
);
}
#[tokio::test]
async fn update_project_context_publishes_project_context_updated_event() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
let bus = Arc::new(SpyBus::default());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::clone(&bus) as Arc<dyn EventBus>,
Arc::new(FixedClock),
);
uc.execute(UpdateProjectContextInput {
project: project(),
content: "# new".to_owned(),
if_match: None,
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
assert_eq!(
bus.events(),
vec![DomainEvent::ProjectContextUpdated {
project_id: project().id,
by: ConversationParty::agent(agent),
at_ms: 42,
}]
);
}
#[tokio::test]
async fn read_context_and_update_project_context_version_round_trip() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec());
let contexts = contexts_with("Dev", agent, "agent body");
let reader = ReadContext::new(
guard(),
Arc::clone(&contexts),
Arc::clone(&fs) as Arc<dyn FileSystem>,
);
let updater = UpdateProjectContext::new(
guard(),
contexts,
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(SpyBus::default()),
Arc::new(FixedClock),
);
let version = reader
.execute(ReadContextInput {
project: project(),
target: None,
requester: ConversationParty::agent(agent),
})
.await
.unwrap()
.version
.unwrap();
updater
.execute(UpdateProjectContextInput {
project: project(),
content: "# second".to_owned(),
if_match: Some(version.clone()),
requester: ConversationParty::agent(agent),
})
.await
.unwrap();
let stale = updater
.execute(UpdateProjectContextInput {
project: project(),
content: "# third".to_owned(),
if_match: Some(version),
requester: ConversationParty::agent(agent),
})
.await
.unwrap_err();
assert_eq!(stale, AppError::Conflict(hex_sha256(b"# second")));
}
#[tokio::test]
async fn propose_agent_context_writes_directly() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));

View File

@ -10,8 +10,9 @@ mod service;
pub mod wake;
pub use context_guard::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput,
ReadContextOutput, ReadMemory, ReadMemoryInput, UpdateProjectContext,
UpdateProjectContextInput, UpdateProjectContextOutput, WriteMemory, WriteMemoryInput,
};
pub use rendezvous::{
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,

View File

@ -47,7 +47,8 @@ use crate::error::AppError;
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
use crate::orchestrator::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
WriteMemoryInput,
};
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
@ -534,6 +535,8 @@ pub struct ContextGuardUseCases {
pub read_context: Arc<ReadContext>,
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
pub propose_context: Arc<ProposeContext>,
/// Écriture directe stricte du contexte projet global.
pub update_project_context: Arc<UpdateProjectContext>,
/// Lecture mémoire sous read-lease.
pub read_memory: Arc<ReadMemory>,
/// Écriture mémoire sous write-lease.
@ -1241,6 +1244,14 @@ impl OrchestratorService {
self.propose_context(project, target, content, requester)
.await
}
OrchestratorCommand::UpdateProjectContext {
content,
if_match,
requester,
} => {
self.update_project_context(project, content, if_match, requester)
.await
}
OrchestratorCommand::ReadMemory { slug, requester } => {
self.read_memory(project, slug, requester).await
}
@ -1363,7 +1374,7 @@ impl OrchestratorService {
target: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let md = self
let out = self
.require_context_guard()?
.read_context
.execute(ReadContextInput {
@ -1372,9 +1383,13 @@ impl OrchestratorService {
requester,
})
.await?;
let mut text = out.content.into_string();
if let Some(version) = &out.version {
text.push_str(&format!("\n\n<!-- idea-context-version: {version} -->"));
}
Ok(OrchestratorOutcome {
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
reply: Some(md.into_string()),
reply: Some(text),
})
}
@ -1411,6 +1426,31 @@ impl OrchestratorService {
})
}
/// `context.update` → strict direct write of the global project context.
async fn update_project_context(
&self,
project: &Project,
content: String,
if_match: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let out = self
.require_context_guard()?
.update_project_context
.execute(UpdateProjectContextInput {
project: project.clone(),
content,
if_match,
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("wrote project context (version {})", out.new_version),
reply: None,
})
}
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
/// content is returned inline in the outcome's `reply`.
async fn read_memory(
@ -2783,6 +2823,7 @@ impl OrchestratorService {
description: None,
content,
scope,
kind: domain::SkillKind::Workflow,
project_root: project.root.clone(),
})
.await?;