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 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub max_turn_secs: Option<u64>,
201}
202
203impl AgentSpec {
204 pub fn turn_timeout(&self, budget: std::time::Duration) -> std::time::Duration {
206 self.max_turn_secs.map_or(budget, |secs| {
207 budget.min(std::time::Duration::from_secs(secs))
208 })
209 }
210
211 pub fn delivery(&self) -> Delivery {
217 self.prompt_delivery.unwrap_or(match self.kind {
218 AgentKind::Claude | AgentKind::Command => Delivery::Stdin,
219 AgentKind::Codex => Delivery::Stdin,
223 AgentKind::Omp => Delivery::Stdin,
227 AgentKind::Opencode | AgentKind::Antigravity => Delivery::File,
228 })
229 }
230
231 pub fn display(&self) -> String {
233 match &self.model {
234 Some(m) => format!("{} ({}:{m})", self.id, self.kind.as_str()),
235 None => format!("{} ({})", self.id, self.kind.as_str()),
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
247#[serde(untagged)]
248pub enum AgentChoice {
249 One(String),
251 Chain(Vec<String>),
253}
254
255impl AgentChoice {
256 pub fn ids(&self) -> Vec<&str> {
258 let all: Vec<&str> = match self {
259 Self::One(id) => vec![id.as_str()],
260 Self::Chain(ids) => ids.iter().map(String::as_str).collect(),
261 };
262 all.into_iter()
263 .map(str::trim)
264 .filter(|id| !id.is_empty())
265 .collect()
266 }
267
268 pub fn primary(&self) -> Option<&str> {
270 self.ids().into_iter().next()
271 }
272}
273
274impl From<&str> for AgentChoice {
275 fn from(id: &str) -> Self {
276 Self::One(id.to_owned())
277 }
278}
279
280pub fn primary(choice: Option<&AgentChoice>) -> Option<&str> {
282 choice.and_then(AgentChoice::primary)
283}
284
285#[derive(Debug, Clone, Default, Deserialize, Serialize)]
288#[serde(deny_unknown_fields, default)]
289pub struct Roles {
290 pub implementers: Vec<String>,
292 pub judges: Vec<String>,
294 pub reviewers: Vec<String>,
296 pub fixer: Option<AgentChoice>,
305 pub chatter: Option<AgentChoice>,
319 pub dupes_judge: Option<AgentChoice>,
323 pub conductor: Option<AgentChoice>,
336 pub advisors: Vec<String>,
347 pub synthesizer: Option<AgentChoice>,
360 pub language_judge: Option<AgentChoice>,
369}
370
371#[derive(Debug, Clone, Deserialize, Serialize)]
373#[serde(deny_unknown_fields, default)]
374pub struct Graph {
375 #[serde(rename = "editing_network", skip_serializing)]
379 pub retired_editing_network: Option<serde::de::IgnoredAny>,
380 pub github_text_guard: bool,
382 pub github_language: String,
388 #[serde(alias = "candidates")]
420 pub implementers: usize,
421 #[serde(default)]
423 pub selected_implementer: Option<String>,
424 pub judges: usize,
426 pub deliberate_rounds: usize,
428 pub reviewers: usize,
436 pub review_rounds: usize,
438 pub gate_fix_rounds: usize,
444 pub max_parallel: usize,
446 pub language: String,
448 pub sessions: bool,
456 pub timeout_implement: u64,
458 pub timeout_judge: u64,
460 pub timeout_review: u64,
462 pub timeout_verify: Option<u64>,
471 pub timeout_fix: u64,
473 pub timeout_talk: u64,
481 pub timeout_resource_wait: u64,
490 pub retries: usize,
492 pub worktree_root: Option<PathBuf>,
494 pub land: bool,
507 pub land_rounds: usize,
509 pub rerun_flaky_checks: bool,
517 pub land_approval: bool,
528 pub hold_contested_merge: bool,
539 pub question_routing: bool,
544 pub file_followups: bool,
551 pub release_deputy_followups: bool,
559 pub answer_timeout: u64,
563 pub incomplete_review: IncompleteReviewPolicy,
566 pub e2e_every_round: bool,
584 pub advise: bool,
598 pub advisors: usize,
603 pub graft_runner_up: bool,
612}
613
614impl Default for Graph {
615 fn default() -> Self {
616 Self {
617 github_text_guard: true,
618 github_language: "en".into(),
619 implementers: 1,
620 selected_implementer: None,
621 retired_editing_network: None,
622 judges: 3,
623 deliberate_rounds: 1,
624 reviewers: 3,
625 review_rounds: 6,
626 gate_fix_rounds: 1,
627 max_parallel: 4,
628 language: "en".to_owned(),
629 sessions: true,
630 timeout_implement: 3600,
631 timeout_judge: 1200,
632 timeout_review: 1200,
633 timeout_verify: None,
634 timeout_fix: 1800,
635 timeout_talk: 3600,
636 timeout_resource_wait: 1800,
637 retries: 1,
638 worktree_root: None,
639 land: true,
640 land_rounds: 4,
641 rerun_flaky_checks: true,
642 land_approval: true,
643 hold_contested_merge: true,
644 question_routing: true,
645 file_followups: true,
646 release_deputy_followups: true,
647 answer_timeout: 86_400,
648 incomplete_review: IncompleteReviewPolicy::Block,
649 e2e_every_round: false,
650 advise: true,
651 advisors: 3,
652 graft_runner_up: true,
653 }
654 }
655}
656
657impl Graph {
658 pub fn verify_timeout(&self) -> u64 {
662 self.timeout_verify.unwrap_or(self.timeout_review)
663 }
664
665 pub fn language_gate(&self) -> bool {
668 self.github_text_guard && self.github_english()
669 }
670
671 pub fn github_english(&self) -> bool {
673 crate::lang::is_english(&self.github_language)
674 }
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
684#[serde(rename_all = "lowercase")]
685pub enum IncompleteReviewPolicy {
686 Block,
690 Warn,
695}
696
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
699#[serde(rename_all = "lowercase")]
700pub enum LeakPolicy {
701 Warn,
703 Redact,
705 Fail,
707}
708
709#[derive(Debug, Clone, Deserialize, Serialize)]
716#[serde(deny_unknown_fields, default)]
717pub struct Blind {
718 pub commit_msg_hook: bool,
721 pub strip_lines: Vec<String>,
725 pub vendor_tokens: Vec<String>,
727 pub on_leak: LeakPolicy,
729 pub seed: Option<u64>,
732}
733
734impl Default for Blind {
735 fn default() -> Self {
736 Self {
737 commit_msg_hook: true,
738 strip_lines: [
739 "Co-Authored-By:",
740 "Signed-off-by:",
741 "Assisted-by:",
742 "Generated-by:",
743 "Generated with",
744 "\u{1f916}",
745 ]
746 .iter()
747 .map(|s| (*s).to_owned())
748 .collect(),
749 vendor_tokens: [
750 "claude",
751 "anthropic",
752 "codex",
753 "openai",
754 "chatgpt",
755 "gemini",
756 "grok",
757 "xai",
758 "copilot",
759 "opencode",
760 "qoder",
761 "cursor",
762 "\u{1f916}",
763 ]
764 .iter()
765 .map(|s| (*s).to_owned())
766 .collect(),
767 on_leak: LeakPolicy::Warn,
768 seed: None,
769 }
770 }
771}
772
773#[derive(Debug, Clone, Default, Deserialize, Serialize)]
775#[serde(deny_unknown_fields, default)]
776pub struct Verify {
777 pub e2e: Vec<String>,
780 pub gate: Vec<String>,
782 #[serde(default)]
791 pub pre_gate: Vec<String>,
792 pub shell: Option<Vec<String>>,
795}
796
797impl Verify {
798 pub fn cache_dir(&self) -> Option<PathBuf> {
804 self.e2e
805 .iter()
806 .chain(self.gate.iter())
807 .find_map(|cmd| crate::disk::extract_cargo_target_dir(cmd))
808 }
809
810 pub fn cargo_manifests(&self) -> Vec<PathBuf> {
813 let all: Vec<String> = self.e2e.iter().chain(self.gate.iter()).cloned().collect();
814 crate::cache::extract_manifest_paths(&all)
815 }
816}
817
818#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
820#[serde(deny_unknown_fields, default)]
821pub struct Worktree {
822 pub setup: Vec<SetupStep>,
825}
826
827#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
829#[serde(deny_unknown_fields, default)]
830pub struct SetupStep {
831 pub copy: Option<String>,
834 pub optional: bool,
836 pub run: Option<String>,
838 pub timeout_secs: Option<u64>,
841}
842
843impl Worktree {
844 pub fn validate(&self) -> Result<()> {
846 for (i, step) in self.setup.iter().enumerate() {
847 let n = i + 1;
848 match (&step.copy, &step.run) {
849 (Some(c), None) => {
850 if step.timeout_secs.is_some() {
851 bail!("[worktree] setup step {n}: `timeout_secs` applies to `run` only");
852 }
853 crate::worktree_setup::validate_copy(c)
854 .with_context(|| format!("[worktree] setup step {n}"))?;
855 }
856 (None, Some(r)) => {
857 if r.trim().is_empty() {
858 bail!("[worktree] setup step {n}: `run` is empty");
859 }
860 if step.optional {
861 bail!("[worktree] setup step {n}: `optional` applies to `copy` only");
862 }
863 }
864 _ => bail!("[worktree] setup step {n} must set exactly one of `copy` and `run`"),
865 }
866 }
867 Ok(())
868 }
869}
870
871#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
881#[serde(deny_unknown_fields, default)]
882pub struct Disk {
883 pub min_free_bytes: u64,
892 pub auto_fold: bool,
898 pub fold_grace_secs: u64,
906 pub cache_limit_bytes: u64,
918 pub worktree_limit_bytes: u64,
920 pub worktree_reclaim_bytes: u64,
922 pub retained_attempt_limit: usize,
926}
927
928impl Default for Disk {
929 fn default() -> Self {
930 Self {
931 min_free_bytes: 8 * 1024 * 1024 * 1024,
932 auto_fold: true,
933 fold_grace_secs: 6 * 60 * 60,
934 cache_limit_bytes: 10 * 1024 * 1024 * 1024,
935 worktree_limit_bytes: 10 * 1024 * 1024 * 1024,
936 worktree_reclaim_bytes: 16 * 1024 * 1024 * 1024,
937 retained_attempt_limit: 8,
938 }
939 }
940}
941
942#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
944#[serde(rename_all = "lowercase")]
945pub enum MergeMode {
946 None,
949 Local,
952 Pr,
956}
957
958#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
972#[serde(rename_all = "lowercase")]
973pub enum MergeStyle {
974 #[default]
979 Merge,
980 Squash,
986 Rebase,
992}
993
994#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
996#[serde(rename_all = "lowercase")]
997pub enum MergeCi {
998 #[default]
1000 Expected,
1001 None,
1003}
1004
1005impl MergeCi {
1006 pub fn expectation(self) -> crate::land::CiExpectation {
1008 match self {
1009 Self::Expected => crate::land::CiExpectation::Expected,
1010 Self::None => crate::land::CiExpectation::Absent,
1011 }
1012 }
1013}
1014
1015#[derive(Debug, Clone, Deserialize, Serialize)]
1017#[serde(deny_unknown_fields, default)]
1018pub struct Merge {
1019 pub mode: MergeMode,
1022 pub ci: MergeCi,
1024 pub base: Option<String>,
1026 pub style: MergeStyle,
1029 pub remote: String,
1031 pub release_bump: bool,
1044}
1045
1046impl Default for Merge {
1047 fn default() -> Self {
1048 Self {
1049 mode: MergeMode::None,
1050 ci: MergeCi::Expected,
1051 base: None,
1052 style: MergeStyle::default(),
1053 remote: "origin".to_owned(),
1054 release_bump: true,
1055 }
1056 }
1057}
1058
1059#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1061#[serde(rename_all = "lowercase")]
1062pub enum UpdateMode {
1063 Off,
1065 Notify,
1068 Install,
1070}
1071
1072#[derive(Debug, Clone, Deserialize, Serialize)]
1074#[serde(deny_unknown_fields, default)]
1075pub struct Update {
1076 pub mode: UpdateMode,
1078 pub interval: Option<String>,
1080 pub auto_restart: bool,
1082}
1083
1084impl Default for Update {
1085 fn default() -> Self {
1086 Self {
1087 mode: UpdateMode::Notify,
1088 interval: None,
1089 auto_restart: false,
1090 }
1091 }
1092}
1093
1094#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
1096#[serde(rename_all = "lowercase")]
1097pub enum ReleaseMode {
1098 #[default]
1101 Actions,
1102 Local,
1107}
1108
1109#[derive(Debug, Clone, Deserialize, Serialize)]
1111#[serde(deny_unknown_fields, default)]
1112pub struct Release {
1113 pub mode: ReleaseMode,
1116 pub commands: Vec<String>,
1123 pub timeout_minutes: u64,
1125 pub version_files: Vec<VersionFile>,
1133}
1134
1135#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1137#[serde(rename_all = "lowercase")]
1138pub enum VersionKind {
1139 Cargo,
1141 Json,
1143}
1144
1145#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
1147#[serde(deny_unknown_fields)]
1148pub struct VersionFile {
1149 pub path: String,
1151 pub kind: VersionKind,
1153}
1154
1155impl Default for Release {
1156 fn default() -> Self {
1157 Self {
1158 mode: ReleaseMode::Actions,
1159 commands: Vec::new(),
1160 timeout_minutes: 30,
1161 version_files: Vec::new(),
1162 }
1163 }
1164}
1165
1166impl Release {
1167 pub fn validate(&self) -> Result<()> {
1169 crate::release_manifest::validate(&self.version_files).context("[release] version_files")
1170 }
1171
1172 pub fn is_local(&self) -> bool {
1174 self.mode == ReleaseMode::Local
1175 }
1176}
1177
1178#[derive(Debug, Clone, Deserialize, Serialize)]
1180#[serde(try_from = "CoordinationFields")]
1181pub struct Coordination {
1182 pub contract_file: String,
1184 pub contract_token: String,
1186 pub lock_file: String,
1188}
1189
1190#[derive(Deserialize, Default)]
1191#[serde(deny_unknown_fields, default)]
1192struct CoordinationFields {
1193 contract_file: String,
1194 contract_token: String,
1195 lock_file: String,
1196}
1197
1198impl TryFrom<CoordinationFields> for Coordination {
1199 type Error = anyhow::Error;
1200
1201 fn try_from(fields: CoordinationFields) -> Result<Self> {
1202 for (key, name) in [
1203 ("coordination.contract_file", &fields.contract_file),
1204 ("coordination.lock_file", &fields.lock_file),
1205 ] {
1206 if name.trim().is_empty()
1207 || name != name.trim()
1208 || name == "."
1209 || name.contains("..")
1210 || name.contains(['/', '\\', ':', '\0'])
1211 {
1212 bail!(
1213 "{key} must be a plain file name inside the Git common directory (no separators or '..')"
1214 );
1215 }
1216 }
1217 let token = &fields.contract_token;
1218 if token.is_empty() || token.trim() != token || token.chars().any(char::is_control) {
1219 bail!(
1220 "coordination.contract_token must be a nonempty single-line token without surrounding whitespace"
1221 );
1222 }
1223 Ok(Self {
1224 contract_file: fields.contract_file,
1225 contract_token: fields.contract_token,
1226 lock_file: fields.lock_file,
1227 })
1228 }
1229}
1230
1231#[cfg(test)]
1232pub(crate) fn test_coordination() -> Option<Coordination> {
1233 Some(Coordination {
1234 contract_file: "updater.contract".into(),
1235 contract_token: "test-protocol-v1".into(),
1236 lock_file: "updater.lock".into(),
1237 })
1238}
1239
1240#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1242#[serde(deny_unknown_fields, default)]
1243pub struct Config {
1244 pub coordination: Option<Coordination>,
1246 pub agents: Vec<AgentSpec>,
1248 pub roles: Roles,
1250 pub graph: Graph,
1252 pub blind: Blind,
1254 pub verify: Verify,
1256 pub worktree: Worktree,
1258 pub disk: Disk,
1260 pub merge: Merge,
1262 pub update: Update,
1264 pub prompts: Prompts,
1266 pub notify: Notify,
1268 pub repos: Repos,
1271 pub talk: Talk,
1273 pub daemon: Daemon,
1275 pub release: Release,
1277 pub context_windows: BTreeMap<String, u64>,
1280}
1281
1282const BUILTIN_CONTEXT_WINDOWS: &[(&str, u64)] = &[
1292 ("claude-opus-4", 200_000),
1293 ("claude-sonnet-4", 200_000),
1294 ("claude-haiku-4", 200_000),
1295 ("claude-3", 200_000),
1296 ("opus", 200_000),
1297 ("sonnet", 200_000),
1298 ("haiku", 200_000),
1299 ("gpt-5", 400_000),
1300 ("gpt-4.1", 1_000_000),
1301 ("gpt-4o", 128_000),
1302 ("o3", 200_000),
1303 ("o4-mini", 200_000),
1304 ("gemini-2.5", 1_000_000),
1305 ("deepseek", 128_000),
1306];
1307
1308impl Config {
1309 pub fn context_window(&self, model: &str) -> Option<u64> {
1314 fn lookup<'a>(model: &str, entries: impl Iterator<Item = (&'a str, u64)>) -> Option<u64> {
1315 let mut best: Option<(usize, u64)> = None;
1316 for (key, window) in entries {
1317 if key == model {
1318 return Some(window);
1319 }
1320 if model.starts_with(key) && best.is_none_or(|(len, _)| key.len() > len) {
1321 best = Some((key.len(), window));
1322 }
1323 }
1324 best.map(|(_, w)| w)
1325 }
1326 lookup(
1327 model,
1328 self.context_windows.iter().map(|(k, w)| (k.as_str(), *w)),
1329 )
1330 .or_else(|| lookup(model, BUILTIN_CONTEXT_WINDOWS.iter().copied()))
1331 }
1332}
1333
1334#[derive(Debug, Clone, Deserialize, Serialize)]
1344#[serde(deny_unknown_fields, default)]
1345pub struct Daemon {
1346 pub max_concurrent_runs: usize,
1367 pub pause_for_interrupts: bool,
1383 pub max_deputies: usize,
1390 pub release_stall_minutes: u64,
1397}
1398
1399impl Default for Daemon {
1400 fn default() -> Self {
1401 Self {
1402 max_concurrent_runs: 1,
1403 pause_for_interrupts: false,
1404 max_deputies: 2,
1405 release_stall_minutes: 60,
1406 }
1407 }
1408}
1409
1410#[derive(Debug, Clone, Deserialize, Serialize)]
1422#[serde(deny_unknown_fields, default)]
1423pub struct Repos {
1424 pub roots: Vec<PathBuf>,
1428 pub scan_ttl: u64,
1435 pub fetch_interval: u64,
1440}
1441
1442impl Default for Repos {
1443 fn default() -> Self {
1444 Self {
1445 roots: Vec::new(),
1446 scan_ttl: 86_400,
1447 fetch_interval: 600,
1448 }
1449 }
1450}
1451
1452#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1468#[serde(deny_unknown_fields, default)]
1469pub struct Prompts {
1470 pub all: String,
1472 pub implement: String,
1474 pub judge: String,
1476 pub review: String,
1478 pub fix: String,
1480}
1481
1482impl Prompts {
1483 pub fn overlay(&self, node: &str) -> Option<String> {
1488 let specific = match node {
1489 "implement" => &self.implement,
1490 "judge" | "vote" | "deliberate" => &self.judge,
1491 "review" => &self.review,
1492 "fix" => &self.fix,
1493 _ => "",
1494 };
1495 let mut parts: Vec<&str> = Vec::new();
1496 for p in [self.all.trim(), specific.trim()] {
1497 if !p.is_empty() {
1498 parts.push(p);
1499 }
1500 }
1501 if parts.is_empty() {
1502 return None;
1503 }
1504 Some(parts.join("\n\n"))
1505 }
1506}
1507
1508#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1515#[serde(deny_unknown_fields, default)]
1516pub struct Notify {
1517 pub command: Vec<String>,
1520}
1521
1522#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1524#[serde(deny_unknown_fields, default)]
1525pub struct Talk {
1526 pub allow_write: bool,
1535 pub personas: Vec<PersonaSpec>,
1540 pub operator_name: Option<String>,
1544}
1545
1546pub const OPERATOR_NAME_MAX: usize = 64;
1548
1549impl Talk {
1550 pub fn operator_name(&self) -> Option<&str> {
1552 self.operator_name
1553 .as_deref()
1554 .map(str::trim)
1555 .filter(|n| !n.is_empty())
1556 }
1557}
1558
1559#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
1561#[serde(deny_unknown_fields)]
1562pub struct PersonaSpec {
1563 pub id: String,
1565 #[serde(default)]
1568 pub name: String,
1569 #[serde(default)]
1572 pub prompt: String,
1573 #[serde(default, skip_serializing_if = "Option::is_none")]
1577 pub icon: Option<String>,
1578}
1579
1580#[derive(Debug, Clone)]
1582pub struct ResolvedRoles {
1583 pub implementers: Vec<AgentSpec>,
1585 pub judges: Vec<AgentSpec>,
1587 pub reviewers: Vec<AgentSpec>,
1589 pub fixer: Option<Vec<AgentSpec>>,
1594 pub conductor: AgentSpec,
1596 pub implementer_roster: Vec<AgentSpec>,
1604 pub judge_roster: Vec<AgentSpec>,
1608 pub reviewer_roster: Vec<AgentSpec>,
1610}
1611
1612fn array_keys(table: &toml::value::Table, prefix: &str) -> Vec<String> {
1619 let mut out = Vec::new();
1620 for (k, v) in table {
1621 if prefix.is_empty() && k == "vars" {
1622 continue;
1623 }
1624 let path = if prefix.is_empty() {
1625 k.clone()
1626 } else {
1627 format!("{prefix}.{k}")
1628 };
1629 match v {
1630 toml::Value::Array(_) => out.push(path),
1631 toml::Value::Table(t) => out.extend(array_keys(t, &path)),
1632 _ => {}
1633 }
1634 }
1635 out
1636}
1637
1638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1640enum ArrayMerge {
1641 Append,
1645 Replace,
1648}
1649
1650fn array_merge_policy(key: &str) -> ArrayMerge {
1689 match key {
1690 "verify.e2e" | "verify.gate" | "verify.pre_gate" | "repos.roots" => ArrayMerge::Append,
1691 _ => ArrayMerge::Replace,
1692 }
1693}
1694
1695impl Config {
1696 pub fn load(path: &Path) -> Result<Self> {
1699 Self::load_layers(&[path.to_path_buf()])
1700 }
1701
1702 fn render_ctx(paths: &[PathBuf]) -> teravars::Context {
1712 let mut ctx = teravars::system_context();
1713 let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
1714 ctx.insert("env", &env);
1715 if let Some(last) = paths.last()
1716 && let Some(dir) = last.parent()
1717 {
1718 let dir = std::fs::read_to_string(dir.join(REPO_MARKER))
1721 .map(|s| PathBuf::from(s.trim()))
1722 .unwrap_or_else(|_| dir.to_path_buf());
1723 ctx.insert("repo", &dir.to_string_lossy());
1724 ctx.insert(
1725 "repo_name",
1726 &dir.file_name().unwrap_or_default().to_string_lossy(),
1727 );
1728 }
1729 ctx
1730 }
1731
1732 pub fn load_layers(paths: &[PathBuf]) -> Result<Self> {
1740 let mut engine = teravars::Engine::default();
1741 let ctx = Self::render_ctx(paths);
1742 if paths.len() > 1 {
1743 Self::refuse_split_arrays(paths, &mut engine, &ctx).map_err(|error| {
1744 if error.downcast_ref::<ConfigFailure>().is_some() {
1745 error
1746 } else {
1747 let message = error.to_string();
1750 error
1751 .context(ConfigFailure::layers("config-merge", paths))
1752 .context(message)
1753 }
1754 })?;
1755 }
1756 let merged = teravars::load_merged(paths, &mut engine, &ctx)
1757 .map_err(|error| {
1758 let cause = ConfigFailure::render(&error, paths);
1759 anyhow::Error::new(error).context(cause)
1760 })
1761 .with_context(|| {
1762 format!(
1763 "rendering config via teravars: {}",
1764 paths
1765 .iter()
1766 .map(|p| p.display().to_string())
1767 .collect::<Vec<_>>()
1768 .join(", ")
1769 )
1770 })?;
1771 let mut table = merged.config;
1772 Self::normalize_renamed_keys(paths, &mut engine, &ctx, &mut table)?;
1773 table.remove("vars");
1776 let detect_agents = !table.contains_key("agents");
1780 let mut cfg: Self = toml::Value::Table(table)
1781 .try_into()
1782 .context("deserializing magi config")
1783 .context(ConfigFailure::layers("config-deserialize", paths))?;
1784 crate::persona::validate(&cfg.talk.personas)?;
1785 cfg.worktree.validate()?;
1786 cfg.release.validate()?;
1787 if let Some(name) = cfg.talk.operator_name() {
1788 if name.chars().count() > OPERATOR_NAME_MAX || name.chars().any(char::is_control) {
1789 bail!(
1790 "[talk] operator_name must be one line of at most {OPERATOR_NAME_MAX} characters"
1791 );
1792 }
1793 }
1794 if detect_agents {
1795 cfg.agents = Self::autodetected().agents;
1796 }
1797 Ok(cfg)
1798 }
1799
1800 fn normalize_renamed_keys(
1808 paths: &[PathBuf],
1809 engine: &mut teravars::Engine,
1810 ctx: &teravars::Context,
1811 table: &mut toml::map::Map<String, toml::Value>,
1812 ) -> Result<()> {
1813 let both = matches!(
1814 table.get("graph"),
1815 Some(toml::Value::Table(g)) if g.contains_key("candidates") && g.contains_key("implementers")
1816 );
1817 if !both {
1818 return Ok(());
1819 }
1820 let mut winner: Option<toml::Value> = None;
1821 for path in paths {
1822 let one = teravars::load_merged([path], engine, ctx)
1823 .map_err(|error| {
1824 let cause = ConfigFailure::render(&error, std::slice::from_ref(path));
1825 anyhow::Error::new(error).context(cause)
1826 })
1827 .with_context(|| format!("rendering {}", path.display()))?;
1828 let Some(toml::Value::Table(g)) = one.config.get("graph") else {
1829 continue;
1830 };
1831 if g.contains_key("candidates") && g.contains_key("implementers") {
1832 bail!(
1833 "`[graph]` sets both `implementers` and its deprecated alias \n `candidates` in {}; keep only `implementers`.",
1834 path.display()
1835 );
1836 }
1837 if let Some(v) = g.get("implementers").or_else(|| g.get("candidates")) {
1838 winner = Some(v.clone());
1839 }
1840 }
1841 if let Some(toml::Value::Table(g)) = table.get_mut("graph") {
1842 g.remove("candidates");
1843 if let Some(v) = winner {
1844 g.insert("implementers".to_owned(), v);
1845 }
1846 }
1847 Ok(())
1848 }
1849
1850 fn refuse_split_arrays(
1874 paths: &[PathBuf],
1875 engine: &mut teravars::Engine,
1876 ctx: &teravars::Context,
1877 ) -> Result<()> {
1878 let mut seen: std::collections::BTreeMap<String, PathBuf> = Default::default();
1879 for path in paths {
1880 let one = teravars::load_merged([path], engine, ctx)
1881 .map_err(|error| {
1882 let cause = ConfigFailure::render(&error, std::slice::from_ref(path));
1883 anyhow::Error::new(error).context(cause)
1884 })
1885 .with_context(|| format!("rendering {}", path.display()))?;
1886 for key in array_keys(&one.config, "") {
1887 if array_merge_policy(&key) == ArrayMerge::Append {
1888 continue;
1889 }
1890 if let Some(first) = seen.get(&key) {
1891 bail!(
1892 "`{key}` is an array declared in two config layers:\n \
1893 {}\n {}\nteravars appends arrays when it merges, so \
1894 magi would run the concatenation of both - which is \
1895 not what either file says. Declare `{key}` in exactly \
1896 one of them.",
1897 first.display(),
1898 path.display()
1899 );
1900 }
1901 seen.insert(key, path.clone());
1902 }
1903 }
1904 Ok(())
1905 }
1906
1907 pub fn array_provenance(paths: &[PathBuf], key: &str) -> Vec<(PathBuf, Vec<String>)> {
1929 let mut engine = teravars::Engine::default();
1930 let ctx = Self::render_ctx(paths);
1931 let mut out = Vec::new();
1932 for path in paths {
1933 let Ok(one) = teravars::load_merged([path], &mut engine, &ctx) else {
1934 continue;
1935 };
1936 let mut cur = &one.config;
1937 let mut found = None;
1938 let parts: Vec<&str> = key.split('.').collect();
1939 for (i, part) in parts.iter().enumerate() {
1940 match cur.get(*part) {
1941 Some(toml::Value::Array(a)) if i == parts.len() - 1 => {
1942 found = Some(a);
1943 break;
1944 }
1945 Some(toml::Value::Table(t)) => cur = t,
1946 _ => break,
1947 }
1948 }
1949 let Some(values) = found else { continue };
1950 let strings: Vec<String> = values
1951 .iter()
1952 .filter_map(|v| v.as_str().map(str::to_owned))
1953 .collect();
1954 if !strings.is_empty() {
1955 out.push((path.clone(), strings));
1956 }
1957 }
1958 out
1959 }
1960
1961 pub(crate) fn layer_tables(paths: &[PathBuf]) -> Vec<(PathBuf, Result<toml::Table>)> {
1967 let mut engine = teravars::Engine::default();
1968 let ctx = Self::render_ctx(paths);
1969 paths
1970 .iter()
1971 .map(|path| {
1972 let one = teravars::load_merged([path], &mut engine, &ctx)
1973 .map(|m| m.config)
1974 .with_context(|| format!("rendering {}", path.display()));
1975 (path.clone(), one)
1976 })
1977 .collect()
1978 }
1979
1980 pub fn describe_composed(
1990 paths: &[PathBuf],
1991 commands: &[String],
1992 key: &str,
1993 empty: &str,
1994 ) -> String {
1995 if commands.is_empty() {
1996 return empty.to_owned();
1997 }
1998 let joined = commands.join(" && ");
1999 let provenance = Self::array_provenance(paths, key);
2000 if provenance.len() <= 1 {
2001 return joined;
2002 }
2003 let mut out = joined;
2004 for (path, cmds) in &provenance {
2005 out.push_str(&format!("\n [{}] {}", path.display(), cmds.join(" && ")));
2006 }
2007 out
2008 }
2009
2010 pub fn discover(repo: &Path, explicit: Option<&Path>) -> Result<(Self, Vec<PathBuf>)> {
2017 #[cfg(test)]
2018 crate::web::request_cost::discovery();
2019 if let Some(p) = explicit {
2020 let paths = vec![p.to_path_buf()];
2021 return Ok((Self::load_layers(&paths)?, paths));
2022 }
2023 let paths = Self::layers_checked(repo)?;
2024 if paths.is_empty() {
2025 return Ok((Self::autodetected(), paths));
2026 }
2027 Ok((Self::load_layers(&paths)?, paths))
2028 }
2029
2030 pub async fn discover_fetched(
2033 repo: &Path,
2034 explicit: Option<&Path>,
2035 ) -> Result<(Self, Vec<PathBuf>)> {
2036 if explicit.is_none() && is_git_worktree(repo) {
2037 let (remote, cfg_base) = probe_remote_base(repo);
2041 let base = crate::git::merge_base_branch(repo, &remote, cfg_base.as_deref())
2042 .await
2043 .with_context(|| ConfigFailure {
2044 stage: "config-base",
2045 target: remote.clone(),
2046 })?;
2047 let out = crate::git::fetch(repo, &remote, &base)
2048 .await
2049 .with_context(|| format!("fetching {remote}/{base} to read magi.toml"))
2050 .with_context(|| ConfigFailure {
2051 stage: "config-fetch",
2052 target: format!("{remote}/{base}"),
2053 })?;
2054 if !out.ok() {
2055 return Err(anyhow::anyhow!(
2056 "cannot read magi.toml: `git fetch {remote} {base}` failed ({}); refusing to use the local checkout's copy, which may be stale",
2057 out.stderr.lines().next().unwrap_or("").trim()
2058 )).context(ConfigFailure { stage: "config-fetch", target: format!("{remote}/{base}") });
2059 }
2060 if cfg_base.is_none() {
2061 crate::git::ensure_remote_head(repo, &remote, &base).await;
2062 }
2063 }
2064 Self::discover(repo, explicit).map_err(|error| {
2065 if error.downcast_ref::<ConfigFailure>().is_some() {
2066 error
2067 } else {
2068 error.context(ConfigFailure {
2069 stage: "config-load",
2070 target: explicit.unwrap_or(repo).to_string_lossy().into_owned(),
2071 })
2072 }
2073 })
2074 }
2075
2076 fn layers_checked(repo: &Path) -> Result<Vec<PathBuf>> {
2078 if !is_git_worktree(repo) {
2079 return Ok(Self::layers(repo));
2080 }
2081 let mut paths: Vec<PathBuf> = Self::machine_layer()
2082 .into_iter()
2083 .filter(|p| p.is_file())
2084 .collect();
2085 paths.extend(Self::repo_layers(repo)?);
2086 Ok(paths)
2087 }
2088
2089 pub(crate) fn repo_layer_description(repo: &Path, path: &Path) -> String {
2091 let cached = path.ancestors().find(|p| p.join(REPO_MARKER).is_file());
2092 if let Some(root) = cached {
2093 let relative = path.strip_prefix(root).unwrap_or(path);
2094 let reference = resolve_remote_base(repo)
2095 .map(|(remote, base)| format!("{remote}/{base}"))
2096 .unwrap_or_else(|_| "remote base".to_owned());
2097 format!(
2098 "{} at {reference} (including its includes)",
2099 repo.join(relative).display()
2100 )
2101 } else {
2102 format!("{} (including its includes)", path.display())
2103 }
2104 }
2105
2106 pub(crate) fn repo_layers(repo: &Path) -> Result<Vec<PathBuf>> {
2109 if !is_git_worktree(repo) {
2110 return Ok([
2111 repo.join(".magi").join("config.toml"),
2112 repo.join("magi.toml"),
2113 ]
2114 .into_iter()
2115 .filter(|p| p.is_file())
2116 .collect());
2117 }
2118 remote_layers(repo)
2119 }
2120
2121 pub const CONFIG_DIR_ENV: &str = "MAGI_CONFIG_DIR";
2141
2142 pub fn layers(repo: &Path) -> Vec<PathBuf> {
2147 let mut paths = Vec::new();
2148 paths.extend(Self::machine_layer());
2149 paths.push(repo.join(".magi").join("config.toml"));
2150 paths.push(repo.join("magi.toml"));
2151 paths.retain(|p| p.is_file());
2152 paths
2153 }
2154
2155 #[cfg(test)]
2162 pub(crate) fn machine_layer() -> Option<PathBuf> {
2163 std::env::var(Self::CONFIG_DIR_ENV)
2164 .ok()
2165 .filter(|dir| !dir.trim().is_empty())
2166 .map(|dir| PathBuf::from(dir).join("magi").join("config.toml"))
2167 }
2168
2169 #[cfg(not(test))]
2171 pub(crate) fn machine_layer() -> Option<PathBuf> {
2172 match std::env::var(Self::CONFIG_DIR_ENV) {
2173 Ok(dir) if dir.trim().is_empty() => None,
2175 Ok(dir) => Some(PathBuf::from(dir).join("magi").join("config.toml")),
2176 Err(_) => dirs::config_dir().map(|dir| dir.join("magi").join("config.toml")),
2177 }
2178 }
2179
2180 pub fn autodetected() -> Self {
2182 let mut cfg = Self::default();
2183 for (kind, id, model) in [
2184 (AgentKind::Claude, "opus", Some("opus")),
2185 (AgentKind::Claude, "sonnet", Some("sonnet")),
2186 (AgentKind::Antigravity, "antigravity", None),
2187 (AgentKind::Opencode, "opencode", None),
2188 (AgentKind::Codex, "codex", None),
2189 (AgentKind::Omp, "omp", None),
2190 ] {
2191 if kind.program().is_some_and(which) && !cfg.agents.iter().any(|a| a.id == id) {
2192 cfg.agents.push(AgentSpec {
2193 id: id.to_owned(),
2194 kind,
2195 model: model.map(str::to_owned),
2196 command: Vec::new(),
2197 extra_args: Vec::new(),
2198 env: BTreeMap::new(),
2199 prompt_delivery: None,
2200 max_turn_secs: None,
2201 });
2202 }
2203 }
2204 cfg
2205 }
2206
2207 pub fn cache_dir(&self) -> Option<PathBuf> {
2210 self.verify.cache_dir()
2211 }
2212
2213 pub fn agent(&self, id: &str) -> Result<&AgentSpec> {
2215 self.agents
2216 .iter()
2217 .find(|a| a.id == id)
2218 .with_context(|| format!("no agent with id `{id}` in the roster"))
2219 }
2220
2221 fn rotate(&self, ids: &[String], count: usize, offset: usize) -> Result<Vec<AgentSpec>> {
2228 let mut out = Vec::with_capacity(count);
2229 for i in 0..count {
2230 let spec = if ids.is_empty() {
2231 self.agents[(i + offset) % self.agents.len()].clone()
2232 } else {
2233 self.agent(&ids[i % ids.len()])?.clone()
2234 };
2235 out.push(spec);
2236 }
2237 Ok(out)
2238 }
2239
2240 fn full_roster(&self, ids: &[String]) -> Result<Vec<AgentSpec>> {
2247 if ids.is_empty() {
2248 Ok(self.agents.clone())
2249 } else {
2250 ids.iter().map(|id| self.agent(id).cloned()).collect()
2251 }
2252 }
2253
2254 pub fn resolve_roles(&self) -> Result<ResolvedRoles> {
2261 if self.agents.is_empty() {
2262 bail!(
2263 "agent roster is empty: no agent CLI found on PATH and no \
2264 [[agents]] in the config. Run `magi init` to write a starter \
2265 magi.toml."
2266 );
2267 }
2268 let pinned = self
2269 .graph
2270 .selected_implementer
2271 .as_ref()
2272 .map(|id| self.agent(id).cloned())
2273 .transpose()?;
2274 Ok(ResolvedRoles {
2275 implementers: match &pinned {
2276 Some(agent) => vec![agent.clone()],
2277 None => self.rotate(&self.roles.implementers, self.graph.implementers, 0)?,
2278 },
2279 judges: self.rotate(&self.roles.judges, self.graph.judges, 1)?,
2280 reviewers: self.rotate(&self.roles.reviewers, self.graph.reviewers, 0)?,
2281 fixer: match self.roles.fixer.as_ref() {
2282 Some(choice) => Some(crate::agent::pick_chain(
2285 &self.agents,
2286 Some(choice),
2287 &|_| true,
2288 "fixer",
2289 )?),
2290 None => None,
2291 },
2292 conductor: match self.roles.conductor.as_ref().map(AgentChoice::ids) {
2297 Some(ids) if ids.len() == 1 => self.agent(ids[0])?.clone(),
2298 Some(ids) if !ids.is_empty() => ids
2301 .iter()
2302 .find_map(|id| self.agent(id).ok())
2303 .cloned()
2304 .with_context(|| {
2305 format!("[roles] conductor names no agent in the roster: {ids:?}")
2306 })?,
2307 _ => crate::agent::pick(&self.agents, None, &crate::agent::installed)
2312 .unwrap_or_else(|_| self.agents[0].clone()),
2313 },
2314 implementer_roster: match pinned {
2315 Some(agent) => vec![agent],
2316 None => self.full_roster(&self.roles.implementers)?,
2317 },
2318 judge_roster: self.full_roster(&self.roles.judges)?,
2319 reviewer_roster: self.full_roster(&self.roles.reviewers)?,
2320 })
2321 }
2322
2323 pub fn advisors(&self) -> Result<Vec<AgentSpec>> {
2345 if self.agents.is_empty() {
2346 bail!(
2347 "agent roster is empty: no agent CLI found on PATH and no \
2348 [[agents]] in the config. Run `magi init` to write a starter \
2349 magi.toml."
2350 );
2351 }
2352 if !self.roles.advisors.is_empty() {
2353 return self.rotate(&self.roles.advisors, self.graph.advisors, 0);
2354 }
2355 self.rotate(&self.roles.judges, self.graph.advisors, 1)
2356 }
2357
2358 pub fn advisor_roster(&self) -> Result<Vec<AgentSpec>> {
2362 if self.roles.advisors.is_empty() {
2363 self.full_roster(&self.roles.judges)
2364 } else {
2365 self.full_roster(&self.roles.advisors)
2366 }
2367 }
2368
2369 pub fn shell(&self) -> Vec<String> {
2371 if let Some(s) = &self.verify.shell {
2372 return s.clone();
2373 }
2374 if which("sh") {
2375 vec!["sh".to_owned(), "-c".to_owned()]
2376 } else {
2377 vec!["cmd".to_owned(), "/C".to_owned()]
2378 }
2379 }
2380
2381 pub fn starter_toml() -> String {
2383 let detected = Self::autodetected();
2384 let mut s = String::from(
2385 "# magi — blind multi-agent implementation competition.\n\
2386 # `magi run \"<task>\"` walks: implement (N parallel worktrees)\n\
2387 # -> blind judging -> deliberation -> private final vote\n\
2388 # -> fold losers -> review + E2E loop -> gate -> merge.\n\
2389 #\n\
2390 # Rendered by teravars: a `[vars]` table, env\n\
2391 # and system lookups, and `include = [...]` all work. Tera\n\
2392 # braces are live everywhere in this file, but comments are\n\
2393 # stripped before rendering (teravars >= 0.2.2), so a comment\n\
2394 # may quote `{{ ... }}` freely.\n\
2395 #\n\
2396 # Layers deep-merge in increasing\n\
2397 # precedence, so the roster can live once per machine in\n\
2398 # <config_dir>/magi/config.toml and each repo only states its own\n\
2399 # gate:\n\
2400 # <config_dir>/magi/config.toml < .magi/config.toml < magi.toml\n\n\
2401 [vars]\n\
2402 # Reference it as vars.cache inside Tera braces, anywhere below.\n\
2403 # Single quotes inside the braces: teravars renders the raw file\n\
2404 # text, so TOML's own \\\" escaping never reaches Tera.\n\
2405 cache = \"{{ env.MAGI_CACHE | default(value='/tmp') }}\"\n\n",
2406 );
2407 if detected.agents.is_empty() {
2408 s.push_str(
2409 "# No agent CLI was found on PATH. Fill this in by hand.\n\
2410 # kind = claude | opencode | antigravity | codex | command\n\
2411 [[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n",
2412 );
2413 } else {
2414 for a in &detected.agents {
2415 s.push_str("[[agents]]\n");
2416 s.push_str(&format!("id = {:?}\n", a.id));
2417 s.push_str(&format!("kind = {:?}\n", a.kind.as_str()));
2418 if let Some(m) = &a.model {
2419 s.push_str(&format!("model = {m:?}\n"));
2420 }
2421 s.push('\n');
2422 }
2423 }
2424 s.push_str(
2425 "# Leave a role list empty to rotate through the roster.\n\
2426 # A non-empty list is also an ordered backup list: seats take entries from the\n\
2427 # front (see [graph] counts) and a failed or out-of-quota seat moves on to the\n\
2428 # next agent to its right, each tried once. Same for judges and reviewers.\n\
2429 # implementers = [\"opus\", \"codex\", \"sonnet\"] # with implementers = 1 below: opus sits, codex then sonnet are spares\n\
2430 [roles]\n\
2431 implementers = []\n\
2432 judges = []\n\
2433 reviewers = []\n\
2434 # conductor = \"opus\" # arranges the queue; unset picks a seat like chatter does\n\
2435 # synthesizer = \"opus\" # blends the advisors into one brief; unset picks a seat like chatter does\n\
2436 # synthesizer = [\"opus\", \"codex\"] # array form: fallback chain, each tried once on quota or failure\n\
2437 # language_judge = \"jev\" # kind = \"command\" wrapper deciding if PR text is English; unset keeps the built-in heuristics\n\
2438 # dupes_judge = \"opus\" # judges duplicate-work claims; unset inherits chatter (string or fallback chain)\n\
2439 # fixer = \"opus\" # applies review findings; unset keeps the winner's own author\n\
2440 # fixer = [\"opus\", \"codex\"] # array form: fallback chain, each tried once on quota or failure\n\n\
2441 [graph]\n\
2442 # Gate GitHub prose; secret redaction is always enabled.\n\
2443 github_text_guard = true\n\
2444 # Language of PR / issue titles, bodies and comments; non-English drops the English-only check (commits stay English).\n\
2445 github_language = \"en\"\n\
2446 implementers = 3\n\
2447 judges = 3\n\
2448 deliberate_rounds = 1\n\
2449 reviewers = 3\n\
2450 review_rounds = 6\n\
2451 # Fix rounds a failing verify.gate gets before the run is blocked (0 = none).\n\
2452 gate_fix_rounds = 1\n\
2453 # Rerun a failed required CI check once before a land fix round (flaky tests).\n\
2454 rerun_flaky_checks = true\n\
2455 # Port the strengths judges named in the losing candidates onto the\n\
2456 # winner before review (best-effort; rolled back on failure or a big diff).\n\
2457 graft_runner_up = true\n\
2458 max_parallel = 4\n\
2459 language = \"en\"\n\
2460 # One CLI conversation per seat: judges keep their own argument\n\
2461 # across deliberation, the fixer keeps its implementation context.\n\
2462 sessions = true\n\
2463 # Reviewer-seat timeout. When timeout_verify is omitted, E2E and\n\
2464 # the final gate inherit this value for compatibility.\n\
2465 timeout_review = 1200\n\
2466 # Optional independent E2E/final-gate timeout; uncomment to keep\n\
2467 # verification independent if timeout_review changes later.\n\
2468 # timeout_verify = 1200\n\
2469 # Longest a seat waits for the shared build cache before it is\n\
2470 # spawned. Never counted against an agent's own timeout; expiry\n\
2471 # holds the run (resumable) instead of failing the agent.\n\
2472 # timeout_resource_wait = 1800\n\n\
2473 # What the chat calls you (persona address and the Chat's turn label).\n\
2474 # [talk]\n\
2475 # operator_name = \"Commander\"\n\n\
2476 # Chat personas (tone only; the web Chat picks one per conversation).\n\
2477 # Built in: magi, rei, misato, ritsuko, shinji, asuka, kaworu. Entries add to\n\
2478 # them, and one with a built-in's id replaces it. `default` is the plain\n\
2479 # voice and cannot be redefined (it may only set `icon`). Declare this in one\n\
2480 # layer only. `icon` is an optional avatar, display only: an absolute or\n\
2481 # ~/ path to a png/jpg/gif/webp, or a data:image/...;base64 URI, up to\n\
2482 # 256 KiB; a missing or bad one falls back to the built-in avatar.\n\
2483 # [[talk.personas]]\n\
2484 # id = \"gendo\"\n\
2485 # name = \"Gendo Ikari\"\n\
2486 # prompt = \"Speak coldly and tersely, hands folded.\"\n\
2487 # icon = \"~/pictures/gendo.png\"\n\n\
2488 [verify]\n\
2489 # Run once per review round in the winner's worktree; failures are\n\
2490 # fed back to the fixer.\n\
2491 e2e = []\n\
2492 # Final gate. Every command must exit 0 before a merge.\n\
2493 gate = []\n\
2494 # Mechanical fixers (formatters) run in the winner's worktree just\n\
2495 # before the gate; whatever they change becomes one commit. A failing\n\
2496 # command only warns - the gate stays the arbiter. Same timeout as the\n\
2497 # gate (timeout_verify). Keep it light and idempotent.\n\
2498 # pre_gate = []\n\n\
2499 # Prepare each worktree magi creates (candidates, judges, advisors,\n\
2500 # reviewers, the operator-fix and review-takeover checkouts), in order,\n\
2501 # before any agent or verify command touches it. `copy` reads from the\n\
2502 # primary checkout and never overwrites; `optional` skips a missing\n\
2503 # source. `run` goes through the shell with the worktree as cwd and\n\
2504 # fails the seat on a non-zero exit or timeout (timeout_secs, default\n\
2505 # the verify timeout). Setup must not edit tracked files, and what it\n\
2506 # leaves untracked is kept out of magi's commits. Declare this in one\n\
2507 # layer only.\n\
2508 # [worktree]\n\
2509 # setup = [\n\
2510 # { copy = \".env.example -> .env\" },\n\
2511 # { copy = \".env.local\", optional = true },\n\
2512 # { run = \"pnpm install --frozen-lockfile\", timeout_secs = 900 },\n\
2513 # ]\n\n\
2514 [merge]\n\
2515 # none | local | pr\n\
2516 mode = \"none\"\n\
2517 # expected (default) | none. Set none only in repository config without CI.\n\
2518 # Ignores all check statuses; reviews and approval still apply.\n\
2519 # ci = \"expected\"\n\
2520 # release_bump = true # Rust (Cargo.toml) repositories only; a no-op elsewhere\n\n\
2521 [update]\n\
2522 # off | notify | install — checked in the background, throttled.\n\
2523 mode = \"notify\"\n\
2524 # interval = \"24h\"\n\n\
2525 # Opt-in: magi web updates and restarts only after runs/chats finish.\n\
2526 # auto_restart = false\n\
2527 # Uses interval above; off / MAGI_NO_AUTOUPDATE disable it.\n\
2528 # Standalone magi serve cannot restart. Busy work waits indefinitely.\n\
2529 # Failed tags are skipped until process restart or a different tag.\n\
2530 # Older binaries reject auto_restart: upgrade before adding the key.\n\n\
2531 [daemon]\n\
2532 # Minutes a release-bump pull request (chore/release-v*) may sit with\n\
2533 # no new commit and no check changing state before `magi serve`\n\
2534 # notices and asks you. A failed check is rerun once on its own;\n\
2535 # still red after that escalates at once. 0 turns the watcher off.\n\
2536 # release_stall_minutes = 60\n\n\
2537 [release]\n\
2538 # actions (default) | local. \"local\" is for repositories whose GitHub\n\
2539 # Actions never run (private, no minutes): the release pull request\n\
2540 # does not wait for CI, you approve the merge in magi, and then\n\
2541 # `magi serve` pushes the vX.Y.Z tag (never over an existing one) and\n\
2542 # runs `commands` from a clean checkout of the merge commit. A\n\
2543 # failure holds the release with a notice; it is never retried blind.\n\
2544 # Needs `magi serve` running and [daemon] release_stall_minutes > 0.\n\
2545 # For ordinary pull requests without CI, also set [merge] ci = \"none\".\n\
2546 # mode = \"actions\"\n\
2547 # Run in order; the first failure stops. They see MAGI_RELEASE_VERSION,\n\
2548 # MAGI_RELEASE_TAG, MAGI_RELEASE_COMMIT and MAGI_RELEASE_PR. Make them\n\
2549 # safe to repeat: a crash mid-command is treated as unfinished.\n\
2550 # commands = [\"cargo build --release\", \"gh release create \\\"$MAGI_RELEASE_TAG\\\" --generate-notes\"]\n\
2551 # timeout_minutes = 30\n\
2552 # Repositories whose Cargo.toml is not at the root (e.g. a Tauri app).\n\
2553 # The first entry is the version of record and must be the file the\n\
2554 # repository's auto-tag.yml reads (kind \"cargo\"); the others are\n\
2555 # rewritten to the same version. Paths are relative to the repository\n\
2556 # root. Cargo.lock beside the first entry follows; package-lock.json\n\
2557 # is not touched.\n\
2558 # version_files = [\n\
2559 # { path = \"src-tauri/Cargo.toml\", kind = \"cargo\" },\n\
2560 # { path = \"package.json\", kind = \"json\" },\n\
2561 # { path = \"src-tauri/tauri.conf.json\", kind = \"json\" },\n\
2562 # ]\n",
2563 );
2564 s
2565 }
2566}
2567
2568pub fn which(program: &str) -> bool {
2570 find_program(program).is_some()
2571}
2572
2573pub fn find_program(program: &str) -> Option<PathBuf> {
2582 find_program_on(program, &std::env::var_os("PATH")?)
2583}
2584
2585pub fn find_program_on(program: &str, paths: &std::ffi::OsStr) -> Option<PathBuf> {
2588 let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
2589 find_program_in(program, paths, cfg!(windows).then_some(pathext.as_str()))
2590}
2591
2592fn find_program_in(
2593 program: &str,
2594 paths: &std::ffi::OsStr,
2595 pathext: Option<&str>,
2596) -> Option<PathBuf> {
2597 let p = Path::new(program);
2598 if p.components().count() > 1 {
2599 return p.is_file().then(|| p.to_path_buf());
2600 }
2601 std::env::split_paths(paths).find_map(|dir| match pathext {
2602 Some(exts) => {
2603 let bare = dir.join(program);
2604 if p.extension().is_some() && bare.is_file() {
2605 return Some(bare);
2606 }
2607 exts.split(';')
2608 .filter(|e| !e.is_empty())
2609 .map(|e| dir.join(format!("{program}{}", e.to_lowercase())))
2610 .find(|c| c.is_file())
2611 }
2612 None => Some(dir.join(program)).filter(|c| c.is_file()),
2613 })
2614}
2615
2616const REPO_MARKER: &str = ".magi-repo";
2618
2619fn git_out(repo: &Path, args: &[&str]) -> Option<String> {
2620 use crate::proc::Quiet as _;
2621 #[cfg(test)]
2622 crate::web::request_cost::git();
2623 let out = std::process::Command::new("git")
2624 .args(args)
2625 .quiet()
2626 .current_dir(repo)
2627 .stdin(std::process::Stdio::null())
2628 .output()
2629 .ok()?;
2630 out.status
2631 .success()
2632 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
2633}
2634
2635fn is_git_worktree(repo: &Path) -> bool {
2638 git_out(repo, &["rev-parse", "--git-dir"]).is_some()
2639}
2640
2641#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2644pub enum OwnerHold {
2645 LandApproval,
2647 HoldContestedMerge,
2649}
2650
2651impl OwnerHold {
2652 pub const ALL: [Self; 2] = [Self::LandApproval, Self::HoldContestedMerge];
2654
2655 pub fn key(self) -> &'static str {
2657 match self {
2658 Self::LandApproval => "land_approval",
2659 Self::HoldContestedMerge => "hold_contested_merge",
2660 }
2661 }
2662
2663 pub fn base_value(self, graph: &Graph) -> bool {
2665 match self {
2666 Self::LandApproval => graph.land_approval,
2667 Self::HoldContestedMerge => graph.hold_contested_merge,
2668 }
2669 }
2670}
2671
2672pub async fn branch_owner_holds(
2675 repo: &Path,
2676 head: &str,
2677) -> (Vec<(OwnerHold, String)>, Vec<String>) {
2678 let mut holds = Vec::new();
2679 let mut diagnostics = Vec::new();
2680 if let Err(error) =
2683 crate::git::git(repo, &["cat-file", "-e", &format!("{head}^{{commit}}")]).await
2684 {
2685 diagnostics.push(format!("cannot read reviewed commit {head}: {error:#}"));
2686 return (holds, diagnostics);
2687 }
2688 for file in ["magi.toml", ".magi/config.toml"] {
2689 let object = format!("{head}:{file}");
2690 let text = match crate::git::git(repo, &["show", &object]).await {
2691 Ok(text) => text,
2692 Err(error) => {
2693 let error = format!("{error:#}");
2695 if !error.contains("does not exist in")
2696 && !error.contains("exists on disk, but not in")
2697 {
2698 diagnostics.push(format!(
2699 "cannot read reviewed branch {file} at {head}: {error}"
2700 ));
2701 }
2702 continue;
2703 }
2704 };
2705 let value = match toml::from_str::<toml::Value>(&text) {
2706 Ok(value) => value,
2707 Err(error) => {
2708 diagnostics.push(format!(
2709 "cannot parse reviewed branch {file} at {head}: {error}"
2710 ));
2711 continue;
2712 }
2713 };
2714 if value.get("include").is_some() || text.contains("{{") || text.contains("{%") {
2715 diagnostics.push(format!("reviewed branch {file} at {head}: only literal owner holds are read; includes and templates are ignored"));
2716 }
2717 for hold in OwnerHold::ALL {
2718 if value
2719 .get("graph")
2720 .and_then(|g| g.get(hold.key()))
2721 .and_then(toml::Value::as_bool)
2722 == Some(true)
2723 {
2724 holds.push((hold, file.to_owned()));
2725 }
2726 }
2727 }
2728 (holds, diagnostics)
2729}
2730
2731#[derive(Deserialize, Default)]
2732struct ProbeMerge {
2733 base: Option<String>,
2734 remote: Option<String>,
2735}
2736
2737#[derive(Deserialize, Default)]
2738struct Probe {
2739 merge: Option<ProbeMerge>,
2740}
2741
2742fn probe_merge(path: &Path, repo: &Path) -> Option<ProbeMerge> {
2748 if !path.is_file() {
2749 return None;
2750 }
2751 let paths = [path.to_path_buf()];
2752 let mut engine = teravars::Engine::default();
2753 let mut ctx = Config::render_ctx(&paths);
2754 ctx.insert("repo", &repo.to_string_lossy());
2757 ctx.insert(
2758 "repo_name",
2759 &repo.file_name().unwrap_or_default().to_string_lossy(),
2760 );
2761 if let Ok(merged) = teravars::load_merged(&paths, &mut engine, &ctx)
2762 && let Ok(probe) = toml::Value::Table(merged.config).try_into::<Probe>()
2763 {
2764 return probe.merge;
2765 }
2766 let text = std::fs::read_to_string(path).ok()?;
2767 toml::from_str::<Probe>(&text).ok()?.merge
2768}
2769
2770fn probe_remote_base(repo: &Path) -> (String, Option<String>) {
2772 let mut remote = None;
2773 let mut base = None;
2774 let mut sources: Vec<PathBuf> = Config::machine_layer().into_iter().collect();
2775 sources.push(repo.join("magi.toml"));
2776 sources.push(repo.join(".magi").join("config.toml"));
2777 for path in sources {
2778 if let Some(m) = probe_merge(&path, repo) {
2779 remote = remote.or(m.remote.filter(|s| !s.trim().is_empty()));
2780 base = base.or(m.base.filter(|s| !s.trim().is_empty()));
2781 }
2782 }
2783 (remote.unwrap_or_else(|| "origin".to_owned()), base)
2784}
2785
2786fn resolve_remote_base(repo: &Path) -> Result<(String, String)> {
2788 let (remote, base) = probe_remote_base(repo);
2789 let base = match base {
2790 Some(b) => b,
2791 None => {
2792 let head = format!("refs/remotes/{remote}/HEAD");
2793 git_out(repo, &["symbolic-ref", "--quiet", &head])
2794 .and_then(|s| crate::git::remote_head_branch(&remote, &s))
2795 .with_context(|| {
2796 format!(
2797 "cannot tell which branch of `{remote}` holds magi.toml: \
2798 set [merge] base, or run `git remote set-head {remote} -a`"
2799 )
2800 })?
2801 }
2802 };
2803 Ok((remote, base))
2804}
2805
2806fn fnv_hex(text: &str) -> String {
2807 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2808 for b in text.bytes() {
2809 hash ^= u64::from(b);
2810 hash = hash.wrapping_mul(0x0100_0000_01b3);
2811 }
2812 format!("{hash:016x}")
2813}
2814
2815fn declares_include(text: &str) -> bool {
2821 match toml::from_str::<toml::Table>(text) {
2822 Ok(table) => {
2823 table.contains_key("include")
2824 || table
2825 .get("teravars")
2826 .and_then(toml::Value::as_table)
2827 .is_some_and(|t| t.contains_key("include"))
2828 }
2829 Err(_) => text.contains("include"),
2830 }
2831}
2832
2833fn extract_tree(repo: &Path, sha: &str, dest: &Path) -> Result<()> {
2836 use crate::proc::Quiet as _;
2837 #[cfg(test)]
2838 crate::web::request_cost::git();
2839 let mut archive = std::process::Command::new("git")
2840 .args(["archive", "--format=tar", sha])
2841 .quiet()
2842 .current_dir(repo)
2843 .stdin(std::process::Stdio::null())
2844 .stdout(std::process::Stdio::piped())
2845 .stderr(std::process::Stdio::null())
2846 .spawn()
2847 .context("running git archive")?;
2848 let tar = std::process::Command::new("tar")
2849 .arg("-x")
2850 .arg("-C")
2851 .arg(dest)
2852 .quiet()
2853 .stdin(archive.stdout.take().context("git archive has no stdout")?)
2854 .output()
2855 .context("running tar")?;
2856 let status = archive.wait().context("waiting for git archive")?;
2857 if !status.success() || !tar.status.success() {
2858 bail!(
2859 "cannot unpack {sha} for the config's includes: {}",
2860 String::from_utf8_lossy(&tar.stderr).trim()
2861 );
2862 }
2863 Ok(())
2864}
2865
2866fn remote_layers(repo: &Path) -> Result<Vec<PathBuf>> {
2869 let (remote, base) = resolve_remote_base(repo)?;
2870 let tracking = format!("refs/remotes/{remote}/{base}");
2871 let spec = format!("{tracking}^{{commit}}");
2872 let sha = git_out(repo, &["rev-parse", "--verify", "--quiet", &spec])
2873 .map(|s| s.trim().to_owned())
2874 .filter(|s| !s.is_empty())
2875 .with_context(|| {
2876 format!(
2877 "`{remote}/{base}` does not exist in this checkout; fetch it \
2878 (`git fetch {remote} {base}`) or set [merge] base/remote. magi will not read \
2879 a possibly stale local magi.toml instead"
2880 )
2881 })?;
2882 let key = format!("{sha}-{}", fnv_hex(&repo.to_string_lossy()));
2883 let rels = [".magi/config.toml", "magi.toml"];
2884 let mut texts: Vec<(&str, String)> = Vec::new();
2885 for rel in rels {
2886 let object = format!("{sha}:{rel}");
2887 if git_out(repo, &["cat-file", "-e", &object]).is_none() {
2888 continue;
2889 }
2890 let Some(text) = git_out(repo, &["show", &object]) else {
2891 bail!("cannot read {rel} from {remote}/{base} ({sha})");
2892 };
2893 texts.push((rel, text));
2894 }
2895 let wants_tree = texts.iter().any(|(_, t)| declares_include(t));
2896 let build = |root: &Path| -> Result<PathBuf> {
2900 let final_dir = root.join(&key);
2901 if !final_dir.is_dir() {
2902 std::fs::create_dir_all(root)?;
2903 let tmp = root.join(format!(
2904 "{key}.tmp-{}-{}",
2905 std::process::id(),
2906 std::time::SystemTime::now()
2907 .duration_since(std::time::UNIX_EPOCH)
2908 .map(|d| d.as_nanos())
2909 .unwrap_or_default()
2910 ));
2911 let filled = (|| -> Result<()> {
2912 std::fs::create_dir_all(&tmp)?;
2913 if wants_tree {
2914 extract_tree(repo, &sha, &tmp)?;
2915 }
2916 for (rel, text) in &texts {
2917 let file = tmp.join(rel);
2918 let parent = file.parent().unwrap_or(&tmp);
2919 std::fs::create_dir_all(parent)?;
2920 std::fs::write(&file, text)?;
2921 std::fs::write(parent.join(REPO_MARKER), repo.to_string_lossy().as_bytes())?;
2922 }
2923 Ok(())
2924 })();
2925 if let Err(e) = filled {
2926 let _ = std::fs::remove_dir_all(&tmp);
2927 return Err(e);
2928 }
2929 if std::fs::rename(&tmp, &final_dir).is_err() {
2930 let _ = std::fs::remove_dir_all(&tmp);
2932 if !final_dir.is_dir() {
2933 bail!("cannot place {}", final_dir.display());
2934 }
2935 }
2936 }
2937 Ok(final_dir)
2938 };
2939 let common = git_out(
2940 repo,
2941 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
2942 )
2943 .map(|s| PathBuf::from(s.trim()).join("magi").join("config"));
2944 let dir = match common.map(|c| build(&c)) {
2945 Some(Ok(d)) => d,
2946 _ => build(&std::env::temp_dir().join("magi-config"))
2947 .with_context(|| format!("extracting config from {remote}/{base}"))?,
2948 };
2949 Ok(texts.iter().map(|(rel, _)| dir.join(rel)).collect())
2950}
2951
2952#[cfg(test)]
2953mod tests {
2954 #[test]
2955 fn run_records_with_the_retired_editing_network_key_still_load() {
2956 for value in ["enabled", "restricted"] {
2958 let mut graph = serde_json::to_value(Graph::default()).unwrap();
2959 graph["editing_network"] = serde_json::json!(value);
2960 let back: Graph = serde_json::from_value(graph).unwrap();
2961 assert!(
2962 serde_json::to_value(&back)
2963 .unwrap()
2964 .get("editing_network")
2965 .is_none()
2966 );
2967 let from_toml: Graph = toml::from_str(&format!("editing_network = {value:?}")).unwrap();
2968 assert!(
2969 !toml::to_string(&from_toml)
2970 .unwrap()
2971 .contains("editing_network")
2972 );
2973 }
2974 }
2975
2976 #[test]
2977 fn coordination_defaults_off_and_validates_rendered_layer_values() {
2978 let dir = tempfile::tempdir().unwrap();
2979 let machine = dir.path().join("machine.toml");
2980 let repo = dir.path().join("repo.toml");
2981 std::fs::write(&machine, "").unwrap();
2982 assert!(Config::default().coordination.is_none());
2983 assert!(Config::load(&machine).unwrap().coordination.is_none());
2984 std::fs::write(
2985 &machine,
2986 r#"
2987[vars]
2988contract = "updater.contract"
2989[coordination]
2990contract_file = "{{ vars.contract }}"
2991contract_token = "protocol-v1"
2992lock_file = "updater.lock"
2993"#,
2994 )
2995 .unwrap();
2996 let cfg = Config::load(&machine).unwrap();
2997 let policy = cfg.coordination.unwrap();
2998 assert_eq!(policy.contract_file, "updater.contract");
2999 assert_eq!(policy.contract_token, "protocol-v1");
3000 assert_eq!(policy.lock_file, "updater.lock");
3001 std::fs::write(&repo, "[coordination]\nlock_file = 'repository.lock'\n").unwrap();
3002 let cfg = Config::load_layers(&[machine.clone(), repo.clone()]).unwrap();
3003 let policy = cfg.coordination.unwrap();
3004 assert_eq!(policy.contract_file, "updater.contract");
3005 assert_eq!(policy.lock_file, "repository.lock");
3006 std::fs::write(
3007 &repo,
3008 r#"
3009[vars]
3010bad = "../escape"
3011[coordination]
3012lock_file = "{{ vars.bad }}"
3013"#,
3014 )
3015 .unwrap();
3016 let error = Config::load_layers(&[machine, repo]).unwrap_err();
3017 assert!(format!("{error:#}").contains("coordination.lock_file"));
3018 }
3019
3020 #[test]
3021 fn coordination_rejects_invalid_names_tokens_and_incomplete_sections() {
3022 let dir = tempfile::tempdir().unwrap();
3023 let path = dir.path().join("config.toml");
3024 let valid = toml::toml! {
3025 [coordination]
3026 contract_file = "updater.contract"
3027 contract_token = "protocol-v1"
3028 lock_file = "updater.lock"
3029 };
3030 for key in ["contract_file", "lock_file"] {
3031 for bad in [
3032 "",
3033 " ",
3034 " file",
3035 "file ",
3036 "\tfile",
3037 "file\t",
3038 ".",
3039 "..",
3040 "file..name",
3041 "../escape",
3042 "a/b",
3043 "a\\b",
3044 "/absolute",
3045 "C:drive",
3046 "C:\\absolute",
3047 "nul\0name",
3048 ] {
3049 let mut table = valid.clone();
3050 table["coordination"][key] = bad.into();
3051 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
3052 let error = Config::load(&path).unwrap_err();
3053 assert!(
3054 format!("{error:#}").contains(&format!("coordination.{key}")),
3055 "{bad:?}: {error:#}"
3056 );
3057 }
3058 }
3059 for bad in ["", " ", " token", "token ", "two\nlines", "two\rlines"] {
3060 let mut table = valid.clone();
3061 table["coordination"]["contract_token"] = bad.into();
3062 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
3063 let error = Config::load(&path).unwrap_err();
3064 assert!(
3065 format!("{error:#}").contains("coordination.contract_token"),
3066 "{error:#}"
3067 );
3068 }
3069 for key in ["contract_file", "contract_token", "lock_file"] {
3070 let mut table = valid.clone();
3071 table["coordination"].as_table_mut().unwrap().remove(key);
3072 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
3073 let error = Config::load(&path).unwrap_err();
3074 assert!(
3075 format!("{error:#}").contains(&format!("coordination.{key}")),
3076 "{error:#}"
3077 );
3078 }
3079 let mut table = valid;
3080 table["coordination"]
3081 .as_table_mut()
3082 .unwrap()
3083 .insert("typo".into(), "value".into());
3084 std::fs::write(&path, toml::to_string(&table).unwrap()).unwrap();
3085 assert!(format!("{:#}", Config::load(&path).unwrap_err()).contains("typo"));
3086 }
3087
3088 #[test]
3089 fn context_window_prefers_exact_then_longest_prefix_and_user_over_builtin() {
3090 let mut cfg = Config::default();
3091 assert_eq!(
3092 cfg.context_window("claude-sonnet-4-5-20250929"),
3093 Some(200_000)
3094 );
3095 assert_eq!(cfg.context_window("gpt-4.1-mini"), Some(1_000_000));
3096 assert_eq!(cfg.context_window("mystery-model"), None);
3097 cfg.context_windows
3098 .insert("claude-sonnet-4-5".into(), 1_000_000);
3099 cfg.context_windows.insert("claude-sonnet-4".into(), 5);
3100 cfg.context_windows.insert("mystery".into(), 32_768);
3101 assert_eq!(
3103 cfg.context_window("claude-sonnet-4-5-20250929"),
3104 Some(1_000_000)
3105 );
3106 cfg.context_windows
3108 .insert("claude-sonnet-4-5-20250929".into(), 7);
3109 assert_eq!(cfg.context_window("claude-sonnet-4-5-20250929"), Some(7));
3110 assert_eq!(cfg.context_window("mystery-model"), Some(32_768));
3111 }
3112
3113 use super::*;
3114
3115 #[test]
3118 fn windows_lookup_picks_the_cmd_shim_over_the_extensionless_script() {
3119 let dir = tempfile::tempdir().unwrap();
3120 std::fs::write(dir.path().join("codex"), "#!/bin/sh\n").unwrap();
3121 std::fs::write(dir.path().join("codex.cmd"), "@echo off\n").unwrap();
3122 let paths = dir.path().as_os_str();
3123 assert_eq!(
3124 find_program_in("codex", paths, Some(".EXE;.CMD")),
3125 Some(dir.path().join("codex.cmd"))
3126 );
3127 assert_eq!(
3128 find_program_in("codex", paths, None),
3129 Some(dir.path().join("codex"))
3130 );
3131 assert_eq!(find_program_in("claude", paths, Some(".EXE;.CMD")), None);
3132 }
3133
3134 fn spec(id: &str) -> AgentSpec {
3135 AgentSpec {
3136 id: id.to_owned(),
3137 kind: AgentKind::Command,
3138 model: None,
3139 command: vec!["true".to_owned()],
3140 extra_args: Vec::new(),
3141 env: BTreeMap::new(),
3142 prompt_delivery: None,
3143 max_turn_secs: None,
3144 }
3145 }
3146
3147 #[test]
3148 fn merge_ci_defaults_expected_and_requires_an_explicit_valid_opt_out() {
3149 let default: Merge = toml::from_str("").unwrap();
3150 assert_eq!(default.ci, MergeCi::Expected);
3151 let config = layered("", "[merge]\nci = \"none\"\n").unwrap();
3152 assert_eq!(
3153 config.merge.ci.expectation(),
3154 crate::land::CiExpectation::Absent
3155 );
3156 assert!(toml::from_str::<Merge>("ci = \"typo\"").is_err());
3157 assert!(Config::starter_toml().contains("# ci = \"expected\""));
3158 }
3159
3160 #[test]
3161 fn github_text_guard_defaults_on_and_can_be_disabled() {
3162 let default: Graph = toml::from_str("").unwrap();
3163 assert!(default.github_text_guard);
3164 let disabled: Graph = toml::from_str("github_text_guard = false").unwrap();
3165 assert!(!disabled.github_text_guard);
3166 let config = layered("", "[graph]\ngithub_text_guard = false\n").unwrap();
3167 assert!(!config.graph.github_text_guard);
3168 assert!(Config::starter_toml().contains("github_text_guard = true"));
3169 }
3170
3171 #[test]
3172 fn rerun_flaky_checks_defaults_on_and_is_in_the_starter() {
3173 assert!(Graph::default().rerun_flaky_checks);
3174 let off: Graph = toml::from_str("rerun_flaky_checks = false").unwrap();
3175 assert!(!off.rerun_flaky_checks);
3176 assert!(Config::starter_toml().contains("rerun_flaky_checks = true"));
3177 }
3178
3179 #[test]
3180 fn timeout_verify_omitted_from_old_toml_inherits_timeout_review() {
3181 let g: Graph = toml::from_str("timeout_review = 3600").expect("parse");
3186 assert_eq!(g.timeout_verify, None);
3187 assert_eq!(g.verify_timeout(), 3600);
3188 }
3189
3190 #[test]
3191 fn shrinking_timeout_review_does_not_shrink_timeout_verify() {
3192 let g: Graph = toml::from_str("timeout_review = 45").expect("parse");
3197 assert_eq!(g.timeout_review, 45);
3198 assert_eq!(
3199 g.verify_timeout(),
3200 45,
3201 "an omitted legacy value follows review"
3202 );
3203 let explicit: Graph = toml::from_str("timeout_review = 45\ntimeout_verify = 1200")
3204 .expect("parse explicit override");
3205 assert_eq!(explicit.verify_timeout(), 1200);
3206 }
3207
3208 #[test]
3209 fn graph_implementers_new_key_loads_and_old_key_is_an_alias() {
3210 let g: Graph = toml::from_str("implementers = 4\n").unwrap();
3211 assert_eq!(g.implementers, 4);
3212 let g: Graph = toml::from_str("candidates = 5\n").unwrap();
3213 assert_eq!(g.implementers, 5);
3214 }
3215
3216 fn layered(lower: &str, upper: &str) -> Result<Config> {
3217 let dir = tempfile::tempdir().unwrap();
3218 let a = dir.path().join("a.toml");
3219 let b = dir.path().join("b.toml");
3220 std::fs::write(&a, lower).unwrap();
3221 std::fs::write(&b, upper).unwrap();
3222 Config::load_layers(&[a, b])
3223 }
3224
3225 #[test]
3226 fn graph_implementers_higher_layer_wins_across_spellings() {
3227 let c = layered("[graph]\ncandidates = 2\n", "[graph]\nimplementers = 5\n").unwrap();
3228 assert_eq!(c.graph.implementers, 5);
3229 let c = layered("[graph]\nimplementers = 2\n", "[graph]\ncandidates = 5\n").unwrap();
3230 assert_eq!(c.graph.implementers, 5);
3231 let c = layered("[graph]\ncandidates = 2\n", "[graph]\njudges = 3\n").unwrap();
3232 assert_eq!(c.graph.implementers, 2);
3233 }
3234
3235 #[test]
3236 fn graph_implementers_both_keys_in_one_layer_is_refused() {
3237 let err = layered("", "[graph]\ncandidates = 2\nimplementers = 3\n")
3238 .unwrap_err()
3239 .to_string();
3240 assert!(
3241 err.contains("candidates") && err.contains("implementers"),
3242 "{err}"
3243 );
3244 assert!(err.contains("b.toml"), "{err}");
3245 }
3246
3247 #[test]
3248 fn a_toml_layer_written_before_these_fields_existed_still_parses() {
3249 let g: Graph =
3253 toml::from_str("candidates = 1\nreviewers = 3\nreview_rounds = 6\nmax_parallel = 4\n")
3254 .expect("an old-shaped [graph] table must still parse");
3255 assert_eq!(g.verify_timeout(), Graph::default().timeout_review);
3256 assert!(
3257 !g.e2e_every_round,
3258 "off by default, same as before this field existed"
3259 );
3260 }
3261
3262 #[test]
3263 fn empty_roles_rotate_judges_off_their_own_candidate() {
3264 let cfg = Config {
3267 agents: vec![spec("a"), spec("b"), spec("c")],
3268 graph: Graph {
3269 implementers: 3,
3270 ..Graph::default()
3271 },
3272 ..Config::default()
3273 };
3274 let roles = cfg.resolve_roles().unwrap();
3275 let impls: Vec<&str> = roles.implementers.iter().map(|a| a.id.as_str()).collect();
3276 let judges: Vec<&str> = roles.judges.iter().map(|a| a.id.as_str()).collect();
3277 assert_eq!(impls, ["a", "b", "c"]);
3278 assert_eq!(judges, ["b", "c", "a"]);
3279 for (i, j) in judges.iter().enumerate() {
3280 assert_ne!(*j, impls[i], "judge {i} must not sit on its own candidate");
3281 }
3282 }
3283
3284 #[test]
3285 fn single_agent_roster_fills_every_seat() {
3286 let cfg = Config {
3287 agents: vec![spec("solo")],
3288 graph: Graph {
3289 implementers: 3,
3290 ..Graph::default()
3291 },
3292 ..Config::default()
3293 };
3294 let roles = cfg.resolve_roles().unwrap();
3295 assert_eq!(roles.implementers.len(), 3);
3296 assert!(roles.judges.iter().all(|a| a.id == "solo"));
3297 }
3298
3299 #[test]
3300 fn implementer_roster_is_the_whole_agent_list_untruncated_when_unset() {
3301 let cfg = Config {
3306 agents: vec![spec("a"), spec("b"), spec("c")],
3307 graph: Graph {
3308 implementers: 1,
3309 ..Graph::default()
3310 },
3311 ..Config::default()
3312 };
3313 let roles = cfg.resolve_roles().unwrap();
3314 assert_eq!(roles.implementers.len(), 1);
3315 let roster: Vec<&str> = roles
3316 .implementer_roster
3317 .iter()
3318 .map(|a| a.id.as_str())
3319 .collect();
3320 assert_eq!(roster, ["a", "b", "c"]);
3321 }
3322
3323 #[test]
3324 fn implementer_roster_follows_explicit_ids_in_the_order_named() {
3325 let cfg = Config {
3326 agents: vec![spec("a"), spec("b"), spec("c")],
3327 roles: Roles {
3328 implementers: vec!["c".to_owned(), "a".to_owned()],
3329 ..Roles::default()
3330 },
3331 graph: Graph {
3332 implementers: 1,
3333 ..Graph::default()
3334 },
3335 ..Config::default()
3336 };
3337 let roles = cfg.resolve_roles().unwrap();
3338 assert_eq!(roles.implementers.len(), 1);
3340 assert_eq!(roles.implementers[0].id, "c");
3341 let roster: Vec<&str> = roles
3342 .implementer_roster
3343 .iter()
3344 .map(|a| a.id.as_str())
3345 .collect();
3346 assert_eq!(roster, ["c", "a"]);
3347 }
3348
3349 #[test]
3350 fn explicit_roles_win() {
3351 let cfg = Config {
3352 agents: vec![spec("a"), spec("b")],
3353 roles: Roles {
3354 implementers: vec!["b".to_owned()],
3355 judges: vec!["a".to_owned()],
3356 reviewers: Vec::new(),
3357 fixer: Some("a".into()),
3358 ..Roles::default()
3359 },
3360 ..Config::default()
3361 };
3362 let roles = cfg.resolve_roles().unwrap();
3363 assert!(roles.implementers.iter().all(|a| a.id == "b"));
3364 assert!(roles.judges.iter().all(|a| a.id == "a"));
3365 let fixer = roles.fixer.unwrap();
3366 assert_eq!(fixer.len(), 1);
3367 assert_eq!(fixer[0].id, "a");
3368 assert_eq!(roles.conductor.id, "a");
3369 }
3370
3371 #[test]
3372 fn fixer_takes_a_string_or_an_ordered_chain() {
3373 let one: Roles = toml::from_str("fixer = \"a\"").unwrap();
3374 assert_eq!(one.fixer.as_ref().unwrap().ids(), ["a"]);
3375 let chain: Roles = toml::from_str("fixer = [\"b\", \"a\"]").unwrap();
3376 assert_eq!(chain.fixer.as_ref().unwrap().ids(), ["b", "a"]);
3377
3378 let resolve = |fixer: Option<AgentChoice>| {
3379 Config {
3380 agents: vec![spec("a"), spec("b"), spec("c")],
3381 roles: Roles {
3382 fixer,
3383 ..Roles::default()
3384 },
3385 ..Config::default()
3386 }
3387 .resolve_roles()
3388 };
3389 let ids = |r: &ResolvedRoles| -> Vec<String> {
3390 r.fixer
3391 .as_ref()
3392 .unwrap()
3393 .iter()
3394 .map(|s| s.id.clone())
3395 .collect()
3396 };
3397 assert_eq!(ids(&resolve(Some("c".into())).unwrap()), ["c"]);
3399 assert!(resolve(None).unwrap().fixer.is_none());
3400 let chain = AgentChoice::Chain(vec!["b".into(), "zzz".into(), "a".into(), "b".into()]);
3402 assert_eq!(ids(&resolve(Some(chain)).unwrap()), ["b", "a"]);
3403 let err = resolve(Some(AgentChoice::Chain(vec!["zzz".into()]))).unwrap_err();
3405 assert!(format!("{err:#}").contains("fixer"), "{err:#}");
3406 }
3407
3408 #[test]
3409 fn unknown_agent_id_is_an_error() {
3410 let cfg = Config {
3411 agents: vec![spec("a")],
3412 roles: Roles {
3413 judges: vec!["nope".to_owned()],
3414 ..Roles::default()
3415 },
3416 ..Config::default()
3417 };
3418 assert!(cfg.resolve_roles().is_err());
3419 }
3420
3421 #[test]
3422 fn conductor_role_is_resolved_validated_and_has_a_fallback() {
3423 let mut cfg = Config {
3424 agents: vec![spec("a"), spec("b")],
3425 ..Config::default()
3426 };
3427 assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "a");
3428
3429 cfg.roles.conductor = Some("b".into());
3430 assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "b");
3431
3432 cfg.roles.conductor = Some("missing".into());
3433 assert!(cfg.resolve_roles().is_err());
3434 }
3435
3436 #[test]
3437 fn a_standalone_role_takes_a_string_or_an_array_and_round_trips() {
3438 let one: Roles = toml::from_str("synthesizer = \"opus\"").unwrap();
3439 assert_eq!(one.synthesizer, Some(AgentChoice::One("opus".to_owned())));
3440 assert_eq!(primary(one.synthesizer.as_ref()), Some("opus"));
3441
3442 let chain: Roles = toml::from_str("chatter = [\"opus\", \"codex\"]").unwrap();
3443 assert_eq!(chain.chatter.as_ref().unwrap().ids(), ["opus", "codex"]);
3444 assert_eq!(primary(chain.chatter.as_ref()), Some("opus"));
3445
3446 for roles in [one, chain] {
3447 let text = toml::to_string(&roles).unwrap();
3448 let back: Roles = toml::from_str(&text).unwrap();
3449 assert_eq!(back.synthesizer, roles.synthesizer);
3450 assert_eq!(back.chatter, roles.chatter);
3451 }
3452 let text = toml::to_string(&Roles {
3453 conductor: Some("x".into()),
3454 ..Roles::default()
3455 })
3456 .unwrap();
3457 assert!(
3458 text.contains("conductor = \"x\""),
3459 "string stays a string: {text}"
3460 );
3461 assert!(toml::from_str::<Roles>("synthesizer = 3").is_err());
3462 }
3463
3464 #[test]
3465 fn dupes_judge_role_accepts_strings_and_chains_and_round_trips() {
3466 for (text, ids) in [
3467 (r#"dupes_judge = "dedicated""#, vec!["dedicated"]),
3468 (
3469 r#"dupes_judge = ["first", "second"]"#,
3470 vec!["first", "second"],
3471 ),
3472 ("dupes_judge = []", vec![]),
3473 ] {
3474 let roles: Roles = toml::from_str(text).unwrap();
3475 assert_eq!(roles.dupes_judge.as_ref().unwrap().ids(), ids);
3476 let serialized = toml::to_string(&roles).unwrap();
3477 let back: Roles = toml::from_str(&serialized).unwrap();
3478 assert_eq!(back.dupes_judge, roles.dupes_judge);
3479 assert!(serialized.contains(text));
3480 }
3481 assert!(toml::from_str::<Roles>("dupes_judge = 3").is_err());
3482 assert!(Roles::default().dupes_judge.is_none());
3483 assert!(Config::starter_toml().contains("# dupes_judge ="));
3484
3485 let mut cfg = Config {
3487 agents: vec![spec("a")],
3488 ..Config::default()
3489 };
3490 cfg.roles.dupes_judge = Some("missing".into());
3491 assert!(cfg.resolve_roles().is_ok());
3492 }
3493
3494 #[test]
3495 fn an_empty_choice_counts_as_unset() {
3496 assert!(AgentChoice::Chain(Vec::new()).ids().is_empty());
3497 assert!(AgentChoice::One(" ".to_owned()).ids().is_empty());
3498 assert_eq!(primary(Some(&AgentChoice::Chain(vec![]))), None);
3499 }
3500
3501 #[test]
3502 fn synthesizer_role_is_a_lazy_lookup_with_the_same_fallback_as_chatter() {
3503 let mut cfg = Config {
3509 agents: vec![spec("a"), spec("b")],
3510 ..Config::default()
3511 };
3512 let want = primary(cfg.roles.synthesizer.as_ref());
3513 assert_eq!(
3514 crate::agent::pick(&cfg.agents, want, &crate::agent::installed)
3515 .unwrap()
3516 .id,
3517 "a",
3518 "unset falls back to agent::pick's own default order"
3519 );
3520
3521 cfg.roles.synthesizer = Some("b".into());
3522 let want = primary(cfg.roles.synthesizer.as_ref());
3523 assert_eq!(
3524 crate::agent::pick(&cfg.agents, want, &crate::agent::installed)
3525 .unwrap()
3526 .id,
3527 "b",
3528 "the named agent wins over the default order"
3529 );
3530 }
3531
3532 #[test]
3533 fn empty_roster_is_an_error() {
3534 assert!(Config::default().resolve_roles().is_err());
3535 }
3536
3537 #[test]
3538 fn advise_defaults_to_on_with_three_proposals() {
3539 let g = Graph::default();
3540 assert!(g.advise);
3541 assert!(g.graft_runner_up, "graft is on by default");
3542 assert_eq!(g.advisors, 3);
3543 }
3544
3545 #[test]
3546 fn unset_advisors_falls_back_to_the_judge_roster() {
3547 let cfg = Config {
3548 agents: vec![spec("a"), spec("b")],
3549 roles: Roles {
3550 judges: vec!["b".to_owned()],
3551 ..Roles::default()
3552 },
3553 graph: Graph {
3554 advisors: 2,
3555 ..Graph::default()
3556 },
3557 ..Config::default()
3558 };
3559 let advisors = cfg.advisors().expect("advisors resolve");
3560 assert_eq!(advisors.len(), 2);
3561 assert!(
3562 advisors.iter().all(|a| a.id == "b"),
3563 "an unset [roles] advisors must fall back to [roles] judges: {advisors:?}"
3564 );
3565 }
3566
3567 #[test]
3568 fn an_explicit_advisor_roster_wins_over_the_judge_fallback() {
3569 let cfg = Config {
3570 agents: vec![spec("a"), spec("b")],
3571 roles: Roles {
3572 judges: vec!["b".to_owned()],
3573 advisors: vec!["a".to_owned()],
3574 ..Roles::default()
3575 },
3576 graph: Graph {
3577 advisors: 2,
3578 ..Graph::default()
3579 },
3580 ..Config::default()
3581 };
3582 let advisors = cfg.advisors().expect("advisors resolve");
3583 assert!(advisors.iter().all(|a| a.id == "a"));
3584 }
3585
3586 #[test]
3592 fn an_unconfigured_advisor_and_judge_roster_resolve_to_the_same_agents() {
3593 let cfg = Config {
3594 agents: vec![spec("a"), spec("b"), spec("c")],
3595 graph: Graph {
3596 advisors: 3,
3597 judges: 3,
3598 ..Graph::default()
3599 },
3600 ..Config::default()
3601 };
3602 let advisors = cfg.advisors().expect("advisors resolve");
3603 let judges = cfg.resolve_roles().expect("roles resolve").judges;
3604 let advisor_ids: Vec<&str> = advisors.iter().map(|a| a.id.as_str()).collect();
3605 let judge_ids: Vec<&str> = judges.iter().map(|a| a.id.as_str()).collect();
3606 assert_eq!(
3607 advisor_ids, judge_ids,
3608 "an unconfigured advisor roster must be the same seats an unconfigured judge panel gets"
3609 );
3610 }
3611
3612 #[test]
3613 fn an_unresolvable_advisor_seat_is_an_error_naming_the_id() {
3614 let cfg = Config {
3615 agents: vec![spec("a")],
3616 roles: Roles {
3617 advisors: vec!["nope".to_owned()],
3618 ..Roles::default()
3619 },
3620 graph: Graph {
3621 advisors: 1,
3622 ..Graph::default()
3623 },
3624 ..Config::default()
3625 };
3626 let err = cfg.advisors().expect_err("`nope` is not in the roster");
3627 assert!(format!("{err:#}").contains("nope"));
3628 }
3629
3630 #[test]
3631 fn repos_default_to_no_roots_and_a_day_of_trust() {
3632 assert_eq!(Config::default().repos.roots, Vec::<PathBuf>::new());
3633 assert_eq!(Config::default().repos.scan_ttl, 86_400);
3634 assert_eq!(Config::default().repos.fetch_interval, 600);
3635 }
3636
3637 #[test]
3638 fn a_config_file_with_no_repos_table_still_loads() {
3639 let dir = tempfile::tempdir().unwrap();
3640 let path = dir.path().join("magi.toml");
3641 std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
3642 let cfg = Config::load(&path).expect("must load without [repos]");
3643 assert_eq!(cfg.repos.roots, Vec::<PathBuf>::new());
3644 assert_eq!(cfg.repos.scan_ttl, 86_400);
3645 }
3646
3647 #[test]
3662 fn a_test_build_does_not_read_the_operators_machine_config() {
3663 let repo = tempfile::tempdir().unwrap();
3664 std::fs::write(repo.path().join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
3665
3666 let layers = Config::layers(repo.path());
3667 assert_eq!(
3668 layers,
3669 vec![repo.path().join("magi.toml")],
3670 "only the fixture's own file may be a layer"
3671 );
3672 if let Some(real) = dirs::config_dir() {
3673 let machine = real.join("magi").join("config.toml");
3674 assert!(
3675 !layers.contains(&machine),
3676 "the operator's {} must not be a layer in a test build",
3677 machine.display()
3678 );
3679 }
3680 }
3681
3682 #[test]
3683 fn auto_upgrade_config_defaults_and_opt_in() {
3684 let default: Update = toml::from_str("").unwrap();
3685 assert!(!default.auto_restart);
3686 assert_eq!(default.mode, UpdateMode::Notify);
3687 let enabled: Update = toml::from_str("auto_restart = true\ninterval = \"2h\"\n").unwrap();
3688 assert!(enabled.auto_restart);
3689 assert_eq!(enabled.interval.as_deref(), Some("2h"));
3690 assert!(toml::from_str::<Update>("auto_restart = \"yes\"").is_err());
3691 }
3692
3693 #[test]
3694 fn starter_toml_loads_through_teravars() {
3695 let dir = tempfile::tempdir().unwrap();
3696 let path = dir.path().join("magi.toml");
3697 std::fs::write(&path, Config::starter_toml()).unwrap();
3698 let parsed = Config::load(&path).expect("starter config must load");
3699 assert_eq!(parsed.graph.implementers, 3);
3700 assert_eq!(parsed.merge.mode, MergeMode::None);
3701 assert_eq!(parsed.merge.style, MergeStyle::Merge);
3702 assert!(parsed.graph.sessions);
3703 assert!(parsed.graph.github_text_guard);
3704 assert_eq!(parsed.graph.timeout_review, 1200);
3705 assert_eq!(parsed.graph.verify_timeout(), 1200);
3706 assert_eq!(parsed.update.mode, UpdateMode::Notify);
3707 }
3708
3709 #[test]
3710 fn release_defaults_to_actions_and_the_starter_documents_it() {
3711 let dir = tempfile::tempdir().unwrap();
3712 let path = dir.path().join("magi.toml");
3713 std::fs::write(&path, Config::starter_toml()).unwrap();
3714 let r = Config::load(&path).unwrap().release;
3715 assert_eq!(r.mode, ReleaseMode::Actions);
3716 assert!(!r.is_local());
3717 assert!(r.commands.is_empty());
3718 assert_eq!(r.timeout_minutes, 30);
3719 assert!(Config::starter_toml().contains("# mode = \"actions\""));
3720 }
3721
3722 #[test]
3723 fn release_local_parses_and_unknown_keys_are_refused() {
3724 let dir = tempfile::tempdir().unwrap();
3725 let path = dir.path().join("magi.toml");
3726 std::fs::write(
3727 &path,
3728 "[release]\nmode = \"local\"\ncommands = [\"true\", \"echo $MAGI_RELEASE_TAG\"]\ntimeout_minutes = 5\n",
3729 )
3730 .unwrap();
3731 let r = Config::load(&path).unwrap().release;
3732 assert!(r.is_local());
3733 assert_eq!(r.commands.len(), 2);
3734 assert_eq!(r.timeout_minutes, 5);
3735 std::fs::write(&path, "[release]\nmode = \"local\"\nbogus = 1\n").unwrap();
3736 assert!(Config::load(&path).is_err());
3737 std::fs::write(&path, "[release]\nmode = \"gha\"\n").unwrap();
3738 assert!(Config::load(&path).is_err());
3739 }
3740
3741 #[test]
3742 fn pre_gate_defaults_to_empty_and_the_starter_documents_it() {
3743 let dir = tempfile::tempdir().unwrap();
3744 let path = dir.path().join("magi.toml");
3745 std::fs::write(&path, Config::starter_toml()).unwrap();
3746 assert!(Config::load(&path).unwrap().verify.pre_gate.is_empty());
3747 assert!(Config::starter_toml().contains("# pre_gate = []"));
3748 }
3749
3750 #[test]
3751 fn release_version_files_parse_default_empty_and_are_validated() {
3752 let dir = tempfile::tempdir().unwrap();
3753 let path = dir.path().join("magi.toml");
3754 std::fs::write(&path, "").unwrap();
3755 assert!(
3756 Config::load(&path)
3757 .unwrap()
3758 .release
3759 .version_files
3760 .is_empty()
3761 );
3762 std::fs::write(
3763 &path,
3764 "[release]\nversion_files = [\n{ path = \"src-tauri/Cargo.toml\", kind = \"cargo\" },\n{ path = \"package.json\", kind = \"json\" },\n]\n",
3765 )
3766 .unwrap();
3767 let files = Config::load(&path).unwrap().release.version_files;
3768 assert_eq!(files.len(), 2);
3769 assert_eq!(files[1].kind, VersionKind::Json);
3770 for bad in [
3771 "{ path = \"package.json\", kind = \"json\" }",
3772 "{ path = \"../Cargo.toml\", kind = \"cargo\" }",
3773 "{ path = \"Cargo.toml\", kind = \"cargo\", extra = 1 }",
3774 "{ path = \"Cargo.toml\", kind = \"yaml\" }",
3775 ] {
3776 std::fs::write(&path, format!("[release]\nversion_files = [{bad}]\n")).unwrap();
3777 assert!(Config::load(&path).is_err(), "{bad}");
3778 }
3779 assert!(Config::starter_toml().contains("# version_files = ["));
3780 }
3781
3782 #[test]
3783 fn starter_toml_explains_inherited_and_explicit_verify_timeouts() {
3784 let starter = Config::starter_toml();
3785 assert!(starter.contains("When timeout_verify is omitted, E2E and"));
3786 assert!(starter.contains("verification independent if timeout_review changes later"));
3787 assert!(starter.contains("# timeout_verify = 1200"));
3788 }
3789
3790 #[test]
3791 fn starter_toml_documents_chat_personas() {
3792 let starter = Config::starter_toml();
3793 assert!(starter.contains("# [[talk.personas]]"));
3794 assert!(starter.contains("tone only"));
3795 }
3796
3797 #[test]
3798 fn talk_personas_parse_and_are_validated() {
3799 let dir = tempfile::tempdir().unwrap();
3800 let path = dir.path().join("magi.toml");
3801 std::fs::write(
3802 &path,
3803 "[[talk.personas]]\nid = \"rei\"\nname = \"Quiet\"\nprompt = \"Be brief.\"\n\n\
3804 [[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n",
3805 )
3806 .unwrap();
3807 let cfg = Config::load(&path).expect("loads");
3808 assert_eq!(cfg.talk.personas.len(), 2);
3809 let catalog = crate::persona::catalog(&cfg.talk.personas);
3810 let rei = catalog.iter().find(|p| p.id == "rei").unwrap();
3811 assert_eq!(
3812 (rei.name.as_str(), rei.prompt.as_str()),
3813 ("Quiet", "Be brief.")
3814 );
3815 assert_eq!(catalog.last().unwrap().id, "gendo");
3816
3817 for (body, needle) in [
3818 (
3819 "[[talk.personas]]\nid = \"\"\nname = \"n\"\nprompt = \"p\"\n",
3820 "empty `id`",
3821 ),
3822 (
3823 "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \" \"\n",
3824 "empty `prompt`",
3825 ),
3826 (
3827 "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \"p\"\n\
3828 [[talk.personas]]\nid = \"a\"\nname = \"m\"\nprompt = \"q\"\n",
3829 "more than once",
3830 ),
3831 (
3832 "[[talk.personas]]\nid = \"default\"\nname = \"n\"\nprompt = \"p\"\n",
3833 "cannot be redefined",
3834 ),
3835 ] {
3836 std::fs::write(&path, body).unwrap();
3837 let err = format!("{:#}", Config::load(&path).unwrap_err());
3838 assert!(err.contains(needle), "{needle}: {err}");
3839 }
3840 }
3841
3842 #[test]
3843 fn talk_operator_name_is_trimmed_and_validated() {
3844 let dir = tempfile::tempdir().unwrap();
3845 let path = dir.path().join("magi.toml");
3846 let load = |body: &str| {
3847 std::fs::write(&path, body).unwrap();
3848 Config::load(&path)
3849 };
3850 let ok = load("[talk]\noperator_name = \" Commander \"\n").expect("ok");
3851 assert_eq!(ok.talk.operator_name(), Some("Commander"));
3852 let blank = load("[talk]\noperator_name = \" \"\n").expect("blank");
3853 assert_eq!(blank.talk.operator_name(), None);
3854 assert!(load("[talk]\noperator_name = \"a\\nb\"\n").is_err());
3855 let long = "x".repeat(65);
3856 assert!(load(&format!("[talk]\noperator_name = \"{long}\"\n")).is_err());
3857 }
3858
3859 #[test]
3860 fn talk_persona_icon_parses_and_a_bad_shape_keeps_config_usable() {
3861 let dir = tempfile::tempdir().unwrap();
3862 let load = |body: &str| {
3863 let path = dir.path().join("c.toml");
3864 std::fs::write(&path, body).unwrap();
3865 Config::load(&path)
3866 };
3867 let ok = load(
3868 "[[talk.personas]]\nid = \"rei\"\nicon = \"~/rei.png\"\n\n\
3869 [[talk.personas]]\nid = \"default\"\nicon = \"data:image/png;base64,AAAA\"\n",
3870 )
3871 .unwrap();
3872 let c = crate::persona::catalog(&ok.talk.personas);
3873 assert_eq!(c[0].icon.as_deref(), Some("data:image/png;base64,AAAA"));
3874 assert_eq!(c[2].icon.as_deref(), Some("~/rei.png"));
3875 assert!(c[2].prompt.contains("Rei"), "built-in voice kept");
3876 let oversized = format!(
3877 "data:image/png;base64,{}",
3878 "A".repeat(crate::persona_icon::ICON_MAX_BYTES)
3879 );
3880 for bad in ["", "rei.png", "/a/b.svg", "/a/../b.png", &oversized] {
3881 for (id, fields) in [
3882 ("rei", ""),
3883 ("default", ""),
3884 ("custom", "name = \"Custom\"\nprompt = \"Keep calm\"\n"),
3885 ] {
3886 let body = format!("[[talk.personas]]\nid = \"{id}\"\nicon = \"{bad}\"\n{fields}");
3887 let cfg = load(&body).expect("bad icon must not invalidate config");
3888 let persona = crate::persona::find(&cfg.talk.personas, id).unwrap();
3889 assert_eq!(persona.id, id);
3890 assert!(crate::persona_icon::load(bad).is_err());
3891 }
3892 }
3893 let doc = Config::starter_toml();
3894 assert!(doc.contains("# icon = "));
3895 }
3896
3897 #[test]
3898 fn talk_personas_in_two_layers_are_refused() {
3899 let dir = tempfile::tempdir().unwrap();
3900 let machine = dir.path().join("machine.toml");
3901 let repo = dir.path().join("repo.toml");
3902 let one = "[[talk.personas]]\nid = \"a\"\nname = \"n\"\nprompt = \"p\"\n";
3903 std::fs::write(&machine, one).unwrap();
3904 std::fs::write(&repo, one.replace("\"a\"", "\"b\"")).unwrap();
3905 let err = format!("{:#}", Config::load_layers(&[machine, repo]).unwrap_err());
3906 assert!(err.contains("talk.personas"), "{err}");
3907 }
3908
3909 #[test]
3910 fn starter_toml_documents_role_lists_as_backup_order() {
3911 let starter = Config::starter_toml();
3912 assert!(starter.contains("also an ordered backup list"));
3913 assert!(starter.contains("codex then sonnet are spares"));
3914 }
3915
3916 #[test]
3920 fn a_repository_can_declare_a_linear_history_merge_style() {
3921 let dir = tempfile::tempdir().unwrap();
3922 let path = dir.path().join("magi.toml");
3923 std::fs::write(&path, "[merge]\nmode = \"none\"\nstyle = \"squash\"\n").unwrap();
3924 let parsed = Config::load(&path).expect("config must load");
3925 assert_eq!(parsed.merge.style, MergeStyle::Squash);
3926 }
3927
3928 #[test]
3929 fn later_layers_win_and_vars_render() {
3930 let dir = tempfile::tempdir().unwrap();
3931 let machine = dir.path().join("machine.toml");
3932 let project = dir.path().join("magi.toml");
3933 std::fs::write(
3935 &machine,
3936 "[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n\
3937 [graph]\ncandidates = 3\nmax_parallel = 8\n",
3938 )
3939 .unwrap();
3940 std::fs::write(
3943 &project,
3944 "[vars]\ncache = \"/shared\"\n\n\
3945 [graph]\ncandidates = 2\n\n\
3946 [verify]\ngate = [\"CARGO_TARGET_DIR={{ vars.cache }}/t cargo test\"]\n",
3947 )
3948 .unwrap();
3949
3950 let cfg = Config::load_layers(&[machine, project]).expect("layered load");
3951 assert_eq!(cfg.agents.len(), 1, "roster comes from the machine layer");
3952 assert_eq!(cfg.graph.implementers, 2, "project layer wins");
3953 assert_eq!(cfg.graph.max_parallel, 8, "machine layer survives");
3954 assert_eq!(
3955 cfg.verify.gate,
3956 ["CARGO_TARGET_DIR=/shared/t cargo test".to_owned()]
3957 );
3958 assert_eq!(cfg.cache_dir(), Some(PathBuf::from("/shared/t")));
3960 }
3961
3962 #[test]
3963 fn talk_defaults_to_an_hour_and_an_unwritten_config_still_gets_it() {
3964 let g = Graph::default();
3968 assert_eq!(g.timeout_talk, 3600);
3969
3970 let dir = tempfile::tempdir().unwrap();
3971 let path = dir.path().join("magi.toml");
3972 std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
3973 let cfg = Config::load(&path).expect("must load without timeout_talk set");
3974 assert_eq!(cfg.graph.timeout_talk, 3600);
3975 }
3976
3977 #[test]
3978 fn an_overridden_talk_timeout_reaches_the_loaded_config() {
3979 let dir = tempfile::tempdir().unwrap();
3980 let path = dir.path().join("magi.toml");
3981 std::fs::write(&path, "[graph]\ntimeout_talk = 120\n").unwrap();
3982 let cfg = Config::load(&path).expect("must load");
3983 assert_eq!(cfg.graph.timeout_talk, 120);
3984 }
3985
3986 #[test]
3987 fn the_disk_defaults_are_the_measurements_made_up_front() {
3988 let cfg = Config::default();
3989 assert_eq!(cfg.disk.min_free_bytes, 8 * 1024 * 1024 * 1024);
3990 assert!(cfg.disk.auto_fold);
3991 assert_eq!(cfg.disk.fold_grace_secs, 6 * 60 * 60);
3992 assert_eq!(cfg.disk.cache_limit_bytes, 10 * 1024 * 1024 * 1024);
3993 }
3994
3995 #[test]
3996 fn an_unset_disk_section_is_the_safe_default() {
3997 let dir = tempfile::tempdir().unwrap();
3998 std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 1\n").unwrap();
3999 let cfg = Config::load(&dir.path().join("magi.toml")).expect("load");
4000 assert_eq!(cfg.disk, Disk::default());
4001 }
4002
4003 #[test]
4004 fn env_is_available_to_templates_with_a_default() {
4005 let dir = tempfile::tempdir().unwrap();
4006 let path = dir.path().join("magi.toml");
4007 std::fs::write(
4015 &path,
4016 "[verify]\n\
4017 gate = [\"cache={{ env.MAGI_TEST_UNSET_XYZ | default(value='fallback') }}\", \
4018 \"populated={{ env | length > 0 }}\"]\n",
4019 )
4020 .unwrap();
4021 let cfg = Config::load(&path).expect("env lookup must render");
4022 assert_eq!(cfg.verify.gate[0], "cache=fallback");
4023 assert_eq!(cfg.verify.gate[1], "populated=true");
4024 }
4025
4026 #[test]
4027 fn a_broken_template_names_the_file() {
4028 let dir = tempfile::tempdir().unwrap();
4029 let path = dir.path().join("magi.toml");
4030 std::fs::write(&path, "[graph]\nlanguage = \"{{ nope.\"\n").unwrap();
4031 let err = Config::load(&path).expect_err("must not silently ignore");
4032 assert!(err.to_string().contains("teravars"), "{err}");
4033 }
4034
4035 #[test]
4036 fn tera_syntax_in_comments_is_inert() {
4037 let dir = tempfile::tempdir().unwrap();
4041 let path = dir.path().join("magi.toml");
4042 std::fs::write(
4043 &path,
4044 "# a comment may quote templates: `{{ env.NOPE | default(value='x') }}` and `{% if %}`\n\
4045 [graph]\ncandidates = 2\n",
4046 )
4047 .unwrap();
4048 let cfg = Config::load(&path).expect("comments must be inert, not rendered");
4049 assert_eq!(cfg.graph.implementers, 2);
4050 }
4051
4052 #[test]
4053 fn agent_turn_cap_round_trips_and_never_extends_the_budget() {
4054 use std::time::Duration;
4055
4056 let mut s: AgentSpec =
4057 toml::from_str("id = 'slow'\nkind = 'command'\ncommand = ['true']\n").unwrap();
4058 let budget = Duration::from_millis(1500);
4059 assert_eq!(s.max_turn_secs, None);
4060 assert_eq!(s.turn_timeout(budget), budget);
4061 assert!(!toml::to_string(&s).unwrap().contains("max_turn_secs"));
4062 for (cap, expected) in [
4063 (1, Duration::from_secs(1)),
4064 (2, budget),
4065 (0, Duration::ZERO),
4066 ] {
4067 s.max_turn_secs = Some(cap);
4068 let saved = toml::to_string(&s).unwrap();
4069 let loaded: AgentSpec = toml::from_str(&saved).unwrap();
4070 assert_eq!(loaded.max_turn_secs, Some(cap));
4071 assert_eq!(loaded.turn_timeout(budget), expected);
4072 }
4073 }
4074
4075 #[test]
4076 fn opencode_defaults_to_file_delivery() {
4077 let mut s = spec("oc");
4078 s.kind = AgentKind::Opencode;
4079 assert_eq!(s.delivery(), Delivery::File);
4080 s.prompt_delivery = Some(Delivery::Argv);
4081 assert_eq!(s.delivery(), Delivery::Argv);
4082 }
4083 #[test]
4084 fn the_land_loop_is_on_but_it_cannot_merge_without_being_asked() {
4085 let g = Graph::default();
4090 assert!(
4091 g.land,
4092 "stopping at an open PR left the watching to a human"
4093 );
4094 assert!(
4095 g.land_approval,
4096 "on-by-default land is only defensible while this is also on"
4097 );
4098 assert!(g.land_rounds > 0, "a loop with no budget never terminates");
4099 assert!(
4100 g.hold_contested_merge,
4101 "a contested hand-off must still reach a human when approvals are off"
4102 );
4103 }
4104 #[test]
4105 fn an_array_declared_in_two_layers_is_refused_instead_of_concatenated() {
4106 let dir = tempfile::tempdir().unwrap();
4110 let machine = dir.path().join("machine.toml");
4111 let repo = dir.path().join("magi.toml");
4112 std::fs::write(&machine, "[roles]\nimplementers = [\"a\", \"b\"]\n").unwrap();
4113 std::fs::write(&repo, "[roles]\nimplementers = [\"oc\"]\n").unwrap();
4114
4115 let err = Config::load_layers(&[machine.clone(), repo.clone()])
4116 .expect_err("two layers naming one array must not merge silently");
4117 assert_eq!(
4118 err.downcast_ref::<ConfigFailure>().unwrap().stage,
4119 "config-merge"
4120 );
4121 let err = err.to_string();
4122 assert!(err.contains("roles.implementers"), "{err}");
4123 assert!(err.contains("machine.toml"), "{err}");
4126 assert!(err.contains("magi.toml"), "{err}");
4127 }
4128
4129 #[test]
4130 fn a_scalar_in_one_layer_and_an_array_in_another_still_merges() {
4131 let dir = tempfile::tempdir().unwrap();
4134 let machine = dir.path().join("machine.toml");
4135 let repo = dir.path().join("magi.toml");
4136 std::fs::write(&machine, "[roles]\nchatter = \"opus\"\n").unwrap();
4137 std::fs::write(
4138 &repo,
4139 "[[agents]]\nid = \"oc\"\nkind = \"opencode\"\n\n\
4140 [roles]\nimplementers = [\"oc\"]\n",
4141 )
4142 .unwrap();
4143
4144 let cfg = Config::load_layers(&[machine, repo]).expect("layers merge");
4145 assert_eq!(primary(cfg.roles.chatter.as_ref()), Some("opus"));
4146 assert_eq!(cfg.roles.implementers, ["oc"]);
4147 assert_eq!(cfg.agents.len(), 1, "the roster is not doubled");
4148 }
4149
4150 #[test]
4151 fn two_layers_declaring_verify_gate_run_both_in_priority_order() {
4152 let dir = tempfile::tempdir().unwrap();
4155 let machine = dir.path().join("machine.toml");
4156 let repo = dir.path().join("magi.toml");
4157 std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
4158 std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
4159
4160 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
4161 assert_eq!(
4162 cfg.verify.gate,
4163 [
4164 "editorconfig-checker".to_owned(),
4165 "cargo make check".to_owned()
4166 ],
4167 "low-priority (machine) command first, high-priority (repo) command after"
4168 );
4169 }
4170
4171 fn load_one(body: &str) -> Result<Config> {
4172 let dir = tempfile::tempdir().unwrap();
4173 let path = dir.path().join("magi.toml");
4174 std::fs::write(&path, body).unwrap();
4175 Config::load_layers(&[path])
4176 }
4177
4178 #[test]
4179 fn worktree_setup_defaults_to_empty_and_parses_steps() {
4180 assert!(Config::default().worktree.setup.is_empty());
4181 let cfg = load_one(
4182 "[worktree]\nsetup = [\n { copy = \".env.example -> .env\" },\n \
4183 { copy = \"x\", optional = true },\n { run = \"true\", timeout_secs = 5 },\n]\n",
4184 )
4185 .unwrap();
4186 assert_eq!(cfg.worktree.setup.len(), 3);
4187 assert!(cfg.worktree.setup[1].optional);
4188 assert_eq!(cfg.worktree.setup[2].timeout_secs, Some(5));
4189 }
4190
4191 #[test]
4192 fn worktree_setup_rejects_malformed_steps() {
4193 for bad in [
4194 "{ copy = \"a\", run = \"b\" }",
4195 "{ optional = true }",
4196 "{ copy = \"a -> ../b\" }",
4197 "{ copy = \"a -> /abs\" }",
4198 "{ copy = \"a -> .git/config\" }",
4199 "{ run = \"b\", optional = true }",
4200 "{ copy = \"a\", timeout_secs = 3 }",
4201 "{ run = \"b\", bogus = 1 }",
4202 ] {
4203 let body = format!("[worktree]\nsetup = [{bad}]\n");
4204 assert!(load_one(&body).is_err(), "{bad}");
4205 }
4206 }
4207
4208 #[test]
4209 fn worktree_setup_in_two_layers_is_refused() {
4210 let dir = tempfile::tempdir().unwrap();
4211 let machine = dir.path().join("machine.toml");
4212 let repo = dir.path().join("magi.toml");
4213 std::fs::write(&machine, "[worktree]\nsetup = [{ run = \"a\" }]\n").unwrap();
4214 std::fs::write(&repo, "[worktree]\nsetup = [{ run = \"b\" }]\n").unwrap();
4215 assert!(Config::load_layers(&[machine, repo]).is_err());
4216 }
4217
4218 #[test]
4219 fn starter_toml_documents_worktree_setup() {
4220 let starter = Config::starter_toml();
4221 assert!(starter.contains("# [worktree]") && starter.contains("setup = ["));
4222 let body = starter.replace("# [worktree]", "[worktree]");
4224 let mut out = String::new();
4225 let mut live = false;
4226 for line in body.lines() {
4227 if line == "[worktree]" {
4228 live = true;
4229 } else if live && !line.starts_with("# ") {
4230 live = false;
4231 }
4232 out.push_str(&if live {
4233 line.replacen("# ", "", 1)
4234 } else {
4235 line.to_owned()
4236 });
4237 out.push('\n');
4238 }
4239 let cfg = load_one(&out).expect("uncommented example loads");
4240 assert_eq!(cfg.worktree.setup.len(), 3);
4241 }
4242
4243 #[test]
4244 fn two_layers_declaring_verify_e2e_run_both_in_priority_order() {
4245 let dir = tempfile::tempdir().unwrap();
4246 let machine = dir.path().join("machine.toml");
4247 let repo = dir.path().join("magi.toml");
4248 std::fs::write(&machine, "[verify]\ne2e = [\"shared-smoke-test\"]\n").unwrap();
4249 std::fs::write(&repo, "[verify]\ne2e = [\"cargo test\"]\n").unwrap();
4250
4251 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
4252 assert_eq!(
4253 cfg.verify.e2e,
4254 ["shared-smoke-test".to_owned(), "cargo test".to_owned()]
4255 );
4256 }
4257
4258 #[test]
4259 fn two_layers_declaring_repos_roots_are_both_scanned() {
4260 let dir = tempfile::tempdir().unwrap();
4261 let machine = dir.path().join("machine.toml");
4262 let repo = dir.path().join("magi.toml");
4263 std::fs::write(&machine, "[repos]\nroots = [\"/machine/root\"]\n").unwrap();
4264 std::fs::write(&repo, "[repos]\nroots = [\"/repo/root\"]\n").unwrap();
4265
4266 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
4267 assert_eq!(
4268 cfg.repos.roots,
4269 [PathBuf::from("/machine/root"), PathBuf::from("/repo/root")]
4270 );
4271 }
4272
4273 #[test]
4274 fn duplicate_gate_commands_across_layers_both_run() {
4275 let dir = tempfile::tempdir().unwrap();
4278 let machine = dir.path().join("machine.toml");
4279 let repo = dir.path().join("magi.toml");
4280 std::fs::write(&machine, "[verify]\ngate = [\"same-command\"]\n").unwrap();
4281 std::fs::write(&repo, "[verify]\ngate = [\"same-command\"]\n").unwrap();
4282
4283 let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
4284 assert_eq!(
4285 cfg.verify.gate,
4286 ["same-command".to_owned(), "same-command".to_owned()]
4287 );
4288 }
4289
4290 #[test]
4291 fn notify_command_is_still_refused_across_two_layers() {
4292 let dir = tempfile::tempdir().unwrap();
4294 let machine = dir.path().join("machine.toml");
4295 let repo = dir.path().join("magi.toml");
4296 std::fs::write(&machine, "[notify]\ncommand = [\"ntfy\", \"publish\"]\n").unwrap();
4297 std::fs::write(&repo, "[notify]\ncommand = [\"curl\", \"-X\"]\n").unwrap();
4298
4299 let err = Config::load_layers(&[machine.clone(), repo.clone()])
4300 .expect_err("an argv split across layers must not concatenate");
4301 assert_eq!(
4302 err.downcast_ref::<ConfigFailure>().unwrap().stage,
4303 "config-merge"
4304 );
4305 let err = err.to_string();
4306 assert!(err.contains("notify.command"), "{err}");
4307 assert!(err.contains("machine.toml"), "{err}");
4308 assert!(err.contains("magi.toml"), "{err}");
4309 }
4310
4311 #[test]
4312 fn one_layer_declaring_verify_gate_runs_unchanged() {
4313 let dir = tempfile::tempdir().unwrap();
4316 let path = dir.path().join("magi.toml");
4317 std::fs::write(&path, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
4318
4319 let cfg = Config::load(&path).expect("single layer must still load");
4320 assert_eq!(cfg.verify.gate, ["cargo make check".to_owned()]);
4321 }
4322
4323 #[test]
4324 fn layered_parse_failures_name_the_failing_input() {
4325 let dir = tempfile::tempdir().unwrap();
4326 let a = dir.path().join("a.toml");
4327 let b = dir.path().join("b.toml");
4328 std::fs::write(&a, "[graph").unwrap();
4329 std::fs::write(&b, "[verify").unwrap();
4330 let paths = [a.clone(), b.clone()];
4331 let first = Config::load_layers(&paths).unwrap_err();
4332 let first_cause = first.downcast_ref::<ConfigFailure>().unwrap();
4333 assert_eq!(first_cause.stage, "config-parse");
4334 assert_eq!(first_cause.target, serde_json::to_string(&[&a]).unwrap());
4335 std::fs::write(&a, "").unwrap();
4336 let second = Config::load_layers(&paths).unwrap_err();
4337 let second_cause = second.downcast_ref::<ConfigFailure>().unwrap();
4338 assert_eq!(second_cause.stage, "config-parse");
4339 assert_eq!(second_cause.target, serde_json::to_string(&[&b]).unwrap());
4340 assert_ne!(first_cause.target, second_cause.target);
4341 }
4342
4343 #[test]
4344 fn describe_composed_names_the_contributing_layers_only_when_there_are_two() {
4345 let dir = tempfile::tempdir().unwrap();
4346 let machine = dir.path().join("machine.toml");
4347 let repo = dir.path().join("magi.toml");
4348 std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
4349 std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
4350 let paths = vec![machine.clone(), repo.clone()];
4351
4352 let cfg = Config::load_layers(&paths).expect("appendable arrays must merge");
4353 let described =
4354 Config::describe_composed(&paths, &cfg.verify.gate, "verify.gate", "(none)");
4355 assert!(described.contains("editorconfig-checker && cargo make check"));
4356 assert!(
4357 described.contains(&machine.display().to_string()),
4358 "{described}"
4359 );
4360 assert!(
4361 described.contains(&repo.display().to_string()),
4362 "{described}"
4363 );
4364
4365 let single = vec![repo.clone()];
4367 let solo_cfg = Config::load_layers(&single).expect("single layer loads");
4368 let solo_described =
4369 Config::describe_composed(&single, &solo_cfg.verify.gate, "verify.gate", "(none)");
4370 assert_eq!(solo_described, "cargo make check");
4371 }
4372
4373 mod remote_layers {
4374 use super::*;
4375
4376 fn git(dir: &Path, args: &[&str]) {
4377 use crate::proc::Quiet as _;
4378 let out = std::process::Command::new("git")
4379 .args(args)
4380 .quiet()
4381 .current_dir(dir)
4382 .output()
4383 .unwrap();
4384 assert!(out.status.success(), "git {args:?}: {out:?}");
4385 }
4386
4387 fn fixture(remote: &str, disk: &str) -> (tempfile::TempDir, PathBuf) {
4390 let tmp = tempfile::tempdir().unwrap();
4391 let bare = tmp.path().join("o.git");
4392 git(tmp.path(), &["init", "--bare", "-b", "trunk", "o.git"]);
4393 let repo = tmp.path().join("repo");
4394 std::fs::create_dir_all(&repo).unwrap();
4395 git(&repo, &["init", "-b", "trunk"]);
4396 git(&repo, &["config", "user.name", "t"]);
4397 git(&repo, &["config", "user.email", "t@example.com"]);
4398 std::fs::write(repo.join("magi.toml"), remote).unwrap();
4399 git(&repo, &["add", "-A"]);
4400 git(&repo, &["commit", "-m", "x"]);
4401 git(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
4402 git(&repo, &["push", "origin", "trunk"]);
4403 git(&repo, &["fetch", "origin"]);
4404 git(&repo, &["remote", "set-head", "origin", "trunk"]);
4405 std::fs::write(repo.join("magi.toml"), disk).unwrap();
4406 (tmp, repo)
4407 }
4408
4409 #[tokio::test]
4410 async fn branch_owner_holds_only_add_literal_true_values() {
4411 for (text, expected) in [
4412 ("[graph]\nland_approval = true\n", true),
4413 ("[graph]\nland_approval = false\n", false),
4414 ("[graph]\nland = true\n", false),
4415 ("[graph\n", false),
4416 ("[graph]\nland_approval = \"yes\"\n", false),
4417 ("[graph]\nland_approval = 1\n", false),
4418 ] {
4419 let (_tmp, repo) = fixture(text, "[graph]\nland_approval = false\n");
4420 let head = crate::git::rev_parse(&repo, "HEAD").await.unwrap();
4421 let (holds, _) = branch_owner_holds(&repo, &head).await;
4422 assert_eq!(
4423 holds.contains(&(OwnerHold::LandApproval, "magi.toml".into())),
4424 expected
4425 );
4426 assert!(
4427 !holds
4428 .iter()
4429 .any(|(h, _)| *h == OwnerHold::HoldContestedMerge)
4430 );
4431 for base in [false, true] {
4432 let mut cfg = Config::default();
4433 cfg.graph.land_approval = base;
4434 let mut state = crate::run::RunState::new(
4435 repo.clone(),
4436 "trunk".into(),
4437 head.clone(),
4438 "review".into(),
4439 cfg,
4440 );
4441 state.observe_branch_owner_holds(&head).await;
4442 assert!(
4443 state.branch_owner_holds.is_empty(),
4444 "competition candidates are excluded"
4445 );
4446 state.reviewed_commits = Some(Vec::new());
4447 state.observe_branch_owner_holds(&head).await;
4448 assert_eq!(state.owner_hold(OwnerHold::LandApproval), base || expected);
4449 }
4450 }
4451 }
4452
4453 #[tokio::test]
4454 async fn branch_owner_holds_missing_and_unreadable_preserve_base() {
4455 let (_tmp, repo) = fixture(
4456 "[graph]\nland_approval = true\n",
4457 "[graph]\nland_approval = true\n",
4458 );
4459 git(&repo, &["rm", "magi.toml"]);
4460 std::fs::create_dir(repo.join(".magi")).unwrap();
4461 std::fs::write(
4462 repo.join(".magi/config.toml"),
4463 "[graph]\nhold_contested_merge = true\n",
4464 )
4465 .unwrap();
4466 git(&repo, &["add", "."]);
4467 git(&repo, &["commit", "-m", "nested hold"]);
4468 let head = crate::git::rev_parse(&repo, "HEAD").await.unwrap();
4469 let (holds, _) = branch_owner_holds(&repo, &head).await;
4470 assert_eq!(
4471 holds,
4472 [(OwnerHold::HoldContestedMerge, ".magi/config.toml".into())]
4473 );
4474 let (holds, diagnostics) =
4475 branch_owner_holds(&repo, "0000000000000000000000000000000000000000").await;
4476 assert!(holds.is_empty());
4477 assert!(!diagnostics.is_empty());
4478 let (holds, diagnostics) = branch_owner_holds(&repo, "").await;
4479 assert!(holds.is_empty());
4480 assert!(!diagnostics.is_empty());
4481 for base in [false, true] {
4482 let mut cfg = Config::default();
4483 cfg.graph.land_approval = base;
4484 let mut state = crate::run::RunState::new(
4485 repo.clone(),
4486 "trunk".into(),
4487 head.clone(),
4488 "review".into(),
4489 cfg,
4490 );
4491 state.reviewed_commits = Some(Vec::new());
4492 state.observe_branch_owner_holds(&head).await;
4493 assert_eq!(state.owner_hold(OwnerHold::LandApproval), base);
4494 state
4495 .observe_branch_owner_holds("0000000000000000000000000000000000000000")
4496 .await;
4497 assert_eq!(state.owner_hold(OwnerHold::LandApproval), base);
4498 }
4499 }
4500
4501 #[test]
4502 fn reads_the_remote_blob_not_the_working_tree() {
4503 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4504 let (cfg, _) = Config::discover(&repo, None).unwrap();
4505 assert_eq!(cfg.graph.implementers, 2);
4506 }
4507
4508 #[test]
4509 fn detached_head_still_reads_the_remote() {
4510 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4511 git(&repo, &["checkout", "--detach"]);
4512 let (cfg, _) = Config::discover(&repo, None).unwrap();
4513 assert_eq!(cfg.graph.implementers, 2);
4514 }
4515
4516 #[test]
4517 fn a_missing_ref_is_an_error_not_a_fallback() {
4518 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[graph]\ncandidates = 5\n");
4519 git(&repo, &["update-ref", "-d", "refs/remotes/origin/trunk"]);
4520 let err = format!("{:#}", Config::discover(&repo, None).unwrap_err());
4521 assert!(err.contains("origin"), "{err}");
4522 }
4523
4524 #[test]
4525 fn merge_base_in_the_checkout_locates_the_ref() {
4526 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "[merge]\nbase = \"nope\"\n");
4527 assert!(Config::discover(&repo, None).is_err());
4528 }
4529
4530 #[test]
4531 fn the_magi_dir_layer_is_read_too() {
4532 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4533 std::fs::write(repo.join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
4534 std::fs::create_dir_all(repo.join(".magi")).unwrap();
4535 std::fs::write(repo.join(".magi/config.toml"), "[graph]\njudges = 3\n").unwrap();
4536 git(&repo, &["add", "-A"]);
4537 git(&repo, &["commit", "-m", "y"]);
4538 git(&repo, &["push", "origin", "trunk"]);
4539 git(&repo, &["fetch", "origin"]);
4540 let (cfg, layers) = Config::discover(&repo, None).unwrap();
4541 assert_eq!((cfg.graph.implementers, cfg.graph.judges), (2, 3));
4542 assert_eq!(layers.len(), 2);
4543 }
4544
4545 #[test]
4546 fn an_include_in_a_repository_layer_resolves_against_the_same_commit() {
4547 let (_t, repo) = fixture("include = [\"gate.toml\"]\n", "");
4548 std::fs::write(repo.join("magi.toml"), "include = [\"gate.toml\"]\n").unwrap();
4549 std::fs::write(repo.join("gate.toml"), "[graph]\ncandidates = 2\n").unwrap();
4550 git(&repo, &["add", "-A"]);
4551 git(&repo, &["commit", "-m", "inc"]);
4552 git(&repo, &["push", "origin", "trunk"]);
4553 git(&repo, &["fetch", "origin"]);
4554 std::fs::write(repo.join("gate.toml"), "[graph]\ncandidates = 9\n").unwrap();
4556 let (cfg, _) = Config::discover(&repo, None).unwrap();
4557 assert_eq!(cfg.graph.implementers, 2);
4558 }
4559
4560 #[test]
4561 fn a_machine_layer_base_is_rendered_before_locating_the_ref() {
4562 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "[merge]\nbase = \"nope\"\n");
4563 let m = t.path().join("machine.toml");
4564 std::fs::write(
4565 &m,
4566 "[merge]\nbase = \"{{ env.MAGI_TEST_NO_SUCH | default(value='trunk') }}\"\n",
4567 )
4568 .unwrap();
4569 let (remote, base) = {
4570 let probe = probe_merge(&m, &repo).unwrap();
4571 (probe.remote, probe.base)
4572 };
4573 assert_eq!((remote, base), (None, Some("trunk".to_owned())));
4574 }
4575
4576 #[test]
4577 fn a_machine_layer_base_sees_the_checkouts_repo_name() {
4578 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4579 let m = t.path().join("machine.toml");
4580 std::fs::write(&m, "[merge]\nbase = \"{{ repo_name }}\"\n").unwrap();
4581 let probe = probe_merge(&m, &repo).unwrap();
4582 assert_eq!(probe.base.as_deref(), Some("repo"));
4583 }
4584
4585 #[test]
4586 fn every_include_form_is_recognised() {
4587 assert!(declares_include("include = [\"a.toml\"]\n"));
4588 assert!(declares_include("\"include\" = [\"a.toml\"]\n"));
4589 assert!(declares_include("teravars = { include = [\"a.toml\"] }\n"));
4590 assert!(declares_include("[teravars]\ninclude = [\"a.toml\"]\n"));
4591 assert!(!declares_include("[graph]\ncandidates = 2\n"));
4592 }
4593
4594 #[test]
4595 fn an_explicit_config_is_read_as_named() {
4596 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4597 let own = t.path().join("own.toml");
4598 std::fs::write(&own, "[graph]\ncandidates = 4\n").unwrap();
4599 let (cfg, _) = Config::discover(&repo, Some(&own)).unwrap();
4600 assert_eq!(cfg.graph.implementers, 4);
4601 }
4602
4603 #[test]
4604 fn discover_fetched_fails_when_the_fetch_does() {
4605 let (t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4606 std::fs::remove_dir_all(t.path().join("o.git")).unwrap();
4607 let rt = tokio::runtime::Builder::new_current_thread()
4608 .enable_all()
4609 .build()
4610 .unwrap();
4611 let err = rt
4612 .block_on(Config::discover_fetched(&repo, None))
4613 .unwrap_err();
4614 assert!(format!("{err:#}").contains("fetch"), "{err:#}");
4615 }
4616
4617 #[test]
4618 fn fetch_and_remote_parse_failures_have_distinct_verified_causes() {
4619 let (tmp, repo) = fixture("[graph\ncandidates = 2\n", "");
4620 let remote = tmp.path().join("o.git");
4621 let offline = tmp.path().join("offline.git");
4622 std::fs::rename(&remote, &offline).unwrap();
4623 let rt = tokio::runtime::Builder::new_current_thread()
4624 .enable_all()
4625 .build()
4626 .unwrap();
4627 let fetch = rt
4628 .block_on(Config::discover_fetched(&repo, None))
4629 .unwrap_err();
4630 let fetch_cause = fetch.downcast_ref::<ConfigFailure>().unwrap();
4631 assert_eq!(fetch_cause.stage, "config-fetch");
4632 assert_eq!(fetch_cause.target, "origin/trunk");
4633
4634 std::fs::rename(&offline, &remote).unwrap();
4635 let parse = rt
4636 .block_on(Config::discover_fetched(&repo, None))
4637 .unwrap_err();
4638 let parse_cause = parse.downcast_ref::<ConfigFailure>().unwrap();
4639 assert_eq!(parse_cause.stage, "config-parse");
4640 assert!(parse_cause.target.contains("magi.toml"));
4641 assert!(
4642 !parse_cause.target.contains("/magi/config/"),
4643 "cache commit paths are incidental"
4644 );
4645 assert_ne!(fetch_cause.stage, parse_cause.stage);
4646 }
4647
4648 #[test]
4649 fn discover_fetched_survives_a_fetch_that_leaves_no_origin_head() {
4650 let (_t, repo) = fixture("[graph]\ncandidates = 2\n", "");
4653 git(&repo, &["remote", "set-head", "origin", "-d"]);
4654 git(&repo, &["update-ref", "-d", "refs/remotes/origin/trunk"]);
4655 git(
4656 &repo,
4657 &["config", "remote.origin.followRemoteHEAD", "never"],
4658 );
4659 let rt = tokio::runtime::Builder::new_current_thread()
4660 .enable_all()
4661 .build()
4662 .unwrap();
4663 let (cfg, _) = rt.block_on(Config::discover_fetched(&repo, None)).unwrap();
4664 assert_eq!(cfg.graph.implementers, 2);
4665 }
4666
4667 #[test]
4668 fn a_non_git_directory_reads_its_files() {
4669 let dir = tempfile::tempdir().unwrap();
4670 std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 4\n").unwrap();
4671 let (cfg, _) = Config::discover(dir.path(), None).unwrap();
4672 assert_eq!(cfg.graph.implementers, 4);
4673 }
4674 }
4675
4676 #[test]
4677 fn github_language_defaults_to_english_and_accepts_japanese() {
4678 let default: Graph = toml::from_str("").unwrap();
4679 assert_eq!(default.github_language, "en");
4680 assert!(default.github_english() && default.language_gate());
4681 let ja: Graph = toml::from_str("github_language = \"ja\"").unwrap();
4682 assert!(!ja.github_english() && !ja.language_gate());
4683 let only_ops: Graph = toml::from_str("language = \"ja\"").unwrap();
4685 assert!(only_ops.language_gate());
4686 let off: Graph = toml::from_str("github_text_guard = false").unwrap();
4687 assert!(!off.language_gate() && off.github_english());
4688 assert!(Config::starter_toml().contains("github_language = \"en\""));
4689 }
4690}