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

@ -12,6 +12,7 @@ thiserror = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
subtle = { workspace = true }
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
# dynamic provider catalogue (ticket #92 follow-up). See

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,

View File

@ -26,6 +26,11 @@ pub enum AppError {
#[error("invalid input: {0}")]
Invalid(String),
/// An optimistic-concurrency `if_match` did not match the resource's current
/// version. Carries the current version so the caller can retry.
#[error("concurrency conflict: {0}")]
Conflict(String),
/// A filesystem operation failed.
#[error("filesystem error: {0}")]
FileSystem(String),
@ -107,6 +112,7 @@ impl AppError {
match self {
Self::NotFound(_) => "NOT_FOUND",
Self::Invalid(_) => "INVALID",
Self::Conflict(_) => "CONFLICT",
Self::FileSystem(_) => "FILESYSTEM",
Self::Store(_) => "STORE",
Self::Process(_) => "PROCESS",

View File

@ -61,7 +61,8 @@ pub use agent::{
ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{
@ -162,16 +163,16 @@ pub use plugin::{
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginTaskStatusInput,
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
QueryProjectStructureInput, ReconcilePluginMcpServers, ReviewPluginPackage,
ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, UninstallPlugin,
UninstallPluginInput, UninstallPluginResult,
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess,
PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic,
PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput,
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile,
PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput,
PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput,
PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry,
ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput,
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
};
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,

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?;

View File

@ -7,7 +7,10 @@
use std::sync::Arc;
use domain::ports::PermissionStore;
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
use domain::{
AgentId, EffectivePermissions, PermissionSet, PermissionShadowReport, Project,
ProjectPermissions,
};
use crate::error::AppError;
@ -131,6 +134,7 @@ impl ResolveAgentPermissions {
let doc = self.store.load_permissions(&input.project).await?;
Ok(ResolveAgentPermissionsOutput {
effective: doc.resolve_for(input.agent_id),
shadowed: doc.shadow_for(input.agent_id),
})
}
}
@ -147,4 +151,6 @@ pub struct ResolveAgentPermissionsInput {
pub struct ResolveAgentPermissionsOutput {
/// Resolved policy, or `None` when neither project nor agent policy exists.
pub effective: Option<EffectivePermissions>,
/// Diagnostic report for agent-level allows shadowed by project defaults.
pub shadowed: PermissionShadowReport,
}

View File

@ -8,8 +8,8 @@ use domain::ports::{
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
SpawnSpec,
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
};
use domain::{
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
@ -157,6 +157,28 @@ pub struct PluginRuntimePlugin {
pub contributes: PluginContributionSet,
}
/// Input for plugin-owned storage reads/deletes.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageGetInput {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
}
/// Input for plugin-owned storage writes.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageSetInput {
/// Plugin id owning the value.
pub plugin_id: String,
/// Plugin-owned key.
pub key: String,
/// JSON value to persist.
pub value: serde_json::Value,
}
/// Input for reviewing a package.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewPluginPackageInput {
@ -2023,6 +2045,14 @@ fn map_store(e: PluginStoreError) -> AppError {
}
}
fn map_storage(e: PluginStorageError) -> AppError {
match e {
PluginStorageError::Invalid(m) => AppError::Invalid(m),
PluginStorageError::Io(m) => AppError::FileSystem(m),
PluginStorageError::Serialization(m) => AppError::Store(m),
}
}
fn map_registry(e: PluginRegistryError) -> AppError {
match e {
PluginRegistryError::Io(m) => AppError::Store(m),
@ -2516,9 +2546,91 @@ pub struct UninstallPluginInput {
pub plugin_id: String,
}
/// Plugin-owned key/value storage facade.
pub struct PluginStorageAccess {
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
}
impl PluginStorageAccess {
/// Builds the facade.
#[must_use]
pub fn new(
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
) -> Self {
Self { storage, registry }
}
/// Reads one plugin-owned JSON value.
pub async fn get(
&self,
input: PluginStorageGetInput,
) -> Result<Option<serde_json::Value>, AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.get(&plugin_id, &input.key)
.await
.map_err(map_storage)
}
/// Writes one plugin-owned JSON value.
pub async fn set(&self, input: PluginStorageSetInput) -> Result<(), AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.set(&plugin_id, &input.key, input.value)
.await
.map_err(map_storage)
}
/// Deletes one plugin-owned JSON value.
pub async fn delete(&self, input: PluginStorageGetInput) -> Result<bool, AppError> {
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
validate_storage_key(&input.key)?;
self.storage
.delete(&plugin_id, &input.key)
.await
.map_err(map_storage)
}
async fn active_plugin_id(&self, raw: String) -> Result<PluginId, AppError> {
let plugin_id = PluginId::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
let registry = self.registry.load_registry().await.map_err(map_registry)?;
let entry = registry
.find(&plugin_id)
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
if !entry.lifecycle_state.is_runtime_active() {
return Err(AppError::Invalid("plugin is not runtime-active".to_owned()));
}
Ok(plugin_id)
}
}
fn validate_storage_key(key: &str) -> Result<(), AppError> {
if key.trim().is_empty() {
return Err(AppError::Invalid(
"plugin storage key must not be empty".to_owned(),
));
}
if key.len() > 512 {
return Err(AppError::Invalid(
"plugin storage key must not exceed 512 bytes".to_owned(),
));
}
if key.contains('\0') {
return Err(AppError::Invalid(
"plugin storage key must not contain NUL bytes".to_owned(),
));
}
Ok(())
}
/// Uninstalls a plugin.
pub struct UninstallPlugin {
packages: Arc<dyn PluginPackageStore>,
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
@ -2529,12 +2641,14 @@ impl UninstallPlugin {
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
storage: Arc<dyn PluginStorageStore>,
registry: Arc<dyn PluginRegistryStore>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
storage,
registry,
events,
mcp,
@ -2562,6 +2676,10 @@ impl UninstallPlugin {
.remove_package(&plugin_id)
.await
.map_err(map_store)?;
self.storage
.purge_plugin(&plugin_id)
.await
.map_err(map_storage)?;
self.events.publish(DomainEvent::PluginUninstalled {
plugin_id: plugin_id.clone(),
restart_required: true,
@ -3214,7 +3332,8 @@ mod tests {
use domain::ports::{
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator,
PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError,
PluginPackageStore, PluginRegistryStore, PluginStorageError, PluginStorageStore,
PluginStoreError, StoreError,
};
use domain::remote::RemoteRef;
use domain::{BackgroundTaskState, ProjectPath};
@ -3411,6 +3530,69 @@ mod tests {
}
}
#[derive(Default)]
struct FakeStorage {
values: Mutex<HashMap<(String, String), serde_json::Value>>,
purged: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl PluginStorageStore for FakeStorage {
async fn get(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<Option<serde_json::Value>, PluginStorageError> {
Ok(self
.values
.lock()
.unwrap()
.get(&(plugin_id.as_str().to_owned(), key.to_owned()))
.cloned())
}
async fn set(
&self,
plugin_id: &PluginId,
key: &str,
value: serde_json::Value,
) -> Result<(), PluginStorageError> {
self.values
.lock()
.unwrap()
.insert((plugin_id.as_str().to_owned(), key.to_owned()), value);
Ok(())
}
async fn delete(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<bool, PluginStorageError> {
Ok(self
.values
.lock()
.unwrap()
.remove(&(plugin_id.as_str().to_owned(), key.to_owned()))
.is_some())
}
async fn purge_plugin(
&self,
plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStorageError> {
self.purged
.lock()
.unwrap()
.push(plugin_id.as_str().to_owned());
self.values
.lock()
.unwrap()
.retain(|(id, _), _| id != plugin_id.as_str());
Ok(RemovalOutcome::Removed)
}
}
#[derive(Default)]
struct FakeEvents {
events: Mutex<Vec<DomainEvent>>,
@ -3802,6 +3984,7 @@ mod tests {
#[tokio::test]
async fn uninstall_removes_registry_package_and_stops_mcp() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
@ -3809,6 +3992,7 @@ mod tests {
let mcp = Arc::new(FakeMcp::default());
let uninstall = UninstallPlugin::new(
packages.clone(),
storage.clone(),
registry.clone(),
events.clone(),
mcp.clone(),
@ -3825,6 +4009,7 @@ mod tests {
assert!(result.restart_required);
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
assert_eq!(&*storage.purged.lock().unwrap(), &["dev.acme.gitgraph"]);
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
event,
@ -3835,6 +4020,89 @@ mod tests {
)));
}
#[tokio::test]
async fn plugin_storage_round_trips_json_for_runtime_active_plugin() {
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let access = PluginStorageAccess::new(storage, registry);
access
.set(PluginStorageSetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
value: serde_json::json!({"count": 2}),
})
.await
.unwrap();
let value = access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap();
assert_eq!(value, Some(serde_json::json!({"count": 2})));
assert!(access
.delete(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap());
assert_eq!(
access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
})
.await
.unwrap(),
None
);
}
#[tokio::test]
async fn plugin_storage_rejects_inactive_plugin_and_invalid_key() {
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Disabled)),
});
let access = PluginStorageAccess::new(storage, registry);
let inactive = access
.set(PluginStorageSetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: "helloPlugin.launches".to_owned(),
value: serde_json::json!(1),
})
.await
.unwrap_err();
assert_eq!(
inactive,
AppError::Invalid("plugin is not runtime-active".to_owned())
);
let storage = Arc::new(FakeStorage::default());
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let access = PluginStorageAccess::new(storage, registry);
let invalid = access
.get(PluginStorageGetInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
key: " ".to_owned(),
})
.await
.unwrap_err();
assert_eq!(
invalid,
AppError::Invalid("plugin storage key must not be empty".to_owned())
);
}
#[tokio::test]
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
@ -3844,6 +4112,7 @@ mod tests {
let registry = Arc::new(FakeRegistry::default());
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let storage = Arc::new(FakeStorage::default());
let install = InstallPluginFromDirectory::new(
packages.clone(),
registry.clone(),
@ -3851,8 +4120,13 @@ mod tests {
events.clone(),
mcp.clone(),
);
let uninstall =
UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone());
let uninstall = UninstallPlugin::new(
packages.clone(),
storage.clone(),
registry.clone(),
events,
mcp.clone(),
);
install.execute("/source/plugin".to_owned()).await.unwrap();
uninstall

View File

@ -30,6 +30,9 @@ pub struct CreateSkillInput {
/// `None`/empty ⇒ the skill falls back to the first line of its body when
/// surfaced (see [`domain::Skill::effective_description`]).
pub description: Option<String>,
/// Capability nature to expose for this skill. Defaults to workflow when the
/// caller does not specify it.
pub kind: SkillKind,
/// Initial Markdown body.
pub content: String,
/// Scope the skill is created in (selects its backing store).
@ -67,7 +70,8 @@ impl CreateSkill {
let id = SkillId::from_uuid(self.ids.new_uuid());
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
.map_err(|e| AppError::Invalid(e.to_string()))?
.with_description(input.description);
.with_description(input.description)
.with_kind(input.kind);
self.skills.save(&skill, &input.project_root).await?;
Ok(CreateSkillOutput { skill })
}

View File

@ -35,8 +35,8 @@ use domain::ports::{
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
SessionStrategy, StructuredAdapter,
AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter,
};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
@ -50,7 +50,7 @@ use application::{
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
UpdateAgentContext, UpdateAgentContextInput,
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
};
// ---------------------------------------------------------------------------
@ -604,6 +604,7 @@ impl AgentSession for FakeSession {
struct FakeStructuredFactory {
trace: Trace,
starts: Arc<Mutex<Vec<ProfileId>>>,
efforts: Arc<Mutex<Vec<Option<String>>>>,
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
next_session: SessionId,
@ -614,6 +615,7 @@ impl FakeStructuredFactory {
Self {
trace,
starts: Arc::new(Mutex::new(Vec::new())),
efforts: Arc::new(Mutex::new(Vec::new())),
envs: Arc::new(Mutex::new(Vec::new())),
policies: Arc::new(Mutex::new(Vec::new())),
next_session,
@ -624,6 +626,10 @@ impl FakeStructuredFactory {
self.starts.lock().unwrap().clone()
}
fn efforts(&self) -> Vec<Option<String>> {
self.efforts.lock().unwrap().clone()
}
fn envs(&self) -> Vec<Vec<(String, String)>> {
self.envs.lock().unwrap().clone()
}
@ -655,6 +661,10 @@ impl AgentSessionFactory for FakeStructuredFactory {
.unwrap()
.push("structured.start".to_owned());
self.starts.lock().unwrap().push(profile.id);
self.efforts
.lock()
.unwrap()
.push(profile.model_reasoning_effort.clone());
self.envs.lock().unwrap().push(_env.to_vec());
self.policies
.lock()
@ -864,6 +874,54 @@ async fn list_resolves_agent_capabilities_additively() {
);
}
#[tokio::test]
async fn list_agents_marks_effective_orchestrator_as_is_orchestrator_true() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
{
let mut inner = contexts.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
inner.manifest.designate(a2.id).unwrap();
}
let list = ListAgents::new(Arc::new(contexts));
let out = list
.execute(ListAgentsInput { project: project() })
.await
.unwrap();
let entries = out.discovery_entries();
assert_eq!(out.effective_orchestrator, Some(a2.id));
assert_eq!(entries[0].is_orchestrator, false);
assert_eq!(entries[1].is_orchestrator, true);
}
#[tokio::test]
async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
contexts
.0
.lock()
.unwrap()
.manifest
.entries
.push(ManifestEntry::from_agent(&a2));
let list = ListAgents::new(Arc::new(contexts));
let out = list
.execute(ListAgentsInput { project: project() })
.await
.unwrap();
let entries = out.discovery_entries();
assert_eq!(out.effective_orchestrator, Some(a1.id));
assert_eq!(entries[0].is_orchestrator, true);
assert_eq!(entries[1].is_orchestrator, false);
}
#[tokio::test]
async fn read_then_update_context_roundtrips() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
@ -895,6 +953,69 @@ async fn read_then_update_context_roundtrips() {
);
}
#[tokio::test]
async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
let out = update
.execute(UpdateAgentEffortInput {
project: project(),
agent_id: a.id,
effort: Some(EffortSelection::Preset("high".to_owned())),
})
.await
.unwrap();
assert_eq!(
out.agent.effort,
Some(EffortSelection::Preset("high".to_owned()))
);
assert_eq!(
contexts.manifest().entries[0].effort,
Some(EffortSelection::Preset("high".to_owned()))
);
}
#[tokio::test]
async fn update_agent_effort_none_clears_existing_override() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
let out = update
.execute(UpdateAgentEffortInput {
project: project(),
agent_id: a.id,
effort: None,
})
.await
.unwrap();
assert_eq!(out.agent.effort, None);
assert_eq!(contexts.manifest().entries[0].effort, None);
}
#[tokio::test]
async fn update_agent_effort_not_found_for_unknown_agent_id() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentEffort::new(Arc::new(contexts));
let err = update
.execute(UpdateAgentEffortInput {
project: project(),
agent_id: aid(404),
effort: Some(EffortSelection::Preset("medium".to_owned())),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn delete_removes_entry_then_unknown_is_not_found() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
@ -965,6 +1086,15 @@ fn launch_fixture_with_profile_and_recall(
recall: FakeRecall,
) -> LaunchFixture {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
launch_fixture_with_profile_agent_and_recall(profile, agent, plan, recall)
}
fn launch_fixture_with_profile_agent_and_recall(
profile: AgentProfile,
agent: Agent,
plan: Option<ContextInjectionPlan>,
recall: FakeRecall,
) -> LaunchFixture {
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
let profiles = FakeProfiles::new(vec![profile]);
let tr = trace();
@ -1127,6 +1257,63 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt
);
}
#[tokio::test]
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_structured_adapter(StructuredAdapter::Codex)
.with_model_reasoning_effort("low");
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
.with_effort(Some(EffortSelection::Preset("high".to_owned())));
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) =
launch_fixture_with_profile_agent_and_recall(
profile,
agent,
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
FakeRecall::default(),
);
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
let structured = Arc::new(StructuredSessions::new());
let launch = launch
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_structured(Arc::new(factory.clone()), structured);
launch.execute(launch_input(agent.id)).await.unwrap();
assert!(pty.spawns().is_empty());
assert_eq!(factory.efforts(), vec![Some("high".to_owned())]);
}
#[tokio::test]
async fn launch_agent_structured_falls_back_to_profile_default_when_agent_has_no_override() {
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_structured_adapter(StructuredAdapter::Codex)
.with_model_reasoning_effort("low");
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
profile,
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
let structured = Arc::new(StructuredSessions::new());
let launch = launch
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_structured(Arc::new(factory.clone()), structured);
launch.execute(launch_input(agent.id)).await.unwrap();
assert!(pty.spawns().is_empty());
assert_eq!(factory.efforts(), vec![Some("low".to_owned())]);
}
#[tokio::test]
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
let profile = profile(
@ -3264,6 +3451,17 @@ fn launch_with_projection_and_env(
env: Vec<(String, String)>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
launch_with_projection_agent_and_env(profile, agent, plan, registry, perm_doc, env)
}
fn launch_with_projection_agent_and_env(
profile: AgentProfile,
agent: Agent,
plan: Option<ContextInjectionPlan>,
registry: Option<Arc<PermissionProjectorRegistry>>,
perm_doc: Option<ProjectPermissions>,
env: Vec<(String, String)>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
let profiles = FakeProfiles::new(vec![profile]);
let tr = trace();
@ -3896,6 +4094,40 @@ async fn codex_pty_launch_forwards_profile_model_as_config_override() {
);
}
#[tokio::test]
async fn launch_agent_pty_codex_overrides_use_resolved_effort() {
let profile = codex_profile()
.with_projector(ProjectorKey::Codex)
.with_model_reasoning_effort("low");
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
let (launch, agent, _fs, pty, _s) = launch_with_projection_agent_and_env(
profile,
agent,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
None,
Vec::new(),
);
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let args = &pty.spawns()[0].args;
assert!(
args.windows(2).any(|w| w
== [
"-c".to_owned(),
"model_reasoning_effort=\"x-deep\"".to_owned()
]),
"PTY Codex launch must forward the resolved per-agent effort, got {args:?}"
);
}
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected

View File

@ -9,7 +9,10 @@ use domain::ids::{AgentId, ProjectId};
use domain::ports::{PermissionStore, StoreError};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::{PermissionSet, Posture, ProjectPermissions};
use domain::{
Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture,
ProjectPermissions,
};
#[derive(Default)]
struct FakePermissionStore {
@ -125,4 +128,69 @@ async fn resolve_agent_permissions_returns_effective_policy() {
.unwrap();
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
assert_eq!(out.shadowed, PermissionShadowReport::default());
}
#[tokio::test]
async fn resolve_agent_permissions_reports_shadowed_alongside_unchanged_effective() {
let agent = AgentId::new_random();
let store = Arc::new(FakePermissionStore {
doc: Mutex::new(ProjectPermissions::new(
Some(PermissionSet::new(
vec![PermissionRule::bash(Effect::Deny, vec![])],
Posture::Ask,
)),
vec![domain::AgentPermissionOverride::new(
agent,
PermissionSet::new(
vec![PermissionRule::bash(Effect::Allow, vec![])],
Posture::Ask,
),
)],
)),
saves: Mutex::new(0),
});
let use_case = ResolveAgentPermissions::new(store);
let out = use_case
.execute(ResolveAgentPermissionsInput {
project: project(),
agent_id: agent,
})
.await
.unwrap();
assert!(out.shadowed.execute_bash);
assert_eq!(out.effective.unwrap().decide_bash("ls"), Posture::Deny);
}
#[tokio::test]
async fn resolve_agent_permissions_shadowed_defaults_when_no_agent_override() {
let agent = AgentId::new_random();
let store = Arc::new(FakePermissionStore {
doc: Mutex::new(ProjectPermissions::new(
Some(PermissionSet::new(
vec![PermissionRule::file(
Capability::Read,
Effect::Deny,
domain::PathScope::new(["**".to_owned()]).unwrap(),
)
.unwrap()],
Posture::Deny,
)),
vec![],
)),
saves: Mutex::new(0),
});
let use_case = ResolveAgentPermissions::new(store);
let out = use_case
.execute(ResolveAgentPermissionsInput {
project: project(),
agent_id: agent,
})
.await
.unwrap();
assert_eq!(out.shadowed, PermissionShadowReport::default());
}

View File

@ -13,7 +13,7 @@ use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
};
use domain::skill::{Skill, SkillScope};
use domain::skill::{Skill, SkillKind, SkillScope};
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
use uuid::Uuid;
@ -196,6 +196,7 @@ async fn create_skill_persists_in_its_scope() {
.execute(CreateSkillInput {
name: "refactor".to_owned(),
description: Some("Refactors code".to_owned()),
kind: SkillKind::Reference,
content: "# body".to_owned(),
scope: SkillScope::Project,
project_root: root(),
@ -204,6 +205,7 @@ async fn create_skill_persists_in_its_scope() {
.unwrap();
assert_eq!(out.skill.scope, SkillScope::Project);
assert_eq!(out.skill.kind, SkillKind::Reference);
// The one-line affordance description flows through the use case onto the skill.
assert_eq!(out.skill.description.as_deref(), Some("Refactors code"));
assert_eq!(
@ -228,6 +230,7 @@ async fn create_skill_rejects_empty_content() {
.execute(CreateSkillInput {
name: "k".to_owned(),
description: None,
kind: SkillKind::Workflow,
content: String::new(),
scope: SkillScope::Global,
project_root: root(),