1use std::io;
18use std::path::{Path, PathBuf};
19
20use crate::compose::{AgentHandle, Compose, RolePrompt};
21
22const ROLE_PROMPT_SEPARATOR: &str = "\n\n—\n\n";
26
27pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
29 root.join("state/envs")
30 .join(format!("{project}-{agent}.env"))
31}
32
33pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
35 root.join("state/mcp")
36 .join(format!("{project}-{agent}.json"))
37}
38
39pub fn claude_settings_path(root: &Path, project: &str, agent: &str) -> PathBuf {
46 root.join("state/claude")
47 .join(format!("{project}-{agent}.json"))
48}
49
50pub 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
59pub fn agent_scope_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
67 root.join("state/agent-scope")
68 .join(format!("{project}-{agent}"))
69}
70
71pub 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
79pub fn heartbeat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
87 root.join("state/heartbeats")
88 .join(format!("{project}-{agent}"))
89}
90
91pub fn lastseen_path(root: &Path, project: &str, agent: &str) -> PathBuf {
101 root.join("state/heartbeats")
102 .join(format!("{project}-{agent}.lastseen"))
103}
104
105pub fn boot_script_path(root: &Path) -> PathBuf {
112 root.join("bin/boot.sh")
113}
114
115pub 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
126pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
146 if h.spec.runtime != "claude-code" {
147 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 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 let mut v = serde_json::json!({
179 "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 let hooks_obj = v["hooks"].as_object_mut().expect("hooks is a json object");
216
217 {
234 let path = heartbeat_path(&compose.root, h.project, h.agent)
235 .display()
236 .to_string();
237 let marker =
241 crate::supervisor::shlex::try_quote(&path).expect("heartbeat marker path is NUL-free");
242 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 {
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 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 {
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 {
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 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
430pub 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 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
501pub 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
524pub 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 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 return remove_scope_dir(&scope);
563 }
564
565 clear_skills_dir(&skills_dir)?;
569 std::fs::create_dir_all(&skills_dir)?;
570 for rel in &h.spec.skills {
571 let Some(name) = rel.file_name() else {
574 continue; };
576 let link = skills_dir.join(name);
577 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
587fn 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
600fn 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 std::fs::remove_dir_all(&path)?;
619 }
620 }
621 Ok(())
622}
623
624#[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#[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
660fn 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 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 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 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 let settings = claude_settings_path(&compose.root, h.project, h.agent);
738 s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
739 let subagents = subagents_json_path(&compose.root, h.project, h.agent);
744 s.push_str(&format!("CLAUDE_AGENTS_JSON={}\n", subagents.display()));
745 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
755pub 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
771pub 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 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 "--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
827 "--compose-root", compose.root.display().to_string(),
833 ],
834 "env": {}
835 }
836 }
837 });
838
839 if !h.spec.mcps.is_empty() {
848 let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
849 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; }
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 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 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 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 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 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 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 assert_eq!(
1100 v["mcpServers"]["team"]["command"],
1101 "/usr/local/bin/team-mcp"
1102 );
1103 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 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 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 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 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 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 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 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 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 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 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 assert!(
1383 pre[1].get("matcher").is_none(),
1384 "heartbeat touch must be match-all: {}",
1385 pre[1]
1386 );
1387 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 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 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 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 assert_eq!(
1451 hooks["PreToolUse"].as_array().unwrap().len(),
1452 2,
1453 "deny hook + heartbeat touch expected"
1454 );
1455 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 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 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 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 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 assert!(
1535 command.starts_with("command -v teamctl >/dev/null"),
1536 "rl-hit command must lead with the PATH guard: {command}"
1537 );
1538 assert!(
1540 command.contains("--root"),
1541 "rl-hit command must pass the compose --root: {command}"
1542 );
1543 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 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 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 assert!(
1579 command.starts_with("command -v teamctl >/dev/null"),
1580 "budget-record command must lead with the PATH guard: {command}"
1581 );
1582 assert!(
1584 command.contains("--root"),
1585 "budget-record command must pass the compose --root: {command}"
1586 );
1587 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 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 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 let q = crate::supervisor::shlex::try_quote(&path).unwrap();
1621 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}