Skip to main content

team_core/
render.rs

1//! Render a loaded compose into on-disk artifacts.
2//!
3//! Outputs under `<root>/state/`:
4//! - `envs/<project>-<agent>.env`      — env vars for the agent wrapper.
5//! - `mcp/<project>-<agent>.json`      — MCP stdio config for the runtime.
6//! - `claude/<project>-<agent>.json`   — wrapper-managed Claude Code
7//!   settings (currently a `PreToolUse` deny hook for synchronous-prompt
8//!   tools that strand a headless pane). Claude-code agents only.
9//! - `role_prompts/<project>-<agent>.md` (multi-file role_prompt only) —
10//!   the ordered concatenation of every source file declared in the
11//!   role's `role_prompt: [...]` list. Re-materialized on every render
12//!   so any source-file edit lands in the agent's prompt at next boot.
13//!
14//! `systemd` / `launchd` unit rendering lives behind a feature flag when
15//! those back-ends are enabled via `supervisor.type`.
16
17use std::io;
18use std::path::{Path, PathBuf};
19
20use crate::compose::{AgentHandle, Compose, RolePrompt};
21
22/// Separator written between concatenated role-prompt files. Em-dash
23/// framed by blank lines reads cleanly when an operator inspects the
24/// materialized file under `state/role_prompts/`.
25const ROLE_PROMPT_SEPARATOR: &str = "\n\n—\n\n";
26
27/// Absolute path to the rendered env file for a given agent.
28pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
29    root.join("state/envs")
30        .join(format!("{project}-{agent}.env"))
31}
32
33/// Absolute path to the rendered MCP config for a given agent.
34pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
35    root.join("state/mcp")
36        .join(format!("{project}-{agent}.json"))
37}
38
39/// Absolute path to the wrapper-managed Claude Code settings file. The
40/// file carries the default `PreToolUse` deny hook for synchronous-prompt
41/// tools (`AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`) so a
42/// headless agent doesn't strand on a picker no one will answer. The
43/// wrapper applies it via `claude --settings <path>` for every
44/// claude-code agent except those in `permission_mode: attended`.
45pub fn claude_settings_path(root: &Path, project: &str, agent: &str) -> PathBuf {
46    root.join("state/claude")
47        .join(format!("{project}-{agent}.json"))
48}
49
50/// Absolute path to the rendered Claude Code `--agents` JSON for one agent
51/// (#383 Phase 3a). Lives beside the settings file under `state/claude/`
52/// and is written only when the agent declares `subagents:`; the wrapper
53/// passes it via `--agents "$(cat <path>)"` when the file exists.
54pub fn subagents_json_path(root: &Path, project: &str, agent: &str) -> PathBuf {
55    root.join("state/claude")
56        .join(format!("{project}-{agent}.agents.json"))
57}
58
59/// Absolute path to the per-agent scope directory passed to Claude Code
60/// via `--add-dir` (#383 Phase 3b). render materializes
61/// `<this>/.claude/skills/<name>` symlinks to each declared skill; the
62/// wrapper adds `--add-dir <this>` so the agent discovers them on top of
63/// the project `.claude/skills/`. The directory is materialized only when
64/// the agent declares `skills:`; the wrapper's `[ -d ]` guard decides
65/// whether the flag is passed.
66pub fn agent_scope_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
67    root.join("state/agent-scope")
68        .join(format!("{project}-{agent}"))
69}
70
71/// Absolute path to the materialized concatenation of a multi-file
72/// `role_prompt` list. Only ever written for the list form — single-file
73/// `role_prompt` keeps pointing at its source path directly.
74pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
75    root.join("state/role_prompts")
76        .join(format!("{project}-{agent}.md"))
77}
78
79/// Rendered env + MCP content for a single agent.
80pub fn render_agent(
81    compose: &Compose,
82    handle: AgentHandle<'_>,
83    team_mcp_bin: &str,
84) -> (String, String) {
85    let env = render_env(compose, handle);
86    let mcp = render_mcp(compose, handle, team_mcp_bin);
87    (env, mcp)
88}
89
90/// Wrapper-managed Claude Code settings JSON for a single agent. Returns
91/// `Some(json)` for `claude-code` runtime regardless of `permission_mode`
92/// — the wrapper decides whether to apply it. Returns `None` for runtimes
93/// that don't read Claude settings (codex, gemini, …).
94///
95/// The base payload is a single `PreToolUse` deny hook covering the
96/// synchronous-prompt tools that today strand a headless pane:
97/// `AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`. The `systemMessage`
98/// tells the model *why* the deny fired and points it at the `team` MCP
99/// tools as the headless-safe alternative — without that, the model just
100/// sees the call vanish and may retry. Matcher is a regex; extend it
101/// (rather than the hook count) when claude-code gains new synchronous-
102/// prompt tools.
103///
104/// #383 Phase 2: per-agent hooks declared in compose (`Agent.hooks`) are
105/// merged on top of that base. Each declaration is appended as its own
106/// entry under its event, so the built-in deny hook keeps its slot and a
107/// user hook can extend behavior but not clobber the interactive-prompt
108/// deny. Hook commands are compose-root-relative and rendered absolute.
109pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
110    if h.spec.runtime != "claude-code" {
111        // Hooks are a Claude-Code concept. On other runtimes the whole
112        // settings file is skipped; surface a warning so a declared-but-
113        // ignored hook isn't silently dropped (claude-only v1).
114        if !h.spec.hooks.is_empty() {
115            tracing::warn!(
116                target: "team-core::render",
117                "agent `{}:{}` declares {} hook(s) but runtime `{}` does not support hooks (claude-code only); ignoring",
118                h.project,
119                h.agent,
120                h.spec.hooks.len(),
121                h.spec.runtime
122            );
123        }
124        return None;
125    }
126    // PreToolUse deny hook. Picked over `--disallowed-tools` so the
127    // model sees the deny + systemMessage (tighter learning loop) rather
128    // than the tool silently vanishing from its catalog. Emitted first
129    // and never removed; declared hooks (below) are appended after it.
130    let mut v = serde_json::json!({
131        "hooks": {
132            "PreToolUse": [
133                {
134                    "matcher": "AskUserQuestion|EnterPlanMode|ExitPlanMode",
135                    "hooks": [
136                        {
137                            "type": "command",
138                            "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"},\"systemMessage\":\"Interactive prompts are disabled for teamctl agents. Use the `team` MCP tools to ask people or check in.\"}'"
139                        }
140                    ]
141                }
142            ]
143        }
144    });
145
146    // #383 Phase 2: merge per-agent declared hooks on top. Each
147    // declaration becomes its own entry appended to its event's array, so
148    // the built-in deny hook above always keeps its slot. Commands are
149    // compose-root-relative (like `role_prompt`), rendered as absolute
150    // paths.
151    let hooks_obj = v["hooks"].as_object_mut().expect("hooks is a json object");
152    for hook in &h.spec.hooks {
153        let command = compose.root.join(&hook.command);
154        let mut entry = serde_json::json!({
155            "hooks": [
156                {
157                    "type": "command",
158                    "command": command.display().to_string()
159                }
160            ]
161        });
162        if let Some(matcher) = &hook.matcher {
163            entry["matcher"] = serde_json::Value::String(matcher.clone());
164        }
165        hooks_obj
166            .entry(hook.event.clone())
167            .or_insert_with(|| serde_json::Value::Array(Vec::new()))
168            .as_array_mut()
169            .expect("hook event maps to a json array")
170            .push(entry);
171    }
172
173    Some(serde_json::to_string_pretty(&v).expect("json"))
174}
175
176/// #383 Phase 3a: build Claude Code's `--agents` inline JSON for one agent
177/// from its declared `subagents:` list. Each list entry is a
178/// compose-root-relative markdown file with standard sub-agent frontmatter
179/// (`name`, `description`, optional `tools`, `model`) and a body that
180/// becomes the sub-agent's system `prompt`. The result is the
181/// `{ "<name>": { description, prompt, [tools], [model] } }` object the
182/// `--agents` flag consumes — the only cwd-stationary way to scope
183/// sub-agents per agent (no arbitrary-path flag exists; see the Phase-1
184/// spike). Returns `Ok(None)` when none are declared (→ no `--agents`
185/// flag) or the runtime isn't claude-code (logs an "unsupported" warning,
186/// claude-only v1); `Err` if a source is unreadable or its frontmatter is
187/// invalid, so a typo fails the apply loudly rather than dropping a
188/// sub-agent silently.
189pub fn render_subagents(compose: &Compose, h: AgentHandle<'_>) -> io::Result<Option<String>> {
190    if h.spec.subagents.is_empty() {
191        return Ok(None);
192    }
193    if h.spec.runtime != "claude-code" {
194        tracing::warn!(
195            target: "team-core::render",
196            "agent `{}:{}` declares {} sub-agent(s) but runtime `{}` does not support sub-agents (claude-code only); ignoring",
197            h.project,
198            h.agent,
199            h.spec.subagents.len(),
200            h.spec.runtime
201        );
202        return Ok(None);
203    }
204
205    let mut map = serde_json::Map::new();
206    for rel in &h.spec.subagents {
207        let abs = compose.root.join(rel);
208        let raw = std::fs::read_to_string(&abs).map_err(|e| {
209            io::Error::new(
210                e.kind(),
211                format!("read sub-agent source {}: {e}", abs.display()),
212            )
213        })?;
214        let (fm, body) = parse_subagent(&raw).map_err(|e| {
215            io::Error::new(
216                io::ErrorKind::InvalidData,
217                format!("parse sub-agent {}: {e}", abs.display()),
218            )
219        })?;
220        // Name from frontmatter, else the file stem (so `agents/foo.md`
221        // without an explicit `name:` registers as sub-agent `foo`).
222        let name = fm.name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| {
223            rel.file_stem()
224                .map(|s| s.to_string_lossy().into_owned())
225                .unwrap_or_default()
226        });
227        let mut entry = serde_json::json!({
228            "description": fm.description,
229            "prompt": body,
230        });
231        if let Some(tools) = fm.tools {
232            let list = tools.into_list();
233            if !list.is_empty() {
234                entry["tools"] = serde_json::json!(list);
235            }
236        }
237        if let Some(model) = fm.model.filter(|m| !m.trim().is_empty()) {
238            entry["model"] = serde_json::Value::String(model);
239        }
240        map.insert(name, entry);
241    }
242    Ok(Some(
243        serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("json"),
244    ))
245}
246
247/// Write (or clear) the per-agent `--agents` JSON file. Mirrors
248/// [`write_role_prompt_concat`]: the scoped + full render paths both call
249/// it so a `subagents:` edit flows into the agent at the next render. When
250/// the agent declares no sub-agents (or isn't claude-code) the file is
251/// removed if present, so a stale `--agents` set never lingers across a
252/// reload that dropped them.
253pub fn write_subagents_json(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
254    let dest = subagents_json_path(&compose.root, h.project, h.agent);
255    match render_subagents(compose, h)? {
256        Some(json) => {
257            if let Some(parent) = dest.parent() {
258                std::fs::create_dir_all(parent)?;
259            }
260            std::fs::write(&dest, json)
261        }
262        None => match std::fs::remove_file(&dest) {
263            Ok(()) => Ok(()),
264            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
265            Err(e) => Err(e),
266        },
267    }
268}
269
270/// Materialize (or clear) the per-agent skills scope for one agent (#383
271/// Phase 3b). For a claude-code agent declaring `skills:`, this creates
272/// `state/agent-scope/<project>-<agent>/.claude/skills/` and symlinks each
273/// declared skill directory into it (link name = the skill dir's basename),
274/// so `claude --add-dir <scope>` surfaces them additively atop the project
275/// `.claude/skills/`. Mirrors [`write_subagents_json`]: the scoped + full
276/// render paths both call it, and the skills dir is rebuilt from scratch
277/// every render so a renamed or dropped skill never lingers. When the agent
278/// declares no skills (or isn't claude-code) the scope dir is removed if
279/// present.
280///
281/// Symlink targets are absolute (compose-root-relative input resolved
282/// against `compose.root`); a missing source becomes a dangling link rather
283/// than an error, matching how `role_prompt`/`hooks` treat not-yet-created
284/// paths (existence checks across all path-typed fields are a tracked
285/// follow-up). Clearing always unlinks entries individually — render never
286/// hands a symlink to `remove_dir_all`, so a skill's real files are never
287/// followed or deleted.
288pub fn write_agent_skills(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
289    let scope = agent_scope_dir(&compose.root, h.project, h.agent);
290    let skills_dir = scope.join(".claude/skills");
291
292    if h.spec.runtime != "claude-code" || h.spec.skills.is_empty() {
293        if h.spec.runtime != "claude-code" && !h.spec.skills.is_empty() {
294            // Skills are a Claude-Code concept; surface a warning so a
295            // declared-but-ignored skill isn't silently dropped (claude-
296            // only v1, same shape as hooks/sub-agents).
297            tracing::warn!(
298                target: "team-core::render",
299                "agent `{}:{}` declares {} skill(s) but runtime `{}` does not support skills (claude-code only); ignoring",
300                h.project,
301                h.agent,
302                h.spec.skills.len(),
303                h.spec.runtime
304            );
305        }
306        // Clear a stale scope dir so dropped skills don't linger across a
307        // reload that removed them.
308        return remove_scope_dir(&scope);
309    }
310
311    // Rebuild from scratch each render: clear the existing links (each is a
312    // symlink we created — unlink it, never recurse into its target) then
313    // re-create the current set.
314    clear_skills_dir(&skills_dir)?;
315    std::fs::create_dir_all(&skills_dir)?;
316    for rel in &h.spec.skills {
317        // Link name is the skill directory's basename — Claude Code
318        // discovers `.claude/skills/<name>/SKILL.md`.
319        let Some(name) = rel.file_name() else {
320            continue; // path ending in `..` / root has no skill name
321        };
322        let link = skills_dir.join(name);
323        // Last-wins on a duplicate basename (consistent with sub-agents'
324        // name-keyed map): drop any link already placed for this name.
325        if std::fs::symlink_metadata(&link).is_ok() {
326            std::fs::remove_file(&link)?;
327        }
328        std::os::unix::fs::symlink(compose.root.join(rel), &link)?;
329    }
330    Ok(())
331}
332
333/// Remove the per-agent scope dir if present. Clears the managed symlinks
334/// individually first, so `remove_dir_all` only ever sees plain
335/// directories — it never gets a symlink entry that could be followed into
336/// a skill's real files. No-op when the dir doesn't exist.
337fn remove_scope_dir(scope: &Path) -> io::Result<()> {
338    clear_skills_dir(&scope.join(".claude/skills"))?;
339    match std::fs::remove_dir_all(scope) {
340        Ok(()) => Ok(()),
341        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
342        Err(e) => Err(e),
343    }
344}
345
346/// Remove every entry in the per-agent skills dir. Each entry is a symlink
347/// render created, so we `remove_file` (unlink) it — never recursing into
348/// the skill's real contents. No-op when the dir doesn't exist yet.
349fn clear_skills_dir(skills_dir: &Path) -> io::Result<()> {
350    let entries = match std::fs::read_dir(skills_dir) {
351        Ok(e) => e,
352        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
353        Err(e) => return Err(e),
354    };
355    for entry in entries {
356        let entry = entry?;
357        let path = entry.path();
358        let meta = std::fs::symlink_metadata(&path)?;
359        if meta.file_type().is_symlink() || meta.is_file() {
360            std::fs::remove_file(&path)?;
361        } else {
362            // Defensive: we only create symlinks here, but if a real
363            // subdir somehow appears, clear it without following links.
364            std::fs::remove_dir_all(&path)?;
365        }
366    }
367    Ok(())
368}
369
370/// Parsed frontmatter of a sub-agent markdown file. Mirrors the fields
371/// Claude Code's own `.claude/agents/*.md` use; unknown keys are ignored.
372#[derive(serde::Deserialize)]
373struct SubagentFrontmatter {
374    #[serde(default)]
375    name: Option<String>,
376    description: String,
377    #[serde(default)]
378    tools: Option<Tools>,
379    #[serde(default)]
380    model: Option<String>,
381}
382
383/// `tools:` accepts either Claude Code's comma-separated string form
384/// (`Read, Grep`) or a YAML list (`[Read, Grep]`); both normalize to the
385/// JSON array `--agents` expects.
386#[derive(serde::Deserialize)]
387#[serde(untagged)]
388enum Tools {
389    List(Vec<String>),
390    Csv(String),
391}
392
393impl Tools {
394    fn into_list(self) -> Vec<String> {
395        let raw = match self {
396            Tools::List(v) => v,
397            Tools::Csv(s) => s.split(',').map(str::to_string).collect(),
398        };
399        raw.into_iter()
400            .map(|t| t.trim().to_string())
401            .filter(|t| !t.is_empty())
402            .collect()
403    }
404}
405
406/// Split a sub-agent markdown file into (frontmatter, body). Expects the
407/// standard `---\n<yaml>\n---\n<body>` layout; the body is everything after
408/// the closing delimiter, trimmed of surrounding blank lines.
409fn parse_subagent(raw: &str) -> Result<(SubagentFrontmatter, String), String> {
410    let after_open = raw
411        .strip_prefix("---")
412        .ok_or("missing opening `---` frontmatter delimiter")?;
413    let (yaml, body) = after_open
414        .split_once("\n---")
415        .ok_or("missing closing `---` frontmatter delimiter")?;
416    let fm: SubagentFrontmatter =
417        serde_yaml::from_str(yaml.trim()).map_err(|e| format!("invalid frontmatter YAML: {e}"))?;
418    let body = body.trim_start_matches(['\r', '\n']).trim_end().to_string();
419    Ok((fm, body))
420}
421
422fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
423    let project = compose
424        .projects
425        .iter()
426        .find(|p| p.project.id == h.project)
427        .expect("agent belongs to a loaded project");
428    let mailbox = compose.root.join(&compose.global.broker.path);
429    let mcp = mcp_path(&compose.root, h.project, h.agent);
430    let prompt = system_prompt_path(compose, h)
431        .map(|p| p.display().to_string())
432        .unwrap_or_default();
433
434    let mut s = String::new();
435    s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
436    s.push_str(&format!("PROJECT_ID={}\n", h.project));
437    s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
438    if let Some(m) = &h.spec.model {
439        s.push_str(&format!("MODEL={m}\n"));
440    }
441    if let Some(pm) = &h.spec.permission_mode {
442        s.push_str(&format!("PERMISSION_MODE={pm}\n"));
443    }
444    // T-048: per-agent reasoning effort flows through to the runtime
445    // via the wrapper. Workspace-level `.env` `EFFORT=` still wins for
446    // operators not yet on the YAML form (back-compat).
447    if let Some(effort) = h.spec.effort {
448        s.push_str(&format!("EFFORT={}\n", effort.as_str()));
449    }
450    s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
451    s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
452    s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
453    s.push_str(&format!(
454        "CLAUDE_PROJECT_DIR={}\n",
455        project.project.cwd.display()
456    ));
457    // Absolute path to the compose root (the directory holding
458    // `team-compose.yaml`). The wrapper passes this to `teamctl --root`
459    // so rl-watch resolves the right tree regardless of where
460    // `cd "$CLAUDE_PROJECT_DIR"` lands the shell. Without this,
461    // wrapper falls back to CLAUDE_PROJECT_DIR (often a relative `..`)
462    // which compounds with the post-cd cwd and points at the wrong
463    // directory.
464    s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
465    s.push_str(&format!(
466        "TMUX_SESSION={}{}-{}\n",
467        compose.global.supervisor.tmux_prefix, h.project, h.agent
468    ));
469    // T-118: claude-code agents resume their conversation across
470    // teamctl down/up + crash recovery via a deterministic UUIDv5
471    // session id. Other runtimes don't recognize `--session-id`, so
472    // emit these env vars only for `claude-code` — the wrapper's
473    // claude-code arm picks them up; other arms ignore them.
474    if h.spec.runtime == "claude-code" {
475        let session_id = crate::session::derive_session_id(h.project, h.agent);
476        let session_name = crate::session::session_name(h.project, h.agent);
477        s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
478        s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
479        // T-189: path to the wrapper-managed Claude settings file
480        // carrying the synchronous-prompt deny hook. Wrapper applies
481        // it via `--settings` except when `permission_mode: attended`
482        // (human at the keyboard wants the interactive tools back).
483        let settings = claude_settings_path(&compose.root, h.project, h.agent);
484        s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
485        // #383 Phase 3a: path to the rendered `--agents` JSON carrying this
486        // agent's declared sub-agents. Always emitted for claude-code; the
487        // file itself is written only when `subagents:` is non-empty, so
488        // the wrapper's `[ -f ]` guard decides whether `--agents` is passed.
489        let subagents = subagents_json_path(&compose.root, h.project, h.agent);
490        s.push_str(&format!("CLAUDE_AGENTS_JSON={}\n", subagents.display()));
491        // #383 Phase 3b: path to the per-agent skills scope dir passed to
492        // `claude --add-dir`. Always emitted for claude-code; the dir is
493        // materialized only when `skills:` is non-empty, so the wrapper's
494        // `[ -d ]` guard decides whether `--add-dir` is passed.
495        let scope = agent_scope_dir(&compose.root, h.project, h.agent);
496        s.push_str(&format!("CLAUDE_AGENT_SCOPE={}\n", scope.display()));
497    }
498    s
499}
500
501/// Resolve the absolute path that `SYSTEM_PROMPT_PATH` will point at.
502///
503/// - `None` role_prompt → `None` (env line renders as blank).
504/// - Single source file → `<root>/<source>` (back-compat, no concat
505///   file is written — the operator's source is the prompt).
506/// - List form → the materialized concat path under
507///   `<root>/state/role_prompts/<project>-<agent>.md`. The file at that
508///   path is produced by [`write_role_prompt_concat`]; this helper is
509///   pure and only computes the destination.
510pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
511    match h.spec.role_prompt.as_ref()? {
512        RolePrompt::Single(p) => Some(compose.root.join(p)),
513        RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
514    }
515}
516
517/// Materialize the multi-file `role_prompt` concatenation for one agent.
518///
519/// No-op when `role_prompt` is `None` or `Single` — there is nothing to
520/// concatenate. For the list form, every source file is read in declared
521/// order and joined with [`ROLE_PROMPT_SEPARATOR`]; the result overwrites
522/// `<root>/state/role_prompts/<project>-<agent>.md` so subsequent edits
523/// to any source file flow into the agent's prompt at the next render.
524///
525/// Missing source files surface as the underlying `io::Error` so the
526/// caller can fail the apply rather than silently emit a partial concat.
527pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
528    let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
529        return Ok(());
530    };
531
532    let mut buf = String::new();
533    for (idx, rel) in paths.iter().enumerate() {
534        if idx > 0 {
535            buf.push_str(ROLE_PROMPT_SEPARATOR);
536        }
537        let abs = compose.root.join(rel);
538        let bytes = std::fs::read(&abs).map_err(|e| {
539            io::Error::new(
540                e.kind(),
541                format!("read role_prompt source {}: {e}", abs.display()),
542            )
543        })?;
544        // Source files are expected to be UTF-8 markdown; lossy decode
545        // keeps render diagnostics readable if a stray byte sneaks in.
546        buf.push_str(&String::from_utf8_lossy(&bytes));
547    }
548
549    let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
550    if let Some(parent) = dest.parent() {
551        std::fs::create_dir_all(parent)?;
552    }
553    std::fs::write(&dest, buf)
554}
555
556fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
557    let mailbox = compose.root.join(&compose.global.broker.path);
558    let mut v = serde_json::json!({
559        "mcpServers": {
560            "team": {
561                "command": team_mcp_bin,
562                "args": [
563                    "--agent-id", format!("{}:{}", h.project, h.agent),
564                    "--mailbox", mailbox.display().to_string(),
565                    // T-109: compact_self resolves the caller's tmux pane
566                    // as `<prefix><project>-<agent>`. Pass the configured
567                    // prefix explicitly so teams overriding the default
568                    // (`a-`, `oss-`, …) route the slash command to the
569                    // right session. team-bot gets the same arg threaded
570                    // from `teamctl bot up`; this keeps the two MCP-side
571                    // and bot-side resolvers in sync.
572                    "--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
573                    // T-32b: compose root used by `read_attachment`
574                    // for `attachments:` policy + tempfile staging.
575                    // Always passed so the per-agent team-mcp can
576                    // serve attachment reads; the staging dir is
577                    // computed under this root.
578                    "--compose-root", compose.root.display().to_string(),
579                ],
580                "env": {}
581            }
582        }
583    });
584
585    // #383 Phase 4: merge per-agent declared MCP servers alongside the
586    // built-in `team` server. Unlike hooks (claude-only), MCP is the
587    // runtime-agnostic bus, so declared servers render for every runtime
588    // whose descriptor sets `supports_mcp`. The `team` server is the
589    // mailbox transport: it stays unconditional and non-clobberable — a
590    // declared server named `team` is skipped here (and rejected at
591    // validate) so it can never shadow the bus. env values pass through
592    // verbatim; the runtime performs any `${VAR}` expansion.
593    if !h.spec.mcps.is_empty() {
594        let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
595        // Fail open when the descriptor is missing: an unknown runtime is
596        // flagged at validate, and a load failure shouldn't silently drop
597        // declared servers.
598        let supports_mcp = runtimes
599            .get(h.spec.runtime.as_str())
600            .map(|r| r.supports_mcp)
601            .unwrap_or(true);
602        if supports_mcp {
603            let servers = v["mcpServers"]
604                .as_object_mut()
605                .expect("mcpServers is a json object");
606            for (name, server) in &h.spec.mcps {
607                if name == "team" {
608                    continue; // non-clobberable bus; validate rejects this too
609                }
610                servers.insert(
611                    name.clone(),
612                    serde_json::to_value(server).expect("serialize McpServer"),
613                );
614            }
615        } else {
616            tracing::warn!(
617                target: "team-core::render",
618                "agent `{}:{}` declares {} MCP server(s) but runtime `{}` does not set `supports_mcp`; ignoring",
619                h.project,
620                h.agent,
621                h.spec.mcps.len(),
622                h.spec.runtime
623            );
624        }
625    }
626
627    serde_json::to_string_pretty(&v).expect("json")
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::compose::*;
634    use std::collections::BTreeMap;
635    use std::path::PathBuf;
636
637    fn fixture() -> Compose {
638        let mut managers = BTreeMap::new();
639        managers.insert(
640            "mgr".into(),
641            Agent {
642                runtime: "claude-code".into(),
643                model: Some("claude-opus-4-8".into()),
644                role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
645                permission_mode: Some("auto".into()),
646                autonomy: "low_risk_only".into(),
647                can_dm: vec![],
648                can_broadcast: vec![],
649                reports_to: None,
650                on_rate_limit: None,
651                effort: None,
652                interfaces: None,
653                display_name: None,
654                hooks: vec![],
655                mcps: Default::default(),
656                subagents: vec![],
657                skills: vec![],
658            },
659        );
660        Compose {
661            root: PathBuf::from("/teamctl"),
662            global: Global {
663                version: crate::compose::SchemaVersion::new("2.0.0"),
664                broker: Broker {
665                    r#type: "sqlite".into(),
666                    path: PathBuf::from("state/mailbox.db"),
667                },
668                supervisor: SupervisorCfg {
669                    r#type: "tmux".into(),
670                    tmux_prefix: "a-".into(),
671                    drain_timeout_secs: 10,
672                },
673                budget: Default::default(),
674                hitl: Default::default(),
675                rate_limits: Default::default(),
676                interfaces: vec![],
677                projects: vec![],
678                attachments: Default::default(),
679            },
680            projects: vec![Project {
681                version: 2,
682                project: ProjectMeta {
683                    id: "hello".into(),
684                    name: "Hello".into(),
685                    cwd: PathBuf::from("/teamctl/examples/hello-team"),
686                },
687                channels: vec![],
688                managers,
689                workers: Default::default(),
690                interfaces: None,
691            }],
692        }
693    }
694
695    #[test]
696    fn env_contains_agent_id_and_mailbox() {
697        let c = fixture();
698        let h = c.agents().next().unwrap();
699        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
700        assert!(env.contains("AGENT_ID=hello:mgr"));
701        assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
702        assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
703    }
704
705    #[test]
706    fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
707        // T-118: claude-code agents get deterministic UUIDv5 session
708        // ids in their env so the wrapper can pass `--session-id` +
709        // `-n` and resume the conversation across restarts.
710        let c = fixture();
711        let h = c.agents().next().unwrap();
712        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
713        let expected_id = crate::session::derive_session_id(h.project, h.agent);
714        assert!(
715            env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
716            "env was: {env}"
717        );
718        assert!(
719            env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
720            "env was: {env}"
721        );
722    }
723
724    #[test]
725    fn env_omits_claude_session_vars_for_non_claude_runtimes() {
726        // Other runtimes (codex, gemini) don't recognize claude's
727        // `--session-id` flag — their wrapper arms must not see these
728        // vars. Pin the gate so a future render refactor can't leak
729        // them into every runtime.
730        let mut c = fixture();
731        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
732        let h = c.agents().next().unwrap();
733        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
734        assert!(
735            !env.contains("CLAUDE_SESSION_ID="),
736            "non-claude runtime must not get session id: {env}"
737        );
738        assert!(
739            !env.contains("CLAUDE_SESSION_NAME="),
740            "non-claude runtime must not get session name: {env}"
741        );
742    }
743
744    #[test]
745    fn env_pins_teamctl_root_to_compose_root() {
746        // Regression: when project.cwd is a relative path (e.g. `..`),
747        // the wrapper used to fall back to it for `--root`, which
748        // resolves against the post-cd cwd and points at the wrong
749        // directory. Rendering an absolute TEAMCTL_ROOT pins
750        // `teamctl --root` to the compose root regardless of cwd.
751        let c = fixture();
752        let h = c.agents().next().unwrap();
753        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
754        assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
755    }
756
757    #[test]
758    fn env_omits_effort_when_unset() {
759        let c = fixture();
760        let h = c.agents().next().unwrap();
761        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
762        assert!(!env.contains("EFFORT="), "env was: {env}");
763    }
764
765    #[test]
766    fn env_emits_effort_when_set() {
767        let mut c = fixture();
768        c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
769        let h = c.agents().next().unwrap();
770        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
771        assert!(env.contains("EFFORT=max\n"), "env was: {env}");
772    }
773
774    #[test]
775    fn mcp_json_parses_back() {
776        let c = fixture();
777        let h = c.agents().next().unwrap();
778        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
779        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
780        assert_eq!(
781            v["mcpServers"]["team"]["command"],
782            "/usr/local/bin/team-mcp"
783        );
784        assert_eq!(
785            v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
786            "hello:mgr"
787        );
788    }
789
790    #[test]
791    fn mcp_json_threads_tmux_prefix_from_compose() {
792        // T-109: compact_self routes its tmux send-keys to
793        // `<prefix><project>-<agent>` and reads the prefix from a CLI arg
794        // (default `t-` only fits a stock team). Render must surface the
795        // configured prefix so teams overriding it (e.g. `a-` here) get
796        // their pane resolved correctly.
797        let c = fixture();
798        let h = c.agents().next().unwrap();
799        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
800        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
801        let args: Vec<&str> = v["mcpServers"]["team"]["args"]
802            .as_array()
803            .unwrap()
804            .iter()
805            .map(|a| a.as_str().unwrap())
806            .collect();
807        let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
808            "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
809        );
810        assert_eq!(
811            args[i + 1],
812            "a-",
813            "prefix must come from compose, not the default"
814        );
815    }
816
817    /// Build a `McpServer` test value tersely.
818    fn server(command: &str, args: &[&str]) -> McpServer {
819        McpServer {
820            command: command.into(),
821            args: args.iter().map(|s| s.to_string()).collect(),
822            env: Default::default(),
823        }
824    }
825
826    #[test]
827    fn mcp_json_includes_declared_servers_alongside_team() {
828        // #383 Phase 4: a declared server lands in `mcpServers` next to
829        // the built-in `team` server, with command/args/env passed
830        // through verbatim (no `${VAR}` expansion in render).
831        let mut c = fixture();
832        let mut mcps = BTreeMap::new();
833        let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
834        gh.env
835            .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
836        mcps.insert("github".into(), gh);
837        c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
838
839        let h = c.agents().next().unwrap();
840        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
841        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
842
843        // Built-in team server survives untouched.
844        assert_eq!(
845            v["mcpServers"]["team"]["command"],
846            "/usr/local/bin/team-mcp"
847        );
848        // Declared server present with verbatim fields.
849        assert_eq!(v["mcpServers"]["github"]["command"], "npx");
850        assert_eq!(v["mcpServers"]["github"]["args"][0], "-y");
851        assert_eq!(
852            v["mcpServers"]["github"]["env"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}",
853            "env values must pass through verbatim — the runtime expands ${{VAR}}"
854        );
855        assert_eq!(v["mcpServers"].as_object().unwrap().len(), 2);
856    }
857
858    #[test]
859    fn mcp_json_team_server_is_non_clobberable() {
860        // #383 Phase 4: a declared server literally named `team` must not
861        // shadow the built-in mailbox bus — render skips it (validate also
862        // rejects it). The `team` entry keeps the built-in command.
863        let mut c = fixture();
864        let mut mcps = BTreeMap::new();
865        mcps.insert("team".into(), server("evil-team", &[]));
866        mcps.insert("github".into(), server("npx", &[]));
867        c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
868
869        let h = c.agents().next().unwrap();
870        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
871        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
872
873        assert_eq!(
874            v["mcpServers"]["team"]["command"], "/usr/local/bin/team-mcp",
875            "built-in team server must not be clobbered by a declared `team`"
876        );
877        assert!(v["mcpServers"]["github"].is_object());
878        assert_eq!(
879            v["mcpServers"].as_object().unwrap().len(),
880            2,
881            "the declared `team` is dropped, not added as a third entry"
882        );
883    }
884
885    #[test]
886    fn mcp_json_unchanged_when_no_servers_declared() {
887        // #383 Phase 4: empty `mcps` (the default) → only the built-in
888        // team server, exactly as before this feature.
889        let c = fixture();
890        let h = c.agents().next().unwrap();
891        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
892        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
893        let servers = v["mcpServers"].as_object().unwrap();
894        assert_eq!(servers.len(), 1);
895        assert!(servers.contains_key("team"));
896    }
897
898    #[test]
899    fn mcp_json_skips_declared_servers_on_runtime_without_mcp_support() {
900        // #383 Phase 4: declared servers render only for runtimes whose
901        // descriptor sets `supports_mcp`. A custom runtime that opts out
902        // gets the team bus (unconditional) but not the declared servers.
903        let tmp = tempfile::tempdir().unwrap();
904        std::fs::create_dir_all(tmp.path().join("runtimes")).unwrap();
905        std::fs::write(
906            tmp.path().join("runtimes/codex.yaml"),
907            "binary: codex\nsupports_mcp: false\n",
908        )
909        .unwrap();
910
911        let mut c = fixture();
912        c.root = tmp.path().to_path_buf();
913        {
914            let m = c.projects[0].managers.get_mut("mgr").unwrap();
915            m.runtime = "codex".into();
916            let mut mcps = BTreeMap::new();
917            mcps.insert("github".into(), server("npx", &[]));
918            m.mcps = mcps;
919        }
920
921        let h = c.agents().next().unwrap();
922        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
923        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
924        let servers = v["mcpServers"].as_object().unwrap();
925        assert!(servers.contains_key("team"), "team bus stays unconditional");
926        assert!(
927            !servers.contains_key("github"),
928            "declared server skipped when runtime lacks supports_mcp"
929        );
930        assert_eq!(servers.len(), 1);
931    }
932
933    #[test]
934    fn env_points_at_source_for_single_role_prompt() {
935        let c = fixture();
936        let h = c.agents().next().unwrap();
937        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
938        assert!(
939            env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
940            "env was: {env}"
941        );
942    }
943
944    #[test]
945    fn env_points_at_concat_path_for_multi_role_prompt() {
946        let mut c = fixture();
947        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
948            Some(RolePrompt::Multiple(vec![
949                PathBuf::from("roles/_base.md"),
950                PathBuf::from("roles/mgr.md"),
951            ]));
952        let h = c.agents().next().unwrap();
953        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
954        assert!(
955            env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
956            "env was: {env}"
957        );
958    }
959
960    #[test]
961    fn write_role_prompt_concat_is_noop_for_single() {
962        let dir = tempfile::tempdir().unwrap();
963        let mut c = fixture();
964        c.root = dir.path().to_path_buf();
965        let h = c.agents().next().unwrap();
966        write_role_prompt_concat(&c, h).unwrap();
967        assert!(
968            !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
969            "single-form role_prompt should not produce a concat file"
970        );
971    }
972
973    #[test]
974    fn write_role_prompt_concat_joins_in_declared_order() {
975        let dir = tempfile::tempdir().unwrap();
976        let root = dir.path();
977        std::fs::create_dir_all(root.join("roles")).unwrap();
978        std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
979        std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
980
981        let mut c = fixture();
982        c.root = root.to_path_buf();
983        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
984            Some(RolePrompt::Multiple(vec![
985                PathBuf::from("roles/_base.md"),
986                PathBuf::from("roles/mgr.md"),
987            ]));
988        let h = c.agents().next().unwrap();
989        write_role_prompt_concat(&c, h).unwrap();
990
991        let dest = role_prompt_concat_path(root, h.project, h.agent);
992        let got = std::fs::read_to_string(&dest).unwrap();
993        assert_eq!(got, "BASE\n\n—\n\nMGR");
994    }
995
996    #[test]
997    fn write_role_prompt_concat_reflects_source_edits() {
998        // Owner-flagged: editing a source file must show up at the next
999        // render. We re-write unconditionally rather than caching.
1000        let dir = tempfile::tempdir().unwrap();
1001        let root = dir.path();
1002        std::fs::create_dir_all(root.join("roles")).unwrap();
1003        std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
1004        std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
1005
1006        let mut c = fixture();
1007        c.root = root.to_path_buf();
1008        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1009            Some(RolePrompt::Multiple(vec![
1010                PathBuf::from("roles/_base.md"),
1011                PathBuf::from("roles/mgr.md"),
1012            ]));
1013        let h = c.agents().next().unwrap();
1014        write_role_prompt_concat(&c, h).unwrap();
1015
1016        std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
1017        let h = c.agents().next().unwrap();
1018        write_role_prompt_concat(&c, h).unwrap();
1019
1020        let dest = role_prompt_concat_path(root, h.project, h.agent);
1021        let got = std::fs::read_to_string(&dest).unwrap();
1022        assert_eq!(got, "v2\n\n—\n\nMGR");
1023    }
1024
1025    #[test]
1026    fn claude_settings_present_for_claude_code() {
1027        // T-189: claude-code agents get a wrapper-managed settings
1028        // file with a PreToolUse deny hook for synchronous-prompt
1029        // tools that would otherwise strand a headless pane.
1030        let c = fixture();
1031        let h = c.agents().next().unwrap();
1032        let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
1033        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1034        let pre = &v["hooks"]["PreToolUse"][0];
1035        assert_eq!(
1036            pre["matcher"].as_str().unwrap(),
1037            "AskUserQuestion|EnterPlanMode|ExitPlanMode"
1038        );
1039        let cmd = pre["hooks"][0]["command"].as_str().unwrap();
1040        assert!(
1041            cmd.contains(r#""permissionDecision":"deny""#),
1042            "deny verdict missing from hook command: {cmd}"
1043        );
1044        assert!(
1045            cmd.contains("Interactive prompts are disabled"),
1046            "systemMessage missing from hook command: {cmd}"
1047        );
1048    }
1049
1050    #[test]
1051    fn claude_settings_absent_for_non_claude_runtimes() {
1052        // codex/gemini don't read claude settings; the file would be
1053        // dead weight and a confusing artifact on disk.
1054        let mut c = fixture();
1055        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1056        let h = c.agents().next().unwrap();
1057        assert!(render_claude_settings(&c, h).is_none());
1058    }
1059
1060    #[test]
1061    fn declared_hook_merges_alongside_deny_hook() {
1062        // #383 Phase 2: a per-agent hook is appended AFTER the built-in
1063        // deny hook in the same PreToolUse bucket — the deny keeps slot 0
1064        // and the command resolves compose-root-relative to absolute.
1065        let mut c = fixture();
1066        c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
1067            event: "PreToolUse".into(),
1068            matcher: Some("Bash".into()),
1069            command: PathBuf::from("hooks/guard.sh"),
1070        }];
1071        let h = c.agents().next().unwrap();
1072        let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
1073        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1074        let pre = v["hooks"]["PreToolUse"].as_array().unwrap();
1075        assert_eq!(pre.len(), 2, "deny hook + declared hook expected");
1076        // Built-in deny hook survives in slot 0.
1077        assert_eq!(
1078            pre[0]["matcher"].as_str().unwrap(),
1079            "AskUserQuestion|EnterPlanMode|ExitPlanMode"
1080        );
1081        assert!(pre[0]["hooks"][0]["command"]
1082            .as_str()
1083            .unwrap()
1084            .contains(r#""permissionDecision":"deny""#));
1085        // Declared hook appended after it.
1086        assert_eq!(pre[1]["matcher"].as_str().unwrap(), "Bash");
1087        assert_eq!(pre[1]["hooks"][0]["type"].as_str().unwrap(), "command");
1088        assert_eq!(
1089            pre[1]["hooks"][0]["command"].as_str().unwrap(),
1090            "/teamctl/hooks/guard.sh"
1091        );
1092    }
1093
1094    #[test]
1095    fn no_declared_hooks_leaves_settings_unchanged() {
1096        // #383 Phase 2: empty `hooks` (the default) must render exactly
1097        // the built-in deny hook and nothing else.
1098        let c = fixture();
1099        let h = c.agents().next().unwrap();
1100        let v: serde_json::Value =
1101            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
1102        let hooks = v["hooks"].as_object().unwrap();
1103        assert_eq!(
1104            hooks.len(),
1105            1,
1106            "only the built-in PreToolUse bucket expected"
1107        );
1108        assert_eq!(
1109            hooks["PreToolUse"].as_array().unwrap().len(),
1110            1,
1111            "only the deny hook expected"
1112        );
1113    }
1114
1115    #[test]
1116    fn declared_hook_without_matcher_opens_new_event_bucket() {
1117        // #383 Phase 2: a hook on a fresh event (no matcher) creates its
1118        // own bucket and omits `matcher` so Claude Code matches all tools;
1119        // the deny hook's PreToolUse bucket is left untouched.
1120        let mut c = fixture();
1121        c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
1122            event: "PostToolUse".into(),
1123            matcher: None,
1124            command: PathBuf::from("hooks/log.sh"),
1125        }];
1126        let h = c.agents().next().unwrap();
1127        let v: serde_json::Value =
1128            serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
1129        assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
1130        let post = &v["hooks"]["PostToolUse"].as_array().unwrap()[0];
1131        assert!(
1132            post.get("matcher").is_none(),
1133            "matcher must be omitted when unset: {post}"
1134        );
1135        assert_eq!(
1136            post["hooks"][0]["command"].as_str().unwrap(),
1137            "/teamctl/hooks/log.sh"
1138        );
1139    }
1140
1141    #[test]
1142    fn declared_hooks_noop_on_non_claude_runtime() {
1143        // #383 Phase 2: hooks are claude-only v1 — declared on codex the
1144        // whole settings file is still skipped (render warns, returns None).
1145        let mut c = fixture();
1146        {
1147            let m = c.projects[0].managers.get_mut("mgr").unwrap();
1148            m.runtime = "codex".into();
1149            m.hooks = vec![HookSpec {
1150                event: "PreToolUse".into(),
1151                matcher: Some("Bash".into()),
1152                command: PathBuf::from("hooks/guard.sh"),
1153            }];
1154        }
1155        let h = c.agents().next().unwrap();
1156        assert!(
1157            render_claude_settings(&c, h).is_none(),
1158            "hooks must not render on non-claude runtimes"
1159        );
1160    }
1161
1162    #[test]
1163    fn env_emits_claude_settings_path_for_claude_code() {
1164        // T-189: wrapper reads CLAUDE_SETTINGS and passes it to claude
1165        // via `--settings`. Path must resolve under the compose root.
1166        let c = fixture();
1167        let h = c.agents().next().unwrap();
1168        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1169        assert!(
1170            env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
1171            "env was: {env}"
1172        );
1173    }
1174
1175    #[test]
1176    fn env_omits_claude_settings_for_non_claude_runtimes() {
1177        // Only claude-code reads the settings file; other runtimes
1178        // must not see the env var (avoids confusion if they ever add
1179        // a same-named knob).
1180        let mut c = fixture();
1181        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1182        let h = c.agents().next().unwrap();
1183        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1184        assert!(
1185            !env.contains("CLAUDE_SETTINGS="),
1186            "non-claude runtime must not get settings path: {env}"
1187        );
1188    }
1189
1190    #[test]
1191    fn write_role_prompt_concat_errors_on_missing_source() {
1192        let dir = tempfile::tempdir().unwrap();
1193        let mut c = fixture();
1194        c.root = dir.path().to_path_buf();
1195        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
1196            vec![PathBuf::from("roles/missing.md")],
1197        ));
1198        let h = c.agents().next().unwrap();
1199        let err = write_role_prompt_concat(&c, h).unwrap_err();
1200        assert!(err.to_string().contains("missing.md"), "err was: {err}");
1201    }
1202
1203    // ---- #383 Phase 3a: per-agent sub-agents (`--agents` JSON) ----
1204
1205    fn write_file(root: &std::path::Path, rel: &str, contents: &str) {
1206        let abs = root.join(rel);
1207        std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
1208        std::fs::write(abs, contents).unwrap();
1209    }
1210
1211    fn rooted(write: impl FnOnce(&std::path::Path)) -> (tempfile::TempDir, Compose) {
1212        let dir = tempfile::tempdir().unwrap();
1213        let mut c = fixture();
1214        c.root = dir.path().to_path_buf();
1215        write(dir.path());
1216        (dir, c)
1217    }
1218
1219    #[test]
1220    fn render_subagents_builds_agents_json_from_frontmatter() {
1221        let (_d, mut c) = rooted(|root| {
1222            write_file(
1223                root,
1224                "agents/security-auditor.md",
1225                "---\nname: security-auditor\ndescription: Audits diffs for vulns.\n\
1226                 tools: Read, Grep\nmodel: claude-sonnet-4-6\n---\n\
1227                 You are a security auditor.\nFlag risky patterns.\n",
1228            );
1229        });
1230        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1231            vec![PathBuf::from("agents/security-auditor.md")];
1232        let h = c.agents().next().unwrap();
1233        let json = render_subagents(&c, h).unwrap().expect("some json");
1234        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1235        let entry = &v["security-auditor"];
1236        assert_eq!(entry["description"], "Audits diffs for vulns.");
1237        assert_eq!(
1238            entry["prompt"],
1239            "You are a security auditor.\nFlag risky patterns."
1240        );
1241        assert_eq!(entry["tools"], serde_json::json!(["Read", "Grep"]));
1242        assert_eq!(entry["model"], "claude-sonnet-4-6");
1243    }
1244
1245    #[test]
1246    fn render_subagents_name_falls_back_to_file_stem() {
1247        let (_d, mut c) = rooted(|root| {
1248            write_file(
1249                root,
1250                "agents/repo-cartographer.md",
1251                "---\ndescription: Maps the repo.\n---\nMap it.\n",
1252            );
1253        });
1254        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1255            vec![PathBuf::from("agents/repo-cartographer.md")];
1256        let h = c.agents().next().unwrap();
1257        let json = render_subagents(&c, h).unwrap().unwrap();
1258        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1259        assert!(
1260            v.get("repo-cartographer").is_some(),
1261            "stem-derived name missing: {json}"
1262        );
1263        // Nothing declared beyond description → optional keys omitted.
1264        assert!(v["repo-cartographer"].get("tools").is_none());
1265        assert!(v["repo-cartographer"].get("model").is_none());
1266    }
1267
1268    #[test]
1269    fn render_subagents_supports_yaml_list_tools() {
1270        let (_d, mut c) = rooted(|root| {
1271            write_file(
1272                root,
1273                "agents/x.md",
1274                "---\nname: x\ndescription: d\ntools: [Read, Bash]\n---\nbody\n",
1275            );
1276        });
1277        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1278            vec![PathBuf::from("agents/x.md")];
1279        let h = c.agents().next().unwrap();
1280        let json = render_subagents(&c, h).unwrap().unwrap();
1281        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1282        assert_eq!(v["x"]["tools"], serde_json::json!(["Read", "Bash"]));
1283    }
1284
1285    #[test]
1286    fn render_subagents_isolates_per_agent() {
1287        // Two agents declaring different sub-agents must each get only
1288        // their own — the core per-agent-scope guarantee.
1289        let (_d, mut c) = rooted(|root| {
1290            write_file(
1291                root,
1292                "agents/a.md",
1293                "---\nname: a\ndescription: da\n---\nba\n",
1294            );
1295            write_file(
1296                root,
1297                "agents/b.md",
1298                "---\nname: b\ndescription: db\n---\nbb\n",
1299            );
1300        });
1301        let worker = c.projects[0].managers["mgr"].clone();
1302        c.projects[0].workers.insert("dev".into(), worker);
1303        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1304            vec![PathBuf::from("agents/a.md")];
1305        c.projects[0].workers.get_mut("dev").unwrap().subagents =
1306            vec![PathBuf::from("agents/b.md")];
1307
1308        for h in c.agents() {
1309            let v: serde_json::Value =
1310                serde_json::from_str(&render_subagents(&c, h).unwrap().unwrap()).unwrap();
1311            match h.agent {
1312                "mgr" => {
1313                    assert!(v.get("a").is_some() && v.get("b").is_none());
1314                }
1315                "dev" => {
1316                    assert!(v.get("b").is_some() && v.get("a").is_none());
1317                }
1318                other => panic!("unexpected agent {other}"),
1319            }
1320        }
1321    }
1322
1323    #[test]
1324    fn render_subagents_none_when_empty() {
1325        let c = fixture();
1326        let h = c.agents().next().unwrap();
1327        assert!(render_subagents(&c, h).unwrap().is_none());
1328    }
1329
1330    #[test]
1331    fn render_subagents_ignored_on_non_claude_runtime() {
1332        let (_d, mut c) = rooted(|root| {
1333            write_file(
1334                root,
1335                "agents/x.md",
1336                "---\nname: x\ndescription: d\n---\nb\n",
1337            );
1338        });
1339        {
1340            let a = c.projects[0].managers.get_mut("mgr").unwrap();
1341            a.runtime = "codex".into();
1342            a.subagents = vec![PathBuf::from("agents/x.md")];
1343        }
1344        let h = c.agents().next().unwrap();
1345        // claude-only v1: codex ignores declared sub-agents (warns).
1346        assert!(render_subagents(&c, h).unwrap().is_none());
1347    }
1348
1349    #[test]
1350    fn render_subagents_errors_on_missing_source() {
1351        let (_d, mut c) = rooted(|_| {});
1352        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1353            vec![PathBuf::from("agents/nope.md")];
1354        let h = c.agents().next().unwrap();
1355        let err = render_subagents(&c, h).unwrap_err();
1356        assert!(err.to_string().contains("nope.md"), "err was: {err}");
1357    }
1358
1359    #[test]
1360    fn render_subagents_errors_on_unterminated_frontmatter() {
1361        let (_d, mut c) = rooted(|root| {
1362            write_file(
1363                root,
1364                "agents/bad.md",
1365                "---\nname: x\ndescription: d\nno close\n",
1366            );
1367        });
1368        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1369            vec![PathBuf::from("agents/bad.md")];
1370        let h = c.agents().next().unwrap();
1371        assert!(render_subagents(&c, h).is_err());
1372    }
1373
1374    #[test]
1375    fn env_emits_claude_agents_json_for_claude_code() {
1376        let c = fixture();
1377        let h = c.agents().next().unwrap();
1378        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1379        assert!(env.contains("CLAUDE_AGENTS_JSON=/teamctl/state/claude/hello-mgr.agents.json"));
1380    }
1381
1382    #[test]
1383    fn write_subagents_json_writes_then_clears_stale() {
1384        let (_d, mut c) = rooted(|root| {
1385            write_file(
1386                root,
1387                "agents/x.md",
1388                "---\nname: x\ndescription: d\n---\nbody\n",
1389            );
1390        });
1391        let dest = subagents_json_path(&c.root, "hello", "mgr");
1392
1393        // Declared → file materialized.
1394        c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1395            vec![PathBuf::from("agents/x.md")];
1396        let h = c.agents().next().unwrap();
1397        write_subagents_json(&c, h).unwrap();
1398        assert!(dest.exists(), "agents json should be written");
1399
1400        // Dropped → stale file removed so old sub-agents don't linger.
1401        c.projects[0].managers.get_mut("mgr").unwrap().subagents = vec![];
1402        let h = c.agents().next().unwrap();
1403        write_subagents_json(&c, h).unwrap();
1404        assert!(!dest.exists(), "stale agents json should be removed");
1405    }
1406
1407    #[test]
1408    fn write_agent_skills_materializes_symlinks() {
1409        let (_d, mut c) = rooted(|root| {
1410            write_file(root, "skills/pr-review/SKILL.md", "# PR review skill\n");
1411        });
1412        c.projects[0].managers.get_mut("mgr").unwrap().skills =
1413            vec![PathBuf::from("skills/pr-review")];
1414        let h = c.agents().next().unwrap();
1415        write_agent_skills(&c, h).unwrap();
1416
1417        let link = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills/pr-review");
1418        let meta = std::fs::symlink_metadata(&link).expect("link should exist");
1419        assert!(meta.file_type().is_symlink(), "entry must be a symlink");
1420        // Resolves to the source skill dir (so CC finds its SKILL.md).
1421        assert_eq!(
1422            std::fs::canonicalize(&link).unwrap(),
1423            std::fs::canonicalize(c.root.join("skills/pr-review")).unwrap()
1424        );
1425    }
1426
1427    #[test]
1428    fn write_agent_skills_clear_stale_preserves_source() {
1429        // SAFETY: dropping a skill must unlink only the symlink — never
1430        // recurse into and delete the real skill directory it pointed at.
1431        let (_d, mut c) = rooted(|root| {
1432            write_file(root, "skills/foo/SKILL.md", "# foo\n");
1433        });
1434        let source = c.root.join("skills/foo");
1435        let source_md = source.join("SKILL.md");
1436
1437        // Declare → materialize the link.
1438        c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/foo")];
1439        let h = c.agents().next().unwrap();
1440        write_agent_skills(&c, h).unwrap();
1441        let scope = agent_scope_dir(&c.root, "hello", "mgr");
1442        assert!(scope.join(".claude/skills/foo").exists());
1443
1444        // Drop → scope cleared, but the real skill dir + SKILL.md survive.
1445        c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![];
1446        let h = c.agents().next().unwrap();
1447        write_agent_skills(&c, h).unwrap();
1448        assert!(!scope.exists(), "stale scope dir should be removed");
1449        assert!(source.is_dir(), "source skill dir must survive the clear");
1450        assert!(
1451            source_md.is_file(),
1452            "source SKILL.md must survive the clear"
1453        );
1454    }
1455
1456    #[test]
1457    fn write_agent_skills_isolates_per_agent() {
1458        // Two agents declaring different skills must each get only their
1459        // own — the core per-agent-scope guarantee.
1460        let (_d, mut c) = rooted(|root| {
1461            write_file(root, "skills/a/SKILL.md", "# a\n");
1462            write_file(root, "skills/b/SKILL.md", "# b\n");
1463        });
1464        let worker = c.projects[0].managers["mgr"].clone();
1465        c.projects[0].workers.insert("dev".into(), worker);
1466        c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/a")];
1467        c.projects[0].workers.get_mut("dev").unwrap().skills = vec![PathBuf::from("skills/b")];
1468
1469        for h in c.agents() {
1470            write_agent_skills(&c, h).unwrap();
1471        }
1472        let mgr_skills = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills");
1473        let dev_skills = agent_scope_dir(&c.root, "hello", "dev").join(".claude/skills");
1474        assert!(mgr_skills.join("a").exists() && !mgr_skills.join("b").exists());
1475        assert!(dev_skills.join("b").exists() && !dev_skills.join("a").exists());
1476    }
1477
1478    #[test]
1479    fn write_agent_skills_ignored_on_non_claude_runtime() {
1480        let (_d, mut c) = rooted(|root| {
1481            write_file(root, "skills/x/SKILL.md", "# x\n");
1482        });
1483        {
1484            let a = c.projects[0].managers.get_mut("mgr").unwrap();
1485            a.runtime = "codex".into();
1486            a.skills = vec![PathBuf::from("skills/x")];
1487        }
1488        let h = c.agents().next().unwrap();
1489        // claude-only v1: codex ignores declared skills (warns) and no
1490        // scope dir is created.
1491        write_agent_skills(&c, h).unwrap();
1492        assert!(!agent_scope_dir(&c.root, "hello", "mgr").exists());
1493    }
1494
1495    #[test]
1496    fn env_emits_claude_agent_scope_for_claude_code() {
1497        let c = fixture();
1498        let h = c.agents().next().unwrap();
1499        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1500        assert!(env.contains("CLAUDE_AGENT_SCOPE=/teamctl/state/agent-scope/hello-mgr"));
1501    }
1502
1503    #[test]
1504    fn env_omits_claude_agent_scope_for_non_claude_runtimes() {
1505        let mut c = fixture();
1506        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1507        let h = c.agents().next().unwrap();
1508        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1509        assert!(
1510            !env.contains("CLAUDE_AGENT_SCOPE="),
1511            "non-claude runtime must not get the agent scope: {env}"
1512        );
1513    }
1514}