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