From 3be55795a6d0589555b7190145b20092c96e4529 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 6 Jun 2026 17:06:45 +0200 Subject: [PATCH] fix: fix some displays and features --- .gitignore | 3 + .ideai/layouts.json | 4 +- agents-dev/L12-skills.md | 31 +- crates/app-tauri/src/commands.rs | 160 ++++++- crates/app-tauri/src/dto.rs | 104 +++++ crates/app-tauri/src/lib.rs | 6 + crates/app-tauri/src/state.rs | 61 ++- crates/application/src/agent/lifecycle.rs | 104 ++++- crates/application/src/lib.rs | 7 + crates/application/src/skill/mod.rs | 21 + crates/application/src/skill/usecases.rs | 339 +++++++++++++++ crates/application/tests/agent_lifecycle.rs | 143 ++++++- crates/application/tests/skill_usecases.rs | 395 ++++++++++++++++++ crates/domain/src/ports.rs | 54 ++- crates/infrastructure/src/lib.rs | 4 +- crates/infrastructure/src/store/mod.rs | 2 + crates/infrastructure/src/store/skill.rs | 263 ++++++++++++ crates/infrastructure/tests/skill_store.rs | 211 ++++++++++ frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 143 +++++++ frontend/src/adapters/mock/mock.test.ts | 3 +- frontend/src/adapters/skill.ts | 70 ++++ frontend/src/domain/index.ts | 29 ++ frontend/src/features/agents/AgentsPanel.tsx | 117 ++++++ .../src/features/projects/ProjectsView.tsx | 12 +- frontend/src/features/skills/SkillEditor.tsx | 207 +++++++++ frontend/src/features/skills/SkillsPanel.tsx | 154 +++++++ frontend/src/features/skills/index.ts | 6 + frontend/src/features/skills/skills.test.tsx | 297 +++++++++++++ frontend/src/features/skills/useSkills.ts | 145 +++++++ frontend/src/ports/index.ts | 50 +++ 31 files changed, 3118 insertions(+), 30 deletions(-) create mode 100644 crates/application/src/skill/mod.rs create mode 100644 crates/application/src/skill/usecases.rs create mode 100644 crates/application/tests/skill_usecases.rs create mode 100644 crates/infrastructure/src/store/skill.rs create mode 100644 crates/infrastructure/tests/skill_store.rs create mode 100644 frontend/src/adapters/skill.ts create mode 100644 frontend/src/features/skills/SkillEditor.tsx create mode 100644 frontend/src/features/skills/SkillsPanel.tsx create mode 100644 frontend/src/features/skills/index.ts create mode 100644 frontend/src/features/skills/skills.test.tsx create mode 100644 frontend/src/features/skills/useSkills.ts diff --git a/.gitignore b/.gitignore index 7ae9d76..f2cd6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ frontend/coverage/ # ─── Claude Code ──────────────────────────────────────────────────────────── # Personal, machine-local overrides (shared settings.json, if any, stays tracked). .claude/settings.local.json +# Ephemeral git worktrees created by Claude Code's isolated sub-agents — dev +# tooling only, unrelated to IdeA (which stays git-independent). +.claude/worktrees/ # ─── IdeA project data ────────────────────────────────────────────────────── # Ephemeral per-agent run directories (isolated PTY cwd + generated convention diff --git a/.ideai/layouts.json b/.ideai/layouts.json index 265e58d..0499bf4 100644 --- a/.ideai/layouts.json +++ b/.ideai/layouts.json @@ -18,6 +18,7 @@ "type": "leaf", "node": { "id": "d8a86eb1-cd4d-4937-b900-4989da7c868d", + "session": "2e6293fe-250a-4654-a8b2-4fe6996f8288", "agent": "a6ced819-b893-4213-b003-9e9dc79b9641" } }, @@ -27,7 +28,8 @@ "node": { "type": "leaf", "node": { - "id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e" + "id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e", + "session": "9aa5859c-5868-46b3-a045-68cbea6eb629" } }, "weight": 1.0 diff --git a/agents-dev/L12-skills.md b/agents-dev/L12-skills.md index 0a32fd3..5470211 100644 --- a/agents-dev/L12-skills.md +++ b/agents-dev/L12-skills.md @@ -35,8 +35,31 @@ Modéliser les **Skills** : workflows réutilisables (équivalent universel des - **`Agent`** étendu : champ `skills: Vec` (serde `default`), méthodes `assign_skill` (idempotent), `unassign_skill`, `with_skills` (dédup). **`ManifestEntry`** : champ `skills` (serde `default` + `skip_serializing_if` → rétrocompat des manifests pré-L12) ; `from_agent`/`to_agent` préservent les skills. - **Tests** : 8 invariants (`entities.rs`) + 3 serde dont rétrocompat d'un manifest legacy sans clé `skills` (`serde_roundtrip.rs`). `cargo test -p domain` vert ; `cargo test --workspace` vert (0 régression) ; clippy clean. +### ✅ Port + adapter store (vert) +- **Port `SkillStore`** (`domain/ports.rs`) : `list/get/save/delete` portant `scope` + `root: &ProjectPath` **par appel** (root ignoré pour `Global`, résolu pour `Project`) — un seul store sert tous les projets ouverts, comme `AgentContextStore`. +- **Adapter `FsSkillStore`** (`infrastructure/store/skill.rs`) : même forme on-disk que `FsTemplateStore` (`index.json` + `md/.md`), deux racines disjointes : `/skills/` (Global) et `/.ideai/skills/` (Project). Delete laisse l'orphelin md (pas de remove dans le port FS), index = source de vérité. **7 tests** d'intégration tmpdir (`skill_store.rs`) : round-trip 2 scopes, **isolation de scope**, upsert, delete idempotent, camelCase. + +### ✅ Use cases application (vert) +- `application/skill` : `CreateSkill`, `UpdateSkill`, `DeleteSkill`, `ListSkills(scope)` (inputs portant `project_root`), `AssignSkillToAgent` / `UnassignSkillFromAgent` (mutent l'`AgentManifest` via `to_agent`/`from_agent`, dédup, émettent `SkillAssigned`, **idempotents**). **9 tests** (`skill_usecases.rs`). + +### ✅ Injection dans le convention file (vert, fil L6) +- `LaunchAgent` reçoit le port `SkillStore` ; `resolve_skills` lit les `.md` des skills assignés (ordre manifest, déterministe ; skill supprimé = `SkillRef` pendant → ignoré sans bloquer le lancement). +- `compose_convention_file` étendu : section `# Skills` (sous-titres `## `) après le persona ; omise si aucun skill. **3 tests** unitaires + e2e (`agent_lifecycle.rs` : injection ordonnée, ref pendant tolérée). +- **Composition root** (`app-tauri/state.rs`) : `FsSkillStore` construit (app-data global), injecté dans `LaunchAgent`. + +### ✅ IPC `app-tauri` (vert) +- **DTOs** (`dto.rs`) : `SkillDto` (transparent sur `Skill`, camelCase), `SkillListDto`, request DTOs (`Create/Update/Assign/UnassignSkillRequestDto`), `parse_skill_id`. `scope` désérialise directement vers `SkillScope` (`"global"`/`"project"`). +- **Commandes** (`commands.rs`) : `create_skill`, `update_skill`, `list_skills`, `delete_skill`, `assign_skill_to_agent`, `unassign_skill_from_agent` — shells fins qui résolvent le `Project` (→ `project.root`) puis appellent le use case. Enregistrées dans `lib.rs`. +- **Composition root** (`state.rs`) : 6 use cases skill câblés sur le `skill_store_port` (déjà construit pour le launcher) et le `contexts_port` partagé. +- `cargo build -p app-tauri` + `cargo test --workspace` (304) verts ; clippy clean. + +### ✅ Front `features/skills` (vert) +- **Domaine** (`domain/index.ts`) : `SkillScope`, `Skill`, `SkillRef` ; `Agent` étendu avec `skills: SkillRef[]`. +- **Port** (`ports/index.ts`) : `SkillGateway` (list/create/update/delete + assign/unassign) + `CreateSkillInput` ; ajouté à `Gateways`. +- **Adapters** : `TauriSkillGateway` (`adapters/skill.ts`, invoke camelCase) ; `MockSkillGateway` (`adapters/mock`, scopes disjoints + mutation partagée du `MockAgentGateway` via `_setSkills`, assign idempotent). +- **Feature** : `useSkills` (VM 2 scopes), `SkillEditor` (overlay md edit/preview + sélecteur de scope), `SkillsPanel` (listes Project/Global, CRUD). Onglet **Skills** ajouté dans `ProjectsView`. +- **Assignation** dans `AgentsPanel` : chips des skills assignés + sélecteur d'assignation + unassign, sur l'agent sélectionné ; refresh après mutation. +- **Tests** (`skills.test.tsx`, RTL via `DIProvider` + mocks) : CRUD project/global, isolation de scope, édition, suppression, assign/unassign reflétés sur l'agent, idempotence, **garde-fou « no direct invoke »** (aucune action run/launch). `vitest` : **229** verts (0 régression ; test « ten gateways » mis à jour). + ### ⏳ Reste à faire -- Port `SkillStore` (`domain/ports.rs`) + adapter `FsSkillStore` (`infrastructure/store`). -- Use cases `application/skill` : CRUD + `AssignSkillToAgent`/`UnassignSkillFromAgent`. -- Injection des skills assignés dans le convention file à l'activation (fil L6). -- IPC `app-tauri` + front `features/skills`. +- Cycle manuel : créer un skill, l'assigner à un agent, l'activer → vérifier qu'il apparaît dans le convention file de `.ideai/run//` (à faire sur l'AppImage). diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 15116d5..de67bb0 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -14,6 +14,8 @@ use application::{ GitStatusInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput, SetActiveLayoutInput, SyncAgentWithTemplateInput, UpdateAgentContextInput, + AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput, + UnassignSkillFromAgentInput, UpdateSkillInput, }; use domain::ports::PtyHandle; @@ -31,8 +33,10 @@ use crate::dto::{ ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto, - UpdateTemplateRequestDto, WriteTerminalRequestDto, + UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto, + CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto, }; +use domain::{SkillRef, SkillScope}; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::AppState; @@ -1182,3 +1186,157 @@ pub async fn move_tab_to_new_window( Ok(MoveTabResultDto::from(out)) } + +// --------------------------------------------------------------------------- +// Skills (L12) +// --------------------------------------------------------------------------- + +/// `create_skill` — create a skill in its scope's store. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an empty name/content or malformed +/// project id, `NOT_FOUND` if the project is unknown, `STORE` on failure). +#[tauri::command] +pub async fn create_skill( + request: CreateSkillRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + state + .create_skill + .execute(CreateSkillInput { + name: request.name, + content: request.content, + scope: request.scope, + project_root: project.root, + }) + .await + .map(SkillDto::from) + .map_err(ErrorDto::from) +} + +/// `update_skill` — replace a skill's Markdown content. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed ids or empty content, +/// `NOT_FOUND` if the project or skill is unknown, `STORE` on failure). +#[tauri::command] +pub async fn update_skill( + request: UpdateSkillRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let skill_id = parse_skill_id(&request.skill_id)?; + state + .update_skill + .execute(UpdateSkillInput { + scope: request.scope, + skill_id, + content: request.content, + project_root: project.root, + }) + .await + .map(SkillDto::from) + .map_err(ErrorDto::from) +} + +/// `list_skills` — list the skills in one scope. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on failure). +#[tauri::command] +pub async fn list_skills( + project_id: String, + scope: SkillScope, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .list_skills + .execute(ListSkillsInput { + scope, + project_root: project.root, + }) + .await + .map(SkillListDto::from) + .map_err(ErrorDto::from) +} + +/// `delete_skill` — remove a skill from its scope's store. +/// +/// Agents that referenced it keep their `SkillRef`; injection simply skips the +/// now-absent skill. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the +/// project or skill is unknown, `STORE` on failure). +#[tauri::command] +pub async fn delete_skill( + project_id: String, + scope: SkillScope, + skill_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&project_id, &state).await?; + let id = parse_skill_id(&skill_id)?; + state + .delete_skill + .execute(DeleteSkillInput { + scope, + skill_id: id, + project_root: project.root, + }) + .await + .map_err(ErrorDto::from) +} + +/// `assign_skill_to_agent` — record a `SkillRef` in the agent's manifest entry. +/// Idempotent. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the +/// project or agent is unknown, `STORE` on failure). +#[tauri::command] +pub async fn assign_skill_to_agent( + request: AssignSkillRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + let skill_id = parse_skill_id(&request.skill_id)?; + state + .assign_skill + .execute(AssignSkillToAgentInput { + project, + agent_id, + skill: SkillRef::new(skill_id, request.scope), + }) + .await + .map_err(ErrorDto::from) +} + +/// `unassign_skill_from_agent` — drop a skill assignment from an agent. +/// Idempotent. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the +/// project or agent is unknown, `STORE` on failure). +#[tauri::command] +pub async fn unassign_skill_from_agent( + request: UnassignSkillRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + let skill_id = parse_skill_id(&request.skill_id)?; + state + .unassign_skill + .execute(UnassignSkillFromAgentInput { + project, + agent_id, + skill_id, + }) + .await + .map_err(ErrorDto::from) +} diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 33dd873..b5437a6 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1342,3 +1342,107 @@ pub fn parse_tab_id(raw: &str) -> Result { message: format!("invalid tab id: {raw}"), }) } + +// --------------------------------------------------------------------------- +// Skills (L12) +// --------------------------------------------------------------------------- + +use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput}; +use domain::{Skill, SkillId, SkillScope}; + +/// A skill crossing the wire. [`Skill`] already serialises camelCase +/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed +/// it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct SkillDto(pub Skill); + +/// A list of skills (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct SkillListDto(pub Vec); + +impl From for SkillListDto { + fn from(out: ListSkillsOutput) -> Self { + Self(out.skills.into_iter().map(SkillDto).collect()) + } +} + +impl From for SkillDto { + fn from(out: CreateSkillOutput) -> Self { + Self(out.skill) + } +} + +impl From for SkillDto { + fn from(out: UpdateSkillOutput) -> Self { + Self(out.skill) + } +} + +/// Request DTO for `create_skill`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSkillRequestDto { + /// Owning project (resolved to a root; ignored on disk for `Global`). + pub project_id: String, + /// Display name. + pub name: String, + /// Initial Markdown content. + pub content: String, + /// Scope the skill is created in. + pub scope: SkillScope, +} + +/// Request DTO for `update_skill`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSkillRequestDto { + /// Owning project (resolved to a root; ignored on disk for `Global`). + pub project_id: String, + /// Id of the skill to update. + pub skill_id: String, + /// Scope the skill lives in. + pub scope: SkillScope, + /// New Markdown content. + pub content: String, +} + +/// Request DTO for `assign_skill_to_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssignSkillRequestDto { + /// Owning project. + pub project_id: String, + /// Agent receiving the skill. + pub agent_id: String, + /// Skill to assign. + pub skill_id: String, + /// Scope of the skill (recorded alongside the ref). + pub scope: SkillScope, +} + +/// Request DTO for `unassign_skill_from_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnassignSkillRequestDto { + /// Owning project. + pub project_id: String, + /// Agent losing the skill. + pub agent_id: String, + /// Skill to unassign. + pub skill_id: String, +} + +/// Parses a skill-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_skill_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(SkillId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid skill id: {raw}"), + }) +} diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index f80bab1..000aa86 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -119,6 +119,12 @@ pub fn run() { commands::git_log, commands::git_init, commands::git_graph, + commands::create_skill, + commands::update_skill, + commands::list_skills, + commands::delete_skill, + commands::assign_skill_to_agent, + commands::unassign_skill_from_agent, commands::move_tab_to_new_window, ]) .run(tauri::generate_context!()) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 455fe28..ee0a1da 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -13,18 +13,19 @@ use application::{ CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent, DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, - HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListTemplates, - LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, ReadAgentContext, - ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout, + HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListSkills, + ListTemplates, LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, + ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout, + AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill, SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal, }; use domain::ports::{ AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator, - ProcessSpawner, ProfileStore, ProjectStore, PtyPort, TemplateStore, + ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore, }; use infrastructure::{ - CliAgentRuntime, FsProfileStore, FsProjectStore, FsTemplateStore, Git2Repository, + CliAgentRuntime, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, UuidGenerator, }; @@ -147,6 +148,19 @@ pub struct AppState { pub git_init: Arc, /// Return the commit graph for all local branches. pub git_graph: Arc, + // --- Skills (L12) --- + /// Create a skill in a scope's store. + pub create_skill: Arc, + /// Update a skill's content. + pub update_skill: Arc, + /// List skills in a scope. + pub list_skills: Arc, + /// Delete a skill from its scope's store. + pub delete_skill: Arc, + /// Assign a skill to an agent (records a `SkillRef`). + pub assign_skill: Arc, + /// Unassign a skill from an agent. + pub unassign_skill: Arc, } impl AppState { @@ -287,6 +301,17 @@ impl AppState { let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port))); let contexts_port = Arc::clone(&contexts) as Arc; + // --- Skill store (L12) --- + // Global skills live in the machine-local app-data dir; project skills are + // resolved per call from each project's `.ideai/` (so one store serves all + // open projects). Shared by the skill use cases and the agent launcher + // (assigned-skill injection into the convention file, §14.2). + let skill_store = Arc::new(FsSkillStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let skill_store_port = Arc::clone(&skill_store) as Arc; + let create_agent = Arc::new(CreateAgentFromScratch::new( Arc::clone(&contexts_port), Arc::clone(&ids) as Arc, @@ -307,6 +332,7 @@ impl AppState { Arc::clone(&runtime_port), Arc::clone(&fs_port), Arc::clone(&pty_port), + Arc::clone(&skill_store_port), Arc::clone(&terminal_sessions), Arc::clone(&events_port), )); @@ -364,6 +390,25 @@ impl AppState { let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_port))); let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port))); + // --- Skill use cases (L12) --- + // Reuse the skill store (built above for the launcher) and the shared + // agent context store for the agent↔skill assignment. + let create_skill = Arc::new(CreateSkill::new( + Arc::clone(&skill_store_port), + Arc::clone(&ids) as Arc, + )); + let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port))); + let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port))); + let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port))); + let assign_skill = Arc::new(AssignSkillToAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&events_port), + )); + let unassign_skill = Arc::new(UnassignSkillFromAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&events_port), + )); + // --- Windows (L10) --- let move_tab = Arc::new(MoveTabToNewWindow::new( Arc::clone(&store_port), @@ -422,6 +467,12 @@ impl AppState { git_log, git_init, git_graph, + create_skill, + update_skill, + list_skills, + delete_skill, + assign_skill, + unassign_skill, move_tab, } } diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 84b6d0a..c7e64bf 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -15,11 +15,11 @@ use std::sync::Arc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext, - ProfileStore, PtyPort, RemotePath, SpawnSpec, + ProfileStore, PtyPort, RemotePath, SkillStore, SpawnSpec, StoreError, }; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId, - Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession, + Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession, }; use crate::error::AppError; @@ -350,6 +350,7 @@ pub struct LaunchAgent { runtime: Arc, fs: Arc, pty: Arc, + skills: Arc, sessions: Arc, events: Arc, } @@ -364,6 +365,7 @@ impl LaunchAgent { runtime: Arc, fs: Arc, pty: Arc, + skills: Arc, sessions: Arc, events: Arc, ) -> Self { @@ -373,11 +375,35 @@ impl LaunchAgent { runtime, fs, pty, + skills, sessions, events, } } + /// Resolves the Markdown bodies of an agent's assigned skills, in the + /// **manifest order** (deterministic). A skill that no longer exists in its + /// store (deleted out from under the assignment) is silently skipped — a + /// dangling [`domain::SkillRef`] must not block a launch. + /// + /// # Errors + /// [`AppError::Store`] on any store failure other than a missing skill. + async fn resolve_skills( + &self, + agent: &Agent, + root: &ProjectPath, + ) -> Result, AppError> { + let mut out = Vec::with_capacity(agent.skills.len()); + for skill_ref in &agent.skills { + match self.skills.get(skill_ref.scope, root, skill_ref.skill_id).await { + Ok(skill) => out.push(skill), + Err(StoreError::NotFound) => {} + Err(e) => return Err(e.into()), + } + } + Ok(out) + } + /// Executes the launch. /// /// Step order is contractually significant (and unit-tested): resolve the @@ -444,8 +470,10 @@ impl LaunchAgent { .runtime .prepare_invocation(&profile, &prepared, &run_dir)?; - // 5. Apply the injection plan side effects *before* spawning. - self.apply_injection(&input.project, &agent.context_path, &content, &mut spec) + // 5. Resolve the agent's assigned skills (their `.md` bodies), then apply + // the injection plan side effects *before* spawning. + let skills = self.resolve_skills(&agent, &input.project.root).await?; + self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec) .await?; // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. @@ -487,6 +515,7 @@ impl LaunchAgent { project: &Project, context_rel_path: &str, content: &MarkdownDoc, + skills: &[Skill], spec: &mut SpawnSpec, ) -> Result<(), AppError> { match spec.context_plan.clone() { @@ -496,9 +525,10 @@ impl LaunchAgent { // run directory — `spec.cwd` is that run dir, never the project // root, so there is zero collision between agents. The document is // composed: an absolute project-root header (so the agent knows - // where to operate, since its cwd is *not* the root) followed by - // the agent's persona `.md`. - let document = compose_convention_file(project.root.as_str(), content.as_str()); + // where to operate, since its cwd is *not* the root), the agent's + // persona `.md`, then the bodies of its assigned skills (§14.2). + let document = + compose_convention_file(project.root.as_str(), content.as_str(), skills); let path = RemotePath::new(join(&spec.cwd, &target)); self.fs.write(&path, document.as_bytes()).await?; } @@ -537,13 +567,17 @@ fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result String { +pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills: &[Skill]) -> String { let mut out = String::new(); out.push_str("# Project root\n\n"); out.push_str(project_root); @@ -554,6 +588,17 @@ pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> Str ); out.push_str("---\n\n"); out.push_str(agent_md); + + if !skills.is_empty() { + out.push_str("\n\n---\n\n# Skills\n"); + for skill in skills { + out.push_str("\n## "); + out.push_str(&skill.name); + out.push_str("\n\n"); + out.push_str(skill.content_md.as_str()); + out.push('\n'); + } + } out } @@ -610,7 +655,7 @@ mod tests { #[test] fn compose_convention_file_carries_root_then_persona() { - let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things."); + let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[]); // Absolute project root present. assert!(doc.contains("/abs/project/root")); @@ -621,5 +666,38 @@ mod tests { let root_at = doc.find("/abs/project/root").unwrap(); let persona_at = doc.find("# Persona").unwrap(); assert!(root_at < persona_at, "root header must precede the persona"); + // No skills ⇒ no Skills section. + assert!(!doc.contains("# Skills")); + } + + #[test] + fn compose_convention_file_appends_assigned_skills_in_order() { + let s = |n: u128, name: &str, body: &str| { + Skill::new( + domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)), + name, + MarkdownDoc::new(body), + domain::SkillScope::Global, + ) + .unwrap() + }; + let doc = compose_convention_file( + "/root", + "# Persona", + &[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")], + ); + + // Both skill bodies present, after the persona. + assert!(doc.contains("REFAC_BODY")); + assert!(doc.contains("REVIEW_BODY")); + let persona_at = doc.find("# Persona").unwrap(); + let refac_at = doc.find("REFAC_BODY").unwrap(); + let review_at = doc.find("REVIEW_BODY").unwrap(); + 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")); } } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index ce20dba..0dcc620 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -18,6 +18,7 @@ pub mod health; pub mod layout; pub mod project; pub mod remote; +pub mod skill; pub mod template; pub mod terminal; pub mod window; @@ -61,6 +62,12 @@ pub use template::{ SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput, }; +pub use skill::{ + AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput, + DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, + UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, + UpdateSkillOutput, +}; pub use terminal::{ CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal, diff --git a/crates/application/src/skill/mod.rs b/crates/application/src/skill/mod.rs new file mode 100644 index 0000000..8b6b62b --- /dev/null +++ b/crates/application/src/skill/mod.rs @@ -0,0 +1,21 @@ +//! Skill use cases (ARCHITECTURE §14.2; L12). +//! +//! Skills are reusable, model-agnostic workflows (IdeA's universal equivalent of +//! a CLI slash-command). This module owns their CRUD across both scopes +//! ([`domain::skill::SkillScope`]) and the agent↔skill assignment that records a +//! [`domain::skill::SkillRef`] in the project manifest. The actual injection of +//! an assigned skill's body into the generated convention file happens at agent +//! activation (L6). +//! +//! Every use case talks only to ports ([`domain::ports::SkillStore`], +//! [`domain::ports::AgentContextStore`], [`domain::ports::IdGenerator`], +//! [`domain::ports::EventBus`]). + +mod usecases; + +pub use usecases::{ + AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput, + DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, + UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, + UpdateSkillOutput, +}; diff --git a/crates/application/src/skill/usecases.rs b/crates/application/src/skill/usecases.rs new file mode 100644 index 0000000..da16374 --- /dev/null +++ b/crates/application/src/skill/usecases.rs @@ -0,0 +1,339 @@ +//! Skill use cases (ARCHITECTURE §14.2; L12). +//! +//! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`], +//! [`ListSkills`]. +//! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate +//! the project manifest entry's `skills` and announce +//! [`DomainEvent::SkillAssigned`]. Both are idempotent. + +use std::sync::Arc; + +use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore}; +use domain::{ + AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId, + SkillRef, SkillScope, +}; + +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// CreateSkill +// --------------------------------------------------------------------------- + +/// Input for [`CreateSkill::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateSkillInput { + /// Display name (also the `.md` stem on disk). + pub name: String, + /// Initial Markdown body. + pub content: String, + /// Scope the skill is created in (selects its backing store). + pub scope: SkillScope, + /// Active project root (used only for [`SkillScope::Project`]). + pub project_root: ProjectPath, +} + +/// Output of [`CreateSkill::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateSkillOutput { + /// The created skill. + pub skill: Skill, +} + +/// Creates a skill in the store of its [`SkillScope`]. +pub struct CreateSkill { + skills: Arc, + ids: Arc, +} + +impl CreateSkill { + /// Builds the use case from its ports. + #[must_use] + pub fn new(skills: Arc, ids: Arc) -> Self { + Self { skills, ids } + } + + /// Executes creation. + /// + /// # Errors + /// - [`AppError::Invalid`] if `name`/`content` is empty, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: CreateSkillInput) -> Result { + 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()))?; + self.skills.save(&skill, &input.project_root).await?; + Ok(CreateSkillOutput { skill }) + } +} + +// --------------------------------------------------------------------------- +// UpdateSkill +// --------------------------------------------------------------------------- + +/// Input for [`UpdateSkill::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateSkillInput { + /// Scope the skill lives in. + pub scope: SkillScope, + /// Skill to update. + pub skill_id: SkillId, + /// New Markdown body. + pub content: String, + /// Active project root (used only for [`SkillScope::Project`]). + pub project_root: ProjectPath, +} + +/// Output of [`UpdateSkill::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateSkillOutput { + /// The updated skill. + pub skill: Skill, +} + +/// Replaces a skill's content (re-validating the non-empty invariant). +pub struct UpdateSkill { + skills: Arc, +} + +impl UpdateSkill { + /// Builds the use case. + #[must_use] + pub fn new(skills: Arc) -> Self { + Self { skills } + } + + /// Executes the update. + /// + /// # Errors + /// - [`AppError::NotFound`] if the skill is unknown in that scope, + /// - [`AppError::Invalid`] if the new content is empty, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: UpdateSkillInput) -> Result { + let current = self + .skills + .get(input.scope, &input.project_root, input.skill_id) + .await?; + let updated = current + .with_content(MarkdownDoc::new(input.content)) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.skills.save(&updated, &input.project_root).await?; + Ok(UpdateSkillOutput { skill: updated }) + } +} + +// --------------------------------------------------------------------------- +// ListSkills / DeleteSkill +// --------------------------------------------------------------------------- + +/// Input for [`ListSkills::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListSkillsInput { + /// Scope to list. + pub scope: SkillScope, + /// Active project root (used only for [`SkillScope::Project`]). + pub project_root: ProjectPath, +} + +/// Output of [`ListSkills::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListSkillsOutput { + /// All skills in the requested scope. + pub skills: Vec, +} + +/// Lists the skills in one scope. +pub struct ListSkills { + skills: Arc, +} + +impl ListSkills { + /// Builds the use case. + #[must_use] + pub fn new(skills: Arc) -> Self { + Self { skills } + } + + /// Lists skills in `input.scope`. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: ListSkillsInput) -> Result { + Ok(ListSkillsOutput { + skills: self.skills.list(input.scope, &input.project_root).await?, + }) + } +} + +/// Input for [`DeleteSkill::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteSkillInput { + /// Scope the skill lives in. + pub scope: SkillScope, + /// Skill to delete. + pub skill_id: SkillId, + /// Active project root (used only for [`SkillScope::Project`]). + pub project_root: ProjectPath, +} + +/// Deletes a skill from its scope's store. +/// +/// Agents that referenced it keep their [`SkillRef`]; the injection step simply +/// finds nothing to resolve for the now-absent skill and skips it. +pub struct DeleteSkill { + skills: Arc, +} + +impl DeleteSkill { + /// Builds the use case. + #[must_use] + pub fn new(skills: Arc) -> Self { + Self { skills } + } + + /// Deletes the skill. + /// + /// # Errors + /// - [`AppError::NotFound`] if the skill is unknown in that scope, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> { + self.skills + .delete(input.scope, &input.project_root, input.skill_id) + .await?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// AssignSkillToAgent / UnassignSkillFromAgent +// --------------------------------------------------------------------------- + +/// Input for [`AssignSkillToAgent::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssignSkillToAgentInput { + /// The owning project. + pub project: Project, + /// The agent receiving the skill. + pub agent_id: AgentId, + /// The skill to assign. + pub skill: SkillRef, +} + +/// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry. +/// Idempotent: re-assigning the same skill is a no-op (no duplicate). +pub struct AssignSkillToAgent { + contexts: Arc, + events: Arc, +} + +impl AssignSkillToAgent { + /// Builds the use case from its ports. + #[must_use] + pub fn new(contexts: Arc, events: Arc) -> Self { + Self { contexts, events } + } + + /// Executes the assignment. + /// + /// # 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: AssignSkillToAgentInput) -> Result<(), AppError> { + let mut manifest = self.contexts.load_manifest(&input.project).await?; + let entry = manifest + .entries + .iter_mut() + .find(|e| e.agent_id == input.agent_id) + .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + + // Mutate through the domain entity so the dedup invariant is enforced in + // one place, then fold the result back into the manifest entry. + let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?; + let changed = agent.assign_skill(input.skill); + *entry = domain::ManifestEntry::from_agent(&agent); + + if changed { + self.persist_and_announce(&input.project, manifest, input.agent_id, input.skill.skill_id, true) + .await?; + } + Ok(()) + } + + /// Saves the manifest and announces the assignment change. + async fn persist_and_announce( + &self, + project: &Project, + manifest: AgentManifest, + agent_id: AgentId, + skill_id: SkillId, + assigned: bool, + ) -> Result<(), AppError> { + let manifest = AgentManifest::new(manifest.version, manifest.entries) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.contexts.save_manifest(project, &manifest).await?; + self.events.publish(DomainEvent::SkillAssigned { + agent_id, + skill_id, + assigned, + }); + Ok(()) + } +} + +/// Input for [`UnassignSkillFromAgent::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnassignSkillFromAgentInput { + /// The owning project. + pub project: Project, + /// The agent losing the skill. + pub agent_id: AgentId, + /// The skill to unassign. + pub skill_id: SkillId, +} + +/// Removes a skill assignment from an agent. Idempotent: unassigning a skill the +/// agent does not carry is a no-op. +pub struct UnassignSkillFromAgent { + contexts: Arc, + events: Arc, +} + +impl UnassignSkillFromAgent { + /// Builds the use case from its ports. + #[must_use] + pub fn new(contexts: Arc, events: Arc) -> Self { + Self { contexts, events } + } + + /// Executes the un-assignment. + /// + /// # 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: UnassignSkillFromAgentInput) -> Result<(), AppError> { + let mut manifest = self.contexts.load_manifest(&input.project).await?; + let entry = manifest + .entries + .iter_mut() + .find(|e| e.agent_id == input.agent_id) + .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + + let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?; + let changed = agent.unassign_skill(input.skill_id); + *entry = domain::ManifestEntry::from_agent(&agent); + + if changed { + let manifest = AgentManifest::new(manifest.version, manifest.entries) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.contexts.save_manifest(&input.project, &manifest).await?; + self.events.publish(DomainEvent::SkillAssigned { + agent_id: input.agent_id, + skill_id: input.skill_id, + assigned: false, + }); + } + Ok(()) + } +} diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index ec574c7..7166f9a 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -26,12 +26,13 @@ use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, - PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError, + PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; -use domain::{PtySize, SessionId}; +use domain::skill::{Skill, SkillScope}; +use domain::{PtySize, SessionId, SkillId, SkillRef}; use uuid::Uuid; use application::{ @@ -177,6 +178,49 @@ impl ProfileStore for FakeProfiles { } } +// --------------------------------------------------------------------------- +// FakeSkills (SkillStore) — an in-memory store seeded with a few skills +// --------------------------------------------------------------------------- + +#[derive(Clone, Default)] +struct FakeSkills(Arc>); + +impl FakeSkills { + fn with(skills: Vec) -> Self { + Self(Arc::new(skills)) + } +} + +#[async_trait] +impl SkillStore for FakeSkills { + async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result, StoreError> { + Ok(self.0.iter().filter(|s| s.scope == scope).cloned().collect()) + } + async fn get( + &self, + scope: SkillScope, + _root: &ProjectPath, + id: SkillId, + ) -> Result { + self.0 + .iter() + .find(|s| s.scope == scope && s.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { + Ok(()) + } + async fn delete( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result<(), StoreError> { + Ok(()) + } +} + // --------------------------------------------------------------------------- // FakeRuntime (AgentRuntime) — records prepare + returns a configured plan // --------------------------------------------------------------------------- @@ -577,6 +621,7 @@ fn launch_fixture(injection: ContextInjection, plan: Option>>); +#[async_trait] +impl SkillStore for FakeSkills { + async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result, StoreError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .filter(|s| s.scope == scope) + .cloned() + .collect()) + } + async fn get( + &self, + scope: SkillScope, + _root: &ProjectPath, + id: SkillId, + ) -> Result { + self.0 + .lock() + .unwrap() + .iter() + .find(|s| s.scope == scope && s.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { + let mut v = self.0.lock().unwrap(); + if let Some(slot) = v.iter_mut().find(|s| s.scope == skill.scope && s.id == skill.id) { + *slot = skill.clone(); + } else { + v.push(skill.clone()); + } + Ok(()) + } + async fn delete( + &self, + scope: SkillScope, + _root: &ProjectPath, + id: SkillId, + ) -> Result<(), StoreError> { + let mut v = self.0.lock().unwrap(); + let before = v.len(); + v.retain(|s| !(s.scope == scope && s.id == id)); + if v.len() == before { + return Err(StoreError::NotFound); + } + Ok(()) + } +} + +#[derive(Clone)] +struct FakeContexts(Arc>); +impl FakeContexts { + fn new(entries: Vec) -> Self { + Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries }))) + } + fn manifest(&self) -> AgentManifest { + self.0.lock().unwrap().clone() + } +} +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _p: &Project, + _agent: &AgentId, + ) -> Result { + Err(StoreError::NotFound) + } + async fn write_context( + &self, + _p: &Project, + _agent: &AgentId, + _md: &MarkdownDoc, + ) -> Result<(), StoreError> { + Ok(()) + } + async fn load_manifest(&self, _p: &Project) -> Result { + Ok(self.manifest()) + } + async fn save_manifest(&self, _p: &Project, m: &AgentManifest) -> Result<(), StoreError> { + *self.0.lock().unwrap() = m.clone(); + Ok(()) + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + 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()) + } +} + +struct SeqIds(Mutex); +impl SeqIds { + fn new() -> Self { + Self(Mutex::new(1)) + } +} +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +fn pid(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} +fn sid(n: u128) -> SkillId { + SkillId::from_uuid(Uuid::from_u128(n)) +} +fn root() -> ProjectPath { + ProjectPath::new("/home/me/demo").unwrap() +} +fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1000)), + "demo", + root(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} +fn scratch_entry(agent: AgentId) -> ManifestEntry { + ManifestEntry::new(agent, "A", "agents/a.md", pid(1), None, false, None).unwrap() +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn create_skill_persists_in_its_scope() { + let store = FakeSkills::default(); + let out = CreateSkill::new(Arc::new(store.clone()), Arc::new(SeqIds::new())) + .execute(CreateSkillInput { + name: "refactor".to_owned(), + content: "# body".to_owned(), + scope: SkillScope::Project, + project_root: root(), + }) + .await + .unwrap(); + + assert_eq!(out.skill.scope, SkillScope::Project); + assert_eq!( + store.list(SkillScope::Project, &root()).await.unwrap().len(), + 1 + ); + assert!(store + .list(SkillScope::Global, &root()) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn create_skill_rejects_empty_content() { + let store = FakeSkills::default(); + let err = CreateSkill::new(Arc::new(store), Arc::new(SeqIds::new())) + .execute(CreateSkillInput { + name: "k".to_owned(), + content: String::new(), + scope: SkillScope::Global, + project_root: root(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID"); +} + +#[tokio::test] +async fn update_skill_replaces_content() { + let store = FakeSkills::default(); + store + .save( + &Skill::new(sid(1), "k", MarkdownDoc::new("v1"), SkillScope::Global).unwrap(), + &root(), + ) + .await + .unwrap(); + + let out = UpdateSkill::new(Arc::new(store.clone())) + .execute(UpdateSkillInput { + scope: SkillScope::Global, + skill_id: sid(1), + content: "v2".to_owned(), + project_root: root(), + }) + .await + .unwrap(); + assert_eq!(out.skill.content_md.as_str(), "v2"); +} + +#[tokio::test] +async fn update_unknown_skill_is_not_found() { + let store = FakeSkills::default(); + let err = UpdateSkill::new(Arc::new(store)) + .execute(UpdateSkillInput { + scope: SkillScope::Global, + skill_id: sid(404), + content: "x".to_owned(), + project_root: root(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND"); +} + +#[tokio::test] +async fn list_is_scope_filtered() { + let store = FakeSkills::default(); + store + .save( + &Skill::new(sid(1), "g", MarkdownDoc::new("x"), SkillScope::Global).unwrap(), + &root(), + ) + .await + .unwrap(); + store + .save( + &Skill::new(sid(2), "p", MarkdownDoc::new("x"), SkillScope::Project).unwrap(), + &root(), + ) + .await + .unwrap(); + + let listed = ListSkills::new(Arc::new(store)) + .execute(ListSkillsInput { + scope: SkillScope::Global, + project_root: root(), + }) + .await + .unwrap(); + assert_eq!(listed.skills.len(), 1); + assert_eq!(listed.skills[0].id, sid(1)); +} + +#[tokio::test] +async fn delete_then_delete_is_not_found() { + let store = FakeSkills::default(); + store + .save( + &Skill::new(sid(1), "k", MarkdownDoc::new("x"), SkillScope::Project).unwrap(), + &root(), + ) + .await + .unwrap(); + let uc = DeleteSkill::new(Arc::new(store)); + uc.execute(DeleteSkillInput { + scope: SkillScope::Project, + skill_id: sid(1), + project_root: root(), + }) + .await + .unwrap(); + let err = uc + .execute(DeleteSkillInput { + scope: SkillScope::Project, + skill_id: sid(1), + project_root: root(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND"); +} + +// --------------------------------------------------------------------------- +// Assignment +// --------------------------------------------------------------------------- + +fn assigned_event(events: &[DomainEvent]) -> Vec<(SkillId, bool)> { + events + .iter() + .filter_map(|e| match e { + DomainEvent::SkillAssigned { + skill_id, assigned, .. + } => Some((*skill_id, *assigned)), + _ => None, + }) + .collect() +} + +#[tokio::test] +async fn assign_mutates_manifest_emits_event_and_is_idempotent() { + let contexts = FakeContexts::new(vec![scratch_entry(aid(1))]); + let bus = SpyBus::default(); + let uc = AssignSkillToAgent::new(Arc::new(contexts.clone()), Arc::new(bus.clone())); + let input = AssignSkillToAgentInput { + project: project(), + agent_id: aid(1), + skill: SkillRef::new(sid(9), SkillScope::Global), + }; + + uc.execute(input.clone()).await.unwrap(); + // Re-assigning the same skill is a no-op (dedup). + uc.execute(input).await.unwrap(); + + let entry = &contexts.manifest().entries[0]; + assert_eq!(entry.skills.len(), 1, "no duplicate assignment"); + assert_eq!(entry.skills[0].skill_id, sid(9)); + assert_eq!( + assigned_event(&bus.events()), + vec![(sid(9), true)], + "exactly one assign event despite double execute" + ); +} + +#[tokio::test] +async fn unassign_removes_and_emits_false_then_is_noop() { + let mut entry = scratch_entry(aid(1)); + entry.skills.push(SkillRef::new(sid(9), SkillScope::Global)); + let contexts = FakeContexts::new(vec![entry]); + let bus = SpyBus::default(); + let uc = UnassignSkillFromAgent::new(Arc::new(contexts.clone()), Arc::new(bus.clone())); + let input = UnassignSkillFromAgentInput { + project: project(), + agent_id: aid(1), + skill_id: sid(9), + }; + + uc.execute(input.clone()).await.unwrap(); + uc.execute(input).await.unwrap(); // already gone → no-op + + assert!(contexts.manifest().entries[0].skills.is_empty()); + assert_eq!(assigned_event(&bus.events()), vec![(sid(9), false)]); +} + +#[tokio::test] +async fn assign_to_unknown_agent_is_not_found() { + let contexts = FakeContexts::new(vec![]); + let bus = SpyBus::default(); + let err = AssignSkillToAgent::new(Arc::new(contexts), Arc::new(bus)) + .execute(AssignSkillToAgentInput { + project: project(), + agent_id: aid(404), + skill: SkillRef::new(sid(9), SkillScope::Global), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND"); +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index a8ee9a0..dc32cb2 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -34,6 +34,7 @@ use crate::markdown::MarkdownDoc; use crate::profile::AgentProfile; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; +use crate::skill::{Skill, SkillScope}; use crate::template::AgentTemplate; use crate::terminal::PtySize; @@ -209,7 +210,7 @@ pub enum FsError { } /// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`], -/// [`AgentContextStore`]). +/// [`AgentContextStore`], [`SkillStore`]). #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum StoreError { /// The requested item was not found. @@ -460,6 +461,57 @@ pub trait TemplateStore: Send + Sync { async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>; } +/// CRUD for [`Skill`]s across both scopes ([`SkillScope::Global`] in the IDE +/// store, [`SkillScope::Project`] under `.ideai/skills/`), per ARCHITECTURE +/// §14.2 and L12. +/// +/// The two scopes are **isolated**: a skill saved as `Project` never surfaces in +/// a `Global` listing and vice-versa. Each call carries the [`SkillScope`] +/// explicitly (or via the [`Skill`] for [`save`](Self::save)) so the adapter +/// resolves the right backing location. +/// +/// `root` identifies the project whose `.ideai/skills/` to use for +/// [`SkillScope::Project`]; it is **ignored** for [`SkillScope::Global`] (which +/// lives in the machine-global IDE store). Passing the root per call — rather +/// than baking it into the adapter — keeps a single store instance correct +/// across every open project (mirroring [`AgentContextStore`]). +#[async_trait] +pub trait SkillStore: Send + Sync { + /// Lists all skills in `scope` (for `root`'s project when project-scoped). + /// + /// # Errors + /// [`StoreError`] on failure. + async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result, StoreError>; + + /// Gets a skill by id within `scope`. + /// + /// # Errors + /// [`StoreError::NotFound`] if absent in that scope. + async fn get( + &self, + scope: SkillScope, + root: &ProjectPath, + id: crate::ids::SkillId, + ) -> Result; + + /// Saves (creates or replaces by id) a skill in its own [`Skill::scope`]. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError>; + + /// Deletes a skill by id within `scope`. + /// + /// # Errors + /// [`StoreError::NotFound`] if absent in that scope. + async fn delete( + &self, + scope: SkillScope, + root: &ProjectPath, + id: crate::ids::SkillId, + ) -> Result<(), StoreError>; +} + /// Persistence of the known-projects registry and the workspace. #[async_trait] pub trait ProjectStore: Send + Sync { diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 2cb9cb4..4f21ec1 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -32,4 +32,6 @@ pub use process::LocalProcessSpawner; pub use pty::PortablePtyAdapter; pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; -pub use store::{FsProfileStore, FsProjectStore, FsTemplateStore, IdeaiContextStore}; +pub use store::{ + FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, IdeaiContextStore, +}; diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index da7e097..a6c54c8 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -7,9 +7,11 @@ mod context; mod profile; mod project; +mod skill; mod template; pub use context::IdeaiContextStore; pub use profile::FsProfileStore; pub use project::FsProjectStore; +pub use skill::FsSkillStore; pub use template::FsTemplateStore; diff --git a/crates/infrastructure/src/store/skill.rs b/crates/infrastructure/src/store/skill.rs new file mode 100644 index 0000000..36bd130 --- /dev/null +++ b/crates/infrastructure/src/store/skill.rs @@ -0,0 +1,263 @@ +//! [`FsSkillStore`] — file implementation of the [`SkillStore`] port +//! (ARCHITECTURE §14.2, L12). +//! +//! Skills are reusable, model-agnostic workflows. They live in **two isolated +//! scopes**, each backed by its own directory but the same on-disk shape +//! (mirroring [`crate::store::FsTemplateStore`]): a small JSON index carrying the +//! metadata needed to list without parsing every `.md`, plus the Markdown bodies +//! under `md/`: +//! +//! ```text +//! /skills/ # SkillScope::Global (shared across projects) +//! /.ideai/skills/ # SkillScope::Project (travels with the code) +//! ├── index.json # { version, skills: [{ id, name, contentHash }] } +//! └── md/ +//! └── .md # a skill's Markdown content +//! ``` +//! +//! The two scopes never bleed into one another: a `Project` skill is invisible to +//! a `Global` listing and vice-versa, because each resolves to a different +//! directory. All I/O goes through the [`FileSystem`] port, so the adapter is +//! location-neutral (a project hosted over SSH/WSL works unchanged) and +//! Tauri-agnostic. +//! +//! Like the template store, [`delete`](SkillStore::delete) drops the index row +//! and leaves the orphaned `md/.md` on disk (the [`FileSystem`] port exposes +//! no remove); since listing is index-driven, the skill is effectively gone. + +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::ids::SkillId; +use domain::markdown::MarkdownDoc; +use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError}; +use domain::project::ProjectPath; +use domain::skill::{Skill, SkillScope}; + +/// Directory (under app-data) holding the global skills store. +const GLOBAL_SKILLS_DIR: &str = "skills"; + +/// The `.ideai/` directory name inside a project root. +const IDEAI_DIR: &str = ".ideai"; + +/// Sub-path of the project-scoped skills store inside `.ideai/`. +const PROJECT_SKILLS_DIR: &str = "skills"; + +/// Index file name inside a skills dir. +const INDEX_FILE: &str = "index.json"; + +/// Current schema version of the index file. +const INDEX_VERSION: u32 = 1; + +/// One metadata row in `index.json` (the `.md` content lives separately). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IndexEntry { + id: SkillId, + name: String, + content_hash: String, +} + +/// On-disk shape of a scope's `index.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IndexDoc { + version: u32, + skills: Vec, +} + +impl Default for IndexDoc { + fn default() -> Self { + Self { + version: INDEX_VERSION, + skills: Vec::new(), + } + } +} + +/// A stable, dependency-free digest of Markdown content for out-of-app edit +/// detection — deterministic across runs and platforms (fixed-key hasher). +fn content_hash(md: &MarkdownDoc) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + md.as_str().hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +/// File-backed [`SkillStore`], composing a [`FileSystem`] port. Holds only the +/// machine-global app-data dir; the project root for [`SkillScope::Project`] is +/// supplied **per call**, so a single instance serves every open project +/// (mirroring [`crate::store::IdeaiContextStore`]). +#[derive(Clone)] +pub struct FsSkillStore { + fs: Arc, + app_data_dir: String, +} + +impl FsSkillStore { + /// Builds the store from an injected [`FileSystem`] and the global app-data + /// dir (used for [`SkillScope::Global`]). Directories are created on first + /// write. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + /// Root directory of a scope's store. `root` is ignored for `Global`. + fn dir(&self, scope: SkillScope, root: &ProjectPath) -> String { + match scope { + SkillScope::Global => { + let base = self.app_data_dir.trim_end_matches(['/', '\\']); + format!("{base}/{GLOBAL_SKILLS_DIR}") + } + SkillScope::Project => { + let base = root.as_str().trim_end_matches(['/', '\\']); + format!("{base}/{IDEAI_DIR}/{PROJECT_SKILLS_DIR}") + } + } + } + + /// `/index.json`. + fn index_path(&self, scope: SkillScope, root: &ProjectPath) -> RemotePath { + RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(scope, root))) + } + + /// `/md/.md`. + fn md_path(&self, scope: SkillScope, root: &ProjectPath, id: SkillId) -> RemotePath { + RemotePath::new(format!("{}/md/{id}.md", self.dir(scope, root))) + } + + /// Reads a scope's index, returning an empty default if absent. + async fn read_index( + &self, + scope: SkillScope, + root: &ProjectPath, + ) -> Result { + match self.fs.read(&self.index_path(scope, root)).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + /// Writes a scope's index, ensuring its directory exists. + async fn write_index( + &self, + scope: SkillScope, + root: &ProjectPath, + doc: &IndexDoc, + ) -> Result<(), StoreError> { + self.fs + .create_dir_all(&RemotePath::new(self.dir(scope, root))) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + let bytes = + serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?; + self.fs + .write(&self.index_path(scope, root), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + /// Reconstructs the [`Skill`] for an index entry by reading its `.md`. + async fn load( + &self, + scope: SkillScope, + root: &ProjectPath, + entry: &IndexEntry, + ) -> Result { + let bytes = self + .fs + .read(&self.md_path(scope, root, entry.id)) + .await + .map_err(|e| match e { + domain::ports::FsError::NotFound(_) => StoreError::NotFound, + other => StoreError::Io(other.to_string()), + })?; + let content = + String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?; + Skill::new(entry.id, entry.name.clone(), MarkdownDoc::new(content), scope) + .map_err(|e| StoreError::Serialization(e.to_string())) + } +} + +#[async_trait] +impl SkillStore for FsSkillStore { + async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result, StoreError> { + let index = self.read_index(scope, root).await?; + let mut out = Vec::with_capacity(index.skills.len()); + for entry in &index.skills { + out.push(self.load(scope, root, entry).await?); + } + Ok(out) + } + + async fn get( + &self, + scope: SkillScope, + root: &ProjectPath, + id: SkillId, + ) -> Result { + let index = self.read_index(scope, root).await?; + let entry = index + .skills + .iter() + .find(|e| e.id == id) + .ok_or(StoreError::NotFound)?; + self.load(scope, root, entry).await + } + + async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError> { + let scope = skill.scope; + // (1) Write the Markdown content. + self.fs + .create_dir_all(&RemotePath::new(format!("{}/md", self.dir(scope, root)))) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + self.fs + .write( + &self.md_path(scope, root, skill.id), + skill.content_md.as_str().as_bytes(), + ) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + + // (2) Upsert the index metadata. + let mut index = self.read_index(scope, root).await?; + let row = IndexEntry { + id: skill.id, + name: skill.name.clone(), + content_hash: content_hash(&skill.content_md), + }; + if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) { + *slot = row; + } else { + index.skills.push(row); + } + self.write_index(scope, root, &index).await + } + + async fn delete( + &self, + scope: SkillScope, + root: &ProjectPath, + id: SkillId, + ) -> Result<(), StoreError> { + let mut index = self.read_index(scope, root).await?; + let before = index.skills.len(); + index.skills.retain(|e| e.id != id); + if index.skills.len() == before { + return Err(StoreError::NotFound); + } + // The orphaned `md/.md` is left on disk (no FileSystem delete); the + // index no longer references it, so it is effectively gone. + self.write_index(scope, root, &index).await + } +} diff --git a/crates/infrastructure/tests/skill_store.rs b/crates/infrastructure/tests/skill_store.rs new file mode 100644 index 0000000..493b062 --- /dev/null +++ b/crates/infrastructure/tests/skill_store.rs @@ -0,0 +1,211 @@ +//! L12 integration tests for [`FsSkillStore`] against a real temp directory and a +//! real [`LocalFileSystem`]: md + `index.json` round-trip in **both** scopes, +//! scope isolation (a `Project` skill never appears under `Global` and vice +//! versa), upsert, delete, tolerant reads, and the on-disk layout. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::ids::SkillId; +use domain::markdown::MarkdownDoc; +use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError}; +use domain::project::ProjectPath; +use domain::skill::{Skill, SkillScope}; +use infrastructure::{FsSkillStore, LocalFileSystem}; +use uuid::Uuid; + +/// A unique scratch directory under the OS temp dir, cleaned up on drop. It plays +/// **both** roles: its own path is the global app-data dir, and a `project/` +/// child is the project root (so the two scopes resolve to disjoint subtrees). +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l12-skill-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn app_data_dir(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn project_root(&self) -> ProjectPath { + ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap() + } + fn child(&self, rel: &str) -> RemotePath { + RemotePath::new(self.0.join(rel).to_string_lossy().into_owned()) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store(tmp: &TempDir) -> FsSkillStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + FsSkillStore::new(fs, tmp.app_data_dir()) +} + +fn sid(n: u128) -> SkillId { + SkillId::from_uuid(Uuid::from_u128(n)) +} + +fn skill(id: SkillId, name: &str, content: &str, scope: SkillScope) -> Skill { + Skill::new(id, name, MarkdownDoc::new(content), scope).unwrap() +} + +#[tokio::test] +async fn missing_index_lists_empty_in_both_scopes() { + let tmp = TempDir::new(); + let s = store(&tmp); + let root = tmp.project_root(); + assert!(s.list(SkillScope::Global, &root).await.unwrap().is_empty()); + assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn global_roundtrip_save_get_list() { + let tmp = TempDir::new(); + let s = store(&tmp); + let root = tmp.project_root(); + + let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global); + s.save(&sk, &root).await.unwrap(); + + assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk); + assert_eq!( + s.list(SkillScope::Global, &root).await.unwrap(), + vec![sk.clone()] + ); + + // The Markdown landed under the global skills dir. + let fs = LocalFileSystem::new(); + let bytes = fs + .read(&tmp.child(&format!("skills/md/{}.md", sid(1)))) + .await + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str()); +} + +#[tokio::test] +async fn project_roundtrip_lands_under_ideai() { + let tmp = TempDir::new(); + let s = store(&tmp); + let root = tmp.project_root(); + + let sk = skill(sid(1), "review", "# Review workflow", SkillScope::Project); + s.save(&sk, &root).await.unwrap(); + + assert_eq!(s.get(SkillScope::Project, &root, sid(1)).await.unwrap(), sk); + + let fs = LocalFileSystem::new(); + let bytes = fs + .read(&tmp.child(&format!("project/.ideai/skills/md/{}.md", sid(1)))) + .await + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str()); +} + +#[tokio::test] +async fn scopes_are_isolated() { + let tmp = TempDir::new(); + let s = store(&tmp); + let root = tmp.project_root(); + + s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root) + .await + .unwrap(); + s.save( + &skill(sid(2), "p", "project body", SkillScope::Project), + &root, + ) + .await + .unwrap(); + + // A global skill never surfaces in the project scope and vice-versa. + let globals = s.list(SkillScope::Global, &root).await.unwrap(); + let projects = s.list(SkillScope::Project, &root).await.unwrap(); + assert_eq!(globals.len(), 1); + assert_eq!(projects.len(), 1); + assert_eq!(globals[0].id, sid(1)); + assert_eq!(projects[0].id, sid(2)); + + assert!(matches!( + s.get(SkillScope::Global, &root, sid(2)).await.unwrap_err(), + StoreError::NotFound + )); + assert!(matches!( + s.get(SkillScope::Project, &root, sid(1)).await.unwrap_err(), + StoreError::NotFound + )); +} + +#[tokio::test] +async fn save_upserts_content() { + let tmp = TempDir::new(); + let s = store(&tmp); + let root = tmp.project_root(); + + s.save(&skill(sid(1), "k", "v1", SkillScope::Global), &root) + .await + .unwrap(); + s.save(&skill(sid(1), "k", "v2", SkillScope::Global), &root) + .await + .unwrap(); + + assert_eq!( + s.get(SkillScope::Global, &root, sid(1)) + .await + .unwrap() + .content_md + .as_str(), + "v2" + ); + assert_eq!( + s.list(SkillScope::Global, &root).await.unwrap().len(), + 1, + "upsert, not append" + ); +} + +#[tokio::test] +async fn delete_removes_from_index_and_is_not_found_twice() { + let tmp = TempDir::new(); + let s = store(&tmp); + let root = tmp.project_root(); + s.save(&skill(sid(1), "k", "x", SkillScope::Project), &root) + .await + .unwrap(); + + s.delete(SkillScope::Project, &root, sid(1)).await.unwrap(); + assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty()); + assert!(matches!( + s.delete(SkillScope::Project, &root, sid(1)) + .await + .unwrap_err(), + StoreError::NotFound + )); +} + +#[tokio::test] +async fn index_is_camelcase_with_content_hash() { + let tmp = TempDir::new(); + let s = store(&tmp); + let root = tmp.project_root(); + s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root) + .await + .unwrap(); + + let fs = LocalFileSystem::new(); + let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let entry = &json.get("skills").unwrap().as_array().unwrap()[0]; + assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("refactor")); + assert!( + entry.get("contentHash").is_some(), + "camelCase contentHash present" + ); + assert!( + entry.get("content_hash").is_none(), + "no snake_case leak" + ); +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index fd8441c..bcd8dc1 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -19,6 +19,7 @@ import { TauriTerminalGateway } from "./terminal"; import { TauriLayoutGateway } from "./layout"; import { TauriProfileGateway } from "./profile"; import { TauriTemplateGateway } from "./template"; +import { TauriSkillGateway } from "./skill"; import { TauriGitGateway } from "./git"; function notImplemented(what: string): never { @@ -47,6 +48,7 @@ export function createTauriGateways(): Gateways { remote: new TauriRemoteGateway(), profile: new TauriProfileGateway(), template: new TauriTemplateGateway(), + skill: new TauriSkillGateway(), }; } @@ -58,5 +60,6 @@ export { TauriLayoutGateway, TauriProfileGateway, TauriTemplateGateway, + TauriSkillGateway, TauriGitGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 5fff75a..778b152 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -23,12 +23,15 @@ import type { LayoutTree, Project, ProfileAvailability, + Skill, + SkillScope, Template, Unsubscribe, } from "@/domain"; import type { AgentGateway, CreateAgentInput, + CreateSkillInput, CreateTemplateInput, Gateways, GitGateway, @@ -38,6 +41,7 @@ import type { ProjectGateway, ReattachResult, RemoteGateway, + SkillGateway, SystemGateway, TemplateGateway, TerminalGateway, @@ -191,6 +195,7 @@ export class MockAgentGateway implements AgentGateway { profileId: input.profileId, origin: { type: "scratch" }, synchronized: false, + skills: [], }; list.push(agent); this.contexts.set( @@ -282,6 +287,18 @@ export class MockAgentGateway implements AgentGateway { return this.getAgents(projectId); } + /** + * Replaces an agent's assigned skills in-place. + * Used by `MockSkillGateway.assignSkill` / `unassignSkill` so both gateways + * share the same in-memory store. + */ + _setSkills(projectId: string, agentId: string, skills: Agent["skills"]): void { + const list = this.getAgents(projectId); + const idx = list.findIndex((a) => a.id === agentId); + if (idx === -1) return; + list[idx] = { ...list[idx], skills }; + } + async launchAgent( projectId: string, agentId: string, @@ -931,6 +948,7 @@ export class MockTemplateGateway implements TemplateGateway { syncedTemplateVersion: template.version, }, synchronized, + skills: [], }; this.agentGateway._insertAgent(projectId, agent, template.contentMd); @@ -990,6 +1008,130 @@ export class MockTemplateGateway implements TemplateGateway { } } +/** + * Stateful in-memory skill gateway (L12). + * + * Skills are keyed by `${scope}` (project scope is further partitioned by + * `projectId`, mirroring the disjoint on-disk roots). Assignment mutates the + * agent record held by the injected {@link MockAgentGateway}, so both gateways + * share one store — exactly like {@link MockTemplateGateway}. + */ +export class MockSkillGateway implements SkillGateway { + // Global skills are project-independent; project skills live under their id. + private global: Skill[] = []; + private byProject = new Map(); + private seq = 0; + + constructor(private readonly agentGateway: MockAgentGateway) {} + + private bucket(projectId: string, scope: SkillScope): Skill[] { + if (scope === "global") return this.global; + let list = this.byProject.get(projectId); + if (!list) { + list = []; + this.byProject.set(projectId, list); + } + return list; + } + + async listSkills(projectId: string, scope: SkillScope): Promise { + return structuredClone(this.bucket(projectId, scope)); + } + + async createSkill(input: CreateSkillInput): Promise { + this.seq += 1; + const skill: Skill = { + id: `mock-skill-${this.seq}`, + name: input.name, + contentMd: input.content, + scope: input.scope, + }; + this.bucket(input.projectId, input.scope).push(skill); + return structuredClone(skill); + } + + async updateSkill( + projectId: string, + scope: SkillScope, + skillId: string, + content: string, + ): Promise { + const list = this.bucket(projectId, scope); + const idx = list.findIndex((s) => s.id === skillId); + if (idx === -1) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `skill ${skillId} not found`, + }; + throw err; + } + list[idx] = { ...list[idx], contentMd: content }; + return structuredClone(list[idx]); + } + + async deleteSkill( + projectId: string, + scope: SkillScope, + skillId: string, + ): Promise { + const list = this.bucket(projectId, scope); + const idx = list.findIndex((s) => s.id === skillId); + if (idx === -1) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `skill ${skillId} not found`, + }; + throw err; + } + list.splice(idx, 1); + } + + async assignSkill( + projectId: string, + agentId: string, + skillId: string, + scope: SkillScope, + ): Promise { + const agent = this.agentGateway + ._rawAgents(projectId) + .find((a) => a.id === agentId); + if (!agent) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + if (agent.skills.some((s) => s.skillId === skillId)) return; // idempotent + this.agentGateway._setSkills(projectId, agentId, [ + ...agent.skills, + { skillId, scope }, + ]); + } + + async unassignSkill( + projectId: string, + agentId: string, + skillId: string, + ): Promise { + const agent = this.agentGateway + ._rawAgents(projectId) + .find((a) => a.id === agentId); + if (!agent) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + this.agentGateway._setSkills( + projectId, + agentId, + agent.skills.filter((s) => s.skillId !== skillId), + ); + } +} + /** Builds the full set of mock gateways. */ export function createMockGateways(): Gateways { const agentGateway = new MockAgentGateway(); @@ -1003,6 +1145,7 @@ export function createMockGateways(): Gateways { remote: new MockRemoteGateway(), profile: new MockProfileGateway(), template: new MockTemplateGateway(agentGateway), + skill: new MockSkillGateway(agentGateway), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 3f88559..6b75dd5 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -12,7 +12,7 @@ import { createMockGateways, MockSystemGateway } from "./index"; const gateways: Gateways = createMockGateways(); describe("createMockGateways", () => { - it("exposes all nine gateways", () => { + it("exposes all ten gateways", () => { expect(Object.keys(gateways).sort()).toEqual([ "agent", "git", @@ -20,6 +20,7 @@ describe("createMockGateways", () => { "profile", "project", "remote", + "skill", "system", "template", "terminal", diff --git a/frontend/src/adapters/skill.ts b/frontend/src/adapters/skill.ts new file mode 100644 index 0000000..e4a858f --- /dev/null +++ b/frontend/src/adapters/skill.ts @@ -0,0 +1,70 @@ +/** + * Tauri adapter for {@link SkillGateway} (L12). + * + * Commands use snake_case (Tauri convention); payload keys are camelCase + * (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent + * with the other adapters in this directory. The `scope` value (`"global"` / + * `"project"`) maps directly onto the backend `SkillScope` serde enum. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { Skill, SkillScope } from "@/domain"; +import type { CreateSkillInput, SkillGateway } from "@/ports"; + +export class TauriSkillGateway implements SkillGateway { + listSkills(projectId: string, scope: SkillScope): Promise { + return invoke("list_skills", { projectId, scope }); + } + + createSkill(input: CreateSkillInput): Promise { + return invoke("create_skill", { + request: { + projectId: input.projectId, + name: input.name, + content: input.content, + scope: input.scope, + }, + }); + } + + updateSkill( + projectId: string, + scope: SkillScope, + skillId: string, + content: string, + ): Promise { + return invoke("update_skill", { + request: { projectId, scope, skillId, content }, + }); + } + + async deleteSkill( + projectId: string, + scope: SkillScope, + skillId: string, + ): Promise { + await invoke("delete_skill", { projectId, scope, skillId }); + } + + async assignSkill( + projectId: string, + agentId: string, + skillId: string, + scope: SkillScope, + ): Promise { + await invoke("assign_skill_to_agent", { + request: { projectId, agentId, skillId, scope }, + }); + } + + async unassignSkill( + projectId: string, + agentId: string, + skillId: string, + ): Promise { + await invoke("unassign_skill_from_agent", { + request: { projectId, agentId, skillId }, + }); + } +} diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 1356740..1ee5313 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -236,6 +236,35 @@ export interface Agent { profileId: string; origin: AgentOrigin; synchronized: boolean; + /** Skills assigned to this agent (injected into its convention file). */ + skills: SkillRef[]; +} + +// --------------------------------------------------------------------------- +// Skills (L12) — mirror of the domain `Skill` / `SkillRef`. +// --------------------------------------------------------------------------- + +/** + * Where a skill lives (selects its backing store): `global` skills are reusable + * across projects; `project` skills are specific to one project's `.ideai/`. + */ +export type SkillScope = "global" | "project"; + +/** + * A reusable, model-agnostic workflow assignable to agents (mirror of the + * backend `Skill` DTO, camelCase wire format). + */ +export interface Skill { + id: string; + name: string; + contentMd: string; + scope: SkillScope; +} + +/** A reference from an agent to one assigned skill (mirror of `SkillRef`). */ +export interface SkillRef { + skillId: string; + scope: SkillScope; } // --------------------------------------------------------------------------- diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index e4d4203..cf53165 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -54,6 +54,50 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { void templateGw.listTemplates().then(setTemplates).catch(() => {}); } + // Skills available for assignment (global + project), loaded lazily once. + const skillGw = gateways.skill ?? null; + const [skills, setSkills] = useState([]); + const [skillsLoaded, setSkillsLoaded] = useState(false); + const [skillToAssign, setSkillToAssign] = useState(""); + + async function refreshSkills() { + if (!skillGw) return; + try { + const [globals, projects] = await Promise.all([ + skillGw.listSkills(projectId, "global"), + skillGw.listSkills(projectId, "project"), + ]); + setSkills([...projects, ...globals]); + } catch { + // Skills are optional — leave the list empty on failure. + } + } + + if (!skillsLoaded && skillGw) { + setSkillsLoaded(true); + void refreshSkills(); + } + + async function handleAssignSkill() { + if (!skillGw || !vm.selectedAgentId || !skillToAssign) return; + const target = skills.find((s) => s.id === skillToAssign); + if (!target) return; + await skillGw.assignSkill( + projectId, + vm.selectedAgentId, + target.id, + target.scope, + ); + setSkillToAssign(""); + await vm.refresh(); + } + + async function handleUnassignSkill(skillId: string) { + if (!skillGw || !vm.selectedAgentId) return; + await skillGw.unassignSkill(projectId, vm.selectedAgentId, skillId); + await vm.refresh(); + } + // Context editor state — local copy before Save const [editedContext, setEditedContext] = useState(vm.context); @@ -351,6 +395,79 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} + {/* ── Skill assignment ── */} + {selectedAgent && skillGw && ( +
+

+ Skills — {selectedAgent.name} +

+ + {/* Assigned skills */} + {selectedAgent.skills.length === 0 ? ( +

No skills assigned.

+ ) : ( +
    + {selectedAgent.skills.map((ref) => { + const name = + skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId; + return ( +
  • + {name} + +
  • + ); + })} +
+ )} + + {/* Assign selector */} +
+ + +
+
+ )} + {/* ── Context editor ── */} {selectedAgent && (
diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 9848921..b48510b 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -34,17 +34,19 @@ import type { LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { AgentsPanel } from "@/features/agents"; import { TemplatesPanel } from "@/features/templates"; +import { SkillsPanel } from "@/features/skills"; import { GitPanel, GitGraphView } from "@/features/git"; import { Button, Input, Panel, Tabs, cn } from "@/shared"; import { useGateways } from "@/app/di"; import { useProjects } from "./useProjects"; -type SidebarTab = "projects" | "agents" | "templates" | "git"; +type SidebarTab = "projects" | "agents" | "templates" | "skills" | "git"; const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [ { id: "projects", label: "Projects" }, { id: "agents", label: "Agents" }, { id: "templates", label: "Templates" }, + { id: "skills", label: "Skills" }, { id: "git", label: "Git" }, ]; @@ -259,6 +261,14 @@ export function ProjectsView() {

Open a project to manage templates.

)} + {/* Skills panel */} + {sidebarTab === "skills" && active && ( + + )} + {sidebarTab === "skills" && !active && ( +

Open a project to manage skills.

+ )} + {/* Git panel */} {sidebarTab === "git" && active && ( diff --git a/frontend/src/features/skills/SkillEditor.tsx b/frontend/src/features/skills/SkillEditor.tsx new file mode 100644 index 0000000..c78633b --- /dev/null +++ b/frontend/src/features/skills/SkillEditor.tsx @@ -0,0 +1,207 @@ +/** + * `SkillEditor` — a fullscreen overlay for creating or editing skills (L12). + * Rendered on top of the rest of the UI (`fixed inset-0`). + * + * Provides: + * - Name field and scope selector (create-mode only; scope is fixed in edit-mode) + * - Two tabs: "Edit" (textarea) and "Preview" (react-markdown) + * - Save (create/update) and Cancel/Close buttons + * + * Pure presentation: all mutations are delegated to the callbacks supplied by + * the parent (`SkillsPanel`). Styled with `@/shared`; no inline styles. + */ + +import { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import type { Skill, SkillScope } from "@/domain"; +import { Button, Input, cn } from "@/shared"; + +export interface SkillEditorProps { + /** + * When set, the editor is in edit-mode for the given skill; otherwise it is in + * create-mode (all fields start empty). + */ + skill?: Skill | null; + /** Called when the user submits the form. */ + onSave: (name: string, content: string, scope: SkillScope) => Promise; + /** Called when the user cancels / closes the overlay. */ + onClose: () => void; + /** Whether a save operation is in flight. */ + busy?: boolean; +} + +type EditorTab = "edit" | "preview"; + +export function SkillEditor({ + skill, + onSave, + onClose, + busy = false, +}: SkillEditorProps) { + const [name, setName] = useState(skill?.name ?? ""); + const [content, setContent] = useState(skill?.contentMd ?? ""); + const [scope, setScope] = useState(skill?.scope ?? "project"); + const [tab, setTab] = useState("edit"); + + const editing = skill != null; + const canSave = name.trim().length > 0 && content.trim().length > 0 && !busy; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSave) return; + await onSave(name.trim(), content, scope); + } + + return ( +
+ {/* ── Header bar ── */} +
+

+ {editing ? `Edit skill — ${skill.name}` : "New skill"} +

+ +
+ + {/* ── Form body ── */} +
void handleSubmit(e)} + className="flex flex-1 flex-col gap-0 overflow-hidden" + > + {/* ── Meta fields ── */} +
+
+ + setName(e.target.value)} + disabled={busy} + className="min-w-48" + /> +
+ +
+ + +
+
+ + {/* ── Tab strip ── */} +
+ {(["edit", "preview"] as EditorTab[]).map((t) => ( + + ))} +
+ + {/* ── Edit / Preview pane ── */} +
+ {tab === "edit" ? ( +