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