//! Agent lifecycle use cases (ARCHITECTURE §6, L6). //! //! These own the *project-agent* side (distinct from the profile side in //! [`super::usecases`]): creating agents and their `.md` contexts under //! `.ideai/`, listing/reading/updating them, and — the centrepiece — //! [`LaunchAgent`], which resolves the agent's profile + context, applies the //! profile's context-injection strategy, opens a PTY cell at the right `cwd` and //! spawns the CLI. //! //! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`], //! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about //! a concrete adapter or Tauri. use std::sync::Arc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession, }; use crate::error::AppError; use crate::terminal::TerminalSessions; /// Directory (relative to `.ideai/`) under which agent contexts are written. const AGENTS_SUBDIR: &str = "agents"; /// Token budget of the project-memory recall injected into the convention file at /// agent activation (ARCHITECTURE §14.5.4). Bounds the number of index entries /// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in /// config**: it may later become a per-project setting without changing the /// contract. const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048; // --------------------------------------------------------------------------- // CreateAgentFromScratch // --------------------------------------------------------------------------- /// Input for [`CreateAgentFromScratch::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateAgentInput { /// The project that owns the agent. pub project: Project, /// Display name of the agent. pub name: String, /// Runtime profile the agent launches with. pub profile_id: ProfileId, /// Initial `.md` content (empty when `None`). pub initial_content: Option, } /// Output of [`CreateAgentFromScratch::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateAgentOutput { /// The freshly-created agent. pub agent: Agent, } /// Creates a project agent from scratch: mints an id, derives a unique `.md` /// path, records the manifest entry, then writes the (possibly empty) context. pub struct CreateAgentFromScratch { contexts: Arc, ids: Arc, events: Arc, } impl CreateAgentFromScratch { /// Builds the use case from its injected ports. #[must_use] pub fn new( contexts: Arc, ids: Arc, events: Arc, ) -> Self { Self { contexts, ids, events, } } /// Executes creation. /// /// Ordering matters: the manifest entry is persisted **before** the context /// is written, because [`AgentContextStore::write_context`] resolves the /// on-disk path from the manifest. /// /// # Errors /// - [`AppError::Invalid`] if the name is empty or the manifest would become /// inconsistent, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: CreateAgentInput) -> Result { let manifest = self.contexts.load_manifest(&input.project).await?; let id = AgentId::from_uuid(self.ids.new_uuid()); let md_path = unique_md_path(&input.name, &manifest); let agent = Agent::new( id, input.name, md_path, input.profile_id, AgentOrigin::Scratch, false, ) .map_err(|e| AppError::Invalid(e.to_string()))?; // Append the entry and re-validate the whole manifest (unique md_paths). let mut entries = manifest.entries; entries.push(ManifestEntry::from_agent(&agent)); let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts.save_manifest(&input.project, &manifest).await?; // Now the path resolves: write the initial context. let md = MarkdownDoc::new(input.initial_content.unwrap_or_default()); self.contexts .write_context(&input.project, &agent.id, &md) .await?; self.events.publish(DomainEvent::LayoutChanged { project_id: input.project.id, }); Ok(CreateAgentOutput { agent }) } } // --------------------------------------------------------------------------- // ListAgents // --------------------------------------------------------------------------- /// Input for [`ListAgents::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListAgentsInput { /// The project whose agents to list. pub project: Project, } /// Output of [`ListAgents::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListAgentsOutput { /// The project's agents (reconstructed from the manifest). pub agents: Vec, } /// Lists a project's agents by reconstructing them from the manifest entries. pub struct ListAgents { contexts: Arc, } impl ListAgents { /// Builds the use case from the [`AgentContextStore`] port. #[must_use] pub fn new(contexts: Arc) -> Self { Self { contexts } } /// Loads the manifest and folds each entry back into an [`Agent`]. /// /// # Errors /// - [`AppError::Store`] on persistence failure, /// - [`AppError::Invalid`] if a persisted entry violates an agent invariant. pub async fn execute(&self, input: ListAgentsInput) -> Result { let manifest = self.contexts.load_manifest(&input.project).await?; let agents = manifest .entries .iter() .map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string()))) .collect::, _>>()?; Ok(ListAgentsOutput { agents }) } } // --------------------------------------------------------------------------- // ReadAgentContext / UpdateAgentContext // --------------------------------------------------------------------------- /// Input for [`ReadAgentContext::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadAgentContextInput { /// The owning project. pub project: Project, /// The agent whose `.md` to read. pub agent_id: AgentId, } /// Output of [`ReadAgentContext::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadAgentContextOutput { /// The agent's Markdown context. pub content: MarkdownDoc, } /// Reads an agent's `.md` context. pub struct ReadAgentContext { contexts: Arc, } impl ReadAgentContext { /// Builds the use case. #[must_use] pub fn new(contexts: Arc) -> Self { Self { contexts } } /// Reads the context. /// /// # Errors /// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown, /// - [`AppError::Store`] on persistence failure. pub async fn execute( &self, input: ReadAgentContextInput, ) -> Result { let content = self .contexts .read_context(&input.project, &input.agent_id) .await?; Ok(ReadAgentContextOutput { content }) } } /// Input for [`UpdateAgentContext::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateAgentContextInput { /// The owning project. pub project: Project, /// The agent whose `.md` to overwrite. pub agent_id: AgentId, /// New Markdown content. pub content: String, } /// Overwrites an agent's `.md` context. pub struct UpdateAgentContext { contexts: Arc, } impl UpdateAgentContext { /// Builds the use case. #[must_use] pub fn new(contexts: Arc) -> Self { Self { contexts } } /// Writes the new context. /// /// # Errors /// - [`AppError::NotFound`] if the agent is unknown, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> { let md = MarkdownDoc::new(input.content); self.contexts .write_context(&input.project, &input.agent_id, &md) .await?; Ok(()) } } // --------------------------------------------------------------------------- // DeleteAgent // --------------------------------------------------------------------------- /// Input for [`DeleteAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteAgentInput { /// The owning project. pub project: Project, /// The agent to remove. pub agent_id: AgentId, } /// Removes an agent from the project manifest. /// /// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no /// delete, and keeping the file is the safe default (the user may want to recover /// the context). Re-creating an agent with the same name reuses a fresh path. pub struct DeleteAgent { contexts: Arc, events: Arc, } impl DeleteAgent { /// Builds the use case. #[must_use] pub fn new(contexts: Arc, events: Arc) -> Self { Self { contexts, events } } /// Drops the manifest entry for the agent. /// /// # Errors /// - [`AppError::NotFound`] if the agent is not in the manifest, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> { let manifest = self.contexts.load_manifest(&input.project).await?; let before = manifest.entries.len(); let entries: Vec = manifest .entries .into_iter() .filter(|e| e.agent_id != input.agent_id) .collect(); if entries.len() == before { return Err(AppError::NotFound(format!("agent {}", input.agent_id))); } let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts.save_manifest(&input.project, &manifest).await?; self.events.publish(DomainEvent::LayoutChanged { project_id: input.project.id, }); Ok(()) } } // --------------------------------------------------------------------------- // LaunchAgent // --------------------------------------------------------------------------- /// Input for [`LaunchAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchAgentInput { /// The owning project. pub project: Project, /// The agent to launch. pub agent_id: AgentId, /// Initial terminal height in rows. pub rows: u16, /// Initial terminal width in columns. pub cols: u16, /// The layout leaf hosting the session (a fresh node when `None`). pub node_id: Option, /// The persistent CLI conversation id currently recorded on the hosting cell, /// if any. `Some` means a previous conversation exists and the launch should /// **resume** it; `None` means a fresh cell (the launch may *assign* a new id /// when the profile supports it). The caller (which owns the layout) reads this /// from the leaf's [`domain::layout::LeafCell::conversation_id`]. pub conversation_id: Option, } /// Output of [`LaunchAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchAgentOutput { /// The created agent terminal session. pub session: TerminalSession, /// The conversation id **assigned** by this launch, when the profile supports /// session assignment and the cell had none yet. The caller persists it on the /// hosting leaf (via the layout flow, e.g. `set_cell_conversation`) so the next /// open resumes instead of re-assigning. `None` when nothing new was assigned /// (resume of an existing id, degraded mode, or a profile without a session /// block) — the caller has nothing to persist. pub assigned_conversation_id: Option, } /// Launches an agent: resolve profile + context, prepare the invocation, apply /// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI. /// /// This is the orchestrating use case of L6 and therefore consumes several ports /// — each only for the slice it needs (Interface Segregation): the context store /// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime /// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile` /// context), and the PTY (spawn + optional stdin injection). pub struct LaunchAgent { contexts: Arc, profiles: Arc, runtime: Arc, fs: Arc, pty: Arc, skills: Arc, sessions: Arc, events: Arc, ids: Arc, /// Bounded recall of the project's memory index, injected into the convention /// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent /// or empty memory yields an empty list, never blocking a launch. recall: Arc, } impl LaunchAgent { /// Builds the use case from its injected ports. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( contexts: Arc, profiles: Arc, runtime: Arc, fs: Arc, pty: Arc, skills: Arc, sessions: Arc, events: Arc, ids: Arc, recall: Arc, ) -> Self { Self { contexts, profiles, runtime, fs, pty, skills, sessions, events, ids, recall, } } /// 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) } /// Resolves the project's memory recall (index/hooks) to inject into the /// convention file at activation (ARCHITECTURE §14.5.4), mirroring /// [`Self::resolve_skills`]. The query text is the agent's persona `.md` /// (irrelevant to the naïve adapter, but already the right query for the future /// semantic recall — zero refactor at étage 2), bounded by /// [`AGENT_MEMORY_RECALL_BUDGET`]. /// /// **Best-effort, never blocking**: an absent or empty memory yields an empty /// list by the [`MemoryRecall`] contract, and any unexpected error degrades to /// an empty list rather than failing the launch (exactly like a dangling skill). async fn resolve_memory(&self, root: &ProjectPath, persona: &str) -> Vec { let query = MemoryQuery { text: persona.to_owned(), token_budget: AGENT_MEMORY_RECALL_BUDGET, }; self.recall.recall(root, &query).await.unwrap_or_default() } /// Executes the launch. /// /// Step order is contractually significant (and unit-tested): resolve the /// agent + context, **`prepare_invocation`**, **apply the injection plan** /// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the /// resolved `cwd`, and finally pipe the context on stdin for the `Stdin` /// strategy. /// /// # Errors /// - [`AppError::NotFound`] if the agent or its profile is unknown, /// - [`AppError::Invalid`] for a zero-sized terminal, /// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on /// the respective port failures. pub async fn execute(&self, input: LaunchAgentInput) -> Result { let size = PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; // 1. Resolve the agent from the manifest (name + profile + md_path). let manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter() .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; let agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; // 2. Read its context and resolve its profile. let content = self .contexts .read_context(&input.project, &agent.id) .await?; let profile = self .profiles .list() .await? .into_iter() .find(|p| p.id == agent.profile_id) .ok_or_else(|| { AppError::NotFound(format!("profile {} for agent", agent.profile_id)) })?; // 3. Compute and create the agent's isolated run directory // `/.ideai/run//` (ARCHITECTURE §14.1). The PTY cwd is // *never* the project root: each agent gets its own directory so that N // instances of the same profile never collide on a single conventional // file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the // runtime's `prepare_invocation` stays pure. let run_dir = agent_run_dir(&input.project.root, &agent.id) .map_err(|e| AppError::Invalid(e.to_string()))?; self.fs .create_dir_all(&RemotePath::new(run_dir.as_str().to_owned())) .await?; // 3b. Seed the CLI's permission config in the run dir so the agent runs // with the project's full autonomy and never blocks on per-command // permission prompts. The agent's cwd is the run dir, so the CLI // writes/reads its permission file there; without a seed, the CLI // accumulates narrow per-command approvals and keeps prompting. // Pragmatic per-CLI seed pending the universal `.ideai/permissions.json` // + OS-sandbox model. Non-clobbering and best-effort. self.seed_cli_permissions(&profile, &run_dir, &input.project.root) .await?; // 4. Prepare the invocation (pure): command + args + injection plan + cwd. // The run dir is passed as the cwd base; the profile's `{agentRunDir}` // placeholder resolves against it. let prepared = PreparedContext { content: content.clone(), relative_path: agent.context_path.clone(), }; // 4a. Resolve the session intention (T4). The conversation id is a property // of the *cell*, not the PTY: the caller (which owns the layout) passes // the cell's current `conversation_id`. Any id this launch *assigns* is // returned in the output so the caller persists it on the leaf. let (session_plan, assigned_conversation_id) = self.resolve_session_plan(&profile, input.conversation_id.clone()); let mut spec = self.runtime .prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?; // 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?; let memory = self .resolve_memory(&input.project.root, content.as_str()) .await; self.apply_injection( &input.project, &agent.context_path, &content, &skills, &memory, &mut spec, ) .await?; // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. let handle = self.pty.spawn(spec.clone(), size).await?; let session_id = handle.session_id; // 7. For the Stdin strategy, pipe the context once the PTY is live. if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) { self.pty.write(&handle, content.as_str().as_bytes())?; } let node_id = input.node_id.unwrap_or_else(NodeId::new_random); let mut session = TerminalSession::starting( session_id, node_id, spec.cwd.clone(), SessionKind::Agent { agent_id: agent.id, }, size, ); session.status = SessionStatus::Running; self.sessions.insert(handle, session.clone()); self.events.publish(DomainEvent::AgentLaunched { agent_id: agent.id, session_id, }); Ok(LaunchAgentOutput { session, assigned_conversation_id, }) } /// Resolves the [`SessionPlan`] for a launch from the profile's session /// strategy and the cell's current `conversation_id` (T4). /// /// Returns the plan *and* — when this launch mints a fresh id — that id, so the /// caller can persist it on the hosting leaf. The id is only generated for an /// `Assign` (profile has a `session` block with an `assign_flag`, and the cell /// had no id yet); every other branch returns `None` (nothing to persist). /// /// Branches: /// - cell already has an id ⇒ [`SessionPlan::Resume`] (reopen) — no new id; /// - no id, profile has `session.assign_flag` ⇒ mint a UUID, [`SessionPlan::Assign`]; /// - no id, profile has `session` but no `assign_flag` (degraded) ⇒ /// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter /// uses the bare resume flag only on later reopens); /// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy). fn resolve_session_plan( &self, profile: &AgentProfile, cell_conversation_id: Option, ) -> (SessionPlan, Option) { // No session strategy at all: behave exactly as before. let Some(session) = &profile.session else { return (SessionPlan::None, None); }; // The cell already carries a conversation: resume it (no new id minted). if let Some(conversation_id) = cell_conversation_id { return (SessionPlan::Resume { conversation_id }, None); } // Fresh cell. Only mint+assign an id when the profile can assign one; // otherwise (degraded mode) the first launch has nothing to resume. if session.assign_flag.is_some() { let conversation_id = self.ids.new_uuid().to_string(); ( SessionPlan::Assign { conversation_id: conversation_id.clone(), }, Some(conversation_id), ) } else { (SessionPlan::None, None) } } /// Seeds the agent's run dir with the CLI permission config matching its /// context-injection convention, so the agent inherits the project's autonomy /// instead of prompting per command. /// /// Conditioned on the CLI convention (only Claude Code — convention file /// `CLAUDE.md` — has a known seed today); a no-op for any other CLI. /// Best-effort and **non-clobbering**: an existing file (possibly user-edited) /// is left untouched. /// /// # Errors /// [`AppError::FileSystem`] if the directory/file cannot be written. async fn seed_cli_permissions( &self, profile: &AgentProfile, run_dir: &ProjectPath, project_root: &ProjectPath, ) -> Result<(), AppError> { let is_claude = matches!( &profile.context_injection, ContextInjection::ConventionFile { target } if target .rsplit(['/', '\\']) .next() .unwrap_or(target) .eq_ignore_ascii_case("CLAUDE.md") ); if !is_claude { return Ok(()); } let settings_path = RemotePath::new(format!("{}/.claude/settings.local.json", run_dir.as_str())); if self.fs.exists(&settings_path).await? { return Ok(()); } self.fs .create_dir_all(&RemotePath::new(format!("{}/.claude", run_dir.as_str()))) .await?; self.fs .write( &settings_path, claude_settings_seed(project_root.as_str()).as_bytes(), ) .await?; Ok(()) } /// Applies the context-injection plan that must happen *before* spawn: /// materialising a `conventionFile` context (write the `.md` to `/target`) /// or attaching the on-disk context path to an environment variable. `Args` is /// already folded into the spec by the runtime; `Stdin` is handled post-spawn. async fn apply_injection( &self, project: &Project, context_rel_path: &str, content: &MarkdownDoc, skills: &[Skill], memory: &[MemoryIndexEntry], spec: &mut SpawnSpec, ) -> Result<(), AppError> { match spec.context_plan.clone() { Some(ContextInjectionPlan::File { target }) => { // conventionFile (ARCHITECTURE §14.1): IdeA *generates* the // conventional file (e.g. CLAUDE.md) inside the agent's isolated // 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), 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, memory, ); let path = RemotePath::new(join(&spec.cwd, &target)); self.fs.write(&path, document.as_bytes()).await?; } Some(ContextInjectionPlan::Env { var }) => { // Hand the CLI the absolute path of the agent's `.md` (which lives at // `/.ideai/`) via the environment variable. let abspath = join(&project.root, &format!(".ideai/{context_rel_path}")); spec.env.push((var, abspath)); } // Args were folded into spec.args by prepare_invocation; Stdin is // applied after the PTY is live. Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {} } Ok(()) } } /// Builds an absolute path string by joining a [`ProjectPath`] with a relative /// segment using a POSIX separator. fn join(base: &ProjectPath, rel: &str) -> String { let b = base.as_str().trim_end_matches(['/', '\\']); format!("{b}/{rel}") } /// Computes an agent's isolated run directory `/.ideai/run//` /// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project /// root — guaranteeing that two distinct agents on the same project root get two /// distinct cwd (the anti-collision contract). /// /// # Errors /// Propagates [`DomainError`](domain::error::DomainError) if the joined path is /// not a valid [`ProjectPath`] (should not happen for an absolute project root). pub(crate) fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result { ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}"))) } /// Builds the Claude Code permission seed (`.claude/settings.local.json`) written /// into an agent's run dir: full project autonomy (`bypassPermissions` + broad /// Read/Edit/Write/Bash) with the project root granted as an additional working /// directory (the cwd is the run dir, the agent works on the root above it), while /// keeping destructive/out-of-project commands denied. `project_root` is embedded /// verbatim; it is JSON-escaped to stay valid for unusual paths. /// /// Pure (no I/O), so it is unit-testable in isolation. #[must_use] fn claude_settings_seed(project_root: &str) -> String { let root = json_escape(project_root); format!( r#"{{ "permissions": {{ "defaultMode": "bypassPermissions", "additionalDirectories": [ "{root}" ], "allow": [ "Read", "Edit", "Write", "Bash" ], "deny": [ "Bash(sudo *)", "Bash(rm -rf /)", "Bash(rm -rf /*)", "Bash(rm -rf ~)", "Bash(rm -rf ~/)", "Bash(rm -rf ~/*)", "Bash(rm -rf $HOME*)", "Bash(mkfs*)", "Bash(dd if=*)", "Bash(shutdown*)", "Bash(reboot*)" ] }}, "skipDangerousModePermissionPrompt": true, "sandbox": {{ "enabled": false }} }} "# ) } /// Minimal JSON string escaper for embedding a filesystem path in the settings /// seed (handles the characters that actually occur in paths: backslash, quote, /// and control chars). fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), c => out.push(c), } } out } /// 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 agent's persona `.md`, then the bodies /// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2). /// /// Skills are emitted in the order given (the caller passes them in manifest /// order, making the output deterministic); each is introduced by a `##` header /// carrying its name. When `skills` is empty the section is omitted entirely, so /// an agent with no skills gets exactly the previous document. /// /// 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 /// entry, in the order given. When `memory` is empty the section is omitted /// entirely, so an agent with no memory gets exactly the previous document. /// /// Kept as a **pure** function (no I/O) so it is unit-testable in isolation. #[must_use] pub(crate) fn compose_convention_file( project_root: &str, agent_md: &str, skills: &[Skill], memory: &[MemoryIndexEntry], ) -> String { let mut out = String::new(); out.push_str("# Project root\n\n"); out.push_str(project_root); out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). "); out.push_str( "Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run//`) ; \ opère sur le project root, pas sur ce dossier.\n\n", ); 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'); } } if !memory.is_empty() { out.push_str("\n\n---\n\n# Mémoire projet\n\n"); for entry in memory { out.push_str("- ["); out.push_str(&entry.title); out.push_str("]("); out.push_str(entry.slug.as_str()); out.push_str(".md) — "); out.push_str(&entry.hook); out.push_str(" ("); out.push_str(memory_type_label(entry.r#type)); out.push_str(")\n"); } } out } /// Renders a [`MemoryType`] as its stable lowercase label for the convention-file /// memory section (`user`/`feedback`/`project`/`reference`). #[must_use] fn memory_type_label(kind: MemoryType) -> &'static str { match kind { MemoryType::User => "user", MemoryType::Feedback => "feedback", MemoryType::Project => "project", MemoryType::Reference => "reference", } } /// Derives a unique, filesystem-safe `md_path` (`agents/.md`) for a new /// agent, disambiguating against the manifest's existing paths with a numeric /// suffix when needed. Shared with the template-driven agent creation (L7). pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String { let slug = slugify(name); let base = if slug.is_empty() { "agent".to_owned() } else { slug }; let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md"); let mut n = 2; while manifest.entries.iter().any(|e| e.md_path == candidate) { candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md"); n += 1; } candidate } /// Lowercases and slugifies a display name into a safe file stem /// (`[a-z0-9-]`), collapsing runs of separators. fn slugify(name: &str) -> String { let mut out = String::with_capacity(name.len()); let mut prev_dash = false; for ch in name.trim().chars() { if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); prev_dash = false; } else if !prev_dash { out.push('-'); prev_dash = true; } } out.trim_matches('-').to_owned() } #[cfg(test)] mod tests { use super::*; #[test] fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() { let root = ProjectPath::new("/home/me/proj").unwrap(); let a = AgentId::from_uuid(uuid::Uuid::from_u128(1)); let b = AgentId::from_uuid(uuid::Uuid::from_u128(2)); let dir_a = agent_run_dir(&root, &a).unwrap(); let dir_b = agent_run_dir(&root, &b).unwrap(); assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}")); assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs"); // Never the project root. assert_ne!(dir_a.as_str(), "/home/me/proj"); } #[test] fn compose_convention_file_carries_root_then_persona() { let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[], &[]); // Absolute project root present. assert!(doc.contains("/abs/project/root")); // Persona present. assert!(doc.contains("# Persona")); assert!(doc.contains("Do things.")); // Root header precedes the persona body (ordering of the composition). 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")); } /// Builds a memory index entry for the convention-file composition tests. fn mem(slug_str: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry { MemoryIndexEntry { slug: domain::MemorySlug::new(slug_str).unwrap(), title: title.to_owned(), hook: hook.to_owned(), r#type: kind, } } #[test] fn compose_convention_file_empty_memory_is_identical_to_no_memory() { // An empty `memory` must yield exactly the previous document: no section, // byte-for-byte identical to the no-skills/no-memory composition. let with_empty = compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[]); assert!( !with_empty.contains("# Mémoire projet"), "no memory ⇒ no memory section" ); // Same document whether or not we thread an empty slice (it already is the // 4-arg call; this pins the omission contract explicitly). assert_eq!( with_empty, compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[]) ); } #[test] fn compose_convention_file_appends_memory_entries_in_order() { let doc = compose_convention_file( "/root", "# Persona", &[], &[ mem("alpha-note", "Alpha", "the first hook", MemoryType::User), mem("beta-note", "Beta", "the second hook", MemoryType::Reference), ], ); // Section present, after the persona. assert!(doc.contains("# Mémoire projet")); let persona_at = doc.find("# Persona").unwrap(); let section_at = doc.find("# Mémoire projet").unwrap(); assert!(persona_at < section_at, "memory comes after the persona"); // Exact line format: `- [Title](slug.md) — hook (type)`. assert!(doc.contains("- [Alpha](alpha-note.md) — the first hook (user)")); assert!(doc.contains("- [Beta](beta-note.md) — the second hook (reference)")); // Deterministic order: first entry precedes the second. let alpha_at = doc.find("[Alpha]").unwrap(); let beta_at = doc.find("[Beta]").unwrap(); assert!(alpha_at < beta_at, "memory entries emitted in the given order"); } #[test] fn compose_convention_file_memory_and_skills_coexist() { let skill = Skill::new( domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)), "refactor", MarkdownDoc::new("REFAC_BODY"), domain::SkillScope::Global, ) .unwrap(); let doc = compose_convention_file( "/root", "# Persona", std::slice::from_ref(&skill), &[mem("note", "Note", "a hook", MemoryType::Project)], ); // Both sections present. assert!(doc.contains("# Skills")); assert!(doc.contains("REFAC_BODY")); assert!(doc.contains("# Mémoire projet")); assert!(doc.contains("- [Note](note.md) — a hook (project)")); // Skills section precedes the memory section (persona → skills → memory). let skills_at = doc.find("# Skills").unwrap(); let memory_at = doc.find("# Mémoire projet").unwrap(); assert!(skills_at < memory_at, "skills come before memory"); } #[test] fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() { let json = claude_settings_seed("/home/me/proj"); // Full autonomy. assert!(json.contains("\"defaultMode\": \"bypassPermissions\"")); assert!(json.contains("\"Bash\"")); // Project root granted as an additional working directory. assert!(json.contains("\"/home/me/proj\"")); // Destructive guardrails preserved. assert!(json.contains("Bash(sudo *)")); assert!(json.contains("Bash(rm -rf /)")); assert!(json.contains("Bash(mkfs*)")); // Valid JSON. let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON"); assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions"); assert_eq!( parsed["permissions"]["additionalDirectories"][0], "/home/me/proj" ); } #[test] fn claude_settings_seed_escapes_paths_for_valid_json() { // A path with a backslash and a quote must not break the JSON. let json = claude_settings_seed(r#"/weird\path"x"#); let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed with odd path is valid JSON"); assert_eq!( parsed["permissions"]["additionalDirectories"][0], r#"/weird\path"x"# ); } }