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 materialized concatenation of a multi-file
51/// `role_prompt` list. Only ever written for the list form — single-file
52/// `role_prompt` keeps pointing at its source path directly.
53pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
54    root.join("state/role_prompts")
55        .join(format!("{project}-{agent}.md"))
56}
57
58/// Rendered env + MCP content for a single agent.
59pub fn render_agent(
60    compose: &Compose,
61    handle: AgentHandle<'_>,
62    team_mcp_bin: &str,
63) -> (String, String) {
64    let env = render_env(compose, handle);
65    let mcp = render_mcp(compose, handle, team_mcp_bin);
66    (env, mcp)
67}
68
69/// Wrapper-managed Claude Code settings JSON for a single agent. Returns
70/// `Some(json)` for `claude-code` runtime regardless of `permission_mode`
71/// — the wrapper decides whether to apply it. Returns `None` for runtimes
72/// that don't read Claude settings (codex, gemini, …).
73///
74/// The current payload is a single `PreToolUse` deny hook covering the
75/// synchronous-prompt tools that today strand a headless pane:
76/// `AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`. The `systemMessage`
77/// tells the model *why* the deny fired and points it at the `team` MCP
78/// tools as the headless-safe alternative — without that, the model just
79/// sees the call vanish and may retry. Matcher is a regex; extend it
80/// (rather than the hook count) when claude-code gains new synchronous-
81/// prompt tools.
82pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
83    let _ = compose;
84    if h.spec.runtime != "claude-code" {
85        return None;
86    }
87    // PreToolUse deny hook. Picked over `--disallowed-tools` so the
88    // model sees the deny + systemMessage (tighter learning loop) rather
89    // than the tool silently vanishing from its catalog.
90    let v = serde_json::json!({
91        "hooks": {
92            "PreToolUse": [
93                {
94                    "matcher": "AskUserQuestion|EnterPlanMode|ExitPlanMode",
95                    "hooks": [
96                        {
97                            "type": "command",
98                            "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"},\"systemMessage\":\"Interactive prompts are disabled for teamctl agents. Use the `team` MCP tools to ask people or check in.\"}'"
99                        }
100                    ]
101                }
102            ]
103        }
104    });
105    Some(serde_json::to_string_pretty(&v).expect("json"))
106}
107
108fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
109    let project = compose
110        .projects
111        .iter()
112        .find(|p| p.project.id == h.project)
113        .expect("agent belongs to a loaded project");
114    let mailbox = compose.root.join(&compose.global.broker.path);
115    let mcp = mcp_path(&compose.root, h.project, h.agent);
116    let prompt = system_prompt_path(compose, h)
117        .map(|p| p.display().to_string())
118        .unwrap_or_default();
119
120    let mut s = String::new();
121    s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
122    s.push_str(&format!("PROJECT_ID={}\n", h.project));
123    s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
124    if let Some(m) = &h.spec.model {
125        s.push_str(&format!("MODEL={m}\n"));
126    }
127    if let Some(pm) = &h.spec.permission_mode {
128        s.push_str(&format!("PERMISSION_MODE={pm}\n"));
129    }
130    // T-048: per-agent reasoning effort flows through to the runtime
131    // via the wrapper. Workspace-level `.env` `EFFORT=` still wins for
132    // operators not yet on the YAML form (back-compat).
133    if let Some(effort) = h.spec.effort {
134        s.push_str(&format!("EFFORT={}\n", effort.as_str()));
135    }
136    s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
137    s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
138    s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
139    s.push_str(&format!(
140        "CLAUDE_PROJECT_DIR={}\n",
141        project.project.cwd.display()
142    ));
143    // Absolute path to the compose root (the directory holding
144    // `team-compose.yaml`). The wrapper passes this to `teamctl --root`
145    // so rl-watch resolves the right tree regardless of where
146    // `cd "$CLAUDE_PROJECT_DIR"` lands the shell. Without this,
147    // wrapper falls back to CLAUDE_PROJECT_DIR (often a relative `..`)
148    // which compounds with the post-cd cwd and points at the wrong
149    // directory.
150    s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
151    s.push_str(&format!(
152        "TMUX_SESSION={}{}-{}\n",
153        compose.global.supervisor.tmux_prefix, h.project, h.agent
154    ));
155    // T-118: claude-code agents resume their conversation across
156    // teamctl down/up + crash recovery via a deterministic UUIDv5
157    // session id. Other runtimes don't recognize `--session-id`, so
158    // emit these env vars only for `claude-code` — the wrapper's
159    // claude-code arm picks them up; other arms ignore them.
160    if h.spec.runtime == "claude-code" {
161        let session_id = crate::session::derive_session_id(h.project, h.agent);
162        let session_name = crate::session::session_name(h.project, h.agent);
163        s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
164        s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
165        // T-189: path to the wrapper-managed Claude settings file
166        // carrying the synchronous-prompt deny hook. Wrapper applies
167        // it via `--settings` except when `permission_mode: attended`
168        // (human at the keyboard wants the interactive tools back).
169        let settings = claude_settings_path(&compose.root, h.project, h.agent);
170        s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
171    }
172    s
173}
174
175/// Resolve the absolute path that `SYSTEM_PROMPT_PATH` will point at.
176///
177/// - `None` role_prompt → `None` (env line renders as blank).
178/// - Single source file → `<root>/<source>` (back-compat, no concat
179///   file is written — the operator's source is the prompt).
180/// - List form → the materialized concat path under
181///   `<root>/state/role_prompts/<project>-<agent>.md`. The file at that
182///   path is produced by [`write_role_prompt_concat`]; this helper is
183///   pure and only computes the destination.
184pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
185    match h.spec.role_prompt.as_ref()? {
186        RolePrompt::Single(p) => Some(compose.root.join(p)),
187        RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
188    }
189}
190
191/// Materialize the multi-file `role_prompt` concatenation for one agent.
192///
193/// No-op when `role_prompt` is `None` or `Single` — there is nothing to
194/// concatenate. For the list form, every source file is read in declared
195/// order and joined with [`ROLE_PROMPT_SEPARATOR`]; the result overwrites
196/// `<root>/state/role_prompts/<project>-<agent>.md` so subsequent edits
197/// to any source file flow into the agent's prompt at the next render.
198///
199/// Missing source files surface as the underlying `io::Error` so the
200/// caller can fail the apply rather than silently emit a partial concat.
201pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
202    let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
203        return Ok(());
204    };
205
206    let mut buf = String::new();
207    for (idx, rel) in paths.iter().enumerate() {
208        if idx > 0 {
209            buf.push_str(ROLE_PROMPT_SEPARATOR);
210        }
211        let abs = compose.root.join(rel);
212        let bytes = std::fs::read(&abs).map_err(|e| {
213            io::Error::new(
214                e.kind(),
215                format!("read role_prompt source {}: {e}", abs.display()),
216            )
217        })?;
218        // Source files are expected to be UTF-8 markdown; lossy decode
219        // keeps render diagnostics readable if a stray byte sneaks in.
220        buf.push_str(&String::from_utf8_lossy(&bytes));
221    }
222
223    let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
224    if let Some(parent) = dest.parent() {
225        std::fs::create_dir_all(parent)?;
226    }
227    std::fs::write(&dest, buf)
228}
229
230fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
231    let mailbox = compose.root.join(&compose.global.broker.path);
232    let v = serde_json::json!({
233        "mcpServers": {
234            "team": {
235                "command": team_mcp_bin,
236                "args": [
237                    "--agent-id", format!("{}:{}", h.project, h.agent),
238                    "--mailbox", mailbox.display().to_string(),
239                    // T-109: compact_self resolves the caller's tmux pane
240                    // as `<prefix><project>-<agent>`. Pass the configured
241                    // prefix explicitly so teams overriding the default
242                    // (`a-`, `oss-`, …) route the slash command to the
243                    // right session. team-bot gets the same arg threaded
244                    // from `teamctl bot up`; this keeps the two MCP-side
245                    // and bot-side resolvers in sync.
246                    "--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
247                    // T-32b: compose root used by `read_attachment`
248                    // for `attachments:` policy + tempfile staging.
249                    // Always passed so the per-agent team-mcp can
250                    // serve attachment reads; the staging dir is
251                    // computed under this root.
252                    "--compose-root", compose.root.display().to_string(),
253                ],
254                "env": {}
255            }
256        }
257    });
258    serde_json::to_string_pretty(&v).expect("json")
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::compose::*;
265    use std::collections::BTreeMap;
266    use std::path::PathBuf;
267
268    fn fixture() -> Compose {
269        let mut managers = BTreeMap::new();
270        managers.insert(
271            "mgr".into(),
272            Agent {
273                runtime: "claude-code".into(),
274                model: Some("claude-opus-4-8".into()),
275                role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
276                permission_mode: Some("auto".into()),
277                autonomy: "low_risk_only".into(),
278                can_dm: vec![],
279                can_broadcast: vec![],
280                reports_to: None,
281                on_rate_limit: None,
282                effort: None,
283                interfaces: None,
284                display_name: None,
285            },
286        );
287        Compose {
288            root: PathBuf::from("/teamctl"),
289            global: Global {
290                version: crate::compose::SchemaVersion::new("2.0.0"),
291                broker: Broker {
292                    r#type: "sqlite".into(),
293                    path: PathBuf::from("state/mailbox.db"),
294                },
295                supervisor: SupervisorCfg {
296                    r#type: "tmux".into(),
297                    tmux_prefix: "a-".into(),
298                    drain_timeout_secs: 10,
299                },
300                budget: Default::default(),
301                hitl: Default::default(),
302                rate_limits: Default::default(),
303                interfaces: vec![],
304                projects: vec![],
305                attachments: Default::default(),
306            },
307            projects: vec![Project {
308                version: 2,
309                project: ProjectMeta {
310                    id: "hello".into(),
311                    name: "Hello".into(),
312                    cwd: PathBuf::from("/teamctl/examples/hello-team"),
313                },
314                channels: vec![],
315                managers,
316                workers: Default::default(),
317                interfaces: None,
318            }],
319        }
320    }
321
322    #[test]
323    fn env_contains_agent_id_and_mailbox() {
324        let c = fixture();
325        let h = c.agents().next().unwrap();
326        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
327        assert!(env.contains("AGENT_ID=hello:mgr"));
328        assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
329        assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
330    }
331
332    #[test]
333    fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
334        // T-118: claude-code agents get deterministic UUIDv5 session
335        // ids in their env so the wrapper can pass `--session-id` +
336        // `-n` and resume the conversation across restarts.
337        let c = fixture();
338        let h = c.agents().next().unwrap();
339        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
340        let expected_id = crate::session::derive_session_id(h.project, h.agent);
341        assert!(
342            env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
343            "env was: {env}"
344        );
345        assert!(
346            env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
347            "env was: {env}"
348        );
349    }
350
351    #[test]
352    fn env_omits_claude_session_vars_for_non_claude_runtimes() {
353        // Other runtimes (codex, gemini) don't recognize claude's
354        // `--session-id` flag — their wrapper arms must not see these
355        // vars. Pin the gate so a future render refactor can't leak
356        // them into every runtime.
357        let mut c = fixture();
358        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
359        let h = c.agents().next().unwrap();
360        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
361        assert!(
362            !env.contains("CLAUDE_SESSION_ID="),
363            "non-claude runtime must not get session id: {env}"
364        );
365        assert!(
366            !env.contains("CLAUDE_SESSION_NAME="),
367            "non-claude runtime must not get session name: {env}"
368        );
369    }
370
371    #[test]
372    fn env_pins_teamctl_root_to_compose_root() {
373        // Regression: when project.cwd is a relative path (e.g. `..`),
374        // the wrapper used to fall back to it for `--root`, which
375        // resolves against the post-cd cwd and points at the wrong
376        // directory. Rendering an absolute TEAMCTL_ROOT pins
377        // `teamctl --root` to the compose root regardless of cwd.
378        let c = fixture();
379        let h = c.agents().next().unwrap();
380        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
381        assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
382    }
383
384    #[test]
385    fn env_omits_effort_when_unset() {
386        let c = fixture();
387        let h = c.agents().next().unwrap();
388        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
389        assert!(!env.contains("EFFORT="), "env was: {env}");
390    }
391
392    #[test]
393    fn env_emits_effort_when_set() {
394        let mut c = fixture();
395        c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
396        let h = c.agents().next().unwrap();
397        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
398        assert!(env.contains("EFFORT=max\n"), "env was: {env}");
399    }
400
401    #[test]
402    fn mcp_json_parses_back() {
403        let c = fixture();
404        let h = c.agents().next().unwrap();
405        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
406        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
407        assert_eq!(
408            v["mcpServers"]["team"]["command"],
409            "/usr/local/bin/team-mcp"
410        );
411        assert_eq!(
412            v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
413            "hello:mgr"
414        );
415    }
416
417    #[test]
418    fn mcp_json_threads_tmux_prefix_from_compose() {
419        // T-109: compact_self routes its tmux send-keys to
420        // `<prefix><project>-<agent>` and reads the prefix from a CLI arg
421        // (default `t-` only fits a stock team). Render must surface the
422        // configured prefix so teams overriding it (e.g. `a-` here) get
423        // their pane resolved correctly.
424        let c = fixture();
425        let h = c.agents().next().unwrap();
426        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
427        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
428        let args: Vec<&str> = v["mcpServers"]["team"]["args"]
429            .as_array()
430            .unwrap()
431            .iter()
432            .map(|a| a.as_str().unwrap())
433            .collect();
434        let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
435            "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
436        );
437        assert_eq!(
438            args[i + 1],
439            "a-",
440            "prefix must come from compose, not the default"
441        );
442    }
443
444    #[test]
445    fn env_points_at_source_for_single_role_prompt() {
446        let c = fixture();
447        let h = c.agents().next().unwrap();
448        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
449        assert!(
450            env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
451            "env was: {env}"
452        );
453    }
454
455    #[test]
456    fn env_points_at_concat_path_for_multi_role_prompt() {
457        let mut c = fixture();
458        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
459            Some(RolePrompt::Multiple(vec![
460                PathBuf::from("roles/_base.md"),
461                PathBuf::from("roles/mgr.md"),
462            ]));
463        let h = c.agents().next().unwrap();
464        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
465        assert!(
466            env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
467            "env was: {env}"
468        );
469    }
470
471    #[test]
472    fn write_role_prompt_concat_is_noop_for_single() {
473        let dir = tempfile::tempdir().unwrap();
474        let mut c = fixture();
475        c.root = dir.path().to_path_buf();
476        let h = c.agents().next().unwrap();
477        write_role_prompt_concat(&c, h).unwrap();
478        assert!(
479            !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
480            "single-form role_prompt should not produce a concat file"
481        );
482    }
483
484    #[test]
485    fn write_role_prompt_concat_joins_in_declared_order() {
486        let dir = tempfile::tempdir().unwrap();
487        let root = dir.path();
488        std::fs::create_dir_all(root.join("roles")).unwrap();
489        std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
490        std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
491
492        let mut c = fixture();
493        c.root = root.to_path_buf();
494        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
495            Some(RolePrompt::Multiple(vec![
496                PathBuf::from("roles/_base.md"),
497                PathBuf::from("roles/mgr.md"),
498            ]));
499        let h = c.agents().next().unwrap();
500        write_role_prompt_concat(&c, h).unwrap();
501
502        let dest = role_prompt_concat_path(root, h.project, h.agent);
503        let got = std::fs::read_to_string(&dest).unwrap();
504        assert_eq!(got, "BASE\n\n—\n\nMGR");
505    }
506
507    #[test]
508    fn write_role_prompt_concat_reflects_source_edits() {
509        // Owner-flagged: editing a source file must show up at the next
510        // render. We re-write unconditionally rather than caching.
511        let dir = tempfile::tempdir().unwrap();
512        let root = dir.path();
513        std::fs::create_dir_all(root.join("roles")).unwrap();
514        std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
515        std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
516
517        let mut c = fixture();
518        c.root = root.to_path_buf();
519        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
520            Some(RolePrompt::Multiple(vec![
521                PathBuf::from("roles/_base.md"),
522                PathBuf::from("roles/mgr.md"),
523            ]));
524        let h = c.agents().next().unwrap();
525        write_role_prompt_concat(&c, h).unwrap();
526
527        std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
528        let h = c.agents().next().unwrap();
529        write_role_prompt_concat(&c, h).unwrap();
530
531        let dest = role_prompt_concat_path(root, h.project, h.agent);
532        let got = std::fs::read_to_string(&dest).unwrap();
533        assert_eq!(got, "v2\n\n—\n\nMGR");
534    }
535
536    #[test]
537    fn claude_settings_present_for_claude_code() {
538        // T-189: claude-code agents get a wrapper-managed settings
539        // file with a PreToolUse deny hook for synchronous-prompt
540        // tools that would otherwise strand a headless pane.
541        let c = fixture();
542        let h = c.agents().next().unwrap();
543        let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
544        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
545        let pre = &v["hooks"]["PreToolUse"][0];
546        assert_eq!(
547            pre["matcher"].as_str().unwrap(),
548            "AskUserQuestion|EnterPlanMode|ExitPlanMode"
549        );
550        let cmd = pre["hooks"][0]["command"].as_str().unwrap();
551        assert!(
552            cmd.contains(r#""permissionDecision":"deny""#),
553            "deny verdict missing from hook command: {cmd}"
554        );
555        assert!(
556            cmd.contains("Interactive prompts are disabled"),
557            "systemMessage missing from hook command: {cmd}"
558        );
559    }
560
561    #[test]
562    fn claude_settings_absent_for_non_claude_runtimes() {
563        // codex/gemini don't read claude settings; the file would be
564        // dead weight and a confusing artifact on disk.
565        let mut c = fixture();
566        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
567        let h = c.agents().next().unwrap();
568        assert!(render_claude_settings(&c, h).is_none());
569    }
570
571    #[test]
572    fn env_emits_claude_settings_path_for_claude_code() {
573        // T-189: wrapper reads CLAUDE_SETTINGS and passes it to claude
574        // via `--settings`. Path must resolve under the compose root.
575        let c = fixture();
576        let h = c.agents().next().unwrap();
577        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
578        assert!(
579            env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
580            "env was: {env}"
581        );
582    }
583
584    #[test]
585    fn env_omits_claude_settings_for_non_claude_runtimes() {
586        // Only claude-code reads the settings file; other runtimes
587        // must not see the env var (avoids confusion if they ever add
588        // a same-named knob).
589        let mut c = fixture();
590        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
591        let h = c.agents().next().unwrap();
592        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
593        assert!(
594            !env.contains("CLAUDE_SETTINGS="),
595            "non-claude runtime must not get settings path: {env}"
596        );
597    }
598
599    #[test]
600    fn write_role_prompt_concat_errors_on_missing_source() {
601        let dir = tempfile::tempdir().unwrap();
602        let mut c = fixture();
603        c.root = dir.path().to_path_buf();
604        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
605            vec![PathBuf::from("roles/missing.md")],
606        ));
607        let h = c.agents().next().unwrap();
608        let err = write_role_prompt_concat(&c, h).unwrap_err();
609        assert!(err.to_string().contains("missing.md"), "err was: {err}");
610    }
611}