1use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use anyhow::{Context as _, Result, bail};
47use serde::{Deserialize, Serialize};
48
49#[derive(Debug, thiserror::Error)]
52#[error("{stage}: {target}")]
53pub(crate) struct ConfigFailure {
54 pub(crate) stage: &'static str,
55 pub(crate) target: String,
56}
57
58impl ConfigFailure {
59 fn render(error: &teravars::Error, paths: &[PathBuf]) -> Self {
60 let stage = match error {
61 teravars::Error::Io(_) => "config-read",
62 teravars::Error::Toml(_) | teravars::Error::Extract(_) => "config-parse",
63 teravars::Error::Render(_) => "config-template",
64 teravars::Error::IncludeCycle { .. } | teravars::Error::IncludeConflict { .. } => {
65 "config-include"
66 }
67 _ => "config-render",
68 };
69 Self::layers(stage, paths)
70 }
71
72 fn layers(stage: &'static str, paths: &[PathBuf]) -> Self {
73 let targets: Vec<_> = paths
74 .iter()
75 .map(|path| {
76 path.ancestors()
79 .find(|p| p.join(REPO_MARKER).is_file())
80 .and_then(|root| path.strip_prefix(root).ok())
81 .unwrap_or(path)
82 .to_string_lossy()
83 .into_owned()
84 })
85 .collect();
86 Self {
87 stage,
88 target: serde_json::to_string(&targets).expect("config paths"),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
95#[serde(rename_all = "lowercase")]
96pub enum AgentKind {
97 Claude,
99 Opencode,
101 Antigravity,
105 Codex,
109 Omp,
115 Command,
117}
118
119impl AgentKind {
120 pub fn program(self) -> Option<&'static str> {
122 match self {
123 Self::Claude => Some("claude"),
124 Self::Opencode => Some("opencode"),
125 Self::Antigravity => Some("agy"),
126 Self::Codex => Some("codex"),
127 Self::Omp => Some("omp"),
128 Self::Command => None,
129 }
130 }
131
132 pub fn as_str(self) -> &'static str {
134 match self {
135 Self::Claude => "claude",
136 Self::Opencode => "opencode",
137 Self::Antigravity => "antigravity",
138 Self::Codex => "codex",
139 Self::Omp => "omp",
140 Self::Command => "command",
141 }
142 }
143
144 pub const ALL: [Self; 6] = [
152 Self::Claude,
153 Self::Opencode,
154 Self::Antigravity,
155 Self::Codex,
156 Self::Omp,
157 Self::Command,
158 ];
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
163#[serde(rename_all = "lowercase")]
164pub enum Delivery {
165 Stdin,
167 Argv,
169 File,
171}
172
173#[derive(Debug, Clone, Deserialize, Serialize)]
175#[serde(deny_unknown_fields)]
176pub struct AgentSpec {
177 pub id: String,
179 pub kind: AgentKind,
181 #[serde(default)]
183 pub model: Option<String>,
184 #[serde(default)]
187 pub command: Vec<String>,
188 #[serde(default)]
190 pub extra_args: Vec<String>,
191 #[serde(default)]
193 pub env: BTreeMap<String, String>,
194 #[serde(default)]
196 pub prompt_delivery: Option<Delivery>,
197}
198
199impl AgentSpec {
200 pub fn delivery(&self) -> Delivery {
206 self.prompt_delivery.unwrap_or(match self.kind {
207 AgentKind::Claude | AgentKind::Command => Delivery::Stdin,
208 AgentKind::Codex => Delivery::Stdin,
212 AgentKind::Omp => Delivery::Stdin,
216 AgentKind::Opencode | AgentKind::Antigravity => Delivery::File,
217 })
218 }
219
220 pub fn display(&self) -> String {
222 match &self.model {
223 Some(m) => format!("{} ({}:{m})", self.id, self.kind.as_str()),
224 None => format!("{} ({})", self.id, self.kind.as_str()),
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
236#[serde(untagged)]
237pub enum AgentChoice {
238 One(String),
240 Chain(Vec<String>),
242}
243
244impl AgentChoice {
245 pub fn ids(&self) -> Vec<&str> {
247 let all: Vec<&str> = match self {
248 Self::One(id) => vec![id.as_str()],
249 Self::Chain(ids) => ids.iter().map(String::as_str).collect(),
250 };
251 all.into_iter()
252 .map(str::trim)
253 .filter(|id| !id.is_empty())
254 .collect()
255 }
256
257 pub fn primary(&self) -> Option<&str> {
259 self.ids().into_iter().next()
260 }
261}
262
263impl From<&str> for AgentChoice {
264 fn from(id: &str) -> Self {
265 Self::One(id.to_owned())
266 }
267}
268
269pub fn primary(choice: Option<&AgentChoice>) -> Option<&str> {
271 choice.and_then(AgentChoice::primary)
272}
273
274#[derive(Debug, Clone, Default, Deserialize, Serialize)]
277#[serde(deny_unknown_fields, default)]
278pub struct Roles {
279 pub implementers: Vec<String>,
281 pub judges: Vec<String>,
283 pub reviewers: Vec<String>,
285 pub fixer: Option<AgentChoice>,
294 pub chatter: Option<AgentChoice>,
308 pub dupes_judge: Option<AgentChoice>,
312 pub conductor: Option<AgentChoice>,
325 pub advisors: Vec<String>,
336 pub synthesizer: Option<AgentChoice>,
349 pub language_judge: Option<AgentChoice>,
358}
359
360#[derive(Debug, Clone, Deserialize, Serialize)]
362#[serde(deny_unknown_fields, default)]
363pub struct Graph {
364 pub github_text_guard: bool,
366 pub github_language: String,
372 #[serde(alias = "candidates")]
404 pub implementers: usize,
405 #[serde(default)]
407 pub selected_implementer: Option<String>,
408 pub judges: usize,
410 pub deliberate_rounds: usize,
412 pub reviewers: usize,
420 pub review_rounds: usize,
422 pub gate_fix_rounds: usize,
428 pub max_parallel: usize,
430 pub language: String,
432 pub sessions: bool,
440 pub timeout_implement: u64,
442 pub timeout_judge: u64,
444 pub timeout_review: u64,
446 pub timeout_verify: Option<u64>,
455 pub timeout_fix: u64,
457 pub timeout_talk: u64,
465 pub timeout_resource_wait: u64,
474 pub retries: usize,
476 pub worktree_root: Option<PathBuf>,
478 pub land: bool,
491 pub land_rounds: usize,
493 pub rerun_flaky_checks: bool,
501 pub land_approval: bool,
512 pub hold_contested_merge: bool,
523 pub file_followups: bool,
530 pub release_deputy_followups: bool,
538 pub answer_timeout: u64,
542 pub incomplete_review: IncompleteReviewPolicy,
545 pub e2e_every_round: bool,
563 pub advise: bool,
577 pub advisors: usize,
582 pub graft_runner_up: bool,
591}
592
593impl Default for Graph {
594 fn default() -> Self {
595 Self {
596 github_text_guard: true,
597 github_language: "en".into(),
598 implementers: 1,
599 selected_implementer: None,
600 judges: 3,
601 deliberate_rounds: 1,
602 reviewers: 3,
603 review_rounds: 6,
604 gate_fix_rounds: 1,
605 max_parallel: 4,
606 language: "en".to_owned(),
607 sessions: true,
608 timeout_implement: 3600,
609 timeout_judge: 1200,
610 timeout_review: 1200,
611 timeout_verify: None,
612 timeout_fix: 1800,
613 timeout_talk: 3600,
614 timeout_resource_wait: 1800,
615 retries: 1,
616 worktree_root: None,
617 land: true,
618 land_rounds: 4,
619 rerun_flaky_checks: true,
620 land_approval: true,
621 hold_contested_merge: true,
622 file_followups: true,
623 release_deputy_followups: true,
624 answer_timeout: 86_400,
625 incomplete_review: IncompleteReviewPolicy::Block,
626 e2e_every_round: false,
627 advise: true,
628 advisors: 3,
629 graft_runner_up: true,
630 }
631 }
632}
633
634impl Graph {
635 pub fn verify_timeout(&self) -> u64 {
639 self.timeout_verify.unwrap_or(self.timeout_review)
640 }
641
642 pub fn language_gate(&self) -> bool {
645 self.github_text_guard && self.github_english()
646 }
647
648 pub fn github_english(&self) -> bool {
650 crate::lang::is_english(&self.github_language)
651 }
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
661#[serde(rename_all = "lowercase")]
662pub enum IncompleteReviewPolicy {
663 Block,
667 Warn,
672}
673
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
676#[serde(rename_all = "lowercase")]
677pub enum LeakPolicy {
678 Warn,
680 Redact,
682 Fail,
684}
685
686#[derive(Debug, Clone, Deserialize, Serialize)]
693#[serde(deny_unknown_fields, default)]
694pub struct Blind {
695 pub commit_msg_hook: bool,
698 pub strip_lines: Vec<String>,
702 pub vendor_tokens: Vec<String>,
704 pub on_leak: LeakPolicy,
706 pub seed: Option<u64>,
709}
710
711impl Default for Blind {
712 fn default() -> Self {
713 Self {
714 commit_msg_hook: true,
715 strip_lines: [
716 "Co-Authored-By:",
717 "Signed-off-by:",
718 "Assisted-by:",
719 "Generated-by:",
720 "Generated with",
721 "\u{1f916}",
722 ]
723 .iter()
724 .map(|s| (*s).to_owned())
725 .collect(),
726 vendor_tokens: [
727 "claude",
728 "anthropic",
729 "codex",
730 "openai",
731 "chatgpt",
732 "gemini",
733 "grok",
734 "xai",
735 "copilot",
736 "opencode",
737 "qoder",
738 "cursor",
739 "\u{1f916}",
740 ]
741 .iter()
742 .map(|s| (*s).to_owned())
743 .collect(),
744 on_leak: LeakPolicy::Warn,
745 seed: None,
746 }
747 }
748}
749
750#[derive(Debug, Clone, Default, Deserialize, Serialize)]
752#[serde(deny_unknown_fields, default)]
753pub struct Verify {
754 pub e2e: Vec<String>,
757 pub gate: Vec<String>,
759 #[serde(default)]
768 pub pre_gate: Vec<String>,
769 pub shell: Option<Vec<String>>,
772}
773
774impl Verify {
775 pub fn cache_dir(&self) -> Option<PathBuf> {
781 self.e2e
782 .iter()
783 .chain(self.gate.iter())
784 .find_map(|cmd| crate::disk::extract_cargo_target_dir(cmd))
785 }
786
787 pub fn cargo_manifests(&self) -> Vec<PathBuf> {
790 let all: Vec<String> = self.e2e.iter().chain(self.gate.iter()).cloned().collect();
791 crate::cache::extract_manifest_paths(&all)
792 }
793}
794
795#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
797#[serde(deny_unknown_fields, default)]
798pub struct Worktree {
799 pub setup: Vec<SetupStep>,
802}
803
804#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
806#[serde(deny_unknown_fields, default)]
807pub struct SetupStep {
808 pub copy: Option<String>,
811 pub optional: bool,
813 pub run: Option<String>,
815 pub timeout_secs: Option<u64>,
818}
819
820impl Worktree {
821 pub fn validate(&self) -> Result<()> {
823 for (i, step) in self.setup.iter().enumerate() {
824 let n = i + 1;
825 match (&step.copy, &step.run) {
826 (Some(c), None) => {
827 if step.timeout_secs.is_some() {
828 bail!("[worktree] setup step {n}: `timeout_secs` applies to `run` only");
829 }
830 crate::worktree_setup::validate_copy(c)
831 .with_context(|| format!("[worktree] setup step {n}"))?;
832 }
833 (None, Some(r)) => {
834 if r.trim().is_empty() {
835 bail!("[worktree] setup step {n}: `run` is empty");
836 }
837 if step.optional {
838 bail!("[worktree] setup step {n}: `optional` applies to `copy` only");
839 }
840 }
841 _ => bail!("[worktree] setup step {n} must set exactly one of `copy` and `run`"),
842 }
843 }
844 Ok(())
845 }
846}
847
848#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
858#[serde(deny_unknown_fields, default)]
859pub struct Disk {
860 pub min_free_bytes: u64,
869 pub auto_fold: bool,
875 pub fold_grace_secs: u64,
883 pub cache_limit_bytes: u64,
895 pub worktree_limit_bytes: u64,
897 pub worktree_reclaim_bytes: u64,
899 pub retained_attempt_limit: usize,
903}
904
905impl Default for Disk {
906 fn default() -> Self {
907 Self {
908 min_free_bytes: 8 * 1024 * 1024 * 1024,
909 auto_fold: true,
910 fold_grace_secs: 6 * 60 * 60,
911 cache_limit_bytes: 10 * 1024 * 1024 * 1024,
912 worktree_limit_bytes: 10 * 1024 * 1024 * 1024,
913 worktree_reclaim_bytes: 16 * 1024 * 1024 * 1024,
914 retained_attempt_limit: 8,
915 }
916 }
917}
918
919#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
921#[serde(rename_all = "lowercase")]
922pub enum MergeMode {
923 None,
926 Local,
929 Pr,
933}
934
935#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
949#[serde(rename_all = "lowercase")]
950pub enum MergeStyle {
951 #[default]
956 Merge,
957 Squash,
963 Rebase,
969}
970
971#[derive(Debug, Clone, Deserialize, Serialize)]
973#[serde(deny_unknown_fields, default)]
974pub struct Merge {
975 pub mode: MergeMode,
978 pub base: Option<String>,
980 pub style: MergeStyle,
983 pub remote: String,
985 pub release_bump: bool,
998}
999
1000impl Default for Merge {
1001 fn default() -> Self {
1002 Self {
1003 mode: MergeMode::None,
1004 base: None,
1005 style: MergeStyle::default(),
1006 remote: "origin".to_owned(),
1007 release_bump: true,
1008 }
1009 }
1010}
1011
1012#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1014#[serde(rename_all = "lowercase")]
1015pub enum UpdateMode {
1016 Off,
1018 Notify,
1021 Install,
1023}
1024
1025#[derive(Debug, Clone, Deserialize, Serialize)]
1027#[serde(deny_unknown_fields, default)]
1028pub struct Update {
1029 pub mode: UpdateMode,
1031 pub interval: Option<String>,
1033}
1034
1035impl Default for Update {
1036 fn default() -> Self {
1037 Self {
1038 mode: UpdateMode::Notify,
1039 interval: None,
1040 }
1041 }
1042}
1043
1044#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
1046#[serde(rename_all = "lowercase")]
1047pub enum ReleaseMode {
1048 #[default]
1051 Actions,
1052 Local,
1056}
1057
1058#[derive(Debug, Clone, Deserialize, Serialize)]
1060#[serde(deny_unknown_fields, default)]
1061pub struct Release {
1062 pub mode: ReleaseMode,
1065 pub commands: Vec<String>,
1072 pub timeout_minutes: u64,
1074 pub version_files: Vec<VersionFile>,
1082}
1083
1084#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1086#[serde(rename_all = "lowercase")]
1087pub enum VersionKind {
1088 Cargo,
1090 Json,
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
1096#[serde(deny_unknown_fields)]
1097pub struct VersionFile {
1098 pub path: String,
1100 pub kind: VersionKind,
1102}
1103
1104impl Default for Release {
1105 fn default() -> Self {
1106 Self {
1107 mode: ReleaseMode::Actions,
1108 commands: Vec::new(),
1109 timeout_minutes: 30,
1110 version_files: Vec::new(),
1111 }
1112 }
1113}
1114
1115impl Release {
1116 pub fn validate(&self) -> Result<()> {
1118 crate::release_manifest::validate(&self.version_files).context("[release] version_files")
1119 }
1120
1121 pub fn is_local(&self) -> bool {
1123 self.mode == ReleaseMode::Local
1124 }
1125}
1126
1127#[derive(Debug, Clone, Deserialize, Serialize)]
1129#[serde(try_from = "CoordinationFields")]
1130pub struct Coordination {
1131 pub contract_file: String,
1133 pub contract_token: String,
1135 pub lock_file: String,
1137}
1138
1139#[derive(Deserialize, Default)]
1140#[serde(deny_unknown_fields, default)]
1141struct CoordinationFields {
1142 contract_file: String,
1143 contract_token: String,
1144 lock_file: String,
1145}
1146
1147impl TryFrom<CoordinationFields> for Coordination {
1148 type Error = anyhow::Error;
1149
1150 fn try_from(fields: CoordinationFields) -> Result<Self> {
1151 for (key, name) in [
1152 ("coordination.contract_file", &fields.contract_file),
1153 ("coordination.lock_file", &fields.lock_file),
1154 ] {
1155 if name.trim().is_empty()
1156 || name != name.trim()
1157 || name == "."
1158 || name.contains("..")
1159 || name.contains(['/', '\\', ':', '\0'])
1160 {
1161 bail!(
1162 "{key} must be a plain file name inside the Git common directory (no separators or '..')"
1163 );
1164 }
1165 }
1166 let token = &fields.contract_token;
1167 if token.is_empty() || token.trim() != token || token.chars().any(char::is_control) {
1168 bail!(
1169 "coordination.contract_token must be a nonempty single-line token without surrounding whitespace"
1170 );
1171 }
1172 Ok(Self {
1173 contract_file: fields.contract_file,
1174 contract_token: fields.contract_token,
1175 lock_file: fields.lock_file,
1176 })
1177 }
1178}
1179
1180#[cfg(test)]
1181pub(crate) fn test_coordination() -> Option<Coordination> {
1182 Some(Coordination {
1183 contract_file: "updater.contract".into(),
1184 contract_token: "test-protocol-v1".into(),
1185 lock_file: "updater.lock".into(),
1186 })
1187}
1188
1189#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1191#[serde(deny_unknown_fields, default)]
1192pub struct Config {
1193 pub coordination: Option<Coordination>,
1195 pub agents: Vec<AgentSpec>,
1197 pub roles: Roles,
1199 pub graph: Graph,
1201 pub blind: Blind,
1203 pub verify: Verify,
1205 pub worktree: Worktree,
1207 pub disk: Disk,
1209 pub merge: Merge,
1211 pub update: Update,
1213 pub prompts: Prompts,
1215 pub notify: Notify,
1217 pub repos: Repos,
1220 pub talk: Talk,
1222 pub daemon: Daemon,
1224 pub release: Release,
1226 pub context_windows: BTreeMap<String, u64>,
1229}
1230
1231const BUILTIN_CONTEXT_WINDOWS: &[(&str, u64)] = &[
1241 ("claude-opus-4", 200_000),
1242 ("claude-sonnet-4", 200_000),
1243 ("claude-haiku-4", 200_000),
1244 ("claude-3", 200_000),
1245 ("opus", 200_000),
1246 ("sonnet", 200_000),
1247 ("haiku", 200_000),
1248 ("gpt-5", 400_000),
1249 ("gpt-4.1", 1_000_000),
1250 ("gpt-4o", 128_000),
1251 ("o3", 200_000),
1252 ("o4-mini", 200_000),
1253 ("gemini-2.5", 1_000_000),
1254 ("deepseek", 128_000),
1255];
1256
1257impl Config {
1258 pub fn context_window(&self, model: &str) -> Option<u64> {
1263 fn lookup<'a>(model: &str, entries: impl Iterator<Item = (&'a str, u64)>) -> Option<u64> {
1264 let mut best: Option<(usize, u64)> = None;
1265 for (key, window) in entries {
1266 if key == model {
1267 return Some(window);
1268 }
1269 if model.starts_with(key) && best.is_none_or(|(len, _)| key.len() > len) {
1270 best = Some((key.len(), window));
1271 }
1272 }
1273 best.map(|(_, w)| w)
1274 }
1275 lookup(
1276 model,
1277 self.context_windows.iter().map(|(k, w)| (k.as_str(), *w)),
1278 )
1279 .or_else(|| lookup(model, BUILTIN_CONTEXT_WINDOWS.iter().copied()))
1280 }
1281}
1282
1283#[derive(Debug, Clone, Deserialize, Serialize)]
1293#[serde(deny_unknown_fields, default)]
1294pub struct Daemon {
1295 pub max_concurrent_runs: usize,
1316 pub pause_for_interrupts: bool,
1332 pub max_deputies: usize,
1339 pub release_stall_minutes: u64,
1346}
1347
1348impl Default for Daemon {
1349 fn default() -> Self {
1350 Self {
1351 max_concurrent_runs: 1,
1352 pause_for_interrupts: false,
1353 max_deputies: 2,
1354 release_stall_minutes: 60,
1355 }
1356 }
1357}
1358
1359#[derive(Debug, Clone, Deserialize, Serialize)]
1371#[serde(deny_unknown_fields, default)]
1372pub struct Repos {
1373 pub roots: Vec<PathBuf>,
1377 pub scan_ttl: u64,
1384 pub fetch_interval: u64,
1389}
1390
1391impl Default for Repos {
1392 fn default() -> Self {
1393 Self {
1394 roots: Vec::new(),
1395 scan_ttl: 86_400,
1396 fetch_interval: 600,
1397 }
1398 }
1399}
1400
1401#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1417#[serde(deny_unknown_fields, default)]
1418pub struct Prompts {
1419 pub all: String,
1421 pub implement: String,
1423 pub judge: String,
1425 pub review: String,
1427 pub fix: String,
1429}
1430
1431impl Prompts {
1432 pub fn overlay(&self, node: &str) -> Option<String> {
1437 let specific = match node {
1438 "implement" => &self.implement,
1439 "judge" | "vote" | "deliberate" => &self.judge,
1440 "review" => &self.review,
1441 "fix" => &self.fix,
1442 _ => "",
1443 };
1444 let mut parts: Vec<&str> = Vec::new();
1445 for p in [self.all.trim(), specific.trim()] {
1446 if !p.is_empty() {
1447 parts.push(p);
1448 }
1449 }
1450 if parts.is_empty() {
1451 return None;
1452 }
1453 Some(parts.join("\n\n"))
1454 }
1455}
1456
1457#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1464#[serde(deny_unknown_fields, default)]
1465pub struct Notify {
1466 pub command: Vec<String>,
1469}
1470
1471#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1473#[serde(deny_unknown_fields, default)]
1474pub struct Talk {
1475 pub allow_write: bool,
1484 pub personas: Vec<PersonaSpec>,
1489 pub operator_name: Option<String>,
1493}
1494
1495pub const OPERATOR_NAME_MAX: usize = 64;
1497
1498impl Talk {
1499 pub fn operator_name(&self) -> Option<&str> {
1501 self.operator_name
1502 .as_deref()
1503 .map(str::trim)
1504 .filter(|n| !n.is_empty())
1505 }
1506}
1507
1508#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
1510#[serde(deny_unknown_fields)]
1511pub struct PersonaSpec {
1512 pub id: String,
1514 #[serde(default)]
1517 pub name: String,
1518 #[serde(default)]
1521 pub prompt: String,
1522 #[serde(default, skip_serializing_if = "Option::is_none")]
1526 pub icon: Option<String>,
1527}
1528
1529#[derive(Debug, Clone)]
1531pub struct ResolvedRoles {
1532 pub implementers: Vec<AgentSpec>,
1534 pub judges: Vec<AgentSpec>,
1536 pub reviewers: Vec<AgentSpec>,
1538 pub fixer: Option<Vec<AgentSpec>>,
1543 pub conductor: AgentSpec,
1545 pub implementer_roster: Vec<AgentSpec>,
1553 pub judge_roster: Vec<AgentSpec>,
1557 pub reviewer_roster: Vec<AgentSpec>,
1559}
1560
1561fn array_keys(table: &toml::value::Table, prefix: &str) -> Vec<String> {
1568 let mut out = Vec::new();
1569 for (k, v) in table {
1570 if prefix.is_empty() && k == "vars" {
1571 continue;
1572 }
1573 let path = if prefix.is_empty() {
1574 k.clone()
1575 } else {
1576 format!("{prefix}.{k}")
1577 };
1578 match v {
1579 toml::Value::Array(_) => out.push(path),
1580 toml::Value::Table(t) => out.extend(array_keys(t, &path)),
1581 _ => {}
1582 }
1583 }
1584 out
1585}
1586
1587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1589enum ArrayMerge {
1590 Append,
1594 Replace,
1597}
1598
1599fn array_merge_policy(key: &str) -> ArrayMerge {
1638 match key {
1639 "verify.e2e" | "verify.gate" | "verify.pre_gate" | "repos.roots" => ArrayMerge::Append,
1640 _ => ArrayMerge::Replace,
1641 }
1642}
1643
1644impl Config {
1645 pub fn load(path: &Path) -> Result<Self> {
1648 Self::load_layers(&[path.to_path_buf()])
1649 }
1650
1651 fn render_ctx(paths: &[PathBuf]) -> teravars::Context {
1661 let mut ctx = teravars::system_context();
1662 let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
1663 ctx.insert("env", &env);
1664 if let Some(last) = paths.last()
1665 && let Some(dir) = last.parent()
1666 {
1667 let dir = std::fs::read_to_string(dir.join(REPO_MARKER))
1670 .map(|s| PathBuf::from(s.trim()))
1671 .unwrap_or_else(|_| dir.to_path_buf());
1672 ctx.insert("repo", &dir.to_string_lossy());
1673 ctx.insert(
1674 "repo_name",
1675 &dir.file_name().unwrap_or_default().to_string_lossy(),
1676 );
1677 }
1678 ctx
1679 }
1680
1681 pub fn load_layers(paths: &[PathBuf]) -> Result<Self> {
1689 let mut engine = teravars::Engine::default();
1690 let ctx = Self::render_ctx(paths);
1691 if paths.len() > 1 {
1692 Self::refuse_split_arrays(paths, &mut engine, &ctx).map_err(|error| {
1693 if error.downcast_ref::<ConfigFailure>().is_some() {
1694 error
1695 } else {
1696 let message = error.to_string();
1699 error
1700 .context(ConfigFailure::layers("config-merge", paths))
1701 .context(message)
1702 }
1703 })?;
1704 }
1705 let merged = teravars::load_merged(paths, &mut engine, &ctx)
1706 .map_err(|error| {
1707 let cause = ConfigFailure::render(&error, paths);
1708 anyhow::Error::new(error).context(cause)
1709 })
1710 .with_context(|| {
1711 format!(
1712 "rendering config via teravars: {}",
1713 paths
1714 .iter()
1715 .map(|p| p.display().to_string())
1716 .collect::<Vec<_>>()
1717 .join(", ")
1718 )
1719 })?;
1720 let mut table = merged.config;
1721 Self::normalize_renamed_keys(paths, &mut engine, &ctx, &mut table)?;
1722 table.remove("vars");
1725 let detect_agents = !table.contains_key("agents");
1729 let mut cfg: Self = toml::Value::Table(table)
1730 .try_into()
1731 .context("deserializing magi config")
1732 .context(ConfigFailure::layers("config-deserialize", paths))?;
1733 crate::persona::validate(&cfg.talk.personas)?;
1734 cfg.worktree.validate()?;
1735 cfg.release.validate()?;
1736 if let Some(name) = cfg.talk.operator_name() {
1737 if name.chars().count() > OPERATOR_NAME_MAX || name.chars().any(char::is_control) {
1738 bail!(
1739 "[talk] operator_name must be one line of at most {OPERATOR_NAME_MAX} characters"
1740 );
1741 }
1742 }
1743 if detect_agents {
1744 cfg.agents = Self::autodetected().agents;
1745 }
1746 Ok(cfg)
1747 }
1748
1749 fn normalize_renamed_keys(
1757 paths: &[PathBuf],
1758 engine: &mut teravars::Engine,
1759 ctx: &teravars::Context,
1760 table: &mut toml::map::Map<String, toml::Value>,
1761 ) -> Result<()> {
1762 let both = matches!(
1763 table.get("graph"),
1764 Some(toml::Value::Table(g)) if g.contains_key("candidates") && g.contains_key("implementers")
1765 );
1766 if !both {
1767 return Ok(());
1768 }
1769 let mut winner: Option<toml::Value> = None;
1770 for path in paths {
1771 let one = teravars::load_merged([path], engine, ctx)
1772 .map_err(|error| {
1773 let cause = ConfigFailure::render(&error, std::slice::from_ref(path));
1774 anyhow::Error::new(error).context(cause)
1775 })
1776 .with_context(|| format!("rendering {}", path.display()))?;
1777 let Some(toml::Value::Table(g)) = one.config.get("graph") else {
1778 continue;
1779 };
1780 if g.contains_key("candidates") && g.contains_key("implementers") {
1781 bail!(
1782 "`[graph]` sets both `implementers` and its deprecated alias \n `candidates` in {}; keep only `implementers`.",
1783 path.display()
1784 );
1785 }
1786 if let Some(v) = g.get("implementers").or_else(|| g.get("candidates")) {
1787 winner = Some(v.clone());
1788 }
1789 }
1790 if let Some(toml::Value::Table(g)) = table.get_mut("graph") {
1791 g.remove("candidates");
1792 if let Some(v) = winner {
1793 g.insert("implementers".to_owned(), v);
1794 }
1795 }
1796 Ok(())
1797 }
1798
1799 fn refuse_split_arrays(
1823 paths: &[PathBuf],
1824 engine: &mut teravars::Engine,
1825 ctx: &teravars::Context,
1826 ) -> Result<()> {
1827 let mut seen: std::collections::BTreeMap<String, PathBuf> = Default::default();
1828 for path in paths {
1829 let one = teravars::load_merged([path], engine, ctx)
1830 .map_err(|error| {
1831 let cause = ConfigFailure::render(&error, std::slice::from_ref(path));
1832 anyhow::Error::new(error).context(cause)
1833 })
1834 .with_context(|| format!("rendering {}", path.display()))?;
1835 for key in array_keys(&one.config, "") {
1836 if array_merge_policy(&key) == ArrayMerge::Append {
1837 continue;
1838 }
1839 if let Some(first) = seen.get(&key) {
1840 bail!(
1841 "`{key}` is an array declared in two config layers:\n \
1842 {}\n {}\nteravars appends arrays when it merges, so \
1843 magi would run the concatenation of both - which is \
1844 not what either file says. Declare `{key}` in exactly \
1845 one of them.",
1846 first.display(),
1847 path.display()
1848 );
1849 }
1850 seen.insert(key, path.clone());
1851 }
1852 }
1853 Ok(())
1854 }
1855
1856 pub fn array_provenance(paths: &[PathBuf], key: &str) -> Vec<(PathBuf, Vec<String>)> {
1878 let mut engine = teravars::Engine::default();
1879 let ctx = Self::render_ctx(paths);
1880 let mut out = Vec::new();
1881 for path in paths {
1882 let Ok(one) = teravars::load_merged([path], &mut engine, &ctx) else {
1883 continue;
1884 };
1885 let mut cur = &one.config;
1886 let mut found = None;
1887 let parts: Vec<&str> = key.split('.').collect();
1888 for (i, part) in parts.iter().enumerate() {
1889 match cur.get(*part) {
1890 Some(toml::Value::Array(a)) if i == parts.len() - 1 => {
1891 found = Some(a);
1892 break;
1893 }
1894 Some(toml::Value::Table(t)) => cur = t,
1895 _ => break,
1896 }
1897 }
1898 let Some(values) = found else { continue };
1899 let strings: Vec<String> = values
1900 .iter()
1901 .filter_map(|v| v.as_str().map(str::to_owned))
1902 .collect();
1903 if !strings.is_empty() {
1904 out.push((path.clone(), strings));
1905 }
1906 }
1907 out
1908 }
1909
1910 pub(crate) fn layer_tables(paths: &[PathBuf]) -> Vec<(PathBuf, Result<toml::Table>)> {
1916 let mut engine = teravars::Engine::default();
1917 let ctx = Self::render_ctx(paths);
1918 paths
1919 .iter()
1920 .map(|path| {
1921 let one = teravars::load_merged([path], &mut engine, &ctx)
1922 .map(|m| m.config)
1923 .with_context(|| format!("rendering {}", path.display()));
1924 (path.clone(), one)
1925 })
1926 .collect()
1927 }
1928
1929 pub fn describe_composed(
1939 paths: &[PathBuf],
1940 commands: &[String],
1941 key: &str,
1942 empty: &str,
1943 ) -> String {
1944 if commands.is_empty() {
1945 return empty.to_owned();
1946 }
1947 let joined = commands.join(" && ");
1948 let provenance = Self::array_provenance(paths, key);
1949 if provenance.len() <= 1 {
1950 return joined;
1951 }
1952 let mut out = joined;
1953 for (path, cmds) in &provenance {
1954 out.push_str(&format!("\n [{}] {}", path.display(), cmds.join(" && ")));
1955 }
1956 out
1957 }
1958
1959 pub fn discover(repo: &Path, explicit: Option<&Path>) -> Result<(Self, Vec<PathBuf>)> {
1966 #[cfg(test)]
1967 crate::web::request_cost::discovery();
1968 if let Some(p) = explicit {
1969 let paths = vec![p.to_path_buf()];
1970 return Ok((Self::load_layers(&paths)?, paths));
1971 }
1972 let paths = Self::layers_checked(repo)?;
1973 if paths.is_empty() {
1974 return Ok((Self::autodetected(), paths));
1975 }
1976 Ok((Self::load_layers(&paths)?, paths))
1977 }
1978
1979 pub async fn discover_fetched(
1982 repo: &Path,
1983 explicit: Option<&Path>,
1984 ) -> Result<(Self, Vec<PathBuf>)> {
1985 if explicit.is_none() && is_git_worktree(repo) {
1986 let (remote, cfg_base) = probe_remote_base(repo);
1990 let base = crate::git::merge_base_branch(repo, &remote, cfg_base.as_deref())
1991 .await
1992 .with_context(|| ConfigFailure {
1993 stage: "config-base",
1994 target: remote.clone(),
1995 })?;
1996 let out = crate::git::fetch(repo, &remote, &base)
1997 .await
1998 .with_context(|| format!("fetching {remote}/{base} to read magi.toml"))
1999 .with_context(|| ConfigFailure {
2000 stage: "config-fetch",
2001 target: format!("{remote}/{base}"),
2002 })?;
2003 if !out.ok() {
2004 return Err(anyhow::anyhow!(
2005 "cannot read magi.toml: `git fetch {remote} {base}` failed ({}); refusing to use the local checkout's copy, which may be stale",
2006 out.stderr.lines().next().unwrap_or("").trim()
2007 )).context(ConfigFailure { stage: "config-fetch", target: format!("{remote}/{base}") });
2008 }
2009 if cfg_base.is_none() {
2010 crate::git::ensure_remote_head(repo, &remote, &base).await;
2011 }
2012 }
2013 Self::discover(repo, explicit).map_err(|error| {
2014 if error.downcast_ref::<ConfigFailure>().is_some() {
2015 error
2016 } else {
2017 error.context(ConfigFailure {
2018 stage: "config-load",
2019 target: explicit.unwrap_or(repo).to_string_lossy().into_owned(),
2020 })
2021 }
2022 })
2023 }
2024
2025 fn layers_checked(repo: &Path) -> Result<Vec<PathBuf>> {
2027 if !is_git_worktree(repo) {
2028 return Ok(Self::layers(repo));
2029 }
2030 let mut paths: Vec<PathBuf> = Self::machine_layer()
2031 .into_iter()
2032 .filter(|p| p.is_file())
2033 .collect();
2034 paths.extend(Self::repo_layers(repo)?);
2035 Ok(paths)
2036 }
2037
2038 pub(crate) fn repo_layer_description(repo: &Path, path: &Path) -> String {
2040 let cached = path.ancestors().find(|p| p.join(REPO_MARKER).is_file());
2041 if let Some(root) = cached {
2042 let relative = path.strip_prefix(root).unwrap_or(path);
2043 let reference = resolve_remote_base(repo)
2044 .map(|(remote, base)| format!("{remote}/{base}"))
2045 .unwrap_or_else(|_| "remote base".to_owned());
2046 format!(
2047 "{} at {reference} (including its includes)",
2048 repo.join(relative).display()
2049 )
2050 } else {
2051 format!("{} (including its includes)", path.display())
2052 }
2053 }
2054
2055 pub(crate) fn repo_layers(repo: &Path) -> Result<Vec<PathBuf>> {
2058 if !is_git_worktree(repo) {
2059 return Ok([
2060 repo.join(".magi").join("config.toml"),
2061 repo.join("magi.toml"),
2062 ]
2063 .into_iter()
2064 .filter(|p| p.is_file())
2065 .collect());
2066 }
2067 remote_layers(repo)
2068 }
2069
2070 pub const CONFIG_DIR_ENV: &str = "MAGI_CONFIG_DIR";
2090
2091 pub fn layers(repo: &Path) -> Vec<PathBuf> {
2096 let mut paths = Vec::new();
2097 paths.extend(Self::machine_layer());
2098 paths.push(repo.join(".magi").join("config.toml"));
2099 paths.push(repo.join("magi.toml"));
2100 paths.retain(|p| p.is_file());
2101 paths
2102 }
2103
2104 #[cfg(test)]
2111 pub(crate) fn machine_layer() -> Option<PathBuf> {
2112 std::env::var(Self::CONFIG_DIR_ENV)
2113 .ok()
2114 .filter(|dir| !dir.trim().is_empty())
2115 .map(|dir| PathBuf::from(dir).join("magi").join("config.toml"))
2116 }
2117
2118 #[cfg(not(test))]
2120 pub(crate) fn machine_layer() -> Option<PathBuf> {
2121 match std::env::var(Self::CONFIG_DIR_ENV) {
2122 Ok(dir) if dir.trim().is_empty() => None,
2124 Ok(dir) => Some(PathBuf::from(dir).join("magi").join("config.toml")),
2125 Err(_) => dirs::config_dir().map(|dir| dir.join("magi").join("config.toml")),
2126 }
2127 }
2128
2129 pub fn autodetected() -> Self {
2131 let mut cfg = Self::default();
2132 for (kind, id, model) in [
2133 (AgentKind::Claude, "opus", Some("opus")),
2134 (AgentKind::Claude, "sonnet", Some("sonnet")),
2135 (AgentKind::Antigravity, "antigravity", None),
2136 (AgentKind::Opencode, "opencode", None),
2137 (AgentKind::Codex, "codex", None),
2138 (AgentKind::Omp, "omp", None),
2139 ] {
2140 if kind.program().is_some_and(which) && !cfg.agents.iter().any(|a| a.id == id) {
2141 cfg.agents.push(AgentSpec {
2142 id: id.to_owned(),
2143 kind,
2144 model: model.map(str::to_owned),
2145 command: Vec::new(),
2146 extra_args: Vec::new(),
2147 env: BTreeMap::new(),
2148 prompt_delivery: None,
2149 });
2150 }
2151 }
2152 cfg
2153 }
2154
2155 pub fn cache_dir(&self) -> Option<PathBuf> {
2158 self.verify.cache_dir()
2159 }
2160
2161 pub fn agent(&self, id: &str) -> Result<&AgentSpec> {
2163 self.agents
2164 .iter()
2165 .find(|a| a.id == id)
2166 .with_context(|| format!("no agent with id `{id}` in the roster"))
2167 }
2168
2169 fn rotate(&self, ids: &[String], count: usize, offset: usize) -> Result<Vec<AgentSpec>> {
2176 let mut out = Vec::with_capacity(count);
2177 for i in 0..count {
2178 let spec = if ids.is_empty() {
2179 self.agents[(i + offset) % self.agents.len()].clone()
2180 } else {
2181 self.agent(&ids[i % ids.len()])?.clone()
2182 };
2183 out.push(spec);
2184 }
2185 Ok(out)
2186 }
2187
2188 fn full_roster(&self, ids: &[String]) -> Result<Vec<AgentSpec>> {
2195 if ids.is_empty() {
2196 Ok(self.agents.clone())
2197 } else {
2198 ids.iter().map(|id| self.agent(id).cloned()).collect()
2199 }
2200 }
2201
2202 pub fn resolve_roles(&self) -> Result<ResolvedRoles> {
2209 if self.agents.is_empty() {
2210 bail!(
2211 "agent roster is empty: no agent CLI found on PATH and no \
2212 [[agents]] in the config. Run `magi init` to write a starter \
2213 magi.toml."
2214 );
2215 }
2216 let pinned = self
2217 .graph
2218 .selected_implementer
2219 .as_ref()
2220 .map(|id| self.agent(id).cloned())
2221 .transpose()?;
2222 Ok(ResolvedRoles {
2223 implementers: match &pinned {
2224 Some(agent) => vec![agent.clone()],
2225 None => self.rotate(&self.roles.implementers, self.graph.implementers, 0)?,
2226 },
2227 judges: self.rotate(&self.roles.judges, self.graph.judges, 1)?,
2228 reviewers: self.rotate(&self.roles.reviewers, self.graph.reviewers, 0)?,
2229 fixer: match self.roles.fixer.as_ref() {
2230 Some(choice) => Some(crate::agent::pick_chain(
2233 &self.agents,
2234 Some(choice),
2235 &|_| true,
2236 "fixer",
2237 )?),
2238 None => None,
2239 },
2240 conductor: match self.roles.conductor.as_ref().map(AgentChoice::ids) {
2245 Some(ids) if ids.len() == 1 => self.agent(ids[0])?.clone(),
2246 Some(ids) if !ids.is_empty() => ids
2249 .iter()
2250 .find_map(|id| self.agent(id).ok())
2251 .cloned()
2252 .with_context(|| {
2253 format!("[roles] conductor names no agent in the roster: {ids:?}")
2254 })?,
2255 _ => crate::agent::pick(&self.agents, None, &crate::agent::installed)
2260 .unwrap_or_else(|_| self.agents[0].clone()),
2261 },
2262 implementer_roster: match pinned {
2263 Some(agent) => vec![agent],
2264 None => self.full_roster(&self.roles.implementers)?,
2265 },
2266 judge_roster: self.full_roster(&self.roles.judges)?,
2267 reviewer_roster: self.full_roster(&self.roles.reviewers)?,
2268 })
2269 }
2270
2271 pub fn advisors(&self) -> Result<Vec<AgentSpec>> {
2293 if self.agents.is_empty() {
2294 bail!(
2295 "agent roster is empty: no agent CLI found on PATH and no \
2296 [[agents]] in the config. Run `magi init` to write a starter \
2297 magi.toml."
2298 );
2299 }
2300 if !self.roles.advisors.is_empty() {
2301 return self.rotate(&self.roles.advisors, self.graph.advisors, 0);
2302 }
2303 self.rotate(&self.roles.judges, self.graph.advisors, 1)
2304 }
2305
2306 pub fn advisor_roster(&self) -> Result<Vec<AgentSpec>> {
2310 if self.roles.advisors.is_empty() {
2311 self.full_roster(&self.roles.judges)
2312 } else {
2313 self.full_roster(&self.roles.advisors)
2314 }
2315 }
2316
2317 pub fn shell(&self) -> Vec<String> {
2319 if let Some(s) = &self.verify.shell {
2320 return s.clone();
2321 }
2322 if which("sh") {
2323 vec!["sh".to_owned(), "-c".to_owned()]
2324 } else {
2325 vec!["cmd".to_owned(), "/C".to_owned()]
2326 }
2327 }
2328
2329 pub fn starter_toml() -> String {
2331 let detected = Self::autodetected();
2332 let mut s = String::from(
2333 "# magi — blind multi-agent implementation competition.\n\
2334 # `magi run \"<task>\"` walks: implement (N parallel worktrees)\n\
2335 # -> blind judging -> deliberation -> private final vote\n\
2336 # -> fold losers -> review + E2E loop -> gate -> merge.\n\
2337 #\n\
2338 # Rendered by teravars: a `[vars]` table, env\n\
2339 # and system lookups, and `include = [...]` all work. Tera\n\
2340 # braces are live everywhere in this file, but comments are\n\
2341 # stripped before rendering (teravars >= 0.2.2), so a comment\n\
2342 # may quote `{{ ... }}` freely.\n\
2343 #\n\
2344 # Layers deep-merge in increasing\n\
2345 # precedence, so the roster can live once per machine in\n\
2346 # <config_dir>/magi/config.toml and each repo only states its own\n\
2347 # gate:\n\
2348 # <config_dir>/magi/config.toml < .magi/config.toml < magi.toml\n\n\
2349 [vars]\n\
2350 # Reference it as vars.cache inside Tera braces, anywhere below.\n\
2351 # Single quotes inside the braces: teravars renders the raw file\n\
2352 # text, so TOML's own \\\" escaping never reaches Tera.\n\
2353 cache = \"{{ env.MAGI_CACHE | default(value='/tmp') }}\"\n\n",
2354 );
2355 if detected.agents.is_empty() {
2356 s.push_str(
2357 "# No agent CLI was found on PATH. Fill this in by hand.\n\
2358 # kind = claude | opencode | antigravity | codex | command\n\
2359 [[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n",
2360 );
2361 } else {
2362 for a in &detected.agents {
2363 s.push_str("[[agents]]\n");
2364 s.push_str(&format!("id = {:?}\n", a.id));
2365 s.push_str(&format!("kind = {:?}\n", a.kind.as_str()));
2366 if let Some(m) = &a.model {
2367 s.push_str(&format!("model = {m:?}\n"));
2368 }
2369 s.push('\n');
2370 }
2371 }
2372 s.push_str(
2373 "# Leave a role list empty to rotate through the roster.\n\
2374 # A non-empty list is also an ordered backup list: seats take entries from the\n\
2375 # front (see [graph] counts) and a failed or out-of-quota seat moves on to the\n\
2376 # next agent to its right, each tried once. Same for judges and reviewers.\n\
2377 # implementers = [\"opus\", \"codex\", \"sonnet\"] # with implementers = 1 below: opus sits, codex then sonnet are spares\n\
2378 [roles]\n\
2379 implementers = []\n\
2380 judges = []\n\
2381 reviewers = []\n\
2382 # conductor = \"opus\" # arranges the queue; unset picks a seat like chatter does\n\
2383 # synthesizer = \"opus\" # blends the advisors into one brief; unset picks a seat like chatter does\n\
2384 # synthesizer = [\"opus\", \"codex\"] # array form: fallback chain, each tried once on quota or failure\n\
2385 # language_judge = \"jev\" # kind = \"command\" wrapper deciding if PR text is English; unset keeps the built-in heuristics\n\
2386 # dupes_judge = \"opus\" # judges duplicate-work claims; unset inherits chatter (string or fallback chain)\n\
2387 # fixer = \"opus\" # applies review findings; unset keeps the winner's own author\n\
2388 # fixer = [\"opus\", \"codex\"] # array form: fallback chain, each tried once on quota or failure\n\n\
2389 [graph]\n\
2390 # Gate GitHub prose; secret redaction is always enabled.\n\
2391 github_text_guard = true\n\
2392 # Language of PR / issue titles, bodies and comments; non-English drops the English-only check (commits stay English).\n\
2393 github_language = \"en\"\n\
2394 implementers = 3\n\
2395 judges = 3\n\
2396 deliberate_rounds = 1\n\
2397 reviewers = 3\n\
2398 review_rounds = 6\n\
2399 # Fix rounds a failing verify.gate gets before the run is blocked (0 = none).\n\
2400 gate_fix_rounds = 1\n\
2401 # Rerun a failed required CI check once before a land fix round (flaky tests).\n\
2402 rerun_flaky_checks = true\n\
2403 # Port the strengths judges named in the losing candidates onto the\n\
2404 # winner before review (best-effort; rolled back on failure or a big diff).\n\
2405 graft_runner_up = true\n\
2406 max_parallel = 4\n\
2407 language = \"en\"\n\
2408 # One CLI conversation per seat: judges keep their own argument\n\
2409 # across deliberation, the fixer keeps its implementation context.\n\
2410 sessions = true\n\
2411 # Reviewer-seat timeout. When timeout_verify is omitted, E2E and\n\
2412 # the final gate inherit this value for compatibility.\n\
2413 timeout_review = 1200\n\
2414 # Optional independent E2E/final-gate timeout; uncomment to keep\n\
2415 # verification independent if timeout_review changes later.\n\
2416 # timeout_verify = 1200\n\
2417 # Longest a seat waits for the shared build cache before it is\n\
2418 # spawned. Never counted against an agent's own timeout; expiry\n\
2419 # holds the run (resumable) instead of failing the agent.\n\
2420 # timeout_resource_wait = 1800\n\n\
2421 # What the chat calls you (persona address and the Chat's turn label).\n\
2422 # [talk]\n\
2423 # operator_name = \"Commander\"\n\n\
2424 # Chat personas (tone only; the web Chat picks one per conversation).\n\
2425 # Built in: magi, rei, misato, ritsuko, shinji, asuka, kaworu. Entries add to\n\
2426 # them, and one with a built-in's id replaces it. `default` is the plain\n\
2427 # voice and cannot be redefined (it may only set `icon`). Declare this in one\n\
2428 # layer only. `icon` is an optional avatar, display only: an absolute or\n\
2429 # ~/ path to a png/jpg/gif/webp, or a data:image/...;base64 URI, up to\n\
2430 # 256 KiB; a missing or bad one falls back to the built-in avatar.\n\
2431 # [[talk.personas]]\n\
2432 # id = \"gendo\"\n\
2433 # name = \"Gendo Ikari\"\n\
2434 # prompt = \"Speak coldly and tersely, hands folded.\"\n\
2435 # icon = \"~/pictures/gendo.png\"\n\n\
2436 [verify]\n\
2437 # Run once per review round in the winner's worktree; failures are\n\
2438 # fed back to the fixer.\n\
2439 e2e = []\n\
2440 # Final gate. Every command must exit 0 before a merge.\n\
2441 gate = []\n\
2442 # Mechanical fixers (formatters) run in the winner's worktree just\n\
2443 # before the gate; whatever they change becomes one commit. A failing\n\
2444 # command only warns - the gate stays the arbiter. Same timeout as the\n\
2445 # gate (timeout_verify). Keep it light and idempotent.\n\
2446 # pre_gate = []\n\n\
2447 # Prepare each worktree magi creates (candidates, judges, advisors,\n\
2448 # reviewers, the operator-fix and review-takeover checkouts), in order,\n\
2449 # before any agent or verify command touches it. `copy` reads from the\n\
2450 # primary checkout and never overwrites; `optional` skips a missing\n\
2451 # source. `run` goes through the shell with the worktree as cwd and\n\
2452 # fails the seat on a non-zero exit or timeout (timeout_secs, default\n\
2453 # the verify timeout). Setup must not edit tracked files, and what it\n\
2454 # leaves untracked is kept out of magi's commits. Declare this in one\n\
2455 # layer only.\n\
2456 # [worktree]\n\
2457 # setup = [\n\
2458 # { copy = \".env.example -> .env\" },\n\
2459 # { copy = \".env.local\", optional = true },\n\
2460 # { run = \"pnpm install --frozen-lockfile\", timeout_secs = 900 },\n\
2461 # ]\n\n\
2462 [merge]\n\
2463 # none | local | pr\n\
2464 mode = \"none\"\n\
2465 # release_bump = true # Rust (Cargo.toml) repositories only; a no-op elsewhere\n\n\
2466 [update]\n\
2467 # off | notify | install — checked in the background, throttled.\n\
2468 mode = \"notify\"\n\
2469 # interval = \"24h\"\n\n\
2470 [daemon]\n\
2471 # Minutes a release-bump pull request (chore/release-v*) may sit with\n\
2472 # no new commit and no check changing state before `magi serve`\n\
2473 # notices and asks you. A failed check is rerun once on its own;\n\
2474 # still red after that escalates at once. 0 turns the watcher off.\n\
2475 # release_stall_minutes = 60\n\n\
2476 [release]\n\
2477 # actions (default) | local. \"local\" is for repositories whose GitHub\n\
2478 # Actions never run (private, no minutes): the release pull request\n\
2479 # does not wait for CI, you approve the merge in magi, and then\n\
2480 # `magi serve` pushes the vX.Y.Z tag (never over an existing one) and\n\
2481 # runs `commands` from a clean checkout of the merge commit. A\n\
2482 # failure holds the release with a notice; it is never retried blind.\n\
2483 # Needs `magi serve` running and [daemon] release_stall_minutes > 0.\n\
2484 # mode = \"actions\"\n\
2485 # Run in order; the first failure stops. They see MAGI_RELEASE_VERSION,\n\
2486 # MAGI_RELEASE_TAG, MAGI_RELEASE_COMMIT and MAGI_RELEASE_PR. Make them\n\
2487 # safe to repeat: a crash mid-command is treated as unfinished.\n\
2488 # commands = [\"cargo build --release\", \"gh release create \\\"$MAGI_RELEASE_TAG\\\" --generate-notes\"]\n\
2489 # timeout_minutes = 30\n\
2490 # Repositories whose Cargo.toml is not at the root (e.g. a Tauri app).\n\
2491 # The first entry is the version of record and must be the file the\n\
2492 # repository's auto-tag.yml reads (kind \"cargo\"); the others are\n\
2493 # rewritten to the same version. Paths are relative to the repository\n\
2494 # root. Cargo.lock beside the first entry follows; package-lock.json\n\
2495 # is not touched.\n\
2496 # version_files = [\n\
2497 # { path = \"src-tauri/Cargo.toml\", kind = \"cargo\" },\n\
2498 # { path = \"package.json\", kind = \"json\" },\n\
2499 # { path = \"src-tauri/tauri.conf.json\", kind = \"json\" },\n\
2500 # ]\n",
2501 );
2502 s
2503 }
2504}
2505
2506pub fn which(program: &str) -> bool {
2508 find_program(program).is_some()
2509}
2510
2511pub fn find_program(program: &str) -> Option<PathBuf> {
2520 find_program_on(program, &std::env::var_os("PATH")?)
2521}
2522
2523pub fn find_program_on(program: &str, paths: &std::ffi::OsStr) -> Option<PathBuf> {
2526 let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
2527 find_program_in(program, paths, cfg!(windows).then_some(pathext.as_str()))
2528}
2529
2530fn find_program_in(
2531 program: &str,
2532 paths: &std::ffi::OsStr,
2533 pathext: Option<&str>,
2534) -> Option<PathBuf> {
2535 let p = Path::new(program);
2536 if p.components().count() > 1 {
2537 return p.is_file().then(|| p.to_path_buf());
2538 }
2539 std::env::split_paths(paths).find_map(|dir| match pathext {
2540 Some(exts) => {
2541 let bare = dir.join(program);
2542 if p.extension().is_some() && bare.is_file() {
2543 return Some(bare);
2544 }
2545 exts.split(';')
2546 .filter(|e| !e.is_empty())
2547 .map(|e| dir.join(format!("{program}{}", e.to_lowercase())))
2548 .find(|c| c.is_file())
2549 }
2550 None => Some(dir.join(program)).filter(|c| c.is_file()),
2551 })
2552}
2553
2554const REPO_MARKER: &str = ".magi-repo";
2556
2557fn git_out(repo: &Path, args: &[&str]) -> Option<String> {
2558 use crate::proc::Quiet as _;
2559 #[cfg(test)]
2560 crate::web::request_cost::git();
2561 let out = std::process::Command::new("git")
2562 .args(args)
2563 .quiet()
2564 .current_dir(repo)
2565 .stdin(std::process::Stdio::null())
2566 .output()
2567 .ok()?;
2568 out.status
2569 .success()
2570 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
2571}
2572
2573fn is_git_worktree(repo: &Path) -> bool {
2576 git_out(repo, &["rev-parse", "--git-dir"]).is_some()
2577}
2578
2579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2582pub enum OwnerHold {
2583 LandApproval,
2585 HoldContestedMerge,
2587}
2588
2589impl OwnerHold {
2590 pub const ALL: [Self; 2] = [Self::LandApproval, Self::HoldContestedMerge];
2592
2593 pub fn key(self) -> &'static str {
2595 match self {
2596 Self::LandApproval => "land_approval",
2597 Self::HoldContestedMerge => "hold_contested_merge",
2598 }
2599 }
2600
2601 pub fn base_value(self, graph: &Graph) -> bool {
2603 match self {
2604 Self::LandApproval => graph.land_approval,
2605 Self::HoldContestedMerge => graph.hold_contested_merge,
2606 }
2607 }
2608}
2609
2610pub async fn branch_owner_holds(
2613 repo: &Path,
2614 head: &str,
2615) -> (Vec<(OwnerHold, String)>, Vec<String>) {
2616 let mut holds = Vec::new();
2617 let mut diagnostics = Vec::new();
2618 if let Err(error) =
2621 crate::git::git(repo, &["cat-file", "-e", &format!("{head}^{{commit}}")]).await
2622 {
2623 diagnostics.push(format!("cannot read reviewed commit {head}: {error:#}"));
2624 return (holds, diagnostics);
2625 }
2626 for file in ["magi.toml", ".magi/config.toml"] {
2627 let object = format!("{head}:{file}");
2628 let text = match crate::git::git(repo, &["show", &object]).await {
2629 Ok(text) => text,
2630 Err(error) => {
2631 let error = format!("{error:#}");
2633 if !error.contains("does not exist in")
2634 && !error.contains("exists on disk, but not in")
2635 {
2636 diagnostics.push(format!(
2637 "cannot read reviewed branch {file} at {head}: {error}"
2638 ));
2639 }
2640 continue;
2641 }
2642 };
2643 let value = match toml::from_str::<toml::Value>(&text) {
2644 Ok(value) => value,
2645 Err(error) => {
2646 diagnostics.push(format!(
2647 "cannot parse reviewed branch {file} at {head}: {error}"
2648 ));
2649 continue;
2650 }
2651 };
2652 if value.get("include").is_some() || text.contains("{{") || text.contains("{%") {
2653 diagnostics.push(format!("reviewed branch {file} at {head}: only literal owner holds are read; includes and templates are ignored"));
2654 }
2655 for hold in OwnerHold::ALL {
2656 if value
2657 .get("graph")
2658 .and_then(|g| g.get(hold.key()))
2659 .and_then(toml::Value::as_bool)
2660 == Some(true)
2661 {
2662 holds.push((hold, file.to_owned()));
2663 }
2664 }
2665 }
2666 (holds, diagnostics)
2667}
2668
2669#[derive(Deserialize, Default)]
2670struct ProbeMerge {
2671 base: Option<String>,
2672 remote: Option<String>,
2673}
2674
2675#[derive(Deserialize, Default)]
2676struct Probe {
2677 merge: Option<ProbeMerge>,
2678}
2679
2680fn probe_merge(path: &Path, repo: &Path) -> Option<ProbeMerge> {
2686 if !path.is_file() {
2687 return None;
2688 }
2689 let paths = [path.to_path_buf()];
2690 let mut engine = teravars::Engine::default();
2691 let mut ctx = Config::render_ctx(&paths);
2692 ctx.insert("repo", &repo.to_string_lossy());
2695 ctx.insert(
2696 "repo_name",
2697 &repo.file_name().unwrap_or_default().to_string_lossy(),
2698 );
2699 if let Ok(merged) = teravars::load_merged(&paths, &mut engine, &ctx)
2700 && let Ok(probe) = toml::Value::Table(merged.config).try_into::<Probe>()
2701 {
2702 return probe.merge;
2703 }
2704 let text = std::fs::read_to_string(path).ok()?;
2705 toml::from_str::<Probe>(&text).ok()?.merge
2706}
2707
2708fn probe_remote_base(repo: &Path) -> (String, Option<String>) {
2710 let mut remote = None;
2711 let mut base = None;
2712 let mut sources: Vec<PathBuf> = Config::machine_layer().into_iter().collect();
2713 sources.push(repo.join("magi.toml"));
2714 sources.push(repo.join(".magi").join("config.toml"));
2715 for path in sources {
2716 if let Some(m) = probe_merge(&path, repo) {
2717 remote = remote.or(m.remote.filter(|s| !s.trim().is_empty()));
2718 base = base.or(m.base.filter(|s| !s.trim().is_empty()));
2719 }
2720 }
2721 (remote.unwrap_or_else(|| "origin".to_owned()), base)
2722}
2723
2724fn resolve_remote_base(repo: &Path) -> Result<(String, String)> {
2726 let (remote, base) = probe_remote_base(repo);
2727 let base = match base {
2728 Some(b) => b,
2729 None => {
2730 let head = format!("refs/remotes/{remote}/HEAD");
2731 git_out(repo, &["symbolic-ref", "--quiet", &head])
2732 .and_then(|s| crate::git::remote_head_branch(&remote, &s))
2733 .with_context(|| {
2734 format!(
2735 "cannot tell which branch of `{remote}` holds magi.toml: \
2736 set [merge] base, or run `git remote set-head {remote} -a`"
2737 )
2738 })?
2739 }
2740 };
2741 Ok((remote, base))
2742}
2743
2744fn fnv_hex(text: &str) -> String {
2745 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2746 for b in text.bytes() {
2747 hash ^= u64::from(b);
2748 hash = hash.wrapping_mul(0x0100_0000_01b3);
2749 }
2750 format!("{hash:016x}")
2751}
2752
2753fn declares_include(text: &str) -> bool {
2759 match toml::from_str::<toml::Table>(text) {
2760 Ok(table) => {
2761 table.contains_key("include")
2762 || table
2763 .get("teravars")
2764 .and_then(toml::Value::as_table)
2765 .is_some_and(|t| t.contains_key("include"))
2766 }
2767 Err(_) => text.contains("include"),
2768 }
2769}
2770
2771fn extract_tree(repo: &Path, sha: &str, dest: &Path) -> Result<()> {
2774 use crate::proc::Quiet as _;
2775 #[cfg(test)]
2776 crate::web::request_cost::git();
2777 let mut archive = std::process::Command::new("git")
2778 .args(["archive", "--format=tar", sha])
2779 .quiet()
2780 .current_dir(repo)
2781 .stdin(std::process::Stdio::null())
2782 .stdout(std::process::Stdio::piped())
2783 .stderr(std::process::Stdio::null())
2784 .spawn()
2785 .context("running git archive")?;
2786 let tar = std::process::Command::new("tar")
2787 .arg("-x")
2788 .arg("-C")
2789 .arg(dest)
2790 .quiet()
2791 .stdin(archive.stdout.take().context("git archive has no stdout")?)
2792 .output()
2793 .context("running tar")?;
2794 let status = archive.wait().context("waiting for git archive")?;
2795 if !status.success() || !tar.status.success() {
2796 bail!(
2797 "cannot unpack {sha} for the config's includes: {}",
2798 String::from_utf8_lossy(&tar.stderr).trim()
2799 );
2800 }
2801 Ok(())
2802}
2803
2804fn remote_layers(repo: &Path) -> Result<Vec<PathBuf>> {
2807 let (remote, base) = resolve_remote_base(repo)?;
2808 let tracking = format!("refs/remotes/{remote}/{base}");
2809 let spec = format!("{tracking}^{{commit}}");
2810 let sha = git_out(repo, &["rev-parse", "--verify", "--quiet", &spec])
2811 .map(|s| s.trim().to_owned())
2812 .filter(|s| !s.is_empty())
2813 .with_context(|| {
2814 format!(
2815 "`{remote}/{base}` does not exist in this checkout; fetch it \
2816 (`git fetch {remote} {base}`) or set [merge] base/remote. magi will not read \
2817 a possibly stale local magi.toml instead"
2818 )
2819 })?;
2820 let key = format!("{sha}-{}", fnv_hex(&repo.to_string_lossy()));
2821 let rels = [".magi/config.toml", "magi.toml"];
2822 let mut texts: Vec<(&str, String)> = Vec::new();
2823 for rel in rels {
2824 let object = format!("{sha}:{rel}");
2825 if git_out(repo, &["cat-file", "-e", &object]).is_none() {
2826 continue;
2827 }
2828 let Some(text) = git_out(repo, &["show", &object]) else {
2829 bail!("cannot read {rel} from {remote}/{base} ({sha})");
2830 };
2831 texts.push((rel, text));
2832 }
2833 let wants_tree = texts.iter().any(|(_, t)| declares_include(t));
2834 let build = |root: &Path| -> Result<PathBuf> {
2838 let final_dir = root.join(&key);
2839 if !final_dir.is_dir() {
2840 std::fs::create_dir_all(root)?;
2841 let tmp = root.join(format!(
2842 "{key}.tmp-{}-{}",
2843 std::process::id(),
2844 std::time::SystemTime::now()
2845 .duration_since(std::time::UNIX_EPOCH)
2846 .map(|d| d.as_nanos())
2847 .unwrap_or_default()
2848 ));
2849 let filled = (|| -> Result<()> {
2850 std::fs::create_dir_all(&tmp)?;
2851 if wants_tree {
2852 extract_tree(repo, &sha, &tmp)?;
2853 }
2854 for (rel, text) in &texts {
2855 let file = tmp.join(rel);
2856 let parent = file.parent().unwrap_or(&tmp);
2857 std::fs::create_dir_all(parent)?;
2858 std::fs::write(&file, text)?;
2859 std::fs::write(parent.join(REPO_MARKER), repo.to_string_lossy().as_bytes())?;
2860 }
2861 Ok(())
2862 })();
2863 if let Err(e) = filled {
2864 let _ = std::fs::remove_dir_all(&tmp);
2865 return Err(e);
2866 }
2867 if std::fs::rename(&tmp, &final_dir).is_err() {
2868 let _ = std::fs::remove_dir_all(&tmp);
2870 if !final_dir.is_dir() {
2871 bail!("cannot place {}", final_dir.display());
2872 }
2873 }
2874 }
2875 Ok(final_dir)
2876 };
2877 let common = git_out(
2878 repo,
2879 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
2880 )
2881 .map(|s| PathBuf::from(s.trim()).join("magi").join("config"));
2882 let dir = match common.map(|c| build(&c)) {
2883 Some(Ok(d)) => d,
2884 _ => build(&std::env::temp_dir().join("magi-config"))
2885 .with_context(|| format!("extracting config from {remote}/{base}"))?,
2886 };
2887 Ok(texts.iter().map(|(rel, _)| dir.join(rel)).collect())
2888}
2889
2890#[cfg(test)]
2891mod tests {
2892 #[test]
2893 fn coordination_defaults_off_and_validates_rendered_layer_values() {
2894 let dir = tempfile::tempdir().unwrap();
2895 let machine = dir.path().join("machine.toml");
2896 let repo = dir.path().join("repo.toml");
2897 std::fs::write(&machine, "").unwrap();
2898 assert!(Config::default().coordination.is_none());
2899 assert!(Config::load(&machine).unwrap().coordination.is_none());
2900 std::fs::write(
2901 &machine,
2902 r#"
2903[vars]
2904contract = "updater.contract"
2905[coordination]
2906contract_file = "{{ vars.contract }}"
2907contract_token = "protocol-v1"
2908lock_file = "updater.lock"
2909"#,
2910 )
2911 .unwrap();
2912 let cfg = Config::load(&machine).unwrap();
2913 let policy = cfg.coordination.unwrap();
2914 assert_eq!(policy.contract_file, "updater.contract");
2915 assert_eq!(policy.contract_token, "protocol-v1");
2916 assert_eq!(policy.lock_file, "updater.lock");
2917 std::fs::write(&repo, "[coordination]\nlock_file = 'repository.lock'\n").unwrap();
2918 let cfg = Config::load_layers(&[machine.clone(), repo.clone()]).unwrap();
2919 let policy = cfg.coordination.unwrap();
2920 assert_eq!(policy.contract_file, "updater.contract");
2921 assert_eq!(policy.lock_file, "repository.lock");
2922 std::fs::write(
2923 &repo,
2924 r#"
2925[vars]
2926bad = "../escape"
2927[coordination]
2928lock_file = "{{ vars.bad }}"
2929"#,
2930 )
2931 .unwrap();
2932 let error = Config::load_layers(&[machine, repo]).unwrap_err();
2933 assert!(format!("{error:#}").contains("coordination.lock_file"));
2934 }
2935
2936 #[test]
2937 fn coordination_rejects_invalid_names_tokens_and_incomplete_sections() {
2938 let dir = tempfile::tempdir().unwrap();
2939 let path = dir.path().join("config.toml");
2940 let valid = toml::toml! {
2941 [coordination]
2942 contract_file = "updater.contract"
2943 contract_token = "protocol-v1"
2944 lock_file = "updater.lock"
2945 };
2946 for key in ["contract_file", "lock_file"] {
2947 for bad in [
2948 "",
2949 " ",
2950 " file",
2951 "file ",
2952 "\tfile",
2953 "file\t",
2954 ".",
2955 "..",
2956 "file..name",
2957 "../escape",
2958 "a/b",
2959 "a\\b",
2960 "/absolute",
2961 "C:drive",
2962 "C:\\absolute",
2963 "nul\0name",
2964 ] {
2965 let mut table = valid.clone();
2966 table["coordination"][key] = bad.into();
2967 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
2968 let error = Config::load(&path).unwrap_err();
2969 assert!(
2970 format!("{error:#}").contains(&format!("coordination.{key}")),
2971 "{bad:?}: {error:#}"
2972 );
2973 }
2974 }
2975 for bad in ["", " ", " token", "token ", "two\nlines", "two\rlines"] {
2976 let mut table = valid.clone();
2977 table["coordination"]["contract_token"] = bad.into();
2978 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
2979 let error = Config::load(&path).unwrap_err();
2980 assert!(
2981 format!("{error:#}").contains("coordination.contract_token"),
2982 "{error:#}"
2983 );
2984 }
2985 for key in ["contract_file", "contract_token", "lock_file"] {
2986 let mut table = valid.clone();
2987 table["coordination"].as_table_mut().unwrap().remove(key);
2988 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
2989 let error = Config::load(&path).unwrap_err();
2990 assert!(
2991 format!("{error:#}").contains(&format!("coordination.{key}")),
2992 "{error:#}"
2993 );
2994 }
2995 let mut table = valid;
2996 table["coordination"]
2997 .as_table_mut()
2998 .unwrap()
2999 .insert("typo".into(), "value".into());
3000 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
3001 assert!(format!("{:#}", Config::load(&path).unwrap_err()).contains("typo"));
3002 }
3003
3004 #[test]
3005 fn context_window_prefers_exact_then_longest_prefix_and_user_over_builtin() {
3006 let mut cfg = Config::default();
3007 assert_eq!(
3008 cfg.context_window("claude-sonnet-4-5-20250929"),
3009 Some(200_000)
3010 );
3011 assert_eq!(cfg.context_window("gpt-4.1-mini"), Some(1_000_000));
3012 assert_eq!(cfg.context_window("mystery-model"), None);
3013 cfg.context_windows
3014 .insert("claude-sonnet-4-5".into(), 1_000_000);
3015 cfg.context_windows.insert("claude-sonnet-4".into(), 5);
3016 cfg.context_windows.insert("mystery".into(), 32_768);
3017 assert_eq!(
3019 cfg.context_window("claude-sonnet-4-5-20250929"),
3020 Some(1_000_000)
3021 );
3022 cfg.context_windows
3024 .insert("claude-sonnet-4-5-20250929".into(), 7);
3025 assert_eq!(cfg.context_window("claude-sonnet-4-5-20250929"), Some(7));
3026 assert_eq!(cfg.context_window("mystery-model"), Some(32_768));
3027 }
3028
3029 use super::*;
3030
3031 #[test]
3034 fn windows_lookup_picks_the_cmd_shim_over_the_extensionless_script() {
3035 let dir = tempfile::tempdir().unwrap();
3036 std::fs::write(dir.path().join("codex"), "#!/bin/sh\n").unwrap();
3037 std::fs::write(dir.path().join("codex.cmd"), "@echo off\n").unwrap();
3038 let paths = dir.path().as_os_str();
3039 assert_eq!(
3040 find_program_in("codex", paths, Some(".EXE;.CMD")),
3041 Some(dir.path().join("codex.cmd"))
3042 );
3043 assert_eq!(
3044 find_program_in("codex", paths, None),
3045 Some(dir.path().join("codex"))
3046 );
3047 assert_eq!(find_program_in("claude", paths, Some(".EXE;.CMD")), None);
3048 }
3049
3050 fn spec(id: &str) -> AgentSpec {
3051 AgentSpec {
3052 id: id.to_owned(),
3053 kind: AgentKind::Command,
3054 model: None,
3055 command: vec!["true".to_owned()],
3056 extra_args: Vec::new(),
3057 env: BTreeMap::new(),
3058 prompt_delivery: None,
3059 }
3060 }
3061
3062 #[test]
3063 fn github_text_guard_defaults_on_and_can_be_disabled() {
3064 let default: Graph = toml::from_str("").unwrap();
3065 assert!(default.github_text_guard);
3066 let disabled: Graph = toml::from_str("github_text_guard = false").unwrap();
3067 assert!(!disabled.github_text_guard);
3068 let config = layered("", "[graph]\ngithub_text_guard = false\n").unwrap();
3069 assert!(!config.graph.github_text_guard);
3070 assert!(Config::starter_toml().contains("github_text_guard = true"));
3071 }
3072
3073 #[test]
3074 fn rerun_flaky_checks_defaults_on_and_is_in_the_starter() {
3075 assert!(Graph::default().rerun_flaky_checks);
3076 let off: Graph = toml::from_str("rerun_flaky_checks = false").unwrap();
3077 assert!(!off.rerun_flaky_checks);
3078 assert!(Config::starter_toml().contains("rerun_flaky_checks = true"));
3079 }
3080
3081 #[test]
3082 fn timeout_verify_omitted_from_old_toml_inherits_timeout_review() {
3083 let g: Graph = toml::from_str("timeout_review = 3600").expect("parse");
3088 assert_eq!(g.timeout_verify, None);
3089 assert_eq!(g.verify_timeout(), 3600);
3090 }
3091
3092 #[test]
3093 fn shrinking_timeout_review_does_not_shrink_timeout_verify() {
3094 let g: Graph = toml::from_str("timeout_review = 45").expect("parse");
3099 assert_eq!(g.timeout_review, 45);
3100 assert_eq!(
3101 g.verify_timeout(),
3102 45,
3103 "an omitted legacy value follows review"
3104 );
3105 let explicit: Graph = toml::from_str("timeout_review = 45\ntimeout_verify = 1200")
3106 .expect("parse explicit override");
3107 assert_eq!(explicit.verify_timeout(), 1200);
3108 }
3109
3110 #[test]
3111 fn graph_implementers_new_key_loads_and_old_key_is_an_alias() {
3112 let g: Graph = toml::from_str("implementers = 4\n").unwrap();
3113 assert_eq!(g.implementers, 4);
3114 let g: Graph = toml::from_str("candidates = 5\n").unwrap();
3115 assert_eq!(g.implementers, 5);
3116 }
3117
3118 fn layered(lower: &str, upper: &str) -> Result<Config> {
3119 let dir = tempfile::tempdir().unwrap();
3120 let a = dir.path().join("a.toml");
3121 let b = dir.path().join("b.toml");
3122 std::fs::write(&a, lower).unwrap();
3123 std::fs::write(&b, upper).unwrap();
3124 Config::load_layers(&[a, b])
3125 }
3126
3127 #[test]
3128 fn graph_implementers_higher_layer_wins_across_spellings() {
3129 let c = layered("[graph]\ncandidates = 2\n", "[graph]\nimplementers = 5\n").unwrap();
3130 assert_eq!(c.graph.implementers, 5);
3131 let c = layered("[graph]\nimplementers = 2\n", "[graph]\ncandidates = 5\n").unwrap();
3132 assert_eq!(c.graph.implementers, 5);
3133 let c = layered("[graph]\ncandidates = 2\n", "[graph]\njudges = 3\n").unwrap();
3134 assert_eq!(c.graph.implementers, 2);
3135 }
3136
3137 #[test]
3138 fn graph_implementers_both_keys_in_one_layer_is_refused() {
3139 let err = layered("", "[graph]\ncandidates = 2\nimplementers = 3\n")
3140 .unwrap_err()
3141 .to_string();
3142 assert!(
3143 err.contains("candidates") && err.contains("implementers"),
3144 "{err}"
3145 );
3146 assert!(err.contains("b.toml"), "{err}");
3147 }
3148
3149 #[test]
3150 fn a_toml_layer_written_before_these_fields_existed_still_parses() {
3151 let g: Graph =
3155 toml::from_str("candidates = 1\nreviewers = 3\nreview_rounds = 6\nmax_parallel = 4\n")
3156 .expect("an old-shaped [graph] table must still parse");
3157 assert_eq!(g.verify_timeout(), Graph::default().timeout_review);
3158 assert!(
3159 !g.e2e_every_round,
3160 "off by default, same as before this field existed"
3161 );
3162 }
3163
3164 #[test]
3165 fn empty_roles_rotate_judges_off_their_own_candidate() {
3166 let cfg = Config {
3169 agents: vec![spec("a"), spec("b"), spec("c")],
3170 graph: Graph {
3171 implementers: 3,
3172 ..Graph::default()
3173 },
3174 ..Config::default()
3175 };
3176 let roles = cfg.resolve_roles().unwrap();
3177 let impls: Vec<&str> = roles.implementers.iter().map(|a| a.id.as_str()).collect();
3178 let judges: Vec<&str> = roles.judges.iter().map(|a| a.id.as_str()).collect();
3179 assert_eq!(impls, ["a", "b", "c"]);
3180 assert_eq!(judges, ["b", "c", "a"]);
3181 for (i, j) in judges.iter().enumerate() {
3182 assert_ne!(*j, impls[i], "judge {i} must not sit on its own candidate");
3183 }
3184 }
3185
3186 #[test]
3187 fn single_agent_roster_fills_every_seat() {
3188 let cfg = Config {
3189 agents: vec![spec("solo")],
3190 graph: Graph {
3191 implementers: 3,
3192 ..Graph::default()
3193 },
3194 ..Config::default()
3195 };
3196 let roles = cfg.resolve_roles().unwrap();
3197 assert_eq!(roles.implementers.len(), 3);
3198 assert!(roles.judges.iter().all(|a| a.id == "solo"));
3199 }
3200
3201 #[test]
3202 fn implementer_roster_is_the_whole_agent_list_untruncated_when_unset() {
3203 let cfg = Config {
3208 agents: vec![spec("a"), spec("b"), spec("c")],
3209 graph: Graph {
3210 implementers: 1,
3211 ..Graph::default()
3212 },
3213 ..Config::default()
3214 };
3215 let roles = cfg.resolve_roles().unwrap();
3216 assert_eq!(roles.implementers.len(), 1);
3217 let roster: Vec<&str> = roles
3218 .implementer_roster
3219 .iter()
3220 .map(|a| a.id.as_str())
3221 .collect();
3222 assert_eq!(roster, ["a", "b", "c"]);
3223 }
3224
3225 #[test]
3226 fn implementer_roster_follows_explicit_ids_in_the_order_named() {
3227 let cfg = Config {
3228 agents: vec![spec("a"), spec("b"), spec("c")],
3229 roles: Roles {
3230 implementers: vec!["c".to_owned(), "a".to_owned()],
3231 ..Roles::default()
3232 },
3233 graph: Graph {
3234 implementers: 1,
3235 ..Graph::default()
3236 },
3237 ..Config::default()
3238 };
3239 let roles = cfg.resolve_roles().unwrap();
3240 assert_eq!(roles.implementers.len(), 1);
3242 assert_eq!(roles.implementers[0].id, "c");
3243 let roster: Vec<&str> = roles
3244 .implementer_roster
3245 .iter()
3246 .map(|a| a.id.as_str())
3247 .collect();
3248 assert_eq!(roster, ["c", "a"]);
3249 }
3250
3251 #[test]
3252 fn explicit_roles_win() {
3253 let cfg = Config {
3254 agents: vec![spec("a"), spec("b")],
3255 roles: Roles {
3256 implementers: vec!["b".to_owned()],
3257 judges: vec!["a".to_owned()],
3258 reviewers: Vec::new(),
3259 fixer: Some("a".into()),
3260 ..Roles::default()
3261 },
3262 ..Config::default()
3263 };
3264 let roles = cfg.resolve_roles().unwrap();
3265 assert!(roles.implementers.iter().all(|a| a.id == "b"));
3266 assert!(roles.judges.iter().all(|a| a.id == "a"));
3267 let fixer = roles.fixer.unwrap();
3268 assert_eq!(fixer.len(), 1);
3269 assert_eq!(fixer[0].id, "a");
3270 assert_eq!(roles.conductor.id, "a");
3271 }
3272
3273 #[test]
3274 fn fixer_takes_a_string_or_an_ordered_chain() {
3275 let one: Roles = toml::from_str("fixer = \"a\"").unwrap();
3276 assert_eq!(one.fixer.as_ref().unwrap().ids(), ["a"]);
3277 let chain: Roles = toml::from_str("fixer = [\"b\", \"a\"]").unwrap();
3278 assert_eq!(chain.fixer.as_ref().unwrap().ids(), ["b", "a"]);
3279
3280 let resolve = |fixer: Option<AgentChoice>| {
3281 Config {
3282 agents: vec![spec("a"), spec("b"), spec("c")],
3283 roles: Roles {
3284 fixer,
3285 ..Roles::default()
3286 },
3287 ..Config::default()
3288 }
3289 .resolve_roles()
3290 };
3291 let ids = |r: &ResolvedRoles| -> Vec<String> {
3292 r.fixer
3293 .as_ref()
3294 .unwrap()
3295 .iter()
3296 .map(|s| s.id.clone())
3297 .collect()
3298 };
3299 assert_eq!(ids(&resolve(Some("c".into())).unwrap()), ["c"]);
3301 assert!(resolve(None).unwrap().fixer.is_none());
3302 let chain = AgentChoice::Chain(vec!["b".into(), "zzz".into(), "a".into(), "b".into()]);
3304 assert_eq!(ids(&resolve(Some(chain)).unwrap()), ["b", "a"]);
3305 let err = resolve(Some(AgentChoice::Chain(vec!["zzz".into()]))).unwrap_err();
3307 assert!(format!("{err:#}").contains("fixer"), "{err:#}");
3308 }
3309
3310 #[test]
3311 fn unknown_agent_id_is_an_error() {
3312 let cfg = Config {
3313 agents: vec![spec("a")],
3314 roles: Roles {
3315 judges: vec!["nope".to_owned()],
3316 ..Roles::default()
3317 },
3318 ..Config::default()
3319 };
3320 assert!(cfg.resolve_roles().is_err());
3321 }
3322
3323 #[test]
3324 fn conductor_role_is_resolved_validated_and_has_a_fallback() {
3325 let mut cfg = Config {
3326 agents: vec![spec("a"), spec("b")],
3327 ..Config::default()
3328 };
3329 assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "a");
3330
3331 cfg.roles.conductor = Some("b".into());
3332 assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "b");
3333
3334 cfg.roles.conductor = Some("missing".into());
3335 assert!(cfg.resolve_roles().is_err());
3336 }
3337
3338 #[test]
3339 fn a_standalone_role_takes_a_string_or_an_array_and_round_trips() {
3340 let one: Roles = toml::from_str("synthesizer = \"opus\"").unwrap();
3341 assert_eq!(one.synthesizer, Some(AgentChoice::One("opus".to_owned())));
3342 assert_eq!(primary(one.synthesizer.as_ref()), Some("opus"));
3343
3344 let chain: Roles = toml::from_str("chatter = [\"opus\", \"codex\"]").unwrap();
3345 assert_eq!(chain.chatter.as_ref().unwrap().ids(), ["opus", "codex"]);
3346 assert_eq!(primary(chain.chatter.as_ref()), Some("opus"));
3347
3348 for roles in [one, chain] {
3349 let text = toml::to_string(&roles).unwrap();
3350 let back: Roles = toml::from_str(&text).unwrap();
3351 assert_eq!(back.synthesizer, roles.synthesizer);
3352 assert_eq!(back.chatter, roles.chatter);
3353 }
3354 let text = toml::to_string(&Roles {
3355 conductor: Some("x".into()),
3356 ..Roles::default()
3357 })
3358 .unwrap();
3359 assert!(
3360 text.contains("conductor = \"x\""),
3361 "string stays a string: {text}"
3362 );
3363 assert!(toml::from_str::<Roles>("synthesizer = 3").is_err());
3364 }
3365
3366 #[test]
3367 fn dupes_judge_role_accepts_strings_and_chains_and_round_trips() {
3368 for (text, ids) in [
3369 (r#"dupes_judge = "dedicated""#, vec!["dedicated"]),
3370 (
3371 r#"dupes_judge = ["first", "second"]"#,
3372 vec!["first", "second"],
3373 ),
3374 ("dupes_judge = []", vec![]),
3375 ] {
3376 let roles: Roles = toml::from_str(text).unwrap();
3377 assert_eq!(roles.dupes_judge.as_ref().unwrap().ids(), ids);
3378 let serialized = toml::to_string(&roles).unwrap();
3379 let back: Roles = toml::from_str(&serialized).unwrap();
3380 assert_eq!(back.dupes_judge, roles.dupes_judge);
3381 assert!(serialized.contains(text));
3382 }
3383 assert!(toml::from_str::<Roles>("dupes_judge = 3").is_err());
3384 assert!(Roles::default().dupes_judge.is_none());
3385 assert!(Config::starter_toml().contains("# dupes_judge ="));
3386
3387 let mut cfg = Config {
3389 agents: vec![spec("a")],
3390 ..Config::default()
3391 };
3392 cfg.roles.dupes_judge = Some("missing".into());
3393 assert!(cfg.resolve_roles().is_ok());
3394 }
3395
3396 #[test]
3397 fn an_empty_choice_counts_as_unset() {
3398 assert!(AgentChoice::Chain(Vec::new()).ids().is_empty());
3399 assert!(AgentChoice::One(" ".to_owned()).ids().is_empty());
3400 assert_eq!(primary(Some(&AgentChoice::Chain(vec![]))), None);
3401 }
3402
3403 #[test]
3404 fn synthesizer_role_is_a_lazy_lookup_with_the_same_fallback_as_chatter() {
3405 let mut cfg = Config {
3411 agents: vec![spec("a"), spec("b")],
3412 ..Config::default()
3413 };
3414 let want = primary(cfg.roles.synthesizer.as_ref());
3415 assert_eq!(
3416 crate::agent::pick(&cfg.agents, want, &crate::agent::installed)
3417 .unwrap()
3418 .id,
3419 "a",
3420 "unset falls back to agent::pick's own default order"
3421 );
3422
3423 cfg.roles.synthesizer = Some("b".into());
3424 let want = primary(cfg.roles.synthesizer.as_ref());
3425 assert_eq!(
3426 crate::agent::pick(&cfg.agents, want, &crate::agent::installed)
3427 .unwrap()
3428 .id,
3429 "b",
3430 "the named agent wins over the default order"
3431 );
3432 }
3433
3434 #[test]
3435 fn empty_roster_is_an_error() {
3436 assert!(Config::default().resolve_roles().is_err());
3437 }
3438
3439 #[test]
3440 fn advise_defaults_to_on_with_three_proposals() {
3441 let g = Graph::default();
3442 assert!(g.advise);
3443 assert!(g.graft_runner_up, "graft is on by default");
3444 assert_eq!(g.advisors, 3);
3445 }
3446
3447 #[test]
3448 fn unset_advisors_falls_back_to_the_judge_roster() {
3449 let cfg = Config {
3450 agents: vec![spec("a"), spec("b")],
3451 roles: Roles {
3452 judges: vec!["b".to_owned()],
3453 ..Roles::default()
3454 },
3455 graph: Graph {
3456 advisors: 2,
3457 ..Graph::default()
3458 },
3459 ..Config::default()
3460 };
3461 let advisors = cfg.advisors().expect("advisors resolve");
3462 assert_eq!(advisors.len(), 2);
3463 assert!(
3464 advisors.iter().all(|a| a.id == "b"),
3465 "an unset [roles] advisors must fall back to [roles] judges: {advisors:?}"
3466 );
3467 }
3468
3469 #[test]
3470 fn an_explicit_advisor_roster_wins_over_the_judge_fallback() {
3471 let cfg = Config {
3472 agents: vec![spec("a"), spec("b")],
3473 roles: Roles {
3474 judges: vec!["b".to_owned()],
3475 advisors: vec!["a".to_owned()],
3476 ..Roles::default()
3477 },
3478 graph: Graph {
3479 advisors: 2,
3480 ..Graph::default()
3481 },
3482 ..Config::default()
3483 };
3484 let advisors = cfg.advisors().expect("advisors resolve");
3485 assert!(advisors.iter().all(|a| a.id == "a"));
3486 }
3487
3488 #[test]
3494 fn an_unconfigured_advisor_and_judge_roster_resolve_to_the_same_agents() {
3495 let cfg = Config {
3496 agents: vec![spec("a"), spec("b"), spec("c")],
3497 graph: Graph {
3498 advisors: 3,
3499 judges: 3,
3500 ..Graph::default()
3501 },
3502 ..Config::default()
3503 };
3504 let advisors = cfg.advisors().expect("advisors resolve");
3505 let judges = cfg.resolve_roles().expect("roles resolve").judges;
3506 let advisor_ids: Vec<&str> = advisors.iter().map(|a| a.id.as_str()).collect();
3507 let judge_ids: Vec<&str> = judges.iter().map(|a| a.id.as_str()).collect();
3508 assert_eq!(
3509 advisor_ids, judge_ids,
3510 "an unconfigured advisor roster must be the same seats an unconfigured judge panel gets"
3511 );
3512 }
3513
3514 #[test]
3515 fn an_unresolvable_advisor_seat_is_an_error_naming_the_id() {
3516 let cfg = Config {
3517 agents: vec![spec("a")],
3518 roles: Roles {
3519 advisors: vec!["nope".to_owned()],
3520 ..Roles::default()
3521 },
3522 graph: Graph {
3523 advisors: 1,
3524 ..Graph::default()
3525 },
3526 ..Config::default()
3527 };
3528 let err = cfg.advisors().expect_err("`nope` is not in the roster");
3529 assert!(format!("{err:#}").contains("nope"));
3530 }
3531
3532 #[test]
3533 fn repos_default_to_no_roots_and_a_day_of_trust() {
3534 assert_eq!(Config::default().repos.roots, Vec::<PathBuf>::new());
3535 assert_eq!(Config::default().repos.scan_ttl, 86_400);
3536 assert_eq!(Config::default().repos.fetch_interval, 600);
3537 }
3538
3539 #[test]
3540 fn a_config_file_with_no_repos_table_still_loads() {
3541 let dir = tempfile::tempdir().unwrap();
3542 let path = dir.path().join("magi.toml");
3543 std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
3544 let cfg = Config::load(&path).expect("must load without [repos]");
3545 assert_eq!(cfg.repos.roots, Vec::<PathBuf>::new());
3546 assert_eq!(cfg.repos.scan_ttl, 86_400);
3547 }
3548
3549 #[test]
3564 fn a_test_build_does_not_read_the_operators_machine_config() {
3565 let repo = tempfile::tempdir().unwrap();
3566 std::fs::write(repo.path().join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
3567
3568 let layers = Config::layers(repo.path());
3569 assert_eq!(
3570 layers,
3571 vec![repo.path().join("magi.toml")],
3572 "only the fixture's own file may be a layer"
3573 );
3574 if let Some(real) = dirs::config_dir() {
3575 let machine = real.join("magi").join("config.toml");
3576 assert!(
3577 !layers.contains(&machine),
3578 "the operator's {} must not be a layer in a test build",
3579 machine.display()
3580 );
3581 }
3582 }
3583
3584 #[test]
3585 fn starter_toml_loads_through_teravars() {
3586 let dir = tempfile::tempdir().unwrap();
3587 let path = dir.path().join("magi.toml");
3588 std::fs::write(&path, Config::starter_toml()).unwrap();
3589 let parsed = Config::load(&path).expect("starter config must load");
3590 assert_eq!(parsed.graph.implementers, 3);
3591 assert_eq!(parsed.merge.mode, MergeMode::None);
3592 assert_eq!(parsed.merge.style, MergeStyle::Merge);
3593 assert!(parsed.graph.sessions);
3594 assert!(parsed.graph.github_text_guard);
3595 assert_eq!(parsed.graph.timeout_review, 1200);
3596 assert_eq!(parsed.graph.verify_timeout(), 1200);
3597 assert_eq!(parsed.update.mode, UpdateMode::Notify);
3598 }
3599
3600 #[test]
3601 fn release_defaults_to_actions_and_the_starter_documents_it() {
3602 let dir = tempfile::tempdir().unwrap();
3603 let path = dir.path().join("magi.toml");
3604 std::fs::write(&path, Config::starter_toml()).unwrap();
3605 let r = Config::load(&path).unwrap().release;
3606 assert_eq!(r.mode, ReleaseMode::Actions);
3607 assert!(!r.is_local());
3608 assert!(r.commands.is_empty());
3609 assert_eq!(r.timeout_minutes, 30);
3610 assert!(Config::starter_toml().contains("# mode = \"actions\""));
3611 }
3612
3613 #[test]
3614 fn release_local_parses_and_unknown_keys_are_refused() {
3615 let dir = tempfile::tempdir().unwrap();
3616 let path = dir.path().join("magi.toml");
3617 std::fs::write(
3618 &path,
3619 "[release]\nmode = \"local\"\ncommands = [\"true\", \"echo $MAGI_RELEASE_TAG\"]\ntimeout_minutes = 5\n",
3620 )
3621 .unwrap();
3622 let r = Config::load(&path).unwrap().release;
3623 assert!(r.is_local());
3624 assert_eq!(r.commands.len(), 2);
3625 assert_eq!(r.timeout_minutes, 5);
3626 std::fs::write(&path, "[release]\nmode = \"local\"\nbogus = 1\n").unwrap();
3627 assert!(Config::load(&path).is_err());
3628 std::fs::write(&path, "[release]\nmode = \"gha\"\n").unwrap();
3629 assert!(Config::load(&path).is_err());
3630 }
3631
3632 #[test]
3633 fn pre_gate_defaults_to_empty_and_the_starter_documents_it() {
3634 let dir = tempfile::tempdir().unwrap();
3635 let path = dir.path().join("magi.toml");
3636 std::fs::write(&path, Config::starter_toml()).unwrap();
3637 assert!(Config::load(&path).unwrap().verify.pre_gate.is_empty());
3638 assert!(Config::starter_toml().contains("# pre_gate = []"));
3639 }
3640
3641 #[test]
3642 fn release_version_files_parse_default_empty_and_are_validated() {
3643 let dir = tempfile::tempdir().unwrap();
3644 let path = dir.path().join("magi.toml");
3645 std::fs::write(&path, "").unwrap();
3646 assert!(
3647 Config::load(&path)
3648 .unwrap()
3649 .release
3650 .version_files
3651 .is_empty()
3652 );
3653 std::fs::write(
3654 &path,
3655 "[release]\nversion_files = [\n{ path = \"src-tauri/Cargo.toml\", kind = \"cargo\" },\n{ path = \"package.json\", kind = \"json\" },\n]\n",
3656 )
3657 .unwrap();
3658 let files = Config::load(&path).unwrap().release.version_files;
3659 assert_eq!(files.len(), 2);
3660 assert_eq!(files[1].kind, VersionKind::Json);
3661 for bad in [
3662 "{ path = \"package.json\", kind = \"json\" }",
3663 "{ path = \"../Cargo.toml\", kind = \"cargo\" }",
3664 "{ path = \"Cargo.toml\", kind = \"cargo\", extra = 1 }",
3665 "{ path = \"Cargo.toml\", kind = \"yaml\" }",
3666 ] {
3667 std::fs::write(&path, format!("[release]\nversion_files = [{bad}]\n")).unwrap();
3668 assert!(Config::load(&path).is_err(), "{bad}");
3669 }
3670 assert!(Config::starter_toml().contains("# version_files = ["));
3671 }
3672
3673 #[test]
3674 fn starter_toml_explains_inherited_and_explicit_verify_timeouts() {
3675 let starter = Config::starter_toml();
3676 assert!(starter.contains("When timeout_verify is omitted, E2E and"));
3677 assert!(starter.contains("verification independent if timeout_review changes later"));
3678 assert!(starter.contains("# timeout_verify = 1200"));
3679 }
3680
3681 #[test]
3682 fn starter_toml_documents_chat_personas() {
3683 let starter = Config::starter_toml();
3684 assert!(starter.contains("# [[talk.personas]]"));
3685 assert!(starter.contains("tone only"));
3686 }
3687
3688 #[test]
3689 fn talk_personas_parse_and_are_validated() {
3690 let dir = tempfile::tempdir().unwrap();
3691 let path = dir.path().join("magi.toml");
3692 std::fs::write(
3693 &path,
3694 "[[talk.personas]]\nid = \"rei\"\nname = \"Quiet\"\nprompt = \"Be brief.\"\n\n\
3695 [[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n",
3696 )
3697 .unwrap();
3698 let cfg = Config::load(&path).expect("loads");
3699 assert_eq!(cfg.talk.personas.len(), 2);
3700 let catalog = crate::persona::catalog(&cfg.talk.personas);
3701 let rei = catalog.iter().find(|p| p.id == "rei").unwrap();
3702 assert_eq!(
3703 (rei.name.as_str(), rei.prompt.as_str()),
3704 ("Quiet", "Be brief.")
3705 );
3706 assert_eq!(catalog.last().unwrap().id, "gendo");
3707
3708 for (body, needle) in [
3709 (
3710 "[[talk.personas]]\nid = \"\"\nname = \"n\"\nprompt = \"p\"\n",
3711 "empty `id`",
3712 ),
3713 (
3714 "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \" \"\n",
3715 "empty `prompt`",
3716 ),
3717 (
3718 "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \"p\"\n\
3719 [[talk.personas]]\nid = \"a\"\nname = \"m\"\nprompt = \"q\"\n",
3720 "more than once",
3721 ),
3722 (
3723 "[[talk.personas]]\nid = \"default\"\nname = \"n\"\nprompt = \"p\"\n",
3724 "cannot be redefined",
3725 ),
3726 ] {
3727 std::fs::write(&path, body).unwrap();
3728 let err = format!("{:#}", Config::load(&path).unwrap_err());
3729 assert!(err.contains(needle), "{needle}: {err}");
3730 }
3731 }
3732
3733 #[test]
3734 fn talk_operator_name_is_trimmed_and_validated() {
3735 let dir = tempfile::tempdir().unwrap();
3736 let path = dir.path().join("magi.toml");
3737 let load = |body: &str| {
3738 std::fs::write(&path, body).unwrap();
3739 Config::load(&path)
3740 };
3741 let ok = load("[talk]\noperator_name = \" Commander \"\n").expect("ok");
3742 assert_eq!(ok.talk.operator_name(), Some("Commander"));
3743 let blank = load("[talk]\noperator_name = \" \"\n").expect("blank");
3744 assert_eq!(blank.talk.operator_name(), None);
3745 assert!(load("[talk]\noperator_name = \"a\\nb\"\n").is_err());
3746 let long = "x".repeat(65);
3747 assert!(load(&format!("[talk]\noperator_name = \"{long}\"\n")).is_err());
3748 }
3749
3750 #[test]
3751 fn talk_persona_icon_parses_and_a_bad_shape_keeps_config_usable() {
3752 let dir = tempfile::tempdir().unwrap();
3753 let load = |body: &str| {
3754 let path = dir.path().join("c.toml");
3755 std::fs::write(&path, body).unwrap();
3756 Config::load(&path)
3757 };
3758 let ok = load(
3759 "[[talk.personas]]\nid = \"rei\"\nicon = \"~/rei.png\"\n\n\
3760 [[talk.personas]]\nid = \"default\"\nicon = \"data:image/png;base64,AAAA\"\n",
3761 )
3762 .unwrap();
3763 let c = crate::persona::catalog(&ok.talk.personas);
3764 assert_eq!(c[0].icon.as_deref(), Some("data:image/png;base64,AAAA"));
3765 assert_eq!(c[2].icon.as_deref(), Some("~/rei.png"));
3766 assert!(c[2].prompt.contains("Rei"), "built-in voice kept");
3767 let oversized = format!(
3768 "data:image/png;base64,{}",
3769 "A".repeat(crate::persona_icon::ICON_MAX_BYTES)
3770 );
3771 for bad in ["", "rei.png", "/a/b.svg", "/a/../b.png", &oversized] {
3772 for (id, fields) in [
3773 ("rei", ""),
3774 ("default", ""),
3775 ("custom", "name = \"Custom\"\nprompt = \"Keep calm\"\n"),
3776 ] {
3777 let body = format!("[[talk.personas]]\nid = \"{id}\"\nicon = \"{bad}\"\n{fields}");
3778 let cfg = load(&body).expect("bad icon must not invalidate config");
3779 let persona = crate::persona::find(&cfg.talk.personas, id).unwrap();
3780 assert_eq!(persona.id, id);
3781 assert!(crate::persona_icon::load(bad).is_err());
3782 }
3783 }
3784 let doc = Config::starter_toml();
3785 assert!(doc.contains("# icon = "));
3786 }
3787
3788 #[test]
3789 fn talk_personas_in_two_layers_are_refused() {
3790 let dir = tempfile::tempdir().unwrap();
3791 let machine = dir.path().join("machine.toml");
3792 let repo = dir.path().join("repo.toml");
3793 let one = "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \"p\"\n";
3794 std::fs::write(&machine, one).unwrap();
3795 std::fs::write(&repo, one.replace("\"a\"", "\"b\"")).unwrap();
3796 let err = format!("{:#}", Config::load_layers(&[machine, repo]).unwrap_err());
3797 assert!(err.contains("talk.personas"), "{err}");
3798 }
3799
3800 #[test]
3801 fn starter_toml_documents_role_lists_as_backup_order() {
3802 let starter = Config::starter_toml();
3803 assert!(starter.contains("also an ordered backup list"));
3804 assert!(starter.contains("codex then sonnet are spares"));
3805 }
3806
3807 #[test]
3811 fn a_repository_can_declare_a_linear_history_merge_style() {
3812 let dir = tempfile::tempdir().unwrap();
3813 let path = dir.path().join("magi.toml");
3814 std::fs::write(&path, "[merge]\nmode = \"none\"\nstyle = \"squash\"\n").unwrap();
3815 let parsed = Config::load(&path).expect("config must load");
3816 assert_eq!(parsed.merge.style, MergeStyle::Squash);
3817 }
3818
3819 #[test]
3820 fn later_layers_win_and_vars_render() {
3821 let dir = tempfile::tempdir().unwrap();
3822 let machine = dir.path().join("machine.toml");
3823 let project = dir.path().join("magi.toml");
3824 std::fs::write(
3826 &machine,
3827 "[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n\
3828 [graph]\ncandidates = 3\nmax_parallel = 8\n",
3829 )
3830 .unwrap();
3831 std::fs::write(
3834 &project,
3835 "[vars]\ncache = \"/shared\"\n\n\
3836 [graph]\ncandidates = 2\n\n\
3837 [verify]\ngate = [\"CARGO_TARGET_DIR={{ vars.cache }}/t cargo test\"]\n",
3838 )
3839 .unwrap();
3840
3841 let cfg = Config::load_layers(&[machine, project]).expect("layered load");
3842 assert_eq!(cfg.agents.len(), 1, "roster comes from the machine layer");
3843 assert_eq!(cfg.graph.implementers, 2, "project layer wins");
3844 assert_eq!(cfg.graph.max_parallel, 8, "machine layer survives");
3845 assert_eq!(
3846 cfg.verify.gate,
3847 ["CARGO_TARGET_DIR=/shared/t cargo test".to_owned()]
3848 );
3849 assert_eq!(cfg.cache_dir(), Some(PathBuf::from("/shared/t")));
3851 }
3852
3853 #[test]
3854 fn talk_defaults_to_an_hour_and_an_unwritten_config_still_gets_it() {
3855 let g = Graph::default();
3859 assert_eq!(g.timeout_talk, 3600);
3860
3861 let dir = tempfile::tempdir().unwrap();
3862 let path = dir.path().join("magi.toml");
3863 std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
3864 let cfg = Config::load(&path).expect("must load without timeout_talk set");
3865 assert_eq!(cfg.graph.timeout_talk, 3600);
3866 }
3867
3868 #[test]
3869 fn an_overridden_talk_timeout_reaches_the_loaded_config() {
3870 let dir = tempfile::tempdir().unwrap();
3871 let path = dir.path().join("magi.toml");
3872 std::fs::write(&path, "[graph]\ntimeout_talk = 120\n").unwrap();
3873 let cfg = Config::load(&path).expect("must load");
3874 assert_eq!(cfg.graph.timeout_talk, 120);
3875 }
3876
3877 #[test]
3878 fn the_disk_defaults_are_the_measurements_made_up_front() {
3879 let cfg = Config::default();
3880 assert_eq!(cfg.disk.min_free_bytes, 8 * 1024 * 1024 * 1024);
3881 assert!(cfg.disk.auto_fold);
3882 assert_eq!(cfg.disk.fold_grace_secs, 6 * 60 * 60);
3883 assert_eq!(cfg.disk.cache_limit_bytes, 10 * 1024 * 1024 * 1024);
3884 }
3885
3886 #[test]
3887 fn an_unset_disk_section_is_the_safe_default() {
3888 let dir = tempfile::tempdir().unwrap();
3889 std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 1\n").unwrap();
3890 let cfg = Config::load(&dir.path().join("magi.toml")).expect("load");
3891 assert_eq!(cfg.disk, Disk::default());
3892 }
3893
3894 #[test]
3895 fn env_is_available_to_templates_with_a_default() {
3896 let dir = tempfile::tempdir().unwrap();
3897 let path = dir.path().join("magi.toml");
3898 std::fs::write(
3906 &path,
3907 "[verify]\n\
3908 gate = [\"cache={{ env.MAGI_TEST_UNSET_XYZ | default(value='fallback') }}\", \
3909 \"populated={{ env | length > 0 }}\"]\n",
3910 )
3911 .unwrap();
3912 let cfg = Config::load(&path).expect("env lookup must render");
3913 assert_eq!(cfg.verify.gate[0], "cache=fallback");
3914 assert_eq!(cfg.verify.gate[1], "populated=true");
3915 }
3916
3917 #[test]
3918 fn a_broken_template_names_the_file() {
3919 let dir = tempfile::tempdir().unwrap();
3920 let path = dir.path().join("magi.toml");
3921 std::fs::write(&path, "[graph]\nlanguage = \"{{ nope.\"\n").unwrap();
3922 let err = Config::load(&path).expect_err("must not silently ignore");
3923 assert!(err.to_string().contains("teravars"), "{err}");
3924 }
3925
3926 #[test]
3927 fn tera_syntax_in_comments_is_inert() {
3928 let dir = tempfile::tempdir().unwrap();
3932 let path = dir.path().join("magi.toml");
3933 std::fs::write(
3934 &path,
3935 "# a comment may quote templates: `{{ env.NOPE | default(value='x') }}` and `{% if %}`\n\
3936 [graph]\ncandidates = 2\n",
3937 )
3938 .unwrap();
3939 let cfg = Config::load(&path).expect("comments must be inert, not rendered");
3940 assert_eq!(cfg.graph.implementers, 2);
3941 }
3942
3943 #[test]
3944 fn opencode_defaults_to_file_delivery() {
3945 let mut s = spec("oc");
3946 s.kind = AgentKind::Opencode;
3947 assert_eq!(s.delivery(), Delivery::File);
3948 s.prompt_delivery = Some(Delivery::Argv);
3949 assert_eq!(s.delivery(), Delivery::Argv);
3950 }
3951 #[test]
3952 fn the_land_loop_is_on_but_it_cannot_merge_without_being_asked() {
3953 let g = Graph::default();
3958 assert!(
3959 g.land,
3960 "stopping at an open PR left the watching to a human"
3961 );
3962 assert!(
3963 g.land_approval,
3964 "on-by-default land is only defensible while this is also on"
3965 );
3966 assert!(g.land_rounds > 0, "a loop with no budget never terminates");
3967 assert!(
3968 g.hold_contested_merge,
3969 "a contested hand-off must still reach a human when approvals are off"
3970 );
3971 }
3972 #[test]
3973 fn an_array_declared_in_two_layers_is_refused_instead_of_concatenated() {
3974 let dir = tempfile::tempdir().unwrap();
3978 let machine = dir.path().join("machine.toml");
3979 let repo = dir.path().join("magi.toml");
3980 std::fs::write(&machine, "[roles]\nimplementers = [\"a\", \"b\"]\n").unwrap();
3981 std::fs::write(&repo, "[roles]\nimplementers = [\"oc\"]\n").unwrap();
3982
3983 let err = Config::load_layers(&[machine.clone(), repo.clone()])
3984 .expect_err("two layers naming one array must not merge silently");
3985 assert_eq!(
3986 err.downcast_ref::<ConfigFailure>().unwrap().stage,
3987 "config-merge"
3988 );
3989 let err = err.to_string();
3990 assert!(err.contains("roles.implementers"), "{err}");
3991 assert!(err.contains("machine.toml"), "{err}");
3994 assert!(err.contains("magi.toml"), "{err}");
3995 }
3996
3997 #[test]
3998 fn a_scalar_in_one_layer_and_an_array_in_another_still_merges() {
3999 let dir = tempfile::tempdir().unwrap();
4002 let machine = dir.path().join("machine.toml");
4003 let repo = dir.path().join("magi.toml");
4004 std::fs::write(&machine, "[roles]\nchatter = \"opus\"\n").unwrap();
4005 std::fs::write(
4006 &repo,
4007 "[[agents]]\nid = \"oc\"\nkind = \"opencode\"\n\n\
4008 [roles]\nimplementers = [\"oc\"]\n",
4009 )
4010 .unwrap();
4011
4012 let cfg = Config::load_layers(&[machine, repo]).expect("layers merge");
4013 assert_eq!(primary(cfg.roles.chatter.as_ref()), Some("opus"));
4014 assert_eq!(cfg.roles.implementers, ["oc"]);
4015 assert_eq!(cfg.agents.len(), 1, "the roster is not doubled");
4016 }
4017
4018 #[test]
4019 fn two_layers_declaring_verify_gate_run_both_in_priority_order() {
4020 let dir = tempfile::tempdir().unwrap();
4023 let machine = dir.path().join("machine.toml");
4024 let repo = dir.path().join("magi.toml");
4025 std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
4026 std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
4027
4028 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
4029 assert_eq!(
4030 cfg.verify.gate,
4031 [
4032 "editorconfig-checker".to_owned(),
4033 "cargo make check".to_owned()
4034 ],
4035 "low-priority (machine) command first, high-priority (repo) command after"
4036 );
4037 }
4038
4039 fn load_one(body: &str) -> Result<Config> {
4040 let dir = tempfile::tempdir().unwrap();
4041 let path = dir.path().join("magi.toml");
4042 std::fs::write(&path, body).unwrap();
4043 Config::load_layers(&[path])
4044 }
4045
4046 #[test]
4047 fn worktree_setup_defaults_to_empty_and_parses_steps() {
4048 assert!(Config::default().worktree.setup.is_empty());
4049 let cfg = load_one(
4050 "[worktree]\nsetup = [\n { copy = \".env.example -> .env\" },\n \
4051 { copy = \"x\", optional = true },\n { run = \"true\", timeout_secs = 5 },\n]\n",
4052 )
4053 .unwrap();
4054 assert_eq!(cfg.worktree.setup.len(), 3);
4055 assert!(cfg.worktree.setup[1].optional);
4056 assert_eq!(cfg.worktree.setup[2].timeout_secs, Some(5));
4057 }
4058
4059 #[test]
4060 fn worktree_setup_rejects_malformed_steps() {
4061 for bad in [
4062 "{ copy = \"a\", run = \"b\" }",
4063 "{ optional = true }",
4064 "{ copy = \"a -> ../b\" }",
4065 "{ copy = \"a -> /abs\" }",
4066 "{ copy = \"a -> .git/config\" }",
4067 "{ run = \"b\", optional = true }",
4068 "{ copy = \"a\", timeout_secs = 3 }",
4069 "{ run = \"b\", bogus = 1 }",
4070 ] {
4071 let body = format!("[worktree]\nsetup = [{bad}]\n");
4072 assert!(load_one(&body).is_err(), "{bad}");
4073 }
4074 }
4075
4076 #[test]
4077 fn worktree_setup_in_two_layers_is_refused() {
4078 let dir = tempfile::tempdir().unwrap();
4079 let machine = dir.path().join("machine.toml");
4080 let repo = dir.path().join("magi.toml");
4081 std::fs::write(&machine, "[worktree]\nsetup = [{ run = \"a\" }]\n").unwrap();
4082 std::fs::write(&repo, "[worktree]\nsetup = [{ run = \"b\" }]\n").unwrap();
4083 assert!(Config::load_layers(&[machine, repo]).is_err());
4084 }
4085
4086 #[test]
4087 fn starter_toml_documents_worktree_setup() {
4088 let starter = Config::starter_toml();
4089 assert!(starter.contains("# [worktree]") && starter.contains("setup = ["));
4090 let body = starter.replace("# [worktree]", "[worktree]");
4092 let mut out = String::new();
4093 let mut live = false;
4094 for line in body.lines() {
4095 if line == "[worktree]" {
4096 live = true;
4097 } else if live && !line.starts_with("# ") {
4098 live = false;
4099 }
4100 out.push_str(&if live {
4101 line.replacen("# ", "", 1)
4102 } else {
4103 line.to_owned()
4104 });
4105 out.push('\n');
4106 }
4107 let cfg = load_one(&out).expect("uncommented example loads");
4108 assert_eq!(cfg.worktree.setup.len(), 3);
4109 }
4110
4111 #[test]
4112 fn two_layers_declaring_verify_e2e_run_both_in_priority_order() {
4113 let dir = tempfile::tempdir().unwrap();
4114 let machine = dir.path().join("machine.toml");
4115 let repo = dir.path().join("magi.toml");
4116 std::fs::write(&machine, "[verify]\ne2e = [\"shared-smoke-test\"]\n").unwrap();
4117 std::fs::write(&repo, "[verify]\ne2e = [\"cargo test\"]\n").unwrap();
4118
4119 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
4120 assert_eq!(
4121 cfg.verify.e2e,
4122 ["shared-smoke-test".to_owned(), "cargo test".to_owned()]
4123 );
4124 }
4125
4126 #[test]
4127 fn two_layers_declaring_repos_roots_are_both_scanned() {
4128 let dir = tempfile::tempdir().unwrap();
4129 let machine = dir.path().join("machine.toml");
4130 let repo = dir.path().join("magi.toml");
4131 std::fs::write(&machine, "[repos]\nroots = [\"/machine/root\"]\n").unwrap();
4132 std::fs::write(&repo, "[repos]\nroots = [\"/repo/root\"]\n").unwrap();
4133
4134 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
4135 assert_eq!(
4136 cfg.repos.roots,
4137 [PathBuf::from("/machine/root"), PathBuf::from("/repo/root")]
4138 );
4139 }
4140
4141 #[test]
4142 fn duplicate_gate_commands_across_layers_both_run() {
4143 let dir = tempfile::tempdir().unwrap();
4146 let machine = dir.path().join("machine.toml");
4147 let repo = dir.path().join("magi.toml");
4148 std::fs::write(&machine, "[verify]\ngate = [\"same-command\"]\n").unwrap();
4149 std::fs::write(&repo, "[verify]\ngate = [\"same-command\"]\n").unwrap();
4150
4151 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
4152 assert_eq!(
4153 cfg.verify.gate,
4154 ["same-command".to_owned(), "same-command".to_owned()]
4155 );
4156 }
4157
4158 #[test]
4159 fn notify_command_is_still_refused_across_two_layers() {
4160 let dir = tempfile::tempdir().unwrap();
4162 let machine = dir.path().join("machine.toml");
4163 let repo = dir.path().join("magi.toml");
4164 std::fs::write(&machine, "[notify]\ncommand = [\"ntfy\", \"publish\"]\n").unwrap();
4165 std::fs::write(&repo, "[notify]\ncommand = [\"curl\", \"-X\"]\n").unwrap();
4166
4167 let err = Config::load_layers(&[machine.clone(), repo.clone()])
4168 .expect_err("an argv split across layers must not concatenate");
4169 assert_eq!(
4170 err.downcast_ref::<ConfigFailure>().unwrap().stage,
4171 "config-merge"
4172 );
4173 let err = err.to_string();
4174 assert!(err.contains("notify.command"), "{err}");
4175 assert!(err.contains("machine.toml"), "{err}");
4176 assert!(err.contains("magi.toml"), "{err}");
4177 }
4178
4179 #[test]
4180 fn one_layer_declaring_verify_gate_runs_unchanged() {
4181 let dir = tempfile::tempdir().unwrap();
4184 let path = dir.path().join("magi.toml");
4185 std::fs::write(&path, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
4186
4187 let cfg = Config::load(&path).expect("single layer must still load");
4188 assert_eq!(cfg.verify.gate, ["cargo make check".to_owned()]);
4189 }
4190
4191 #[test]
4192 fn layered_parse_failures_name_the_failing_input() {
4193 let dir = tempfile::tempdir().unwrap();
4194 let a = dir.path().join("a.toml");
4195 let b = dir.path().join("b.toml");
4196 std::fs::write(&a, "[graph").unwrap();
4197 std::fs::write(&b, "[verify").unwrap();
4198 let paths = [a.clone(), b.clone()];
4199 let first = Config::load_layers(&paths).unwrap_err();
4200 let first_cause = first.downcast_ref::<ConfigFailure>().unwrap();
4201 assert_eq!(first_cause.stage, "config-parse");
4202 assert_eq!(first_cause.target, serde_json::to_string(&[&a]).unwrap());
4203 std::fs::write(&a, "").unwrap();
4204 let second = Config::load_layers(&paths).unwrap_err();
4205 let second_cause = second.downcast_ref::<ConfigFailure>().unwrap();
4206 assert_eq!(second_cause.stage, "config-parse");
4207 assert_eq!(second_cause.target, serde_json::to_string(&[&b]).unwrap());
4208 assert_ne!(first_cause.target, second_cause.target);
4209 }
4210
4211 #[test]
4212 fn describe_composed_names_the_contributing_layers_only_when_there_are_two() {
4213 let dir = tempfile::tempdir().unwrap();
4214 let machine = dir.path().join("machine.toml");
4215 let repo = dir.path().join("magi.toml");
4216 std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
4217 std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
4218 let paths = vec![machine.clone(), repo.clone()];
4219
4220 let cfg = Config::load_layers(&paths).expect("appendable arrays must merge");
4221 let described =
4222 Config::describe_composed(&paths, &cfg.verify.gate, "verify.gate", "(none)");
4223 assert!(described.contains("editorconfig-checker && cargo make check"));
4224 assert!(
4225 described.contains(&machine.display().to_string()),
4226 "{described}"
4227 );
4228 assert!(
4229 described.contains(&repo.display().to_string()),
4230 "{described}"
4231 );
4232
4233 let single = vec![repo.clone()];
4235 let solo_cfg = Config::load_layers(&single).expect("single layer loads");
4236 let solo_described =
4237 Config::describe_composed(&single, &solo_cfg.verify.gate, "verify.gate", "(none)");
4238 assert_eq!(solo_described, "cargo make check");
4239 }
4240
4241 mod remote_layers {
4242 use super::*;
4243
4244 fn git(dir: &Path, args: &[&str]) {
4245 use crate::proc::Quiet as _;
4246 let out = std::process::Command::new("git")
4247 .args(args)
4248 .quiet()
4249 .current_dir(dir)
4250 .output()
4251 .unwrap();
4252 assert!(out.status.success(), "git {args:?}: {out:?}");
4253 }
4254
4255 fn fixture(remote: &str, disk: &str) -> (tempfile::TempDir, PathBuf) {
4258 let tmp = tempfile::tempdir().unwrap();
4259 let bare = tmp.path().join("o.git");
4260 git(tmp.path(), &["init", "--bare", "-b", "trunk", "o.git"]);
4261 let repo = tmp.path().join("repo");
4262 std::fs::create_dir_all(&repo).unwrap();
4263 git(&repo, &["init", "-b", "trunk"]);
4264 git(&repo, &["config", "user.name", "t"]);
4265 git(&repo, &["config", "user.email", "t@example.com"]);
4266 std::fs::write(repo.join("magi.toml"), remote).unwrap();
4267 git(&repo, &["add", "-A"]);
4268 git(&repo, &["commit", "-m", "x"]);
4269 git(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
4270 git(&repo, &["push", "origin", "trunk"]);
4271 git(&repo, &["fetch", "origin"]);
4272 git(&repo, &["remote", "set-head", "origin", "trunk"]);
4273 std::fs::write(repo.join("magi.toml"), disk).unwrap();
4274 (tmp, repo)
4275 }
4276
4277 #[tokio::test]
4278 async fn branch_owner_holds_only_add_literal_true_values() {
4279 for (text, expected) in [
4280 ("[graph]\nland_approval = true\n", true),
4281 ("[graph]\nland_approval = false\n", false),
4282 ("[graph]\nland = true\n", false),
4283 ("[graph\n", false),
4284 ("[graph]\nland_approval = \"yes\"\n", false),
4285 ("[graph]\nland_approval = 1\n", false),
4286 ] {
4287 let (_tmp, repo) = fixture(text, "[graph]\nland_approval = false\n");
4288 let head = crate::git::rev_parse(&repo, "HEAD").await.unwrap();
4289 let (holds, _) = branch_owner_holds(&repo, &head).await;
4290 assert_eq!(
4291 holds.contains(&(OwnerHold::LandApproval, "magi.toml".into())),
4292 expected
4293 );
4294 assert!(
4295 !holds
4296 .iter()
4297 .any(|(h, _)| *h == OwnerHold::HoldContestedMerge)
4298 );
4299 for base in [false, true] {
4300 let mut cfg = Config::default();
4301 cfg.graph.land_approval = base;
4302 let mut state = crate::run::RunState::new(
4303 repo.clone(),
4304 "trunk".into(),
4305 head.clone(),
4306 "review".into(),
4307 cfg,
4308 );
4309 state.observe_branch_owner_holds(&head).await;
4310 assert!(
4311 state.branch_owner_holds.is_empty(),
4312 "competition candidates are excluded"
4313 );
4314 state.reviewed_commits = Some(Vec::new());
4315 state.observe_branch_owner_holds(&head).await;
4316 assert_eq!(state.owner_hold(OwnerHold::LandApproval), base || expected);
4317 }
4318 }
4319 }
4320
4321 #[tokio::test]
4322 async fn branch_owner_holds_missing_and_unreadable_preserve_base() {
4323 let (_tmp, repo) = fixture(
4324 "[graph]\nland_approval = true\n",
4325 "[graph]\nland_approval = true\n",
4326 );
4327 git(&repo, &["rm", "magi.toml"]);
4328 std::fs::create_dir(repo.join(".magi")).unwrap();
4329 std::fs::write(
4330 repo.join(".magi/config.toml"),
4331 "[graph]\nhold_contested_merge = true\n",
4332 )
4333 .unwrap();
4334 git(&repo, &["add", "."]);
4335 git(&repo, &["commit", "-m", "nested hold"]);
4336 let head = crate::git::rev_parse(&repo, "HEAD").await.unwrap();
4337 let (holds, _) = branch_owner_holds(&repo, &head).await;
4338 assert_eq!(
4339 holds,
4340 [(OwnerHold::HoldContestedMerge, ".magi/config.toml".into())]
4341 );
4342 let (holds, diagnostics) =
4343 branch_owner_holds(&repo, "0000000000000000000000000000000000000000").await;
4344 assert!(holds.is_empty());
4345 assert!(!diagnostics.is_empty());
4346 let (holds, diagnostics) = branch_owner_holds(&repo, "").await;
4347 assert!(holds.is_empty());
4348 assert!(!diagnostics.is_empty());
4349 for base in [false, true] {
4350 let mut cfg = Config::default();
4351 cfg.graph.land_approval = base;
4352 let mut state = crate::run::RunState::new(
4353 repo.clone(),
4354 "trunk".into(),
4355 head.clone(),
4356 "review".into(),
4357 cfg,
4358 );
4359 state.reviewed_commits = Some(Vec::new());
4360 state.observe_branch_owner_holds(&head).await;
4361 assert_eq!(state.owner_hold(OwnerHold::LandApproval), base);
4362 state
4363 .observe_branch_owner_holds("0000000000000000000000000000000000000000")
4364 .await;
4365 assert_eq!(state.owner_hold(OwnerHold::LandApproval), base);
4366 }
4367 }
4368
4369 #[test]
4370 fn reads_the_remote_blob_not_the_working_tree() {
4371 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4372 let (cfg, _) = Config::discover(&repo, None).unwrap();
4373 assert_eq!(cfg.graph.implementers, 2);
4374 }
4375
4376 #[test]
4377 fn detached_head_still_reads_the_remote() {
4378 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4379 git(&repo, &["checkout", "--detach"]);
4380 let (cfg, _) = Config::discover(&repo, None).unwrap();
4381 assert_eq!(cfg.graph.implementers, 2);
4382 }
4383
4384 #[test]
4385 fn a_missing_ref_is_an_error_not_a_fallback() {
4386 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4387 git(&repo, &["update-ref", "-d", "refs/remotes/origin/trunk"]);
4388 let err = format!("{:#}", Config::discover(&repo, None).unwrap_err());
4389 assert!(err.contains("origin"), "{err}");
4390 }
4391
4392 #[test]
4393 fn merge_base_in_the_checkout_locates_the_ref() {
4394 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[merge]\nbase = \"nope\"\n");
4395 assert!(Config::discover(&repo, None).is_err());
4396 }
4397
4398 #[test]
4399 fn the_magi_dir_layer_is_read_too() {
4400 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4401 std::fs::write(repo.join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
4402 std::fs::create_dir_all(repo.join(".magi")).unwrap();
4403 std::fs::write(repo.join(".magi/config.toml"), "[graph]\njudges = 3\n").unwrap();
4404 git(&repo, &["add", "-A"]);
4405 git(&repo, &["commit", "-m", "y"]);
4406 git(&repo, &["push", "origin", "trunk"]);
4407 git(&repo, &["fetch", "origin"]);
4408 let (cfg, layers) = Config::discover(&repo, None).unwrap();
4409 assert_eq!((cfg.graph.implementers, cfg.graph.judges), (2, 3));
4410 assert_eq!(layers.len(), 2);
4411 }
4412
4413 #[test]
4414 fn an_include_in_a_repository_layer_resolves_against_the_same_commit() {
4415 let (_t, repo) = fixture("include = [\"gate.toml\"]\n", "");
4416 std::fs::write(repo.join("magi.toml"), "include = [\"gate.toml\"]\n").unwrap();
4417 std::fs::write(repo.join("gate.toml"), "[graph]\ncandidates = 2\n").unwrap();
4418 git(&repo, &["add", "-A"]);
4419 git(&repo, &["commit", "-m", "inc"]);
4420 git(&repo, &["push", "origin", "trunk"]);
4421 git(&repo, &["fetch", "origin"]);
4422 std::fs::write(repo.join("gate.toml"), "[graph]\ncandidates = 9\n").unwrap();
4424 let (cfg, _) = Config::discover(&repo, None).unwrap();
4425 assert_eq!(cfg.graph.implementers, 2);
4426 }
4427
4428 #[test]
4429 fn a_machine_layer_base_is_rendered_before_locating_the_ref() {
4430 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "[merge]\nbase = \"nope\"\n");
4431 let m = t.path().join("machine.toml");
4432 std::fs::write(
4433 &m,
4434 "[merge]\nbase = \"{{ env.MAGI_TEST_NO_SUCH | default(value='trunk') }}\"\n",
4435 )
4436 .unwrap();
4437 let (remote, base) = {
4438 let probe = probe_merge(&m, &repo).unwrap();
4439 (probe.remote, probe.base)
4440 };
4441 assert_eq!((remote, base), (None, Some("trunk".to_owned())));
4442 }
4443
4444 #[test]
4445 fn a_machine_layer_base_sees_the_checkouts_repo_name() {
4446 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4447 let m = t.path().join("machine.toml");
4448 std::fs::write(&m, "[merge]\nbase = \"{{ repo_name }}\"\n").unwrap();
4449 let probe = probe_merge(&m, &repo).unwrap();
4450 assert_eq!(probe.base.as_deref(), Some("repo"));
4451 }
4452
4453 #[test]
4454 fn every_include_form_is_recognised() {
4455 assert!(declares_include("include = [\"a.toml\"]\n"));
4456 assert!(declares_include("\"include\" = [\"a.toml\"]\n"));
4457 assert!(declares_include("teravars = { include = [\"a.toml\"] }\n"));
4458 assert!(declares_include("[teravars]\ninclude = [\"a.toml\"]\n"));
4459 assert!(!declares_include("[graph]\ncandidates = 2\n"));
4460 }
4461
4462 #[test]
4463 fn an_explicit_config_is_read_as_named() {
4464 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4465 let own = t.path().join("own.toml");
4466 std::fs::write(&own, "[graph]\ncandidates = 4\n").unwrap();
4467 let (cfg, _) = Config::discover(&repo, Some(&own)).unwrap();
4468 assert_eq!(cfg.graph.implementers, 4);
4469 }
4470
4471 #[test]
4472 fn discover_fetched_fails_when_the_fetch_does() {
4473 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4474 std::fs::remove_dir_all(t.path().join("o.git")).unwrap();
4475 let rt = tokio::runtime::Builder::new_current_thread()
4476 .enable_all()
4477 .build()
4478 .unwrap();
4479 let err = rt
4480 .block_on(Config::discover_fetched(&repo, None))
4481 .unwrap_err();
4482 assert!(format!("{err:#}").contains("fetch"), "{err:#}");
4483 }
4484
4485 #[test]
4486 fn fetch_and_remote_parse_failures_have_distinct_verified_causes() {
4487 let (tmp, repo) = fixture("[graph\ncandidates = 2\n", "");
4488 let remote = tmp.path().join("o.git");
4489 let offline = tmp.path().join("offline.git");
4490 std::fs::rename(&remote, &offline).unwrap();
4491 let rt = tokio::runtime::Builder::new_current_thread()
4492 .enable_all()
4493 .build()
4494 .unwrap();
4495 let fetch = rt
4496 .block_on(Config::discover_fetched(&repo, None))
4497 .unwrap_err();
4498 let fetch_cause = fetch.downcast_ref::<ConfigFailure>().unwrap();
4499 assert_eq!(fetch_cause.stage, "config-fetch");
4500 assert_eq!(fetch_cause.target, "origin/trunk");
4501
4502 std::fs::rename(&offline, &remote).unwrap();
4503 let parse = rt
4504 .block_on(Config::discover_fetched(&repo, None))
4505 .unwrap_err();
4506 let parse_cause = parse.downcast_ref::<ConfigFailure>().unwrap();
4507 assert_eq!(parse_cause.stage, "config-parse");
4508 assert!(parse_cause.target.contains("magi.toml"));
4509 assert!(
4510 !parse_cause.target.contains("/magi/config/"),
4511 "cache commit paths are incidental"
4512 );
4513 assert_ne!(fetch_cause.stage, parse_cause.stage);
4514 }
4515
4516 #[test]
4517 fn discover_fetched_survives_a_fetch_that_leaves_no_origin_head() {
4518 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4521 git(&repo, &["remote", "set-head", "origin", "-d"]);
4522 git(&repo, &["update-ref", "-d", "refs/remotes/origin/trunk"]);
4523 git(
4524 &repo,
4525 &["config", "remote.origin.followRemoteHEAD", "never"],
4526 );
4527 let rt = tokio::runtime::Builder::new_current_thread()
4528 .enable_all()
4529 .build()
4530 .unwrap();
4531 let (cfg, _) = rt.block_on(Config::discover_fetched(&repo, None)).unwrap();
4532 assert_eq!(cfg.graph.implementers, 2);
4533 }
4534
4535 #[test]
4536 fn a_non_git_directory_reads_its_files() {
4537 let dir = tempfile::tempdir().unwrap();
4538 std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 4\n").unwrap();
4539 let (cfg, _) = Config::discover(dir.path(), None).unwrap();
4540 assert_eq!(cfg.graph.implementers, 4);
4541 }
4542 }
4543
4544 #[test]
4545 fn github_language_defaults_to_english_and_accepts_japanese() {
4546 let default: Graph = toml::from_str("").unwrap();
4547 assert_eq!(default.github_language, "en");
4548 assert!(default.github_english() && default.language_gate());
4549 let ja: Graph = toml::from_str("github_language = \"ja\"").unwrap();
4550 assert!(!ja.github_english() && !ja.language_gate());
4551 let only_ops: Graph = toml::from_str("language = \"ja\"").unwrap();
4553 assert!(only_ops.language_gate());
4554 let off: Graph = toml::from_str("github_text_guard = false").unwrap();
4555 assert!(!off.language_gate() && off.github_english());
4556 assert!(Config::starter_toml().contains("github_language = \"en\""));
4557 }
4558}