1use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context as _, Result, bail};
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum AgentKind {
22 Claude,
24 Opencode,
26 Antigravity,
30 Command,
32}
33
34impl AgentKind {
35 pub fn program(self) -> Option<&'static str> {
37 match self {
38 Self::Claude => Some("claude"),
39 Self::Opencode => Some("opencode"),
40 Self::Antigravity => Some("agy"),
41 Self::Command => None,
42 }
43 }
44
45 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::Claude => "claude",
49 Self::Opencode => "opencode",
50 Self::Antigravity => "antigravity",
51 Self::Command => "command",
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
58#[serde(rename_all = "lowercase")]
59pub enum Delivery {
60 Stdin,
62 Argv,
64 File,
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct AgentSpec {
72 pub id: String,
74 pub kind: AgentKind,
76 #[serde(default)]
78 pub model: Option<String>,
79 #[serde(default)]
82 pub command: Vec<String>,
83 #[serde(default)]
85 pub extra_args: Vec<String>,
86 #[serde(default)]
88 pub env: BTreeMap<String, String>,
89 #[serde(default)]
91 pub prompt_delivery: Option<Delivery>,
92}
93
94impl AgentSpec {
95 pub fn delivery(&self) -> Delivery {
101 self.prompt_delivery.unwrap_or(match self.kind {
102 AgentKind::Claude | AgentKind::Command => Delivery::Stdin,
103 AgentKind::Opencode | AgentKind::Antigravity => Delivery::File,
104 })
105 }
106
107 pub fn display(&self) -> String {
109 match &self.model {
110 Some(m) => format!("{} ({}:{m})", self.id, self.kind.as_str()),
111 None => format!("{} ({})", self.id, self.kind.as_str()),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Default, Deserialize, Serialize)]
119#[serde(deny_unknown_fields, default)]
120pub struct Roles {
121 pub implementers: Vec<String>,
123 pub judges: Vec<String>,
125 pub reviewers: Vec<String>,
127 pub fixer: Option<String>,
129 pub planner: Option<String>,
138}
139
140#[derive(Debug, Clone, Deserialize, Serialize)]
142#[serde(deny_unknown_fields, default)]
143pub struct Graph {
144 pub candidates: usize,
146 pub judges: usize,
148 pub deliberate_rounds: usize,
150 pub reviewers: usize,
152 pub review_rounds: usize,
154 pub max_parallel: usize,
156 pub language: String,
158 pub sessions: bool,
166 pub timeout_implement: u64,
168 pub timeout_judge: u64,
170 pub timeout_review: u64,
172 pub timeout_fix: u64,
174 pub retries: usize,
176 pub worktree_root: Option<PathBuf>,
178 pub land: bool,
191 pub land_rounds: usize,
193 pub land_approval: bool,
204 pub answer_timeout: u64,
208}
209
210impl Default for Graph {
211 fn default() -> Self {
212 Self {
213 candidates: 3,
214 judges: 3,
215 deliberate_rounds: 1,
216 reviewers: 2,
217 review_rounds: 6,
218 max_parallel: 4,
219 language: "en".to_owned(),
220 sessions: true,
221 timeout_implement: 3600,
222 timeout_judge: 1200,
223 timeout_review: 1200,
224 timeout_fix: 1800,
225 retries: 1,
226 worktree_root: None,
227 land: true,
228 land_rounds: 4,
229 land_approval: true,
230 answer_timeout: 86_400,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
237#[serde(rename_all = "lowercase")]
238pub enum LeakPolicy {
239 Warn,
241 Redact,
243 Fail,
245}
246
247#[derive(Debug, Clone, Deserialize, Serialize)]
254#[serde(deny_unknown_fields, default)]
255pub struct Blind {
256 pub commit_msg_hook: bool,
259 pub strip_lines: Vec<String>,
263 pub vendor_tokens: Vec<String>,
265 pub on_leak: LeakPolicy,
267 pub seed: Option<u64>,
270}
271
272impl Default for Blind {
273 fn default() -> Self {
274 Self {
275 commit_msg_hook: true,
276 strip_lines: [
277 "Co-Authored-By:",
278 "Signed-off-by:",
279 "Assisted-by:",
280 "Generated-by:",
281 "Generated with",
282 "\u{1f916}",
283 ]
284 .iter()
285 .map(|s| (*s).to_owned())
286 .collect(),
287 vendor_tokens: [
288 "claude",
289 "anthropic",
290 "codex",
291 "openai",
292 "chatgpt",
293 "gemini",
294 "grok",
295 "xai",
296 "copilot",
297 "opencode",
298 "qoder",
299 "cursor",
300 "\u{1f916}",
301 ]
302 .iter()
303 .map(|s| (*s).to_owned())
304 .collect(),
305 on_leak: LeakPolicy::Warn,
306 seed: None,
307 }
308 }
309}
310
311#[derive(Debug, Clone, Default, Deserialize, Serialize)]
313#[serde(deny_unknown_fields, default)]
314pub struct Verify {
315 pub e2e: Vec<String>,
318 pub gate: Vec<String>,
320 pub shell: Option<Vec<String>>,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
327#[serde(rename_all = "lowercase")]
328pub enum MergeMode {
329 None,
331 Local,
333 Pr,
335}
336
337#[derive(Debug, Clone, Deserialize, Serialize)]
339#[serde(deny_unknown_fields, default)]
340pub struct Merge {
341 pub mode: MergeMode,
344 pub base: Option<String>,
346 pub remote: String,
348}
349
350impl Default for Merge {
351 fn default() -> Self {
352 Self {
353 mode: MergeMode::None,
354 base: None,
355 remote: "origin".to_owned(),
356 }
357 }
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
362#[serde(rename_all = "lowercase")]
363pub enum UpdateMode {
364 Off,
366 Notify,
369 Install,
371}
372
373#[derive(Debug, Clone, Deserialize, Serialize)]
375#[serde(deny_unknown_fields, default)]
376pub struct Update {
377 pub mode: UpdateMode,
379 pub interval: Option<String>,
381}
382
383impl Default for Update {
384 fn default() -> Self {
385 Self {
386 mode: UpdateMode::Notify,
387 interval: None,
388 }
389 }
390}
391
392#[derive(Debug, Clone, Default, Deserialize, Serialize)]
394#[serde(deny_unknown_fields, default)]
395pub struct Config {
396 pub agents: Vec<AgentSpec>,
398 pub roles: Roles,
400 pub graph: Graph,
402 pub blind: Blind,
404 pub verify: Verify,
406 pub merge: Merge,
408 pub update: Update,
410 pub prompts: Prompts,
412 pub notify: Notify,
414 pub repos: Repos,
417}
418
419#[derive(Debug, Clone, Deserialize, Serialize)]
428#[serde(deny_unknown_fields, default)]
429pub struct Repos {
430 pub roots: Vec<PathBuf>,
434 pub scan_ttl: u64,
441}
442
443impl Default for Repos {
444 fn default() -> Self {
445 Self {
446 roots: Vec::new(),
447 scan_ttl: 86_400,
448 }
449 }
450}
451
452#[derive(Debug, Clone, Default, Deserialize, Serialize)]
468#[serde(deny_unknown_fields, default)]
469pub struct Prompts {
470 pub all: String,
472 pub implement: String,
474 pub judge: String,
476 pub review: String,
478 pub fix: String,
480}
481
482impl Prompts {
483 pub fn overlay(&self, node: &str) -> Option<String> {
488 let specific = match node {
489 "implement" => &self.implement,
490 "judge" | "vote" | "deliberate" => &self.judge,
491 "review" => &self.review,
492 "fix" => &self.fix,
493 _ => "",
494 };
495 let mut parts: Vec<&str> = Vec::new();
496 for p in [self.all.trim(), specific.trim()] {
497 if !p.is_empty() {
498 parts.push(p);
499 }
500 }
501 if parts.is_empty() {
502 return None;
503 }
504 Some(parts.join("\n\n"))
505 }
506}
507
508#[derive(Debug, Clone, Default, Deserialize, Serialize)]
515#[serde(deny_unknown_fields, default)]
516pub struct Notify {
517 pub command: Vec<String>,
520}
521
522#[derive(Debug, Clone)]
524pub struct ResolvedRoles {
525 pub implementers: Vec<AgentSpec>,
527 pub judges: Vec<AgentSpec>,
529 pub reviewers: Vec<AgentSpec>,
531 pub fixer: Option<AgentSpec>,
533}
534
535fn array_keys(table: &toml::value::Table, prefix: &str) -> Vec<String> {
542 let mut out = Vec::new();
543 for (k, v) in table {
544 if prefix.is_empty() && k == "vars" {
545 continue;
546 }
547 let path = if prefix.is_empty() {
548 k.clone()
549 } else {
550 format!("{prefix}.{k}")
551 };
552 match v {
553 toml::Value::Array(_) => out.push(path),
554 toml::Value::Table(t) => out.extend(array_keys(t, &path)),
555 _ => {}
556 }
557 }
558 out
559}
560
561impl Config {
562 pub fn load(path: &Path) -> Result<Self> {
565 Self::load_layers(&[path.to_path_buf()])
566 }
567
568 pub fn load_layers(paths: &[PathBuf]) -> Result<Self> {
576 let mut engine = teravars::Engine::default();
577 let mut ctx = teravars::system_context();
578 let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
583 ctx.insert("env", &env);
584 if let Some(last) = paths.last()
585 && let Some(dir) = last.parent()
586 {
587 ctx.insert("repo", &dir.to_string_lossy());
588 ctx.insert(
589 "repo_name",
590 &dir.file_name().unwrap_or_default().to_string_lossy(),
591 );
592 }
593 if paths.len() > 1 {
594 Self::refuse_split_arrays(paths, &mut engine, &ctx)?;
595 }
596 let merged = teravars::load_merged(paths, &mut engine, &ctx).with_context(|| {
597 format!(
598 "rendering config via teravars: {}",
599 paths
600 .iter()
601 .map(|p| p.display().to_string())
602 .collect::<Vec<_>>()
603 .join(", ")
604 )
605 })?;
606 let mut table = merged.config;
607 table.remove("vars");
610 toml::Value::Table(table)
611 .try_into()
612 .context("deserializing magi config")
613 }
614
615 fn refuse_split_arrays(
633 paths: &[PathBuf],
634 engine: &mut teravars::Engine,
635 ctx: &teravars::Context,
636 ) -> Result<()> {
637 let mut seen: std::collections::BTreeMap<String, PathBuf> = Default::default();
638 for path in paths {
639 let one = teravars::load_merged([path], engine, ctx)
640 .with_context(|| format!("rendering {}", path.display()))?;
641 for key in array_keys(&one.config, "") {
642 if let Some(first) = seen.get(&key) {
643 bail!(
644 "`{key}` is an array declared in two config layers:\n \
645 {}\n {}\nteravars appends arrays when it merges, so \
646 magi would run the concatenation of both - which is \
647 not what either file says. Declare `{key}` in exactly \
648 one of them.",
649 first.display(),
650 path.display()
651 );
652 }
653 seen.insert(key, path.clone());
654 }
655 }
656 Ok(())
657 }
658
659 pub fn layers(repo: &Path) -> Vec<PathBuf> {
661 let mut paths = Vec::new();
662 if let Some(dir) = dirs::config_dir() {
663 paths.push(dir.join("magi").join("config.toml"));
664 }
665 paths.push(repo.join(".magi").join("config.toml"));
666 paths.push(repo.join("magi.toml"));
667 paths.retain(|p| p.is_file());
668 paths
669 }
670
671 pub fn discover(repo: &Path, explicit: Option<&Path>) -> Result<(Self, Vec<PathBuf>)> {
676 if let Some(p) = explicit {
677 let paths = vec![p.to_path_buf()];
678 return Ok((Self::load_layers(&paths)?, paths));
679 }
680 let paths = Self::layers(repo);
681 if paths.is_empty() {
682 return Ok((Self::autodetected(), paths));
683 }
684 Ok((Self::load_layers(&paths)?, paths))
685 }
686
687 pub fn autodetected() -> Self {
689 let mut cfg = Self::default();
690 for (kind, id, model) in [
691 (AgentKind::Claude, "opus", Some("opus")),
692 (AgentKind::Claude, "sonnet", Some("sonnet")),
693 (AgentKind::Antigravity, "antigravity", None),
694 (AgentKind::Opencode, "opencode", None),
695 ] {
696 if kind.program().is_some_and(which) && !cfg.agents.iter().any(|a| a.id == id) {
697 cfg.agents.push(AgentSpec {
698 id: id.to_owned(),
699 kind,
700 model: model.map(str::to_owned),
701 command: Vec::new(),
702 extra_args: Vec::new(),
703 env: BTreeMap::new(),
704 prompt_delivery: None,
705 });
706 }
707 }
708 cfg
709 }
710
711 pub fn agent(&self, id: &str) -> Result<&AgentSpec> {
713 self.agents
714 .iter()
715 .find(|a| a.id == id)
716 .with_context(|| format!("no agent with id `{id}` in the roster"))
717 }
718
719 pub fn resolve_roles(&self) -> Result<ResolvedRoles> {
726 if self.agents.is_empty() {
727 bail!(
728 "agent roster is empty: no agent CLI found on PATH and no \
729 [[agents]] in the config. Run `magi init` to write a starter \
730 magi.toml."
731 );
732 }
733 let pick = |ids: &[String], count: usize, offset: usize| -> Result<Vec<AgentSpec>> {
734 let mut out = Vec::with_capacity(count);
735 for i in 0..count {
736 let spec = if ids.is_empty() {
737 self.agents[(i + offset) % self.agents.len()].clone()
738 } else {
739 self.agent(&ids[i % ids.len()])?.clone()
740 };
741 out.push(spec);
742 }
743 Ok(out)
744 };
745 Ok(ResolvedRoles {
746 implementers: pick(&self.roles.implementers, self.graph.candidates, 0)?,
747 judges: pick(&self.roles.judges, self.graph.judges, 1)?,
748 reviewers: pick(&self.roles.reviewers, self.graph.reviewers, 0)?,
749 fixer: self
750 .roles
751 .fixer
752 .as_deref()
753 .map(|f| self.agent(f).cloned())
754 .transpose()?,
755 })
756 }
757
758 pub fn shell(&self) -> Vec<String> {
760 if let Some(s) = &self.verify.shell {
761 return s.clone();
762 }
763 if which("sh") {
764 vec!["sh".to_owned(), "-c".to_owned()]
765 } else {
766 vec!["cmd".to_owned(), "/C".to_owned()]
767 }
768 }
769
770 pub fn starter_toml() -> String {
772 let detected = Self::autodetected();
773 let mut s = String::from(
774 "# magi — blind multi-agent implementation competition.\n\
775 # `magi run \"<task>\"` walks: implement (N parallel worktrees)\n\
776 # -> blind judging -> deliberation -> private final vote\n\
777 # -> fold losers -> review + E2E loop -> gate -> merge.\n\
778 #\n\
779 # Rendered by teravars, comments included: a `[vars]` table, env\n\
780 # and system lookups, and `include = [...]` all work. Note that\n\
781 # Tera braces are live everywhere in this file, so do not write\n\
782 # them in a comment unless you mean them.\n\
783 #\n\
784 # Layers deep-merge in increasing\n\
785 # precedence, so the roster can live once per machine in\n\
786 # <config_dir>/magi/config.toml and each repo only states its own\n\
787 # gate:\n\
788 # <config_dir>/magi/config.toml < .magi/config.toml < magi.toml\n\n\
789 [vars]\n\
790 # Reference it as vars.cache inside Tera braces, anywhere below.\n\
791 # Single quotes inside the braces: teravars renders the raw file\n\
792 # text, so TOML's own \\\" escaping never reaches Tera.\n\
793 cache = \"{{ env.MAGI_CACHE | default(value='/tmp') }}\"\n\n",
794 );
795 if detected.agents.is_empty() {
796 s.push_str(
797 "# No agent CLI was found on PATH. Fill this in by hand.\n\
798 # kind = claude | opencode | antigravity | command\n\
799 [[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n",
800 );
801 } else {
802 for a in &detected.agents {
803 s.push_str("[[agents]]\n");
804 s.push_str(&format!("id = {:?}\n", a.id));
805 s.push_str(&format!("kind = {:?}\n", a.kind.as_str()));
806 if let Some(m) = &a.model {
807 s.push_str(&format!("model = {m:?}\n"));
808 }
809 s.push('\n');
810 }
811 }
812 s.push_str(
813 "# Leave a role list empty to rotate through the roster.\n\
814 [roles]\n\
815 implementers = []\n\
816 judges = []\n\
817 reviewers = []\n\n\
818 [graph]\n\
819 candidates = 3\n\
820 judges = 3\n\
821 deliberate_rounds = 1\n\
822 reviewers = 2\n\
823 review_rounds = 6\n\
824 max_parallel = 4\n\
825 language = \"en\"\n\
826 # One CLI conversation per seat: judges keep their own argument\n\
827 # across deliberation, the fixer keeps its implementation context.\n\
828 sessions = true\n\n\
829 [verify]\n\
830 # Run once per review round in the winner's worktree; failures are\n\
831 # fed back to the fixer.\n\
832 e2e = []\n\
833 # Final gate. Every command must exit 0 before a merge.\n\
834 gate = []\n\n\
835 [merge]\n\
836 # none | local | pr\n\
837 mode = \"none\"\n\n\
838 [update]\n\
839 # off | notify | install — checked in the background, throttled.\n\
840 mode = \"notify\"\n\
841 # interval = \"24h\"\n",
842 );
843 s
844 }
845}
846
847pub fn which(program: &str) -> bool {
849 let Some(paths) = std::env::var_os("PATH") else {
850 return false;
851 };
852 let exts: Vec<String> = std::env::var("PATHEXT")
853 .map(|v| v.split(';').map(|e| e.to_lowercase()).collect())
854 .unwrap_or_default();
855 std::env::split_paths(&paths).any(|dir| {
856 let direct = dir.join(program);
857 if direct.is_file() {
858 return true;
859 }
860 exts.iter().any(|ext| {
861 let mut name = program.to_owned();
862 name.push_str(ext);
863 dir.join(name).is_file()
864 })
865 })
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 fn spec(id: &str) -> AgentSpec {
873 AgentSpec {
874 id: id.to_owned(),
875 kind: AgentKind::Command,
876 model: None,
877 command: vec!["true".to_owned()],
878 extra_args: Vec::new(),
879 env: BTreeMap::new(),
880 prompt_delivery: None,
881 }
882 }
883
884 #[test]
885 fn empty_roles_rotate_judges_off_their_own_candidate() {
886 let cfg = Config {
887 agents: vec![spec("a"), spec("b"), spec("c")],
888 ..Config::default()
889 };
890 let roles = cfg.resolve_roles().unwrap();
891 let impls: Vec<&str> = roles.implementers.iter().map(|a| a.id.as_str()).collect();
892 let judges: Vec<&str> = roles.judges.iter().map(|a| a.id.as_str()).collect();
893 assert_eq!(impls, ["a", "b", "c"]);
894 assert_eq!(judges, ["b", "c", "a"]);
895 for (i, j) in judges.iter().enumerate() {
896 assert_ne!(*j, impls[i], "judge {i} must not sit on its own candidate");
897 }
898 }
899
900 #[test]
901 fn single_agent_roster_fills_every_seat() {
902 let cfg = Config {
903 agents: vec![spec("solo")],
904 ..Config::default()
905 };
906 let roles = cfg.resolve_roles().unwrap();
907 assert_eq!(roles.implementers.len(), 3);
908 assert!(roles.judges.iter().all(|a| a.id == "solo"));
909 }
910
911 #[test]
912 fn explicit_roles_win() {
913 let cfg = Config {
914 agents: vec![spec("a"), spec("b")],
915 roles: Roles {
916 implementers: vec!["b".to_owned()],
917 judges: vec!["a".to_owned()],
918 reviewers: Vec::new(),
919 fixer: Some("a".to_owned()),
920 ..Roles::default()
921 },
922 ..Config::default()
923 };
924 let roles = cfg.resolve_roles().unwrap();
925 assert!(roles.implementers.iter().all(|a| a.id == "b"));
926 assert!(roles.judges.iter().all(|a| a.id == "a"));
927 assert_eq!(roles.fixer.unwrap().id, "a");
928 }
929
930 #[test]
931 fn unknown_agent_id_is_an_error() {
932 let cfg = Config {
933 agents: vec![spec("a")],
934 roles: Roles {
935 judges: vec!["nope".to_owned()],
936 ..Roles::default()
937 },
938 ..Config::default()
939 };
940 assert!(cfg.resolve_roles().is_err());
941 }
942
943 #[test]
944 fn empty_roster_is_an_error() {
945 assert!(Config::default().resolve_roles().is_err());
946 }
947
948 #[test]
949 fn repos_default_to_no_roots_and_a_day_of_trust() {
950 assert_eq!(Config::default().repos.roots, Vec::<PathBuf>::new());
951 assert_eq!(Config::default().repos.scan_ttl, 86_400);
952 }
953
954 #[test]
955 fn a_config_file_with_no_repos_table_still_loads() {
956 let dir = tempfile::tempdir().unwrap();
957 let path = dir.path().join("magi.toml");
958 std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
959 let cfg = Config::load(&path).expect("must load without [repos]");
960 assert_eq!(cfg.repos.roots, Vec::<PathBuf>::new());
961 assert_eq!(cfg.repos.scan_ttl, 86_400);
962 }
963
964 #[test]
965 fn starter_toml_loads_through_teravars() {
966 let dir = tempfile::tempdir().unwrap();
967 let path = dir.path().join("magi.toml");
968 std::fs::write(&path, Config::starter_toml()).unwrap();
969 let parsed = Config::load(&path).expect("starter config must load");
970 assert_eq!(parsed.graph.candidates, 3);
971 assert_eq!(parsed.merge.mode, MergeMode::None);
972 assert!(parsed.graph.sessions);
973 assert_eq!(parsed.update.mode, UpdateMode::Notify);
974 }
975
976 #[test]
977 fn later_layers_win_and_vars_render() {
978 let dir = tempfile::tempdir().unwrap();
979 let machine = dir.path().join("machine.toml");
980 let project = dir.path().join("magi.toml");
981 std::fs::write(
983 &machine,
984 "[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n\
985 [graph]\ncandidates = 3\nmax_parallel = 8\n",
986 )
987 .unwrap();
988 std::fs::write(
991 &project,
992 "[vars]\ncache = \"/shared\"\n\n\
993 [graph]\ncandidates = 2\n\n\
994 [verify]\ngate = [\"CARGO_TARGET_DIR={{ vars.cache }}/t cargo test\"]\n",
995 )
996 .unwrap();
997
998 let cfg = Config::load_layers(&[machine, project]).expect("layered load");
999 assert_eq!(cfg.agents.len(), 1, "roster comes from the machine layer");
1000 assert_eq!(cfg.graph.candidates, 2, "project layer wins");
1001 assert_eq!(cfg.graph.max_parallel, 8, "machine layer survives");
1002 assert_eq!(
1003 cfg.verify.gate,
1004 ["CARGO_TARGET_DIR=/shared/t cargo test".to_owned()]
1005 );
1006 }
1007
1008 #[test]
1009 fn env_is_available_to_templates_with_a_default() {
1010 let dir = tempfile::tempdir().unwrap();
1011 let path = dir.path().join("magi.toml");
1012 std::fs::write(
1020 &path,
1021 "[verify]\n\
1022 gate = [\"cache={{ env.MAGI_TEST_UNSET_XYZ | default(value='fallback') }}\", \
1023 \"populated={{ env | length > 0 }}\"]\n",
1024 )
1025 .unwrap();
1026 let cfg = Config::load(&path).expect("env lookup must render");
1027 assert_eq!(cfg.verify.gate[0], "cache=fallback");
1028 assert_eq!(cfg.verify.gate[1], "populated=true");
1029 }
1030
1031 #[test]
1032 fn a_broken_template_names_the_file() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let path = dir.path().join("magi.toml");
1035 std::fs::write(&path, "[graph]\nlanguage = \"{{ nope.\"\n").unwrap();
1036 let err = Config::load(&path).expect_err("must not silently ignore");
1037 assert!(err.to_string().contains("teravars"), "{err}");
1038 }
1039
1040 #[test]
1041 fn opencode_defaults_to_file_delivery() {
1042 let mut s = spec("oc");
1043 s.kind = AgentKind::Opencode;
1044 assert_eq!(s.delivery(), Delivery::File);
1045 s.prompt_delivery = Some(Delivery::Argv);
1046 assert_eq!(s.delivery(), Delivery::Argv);
1047 }
1048 #[test]
1049 fn the_land_loop_is_on_but_it_cannot_merge_without_being_asked() {
1050 let g = Graph::default();
1055 assert!(
1056 g.land,
1057 "stopping at an open PR left the watching to a human"
1058 );
1059 assert!(
1060 g.land_approval,
1061 "on-by-default land is only defensible while this is also on"
1062 );
1063 assert!(g.land_rounds > 0, "a loop with no budget never terminates");
1064 }
1065 #[test]
1066 fn an_array_declared_in_two_layers_is_refused_instead_of_concatenated() {
1067 let dir = tempfile::tempdir().unwrap();
1071 let machine = dir.path().join("machine.toml");
1072 let repo = dir.path().join("magi.toml");
1073 std::fs::write(&machine, "[roles]\nimplementers = [\"a\", \"b\"]\n").unwrap();
1074 std::fs::write(&repo, "[roles]\nimplementers = [\"oc\"]\n").unwrap();
1075
1076 let err = Config::load_layers(&[machine.clone(), repo.clone()])
1077 .expect_err("two layers naming one array must not merge silently")
1078 .to_string();
1079 assert!(err.contains("roles.implementers"), "{err}");
1080 assert!(err.contains("machine.toml"), "{err}");
1083 assert!(err.contains("magi.toml"), "{err}");
1084 }
1085
1086 #[test]
1087 fn a_scalar_in_one_layer_and_an_array_in_another_still_merges() {
1088 let dir = tempfile::tempdir().unwrap();
1091 let machine = dir.path().join("machine.toml");
1092 let repo = dir.path().join("magi.toml");
1093 std::fs::write(&machine, "[roles]\nplanner = \"opus\"\n").unwrap();
1094 std::fs::write(
1095 &repo,
1096 "[[agents]]\nid = \"oc\"\nkind = \"opencode\"\n\n\
1097 [roles]\nimplementers = [\"oc\"]\n",
1098 )
1099 .unwrap();
1100
1101 let cfg = Config::load_layers(&[machine, repo]).expect("layers merge");
1102 assert_eq!(cfg.roles.planner.as_deref(), Some("opus"));
1103 assert_eq!(cfg.roles.implementers, ["oc"]);
1104 assert_eq!(cfg.agents.len(), 1, "the roster is not doubled");
1105 }
1106}