magi/config.rs
1//! Run configuration: the agent roster, the shape of the graph, and the
2//! blindness / verification policy.
3//!
4//! Discovery order (first hit wins):
5//!
6//! 1. `--config <path>`
7//! 2. `<repo>/magi.toml`
8//! 3. `<repo>/.magi/config.toml`
9//! 4. `<config_dir>/magi/config.toml`
10//! 5. built-in defaults, with the agent roster derived from the agent CLIs
11//! actually installed on this machine
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context as _, Result, bail};
16use serde::{Deserialize, Serialize};
17
18/// Which CLI drives an agent.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum AgentKind {
22 /// Anthropic Claude Code (`claude -p`).
23 Claude,
24 /// opencode (`opencode run`).
25 Opencode,
26 /// Antigravity CLI (`agy -p`). Gemini CLI is deliberately absent: Google
27 /// retired the standalone client for individual accounts in favour of this
28 /// one, so an adapter for it would be dead code on a live machine.
29 Antigravity,
30 /// Arbitrary command. The escape hatch, and what the test suite drives.
31 Command,
32}
33
34impl AgentKind {
35 /// Executable that must be on `PATH` for this kind, if any.
36 pub fn program(self) -> Option<&'static str> {
37 match self {
38 Self::Claude => Some("claude"),
39 Self::Opencode => Some("opencode"),
40 Self::Antigravity => Some("agy"),
41 Self::Command => None,
42 }
43 }
44
45 /// Lowercase name as written in the config file.
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::Claude => "claude",
49 Self::Opencode => "opencode",
50 Self::Antigravity => "antigravity",
51 Self::Command => "command",
52 }
53 }
54}
55
56/// How the prompt reaches the agent process.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
58#[serde(rename_all = "lowercase")]
59pub enum Delivery {
60 /// Piped on stdin.
61 Stdin,
62 /// Passed as a positional argument. Beware OS command-line limits.
63 Argv,
64 /// Written to a file; the agent is told to read it. No length limit.
65 File,
66}
67
68/// One addressable agent in the roster.
69#[derive(Debug, Clone, Deserialize, Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct AgentSpec {
72 /// Stable identifier used by `[roles]` and by the stats tables.
73 pub id: String,
74 /// Which CLI to drive.
75 pub kind: AgentKind,
76 /// Model passed through to the CLI (`--model` / `-m`). CLI default if unset.
77 #[serde(default)]
78 pub model: Option<String>,
79 /// `kind = "command"` only: argv. Supports `{prompt_file}`, `{cwd}`,
80 /// `{label}`, `{session}` placeholders.
81 #[serde(default)]
82 pub command: Vec<String>,
83 /// Extra arguments appended to the built command line.
84 #[serde(default)]
85 pub extra_args: Vec<String>,
86 /// Extra environment variables for the child process.
87 #[serde(default)]
88 pub env: BTreeMap<String, String>,
89 /// Override the per-kind prompt delivery default.
90 #[serde(default)]
91 pub prompt_delivery: Option<Delivery>,
92}
93
94impl AgentSpec {
95 /// Default prompt delivery for this agent.
96 ///
97 /// `opencode` and `agy` take the prompt as an argument, which on Windows
98 /// caps out around 32 KiB — well under a judging prompt carrying three
99 /// patches — so both get a file instead.
100 pub fn delivery(&self) -> Delivery {
101 self.prompt_delivery.unwrap_or(match self.kind {
102 AgentKind::Claude | AgentKind::Command => Delivery::Stdin,
103 AgentKind::Opencode | AgentKind::Antigravity => Delivery::File,
104 })
105 }
106
107 /// Human-facing label, e.g. `opus (claude:opus)`.
108 pub fn display(&self) -> String {
109 match &self.model {
110 Some(m) => format!("{} ({}:{m})", self.id, self.kind.as_str()),
111 None => format!("{} ({})", self.id, self.kind.as_str()),
112 }
113 }
114}
115
116/// Explicit role assignment. Empty lists are filled in by
117/// [`Config::resolve_roles`] by rotating the roster.
118#[derive(Debug, Clone, Default, Deserialize, Serialize)]
119#[serde(deny_unknown_fields, default)]
120pub struct Roles {
121 /// Agents that implement the task, one worktree each.
122 pub implementers: Vec<String>,
123 /// Agents that rank the candidates blind.
124 pub judges: Vec<String>,
125 /// Agents that review the winning patch.
126 pub reviewers: Vec<String>,
127 /// Agent that applies review findings. Defaults to the winner's author.
128 pub fixer: Option<String>,
129 /// Agent that runs the `magi plan` interview and the browser conversation.
130 ///
131 /// Unset picks a `claude` seat, else the first runnable agent in roster
132 /// order - which is roster *order*, not a judgement about who interviews
133 /// well. Naming one here is worth it because the interview is the one node
134 /// a human sits through: the model that asks good questions is not
135 /// necessarily the one that writes the best patch, and on a phone there is
136 /// no `--agent` to type.
137 pub planner: Option<String>,
138}
139
140/// Graph shape and limits.
141#[derive(Debug, Clone, Deserialize, Serialize)]
142#[serde(deny_unknown_fields, default)]
143pub struct Graph {
144 /// Parallel implementations of the same task. **One by default.**
145 ///
146 /// Competition is the thing magi is for, and it is still here - it is just
147 /// no longer what every task buys without being asked. Three days and 13
148 /// runs on this repository, which is the workload these numbers are drawn
149 /// from:
150 ///
151 /// - **0 of 13** competed runs reached a merge. Everything that landed in
152 /// that window went through `magi review` - the cheap half, no
153 /// competition - and passed on the first try.
154 /// - The judges' first choices **split 73% of the time** (8 of 11
155 /// tallies). Candidates that close together make the ranking a weak
156 /// signal for what it costs to produce.
157 /// - One run's own breakdown: implement 60min, judge 40min, fix 40min,
158 /// review 28min, **verify 5min** - and verify is the node that caught a
159 /// defect every reviewer had passed as clean. The cheapest step is the
160 /// one that earns its place every time.
161 ///
162 /// It is not worthless: `oc` won 3 of those tallies against `sonnet`, so a
163 /// single-seat default would have shipped the worse implementation in
164 /// roughly a quarter of them. That is exactly why this is a *default* and
165 /// not a removal - `magi run --candidates N` and a per-task seat count are
166 /// how a task that deserves a competition gets one.
167 ///
168 /// A single-candidate run needs no special case: `Runner::review`'s doc
169 /// records that `execute` already degrades to implement -> review -> gate
170 /// -> merge, because `judge` skips a one-candidate field, `deliberate` has
171 /// no two first choices to reconcile and `vote` returns early.
172 pub candidates: usize,
173 /// Independent judges.
174 pub judges: usize,
175 /// Deliberation rounds when the judges' first choices disagree.
176 pub deliberate_rounds: usize,
177 /// Reviewers per review round.
178 pub reviewers: usize,
179 /// Maximum review+fix rounds before the run is declared blocked.
180 pub review_rounds: usize,
181 /// Maximum agent processes running at once.
182 pub max_parallel: usize,
183 /// Language for the prose the agents write (`en` / `ja` / any language name).
184 pub language: String,
185 /// Keep one CLI conversation per seat, so a judge remembers its own
186 /// argument across deliberation rounds and the fixer remembers its own
187 /// implementation across review rounds.
188 ///
189 /// Sessions are scoped to a *seat*, never to an agent id: the same model
190 /// sitting as implementer and as judge gets two unrelated conversations,
191 /// which is what keeps blind judging blind.
192 pub sessions: bool,
193 /// Per-node timeouts, seconds.
194 pub timeout_implement: u64,
195 /// Per-node timeouts, seconds.
196 pub timeout_judge: u64,
197 /// Per-node timeouts, seconds.
198 pub timeout_review: u64,
199 /// Per-node timeouts, seconds.
200 pub timeout_fix: u64,
201 /// Retries for an agent invocation that fails or returns nothing usable.
202 pub retries: usize,
203 /// Root for candidate / judge worktrees. Defaults to `~/wt/magi`.
204 pub worktree_root: Option<PathBuf>,
205 /// After the pull request is open, keep going: watch its checks and
206 /// reviews, run a fix round when they are unhappy, and ask to merge.
207 ///
208 /// On, because stopping at an open pull request left the operator doing
209 /// the watching by hand - six times in the session this was built in - and
210 /// that is the work the loop exists to take. It only engages for
211 /// `merge = "pr"`; every other merge mode ends the run as before.
212 ///
213 /// Turning this on does **not** hand magi the merge button:
214 /// [`Graph::land_approval`] is on too, and nothing merges without an
215 /// explicit answer. Setting both to their non-defaults is the only way to
216 /// get an unattended merge, and it has to be chosen twice.
217 pub land: bool,
218 /// Land rounds - watch, fix, push - before the run is left for a human.
219 pub land_rounds: usize,
220 /// Ask the owner before merging, showing what is about to land.
221 ///
222 /// On, and it is what makes `land` safe to have on: the question carries a
223 /// rendered panel - the diffstat, the patch, the checks, the review
224 /// comments that were addressed, and the subject the squash will use - so
225 /// the decision is made on evidence rather than on trust, from wherever
226 /// the operator happens to be.
227 ///
228 /// Silence is a hold. An unanswered approval never merges, and neither
229 /// does any answer other than the word `merge`.
230 pub land_approval: bool,
231 /// How long to wait for an owner to answer a question before the run is
232 /// abandoned, seconds. A parked run costs nothing, so this is generous;
233 /// it exists so a forgotten question cannot pin a worktree forever.
234 pub answer_timeout: u64,
235}
236
237impl Default for Graph {
238 fn default() -> Self {
239 Self {
240 candidates: 1,
241 judges: 3,
242 deliberate_rounds: 1,
243 reviewers: 2,
244 review_rounds: 6,
245 max_parallel: 4,
246 language: "en".to_owned(),
247 sessions: true,
248 timeout_implement: 3600,
249 timeout_judge: 1200,
250 timeout_review: 1200,
251 timeout_fix: 1800,
252 retries: 1,
253 worktree_root: None,
254 land: true,
255 land_rounds: 4,
256 land_approval: true,
257 answer_timeout: 86_400,
258 }
259 }
260}
261
262/// What to do when vendor-identifying text is found in material shown to judges.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
264#[serde(rename_all = "lowercase")]
265pub enum LeakPolicy {
266 /// Record the leak, show the patch unmodified.
267 Warn,
268 /// Replace the token with `[REDACTED]` in the presented patch.
269 Redact,
270 /// Abort the run.
271 Fail,
272}
273
274/// Blindness policy.
275///
276/// Commit messages and candidate summaries are *always* stripped of
277/// attribution trailers and redacted — that is where signatures actually
278/// appear. [`Blind::on_leak`] governs the patch body only, where blanket
279/// redaction would corrupt the artifact under judgement.
280#[derive(Debug, Clone, Deserialize, Serialize)]
281#[serde(deny_unknown_fields, default)]
282pub struct Blind {
283 /// Install a per-worktree `commit-msg` hook that deletes attribution
284 /// trailers before they can land in a candidate's history.
285 pub commit_msg_hook: bool,
286 /// Literal, case-insensitive substrings. A line containing any of them is
287 /// dropped from commit messages and summaries; the `commit-msg` hook is
288 /// generated from the same list.
289 pub strip_lines: Vec<String>,
290 /// Case-insensitive substrings that identify a vendor or model.
291 pub vendor_tokens: Vec<String>,
292 /// Policy for vendor tokens found in the patch body.
293 pub on_leak: LeakPolicy,
294 /// Seed for label assignment and per-judge presentation order. Derived from
295 /// the run id when unset; set it to make a run reproducible.
296 pub seed: Option<u64>,
297}
298
299impl Default for Blind {
300 fn default() -> Self {
301 Self {
302 commit_msg_hook: true,
303 strip_lines: [
304 "Co-Authored-By:",
305 "Signed-off-by:",
306 "Assisted-by:",
307 "Generated-by:",
308 "Generated with",
309 "\u{1f916}",
310 ]
311 .iter()
312 .map(|s| (*s).to_owned())
313 .collect(),
314 vendor_tokens: [
315 "claude",
316 "anthropic",
317 "codex",
318 "openai",
319 "chatgpt",
320 "gemini",
321 "grok",
322 "xai",
323 "copilot",
324 "opencode",
325 "qoder",
326 "cursor",
327 "\u{1f916}",
328 ]
329 .iter()
330 .map(|s| (*s).to_owned())
331 .collect(),
332 on_leak: LeakPolicy::Warn,
333 seed: None,
334 }
335 }
336}
337
338/// Shell commands that gate the winner.
339#[derive(Debug, Clone, Default, Deserialize, Serialize)]
340#[serde(deny_unknown_fields, default)]
341pub struct Verify {
342 /// Run in the winner's worktree once per review round. Its output is fed
343 /// back to the fixer. This is the "real machine" leg of the review.
344 pub e2e: Vec<String>,
345 /// Final gate. Must all exit 0 before a merge is attempted.
346 pub gate: Vec<String>,
347 /// Shell used to run the commands above. Defaults to `sh -c`, or
348 /// `cmd /C` when `sh` is not on `PATH`.
349 pub shell: Option<Vec<String>>,
350}
351
352/// What to do with the winning branch.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
354#[serde(rename_all = "lowercase")]
355pub enum MergeMode {
356 /// Leave the branch alone and print the merge command.
357 None,
358 /// `git merge --no-ff` into the base branch in the primary worktree.
359 Local,
360 /// Push the branch and open a PR with `gh pr create`.
361 Pr,
362}
363
364/// Merge policy.
365#[derive(Debug, Clone, Deserialize, Serialize)]
366#[serde(deny_unknown_fields, default)]
367pub struct Merge {
368 /// Default is [`MergeMode::None`]: magi never touches your base branch
369 /// unless you ask it to.
370 pub mode: MergeMode,
371 /// Base branch. Defaults to the branch checked out when the run started.
372 pub base: Option<String>,
373 /// Remote for `mode = "pr"`.
374 pub remote: String,
375}
376
377impl Default for Merge {
378 fn default() -> Self {
379 Self {
380 mode: MergeMode::None,
381 base: None,
382 remote: "origin".to_owned(),
383 }
384 }
385}
386
387/// How magi keeps itself current.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
389#[serde(rename_all = "lowercase")]
390pub enum UpdateMode {
391 /// Never check.
392 Off,
393 /// Check in the background and print a one-line banner when a newer
394 /// release exists.
395 Notify,
396 /// Check and install silently.
397 Install,
398}
399
400/// Self-update policy.
401#[derive(Debug, Clone, Deserialize, Serialize)]
402#[serde(deny_unknown_fields, default)]
403pub struct Update {
404 /// Default is [`UpdateMode::Notify`]: magi tells you, and lets you decide.
405 pub mode: UpdateMode,
406 /// Minimum time between checks, e.g. `24h`. kaishin's default when unset.
407 pub interval: Option<String>,
408}
409
410impl Default for Update {
411 fn default() -> Self {
412 Self {
413 mode: UpdateMode::Notify,
414 interval: None,
415 }
416 }
417}
418
419/// Top-level configuration.
420#[derive(Debug, Clone, Default, Deserialize, Serialize)]
421#[serde(deny_unknown_fields, default)]
422pub struct Config {
423 /// Agent roster.
424 pub agents: Vec<AgentSpec>,
425 /// Role assignment.
426 pub roles: Roles,
427 /// Graph shape.
428 pub graph: Graph,
429 /// Blindness policy.
430 pub blind: Blind,
431 /// Verification commands.
432 pub verify: Verify,
433 /// Merge policy.
434 pub merge: Merge,
435 /// Self-update policy.
436 pub update: Update,
437 /// Project-specific text appended to the node prompts.
438 pub prompts: Prompts,
439 /// How the operator is told a run is waiting on them.
440 pub notify: Notify,
441 /// Local repositories the plan surface can start or derive a conversation
442 /// against.
443 pub repos: Repos,
444}
445
446/// Where `magi plan` and the browser interview look for a repository other
447/// than the one they were started against.
448///
449/// `roots` is an array, so per [`array_keys`] it can only be declared in one
450/// config layer - the machine layer, since which checkouts exist on disk is a
451/// *machine* fact in the same way the agent roster is: a repository's own
452/// `magi.toml` cannot state where its siblings live before magi has resolved
453/// which repository to read that file from in the first place.
454#[derive(Debug, Clone, Deserialize, Serialize)]
455#[serde(deny_unknown_fields, default)]
456pub struct Repos {
457 /// Roots to scan for a ghq-layout checkout: `<root>/<host>/<owner>/<repo>`
458 /// with a `.git` directory. Empty by default - nothing is scanned unless
459 /// asked to be.
460 pub roots: Vec<PathBuf>,
461 /// How long a scan is trusted before the next request re-scans it,
462 /// seconds. `0` means never trust it: scan on every request. Defaults to
463 /// a day, the same order of magnitude as [`Graph::answer_timeout`] for
464 /// the same reason - a checkout does not usually appear or vanish inside
465 /// a session, so there is little to gain from scanning more often than
466 /// that, and an explicit refresh exists for the moment one does.
467 pub scan_ttl: u64,
468}
469
470impl Default for Repos {
471 fn default() -> Self {
472 Self {
473 roots: Vec::new(),
474 scan_ttl: 86_400,
475 }
476 }
477}
478
479/// Project-specific text appended to each node's prompt.
480///
481/// **Additive by construction.** These fields cannot replace magi's prompts,
482/// only extend them, and that restriction is the whole design. The built-in
483/// prompts carry the invariants the competition rests on: a judging prompt
484/// names no authors, every structured answer must arrive as one fenced `json`
485/// block, and a judge is told not to speculate about who wrote what. A config
486/// that could overwrite them would let a typo silently un-blind the panel or
487/// break the parser, and the symptom would be "the judges got worse" rather
488/// than an error.
489///
490/// Repository-wide context belongs in `AGENTS.md`, which every agent already
491/// reads from the checkout. Use these fields for the things a *magi node*
492/// needs to know and a repository file cannot say - for instance that
493/// reviewers here should ignore formatting because a hook owns it.
494#[derive(Debug, Clone, Default, Deserialize, Serialize)]
495#[serde(deny_unknown_fields, default)]
496pub struct Prompts {
497 /// Appended to every node's prompt.
498 pub all: String,
499 /// Appended for implementers.
500 pub implement: String,
501 /// Appended for judges, both ranking and voting.
502 pub judge: String,
503 /// Appended for reviewers.
504 pub review: String,
505 /// Appended for the fixer.
506 pub fix: String,
507}
508
509impl Prompts {
510 /// The overlay for one node, or `None` when nothing is configured.
511 ///
512 /// `node` is the graph's own node name, so a new node gets no overlay
513 /// rather than the wrong one.
514 pub fn overlay(&self, node: &str) -> Option<String> {
515 let specific = match node {
516 "implement" => &self.implement,
517 "judge" | "vote" | "deliberate" => &self.judge,
518 "review" => &self.review,
519 "fix" => &self.fix,
520 _ => "",
521 };
522 let mut parts: Vec<&str> = Vec::new();
523 for p in [self.all.trim(), specific.trim()] {
524 if !p.is_empty() {
525 parts.push(p);
526 }
527 }
528 if parts.is_empty() {
529 return None;
530 }
531 Some(parts.join("\n\n"))
532 }
533}
534
535/// How the operator is told that a run is waiting on them.
536///
537/// A command rather than a built-in integration: magi is one binary with no
538/// network dependencies, and every operator's notification path is different -
539/// ntfy, a Slack webhook, a Windows toast, an SSH to a machine that beeps.
540/// Shelling out keeps all of them possible and none of them magi's problem.
541#[derive(Debug, Clone, Default, Deserialize, Serialize)]
542#[serde(deny_unknown_fields, default)]
543pub struct Notify {
544 /// Command and arguments. `{summary}`, `{run}` and `{url}` are replaced.
545 /// Empty means no notification - the web UI is then the only surface.
546 pub command: Vec<String>,
547}
548
549/// Roles resolved to concrete agent specs for one run.
550#[derive(Debug, Clone)]
551pub struct ResolvedRoles {
552 /// One per candidate.
553 pub implementers: Vec<AgentSpec>,
554 /// One per judge.
555 pub judges: Vec<AgentSpec>,
556 /// One per reviewer slot.
557 pub reviewers: Vec<AgentSpec>,
558 /// Explicit fixer, if configured.
559 pub fixer: Option<AgentSpec>,
560}
561
562/// Every array-valued key in a config table, as a dotted path.
563///
564/// Dotted so the error names `roles.implementers` rather than `implementers`:
565/// an operator with three config files needs to know which key, not just that
566/// there was one. `vars` is skipped because it is teravars' own input, merged
567/// on purpose and never deserialised into `Config`.
568fn array_keys(table: &toml::value::Table, prefix: &str) -> Vec<String> {
569 let mut out = Vec::new();
570 for (k, v) in table {
571 if prefix.is_empty() && k == "vars" {
572 continue;
573 }
574 let path = if prefix.is_empty() {
575 k.clone()
576 } else {
577 format!("{prefix}.{k}")
578 };
579 match v {
580 toml::Value::Array(_) => out.push(path),
581 toml::Value::Table(t) => out.extend(array_keys(t, &path)),
582 _ => {}
583 }
584 }
585 out
586}
587
588impl Config {
589 /// Load one file through teravars: Tera rendering, `[vars]` resolution,
590 /// and the `include = [...]` directive.
591 pub fn load(path: &Path) -> Result<Self> {
592 Self::load_layers(&[path.to_path_buf()])
593 }
594
595 /// Load and deep-merge a stack of config files, later files winning.
596 ///
597 /// This is why the config is TOML-through-teravars rather than plain serde:
598 /// the roster is a *machine* fact (which CLIs and plans you pay for) while
599 /// the gate is a *repository* fact (`cargo make check` here, `pnpm test`
600 /// there). Picking one file and ignoring the other would force every repo
601 /// to restate the roster.
602 pub fn load_layers(paths: &[PathBuf]) -> Result<Self> {
603 let mut engine = teravars::Engine::default();
604 let mut ctx = teravars::system_context();
605 // teravars ships `system.*` and `vars`; `env` is left to the consumer.
606 // A config that has to name a shared build-cache directory or a
607 // machine-specific path needs it, so magi provides it as a map:
608 // `{{ env.NAME | default(value='...') }}`.
609 let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
610 ctx.insert("env", &env);
611 if let Some(last) = paths.last()
612 && let Some(dir) = last.parent()
613 {
614 ctx.insert("repo", &dir.to_string_lossy());
615 ctx.insert(
616 "repo_name",
617 &dir.file_name().unwrap_or_default().to_string_lossy(),
618 );
619 }
620 if paths.len() > 1 {
621 Self::refuse_split_arrays(paths, &mut engine, &ctx)?;
622 }
623 let merged = teravars::load_merged(paths, &mut engine, &ctx).with_context(|| {
624 format!(
625 "rendering config via teravars: {}",
626 paths
627 .iter()
628 .map(|p| p.display().to_string())
629 .collect::<Vec<_>>()
630 .join(", ")
631 )
632 })?;
633 let mut table = merged.config;
634 // `[vars]` is teravars' own input, already resolved into the render
635 // context; `deny_unknown_fields` must not trip over it.
636 table.remove("vars");
637 toml::Value::Table(table)
638 .try_into()
639 .context("deserializing magi config")
640 }
641
642 /// Refuse an array that two layers both declare.
643 ///
644 /// teravars **appends** arrays when it merges layers, and that is wrong for
645 /// every array magi has: `implementers` is an ordered list of seats,
646 /// `verify.gate` is the commands to run, `notify.command` is an argv.
647 /// Concatenating two of them yields something nobody wrote - three
648 /// implementers out of a machine's two and a repository's one, or an argv
649 /// of `["ntfy", "publish", "curl", "-X"]`.
650 ///
651 /// Replacing instead would be the right merge rule, but the rule lives in
652 /// teravars, which several other projects depend on; changing it there is
653 /// a decision for that crate, not something to fake here by re-reading the
654 /// files with different semantics and hoping the two paths agree.
655 ///
656 /// So magi refuses the ambiguity rather than resolving it silently. The
657 /// cost of guessing is a roster the operator did not ask for and is paying
658 /// for by the token.
659 fn refuse_split_arrays(
660 paths: &[PathBuf],
661 engine: &mut teravars::Engine,
662 ctx: &teravars::Context,
663 ) -> Result<()> {
664 let mut seen: std::collections::BTreeMap<String, PathBuf> = Default::default();
665 for path in paths {
666 let one = teravars::load_merged([path], engine, ctx)
667 .with_context(|| format!("rendering {}", path.display()))?;
668 for key in array_keys(&one.config, "") {
669 if let Some(first) = seen.get(&key) {
670 bail!(
671 "`{key}` is an array declared in two config layers:\n \
672 {}\n {}\nteravars appends arrays when it merges, so \
673 magi would run the concatenation of both - which is \
674 not what either file says. Declare `{key}` in exactly \
675 one of them.",
676 first.display(),
677 path.display()
678 );
679 }
680 seen.insert(key, path.clone());
681 }
682 }
683 Ok(())
684 }
685
686 /// Resolve the config for `repo`, honouring an explicit `--config` path.
687 ///
688 /// Returns the config and the layers it came from, empty for built-in
689 /// defaults.
690 pub fn discover(repo: &Path, explicit: Option<&Path>) -> Result<(Self, Vec<PathBuf>)> {
691 if let Some(p) = explicit {
692 let paths = vec![p.to_path_buf()];
693 return Ok((Self::load_layers(&paths)?, paths));
694 }
695 let paths = Self::layers(repo);
696 if paths.is_empty() {
697 return Ok((Self::autodetected(), paths));
698 }
699 Ok((Self::load_layers(&paths)?, paths))
700 }
701 /// Environment variable that relocates the machine-wide config layer.
702 ///
703 /// Set it to a directory and magi reads `<dir>/magi/config.toml` instead
704 /// of the one under [`dirs::config_dir`]; set it to the empty string and
705 /// magi reads no machine layer at all.
706 ///
707 /// This exists because the machine layer is otherwise unavoidable, and a
708 /// test that builds a config fixture is not asking for the operator's
709 /// preferences to be merged into it. Adding `[repos] roots` to the real
710 /// machine config on a development box turned two passing tests red -
711 /// `repos_list_returns_name_and_path_for_every_configured_root` and
712 /// `repos_list_only_rescans_within_the_ttl_when_asked_to`, whose fixtures
713 /// declare `[repos] roots` of their own, which [`Config::layers`] then
714 /// found in two layers and [`Config::refuse_split_arrays`] correctly
715 /// refused. CI never saw it: a runner has no machine config, so the suite
716 /// was green there and red only where somebody actually uses magi.
717 ///
718 /// An operator gets the same escape hatch for free: a second machine
719 /// config, or none, without moving files about.
720 pub const CONFIG_DIR_ENV: &str = "MAGI_CONFIG_DIR";
721
722 /// Every config layer that applies to `repo`, in increasing precedence.
723 ///
724 /// The machine layer is whatever [`Config::machine_layer`] resolves to,
725 /// which is nothing at all in a test build.
726 pub fn layers(repo: &Path) -> Vec<PathBuf> {
727 let mut paths = Vec::new();
728 paths.extend(Self::machine_layer());
729 paths.push(repo.join(".magi").join("config.toml"));
730 paths.push(repo.join("magi.toml"));
731 paths.retain(|p| p.is_file());
732 paths
733 }
734
735 /// The machine-wide layer's path, when there is one.
736 ///
737 /// **A test build has none unless it names one.** A fixture is a complete
738 /// statement of the config under test, and the operator's own preferences
739 /// have no business being merged into it - least of all silently, on one
740 /// machine, in a suite that is green everywhere else.
741 #[cfg(test)]
742 fn machine_layer() -> Option<PathBuf> {
743 std::env::var(Self::CONFIG_DIR_ENV)
744 .ok()
745 .filter(|dir| !dir.trim().is_empty())
746 .map(|dir| PathBuf::from(dir).join("magi").join("config.toml"))
747 }
748
749 /// The machine-wide layer's path, when there is one.
750 #[cfg(not(test))]
751 fn machine_layer() -> Option<PathBuf> {
752 match std::env::var(Self::CONFIG_DIR_ENV) {
753 // Named, and empty on purpose: no machine layer.
754 Ok(dir) if dir.trim().is_empty() => None,
755 Ok(dir) => Some(PathBuf::from(dir).join("magi").join("config.toml")),
756 Err(_) => dirs::config_dir().map(|dir| dir.join("magi").join("config.toml")),
757 }
758 }
759
760 /// Built-in config whose roster is the agent CLIs found on `PATH`.
761 pub fn autodetected() -> Self {
762 let mut cfg = Self::default();
763 for (kind, id, model) in [
764 (AgentKind::Claude, "opus", Some("opus")),
765 (AgentKind::Claude, "sonnet", Some("sonnet")),
766 (AgentKind::Antigravity, "antigravity", None),
767 (AgentKind::Opencode, "opencode", None),
768 ] {
769 if kind.program().is_some_and(which) && !cfg.agents.iter().any(|a| a.id == id) {
770 cfg.agents.push(AgentSpec {
771 id: id.to_owned(),
772 kind,
773 model: model.map(str::to_owned),
774 command: Vec::new(),
775 extra_args: Vec::new(),
776 env: BTreeMap::new(),
777 prompt_delivery: None,
778 });
779 }
780 }
781 cfg
782 }
783
784 /// Look an agent up by id.
785 pub fn agent(&self, id: &str) -> Result<&AgentSpec> {
786 self.agents
787 .iter()
788 .find(|a| a.id == id)
789 .with_context(|| format!("no agent with id `{id}` in the roster"))
790 }
791
792 /// Fill the roles out to the configured widths.
793 ///
794 /// An empty role list rotates through the whole roster, so a three-agent
795 /// roster with `candidates = 3` gives one implementation per agent, and
796 /// `judges = 3` rotates the judge seats by one so that judge *i* is not the
797 /// author of candidate *i* whenever the roster has more than one agent.
798 pub fn resolve_roles(&self) -> Result<ResolvedRoles> {
799 if self.agents.is_empty() {
800 bail!(
801 "agent roster is empty: no agent CLI found on PATH and no \
802 [[agents]] in the config. Run `magi init` to write a starter \
803 magi.toml."
804 );
805 }
806 let pick = |ids: &[String], count: usize, offset: usize| -> Result<Vec<AgentSpec>> {
807 let mut out = Vec::with_capacity(count);
808 for i in 0..count {
809 let spec = if ids.is_empty() {
810 self.agents[(i + offset) % self.agents.len()].clone()
811 } else {
812 self.agent(&ids[i % ids.len()])?.clone()
813 };
814 out.push(spec);
815 }
816 Ok(out)
817 };
818 Ok(ResolvedRoles {
819 implementers: pick(&self.roles.implementers, self.graph.candidates, 0)?,
820 judges: pick(&self.roles.judges, self.graph.judges, 1)?,
821 reviewers: pick(&self.roles.reviewers, self.graph.reviewers, 0)?,
822 fixer: self
823 .roles
824 .fixer
825 .as_deref()
826 .map(|f| self.agent(f).cloned())
827 .transpose()?,
828 })
829 }
830
831 /// Shell prefix for [`Verify`] commands.
832 pub fn shell(&self) -> Vec<String> {
833 if let Some(s) = &self.verify.shell {
834 return s.clone();
835 }
836 if which("sh") {
837 vec!["sh".to_owned(), "-c".to_owned()]
838 } else {
839 vec!["cmd".to_owned(), "/C".to_owned()]
840 }
841 }
842
843 /// Starter config, as written by `magi init`.
844 pub fn starter_toml() -> String {
845 let detected = Self::autodetected();
846 let mut s = String::from(
847 "# magi — blind multi-agent implementation competition.\n\
848 # `magi run \"<task>\"` walks: implement (N parallel worktrees)\n\
849 # -> blind judging -> deliberation -> private final vote\n\
850 # -> fold losers -> review + E2E loop -> gate -> merge.\n\
851 #\n\
852 # Rendered by teravars: a `[vars]` table, env\n\
853 # and system lookups, and `include = [...]` all work. Tera\n\
854 # braces are live everywhere in this file, but comments are\n\
855 # stripped before rendering (teravars >= 0.2.2), so a comment\n\
856 # may quote `{{ ... }}` freely.\n\
857 #\n\
858 # Layers deep-merge in increasing\n\
859 # precedence, so the roster can live once per machine in\n\
860 # <config_dir>/magi/config.toml and each repo only states its own\n\
861 # gate:\n\
862 # <config_dir>/magi/config.toml < .magi/config.toml < magi.toml\n\n\
863 [vars]\n\
864 # Reference it as vars.cache inside Tera braces, anywhere below.\n\
865 # Single quotes inside the braces: teravars renders the raw file\n\
866 # text, so TOML's own \\\" escaping never reaches Tera.\n\
867 cache = \"{{ env.MAGI_CACHE | default(value='/tmp') }}\"\n\n",
868 );
869 if detected.agents.is_empty() {
870 s.push_str(
871 "# No agent CLI was found on PATH. Fill this in by hand.\n\
872 # kind = claude | opencode | antigravity | command\n\
873 [[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n",
874 );
875 } else {
876 for a in &detected.agents {
877 s.push_str("[[agents]]\n");
878 s.push_str(&format!("id = {:?}\n", a.id));
879 s.push_str(&format!("kind = {:?}\n", a.kind.as_str()));
880 if let Some(m) = &a.model {
881 s.push_str(&format!("model = {m:?}\n"));
882 }
883 s.push('\n');
884 }
885 }
886 s.push_str(
887 "# Leave a role list empty to rotate through the roster.\n\
888 [roles]\n\
889 implementers = []\n\
890 judges = []\n\
891 reviewers = []\n\n\
892 [graph]\n\
893 candidates = 3\n\
894 judges = 3\n\
895 deliberate_rounds = 1\n\
896 reviewers = 2\n\
897 review_rounds = 6\n\
898 max_parallel = 4\n\
899 language = \"en\"\n\
900 # One CLI conversation per seat: judges keep their own argument\n\
901 # across deliberation, the fixer keeps its implementation context.\n\
902 sessions = true\n\n\
903 [verify]\n\
904 # Run once per review round in the winner's worktree; failures are\n\
905 # fed back to the fixer.\n\
906 e2e = []\n\
907 # Final gate. Every command must exit 0 before a merge.\n\
908 gate = []\n\n\
909 [merge]\n\
910 # none | local | pr\n\
911 mode = \"none\"\n\n\
912 [update]\n\
913 # off | notify | install — checked in the background, throttled.\n\
914 mode = \"notify\"\n\
915 # interval = \"24h\"\n",
916 );
917 s
918 }
919}
920
921/// Is `program` on `PATH`?
922pub fn which(program: &str) -> bool {
923 let Some(paths) = std::env::var_os("PATH") else {
924 return false;
925 };
926 let exts: Vec<String> = std::env::var("PATHEXT")
927 .map(|v| v.split(';').map(|e| e.to_lowercase()).collect())
928 .unwrap_or_default();
929 std::env::split_paths(&paths).any(|dir| {
930 let direct = dir.join(program);
931 if direct.is_file() {
932 return true;
933 }
934 exts.iter().any(|ext| {
935 let mut name = program.to_owned();
936 name.push_str(ext);
937 dir.join(name).is_file()
938 })
939 })
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945
946 fn spec(id: &str) -> AgentSpec {
947 AgentSpec {
948 id: id.to_owned(),
949 kind: AgentKind::Command,
950 model: None,
951 command: vec!["true".to_owned()],
952 extra_args: Vec::new(),
953 env: BTreeMap::new(),
954 prompt_delivery: None,
955 }
956 }
957
958 #[test]
959 fn empty_roles_rotate_judges_off_their_own_candidate() {
960 // Three seats, said out loud: this is a test about *rotation*, and it
961 // has nothing to say about how many candidates a task buys by default.
962 let cfg = Config {
963 agents: vec![spec("a"), spec("b"), spec("c")],
964 graph: Graph {
965 candidates: 3,
966 ..Graph::default()
967 },
968 ..Config::default()
969 };
970 let roles = cfg.resolve_roles().unwrap();
971 let impls: Vec<&str> = roles.implementers.iter().map(|a| a.id.as_str()).collect();
972 let judges: Vec<&str> = roles.judges.iter().map(|a| a.id.as_str()).collect();
973 assert_eq!(impls, ["a", "b", "c"]);
974 assert_eq!(judges, ["b", "c", "a"]);
975 for (i, j) in judges.iter().enumerate() {
976 assert_ne!(*j, impls[i], "judge {i} must not sit on its own candidate");
977 }
978 }
979
980 #[test]
981 fn single_agent_roster_fills_every_seat() {
982 let cfg = Config {
983 agents: vec![spec("solo")],
984 graph: Graph {
985 candidates: 3,
986 ..Graph::default()
987 },
988 ..Config::default()
989 };
990 let roles = cfg.resolve_roles().unwrap();
991 assert_eq!(roles.implementers.len(), 3);
992 assert!(roles.judges.iter().all(|a| a.id == "solo"));
993 }
994
995 #[test]
996 fn explicit_roles_win() {
997 let cfg = Config {
998 agents: vec![spec("a"), spec("b")],
999 roles: Roles {
1000 implementers: vec!["b".to_owned()],
1001 judges: vec!["a".to_owned()],
1002 reviewers: Vec::new(),
1003 fixer: Some("a".to_owned()),
1004 ..Roles::default()
1005 },
1006 ..Config::default()
1007 };
1008 let roles = cfg.resolve_roles().unwrap();
1009 assert!(roles.implementers.iter().all(|a| a.id == "b"));
1010 assert!(roles.judges.iter().all(|a| a.id == "a"));
1011 assert_eq!(roles.fixer.unwrap().id, "a");
1012 }
1013
1014 #[test]
1015 fn unknown_agent_id_is_an_error() {
1016 let cfg = Config {
1017 agents: vec![spec("a")],
1018 roles: Roles {
1019 judges: vec!["nope".to_owned()],
1020 ..Roles::default()
1021 },
1022 ..Config::default()
1023 };
1024 assert!(cfg.resolve_roles().is_err());
1025 }
1026
1027 #[test]
1028 fn empty_roster_is_an_error() {
1029 assert!(Config::default().resolve_roles().is_err());
1030 }
1031
1032 #[test]
1033 fn repos_default_to_no_roots_and_a_day_of_trust() {
1034 assert_eq!(Config::default().repos.roots, Vec::<PathBuf>::new());
1035 assert_eq!(Config::default().repos.scan_ttl, 86_400);
1036 }
1037
1038 #[test]
1039 fn a_config_file_with_no_repos_table_still_loads() {
1040 let dir = tempfile::tempdir().unwrap();
1041 let path = dir.path().join("magi.toml");
1042 std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
1043 let cfg = Config::load(&path).expect("must load without [repos]");
1044 assert_eq!(cfg.repos.roots, Vec::<PathBuf>::new());
1045 assert_eq!(cfg.repos.scan_ttl, 86_400);
1046 }
1047
1048 /// A fixture is the whole config under test.
1049 ///
1050 /// `layers` used to reach for `dirs::config_dir()` unconditionally, so on
1051 /// a machine where somebody had written `<config_dir>/magi/config.toml`
1052 /// the suite silently loaded it as the lowest layer. Adding `[repos]
1053 /// roots` there turned two web tests red - their fixtures declare
1054 /// `[repos] roots` too, and `refuse_split_arrays` rightly refuses one
1055 /// array key spread across two layers. CI stayed green throughout,
1056 /// because a runner has no such file: the suite failed only where magi is
1057 /// actually used.
1058 ///
1059 /// So a test build has no machine layer unless it asks for one, and this
1060 /// is that promise. Written against a real file at the real location so
1061 /// it fails if `machine_layer` starts reading it again.
1062 #[test]
1063 fn a_test_build_does_not_read_the_operators_machine_config() {
1064 let repo = tempfile::tempdir().unwrap();
1065 std::fs::write(repo.path().join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
1066
1067 let layers = Config::layers(repo.path());
1068 assert_eq!(
1069 layers,
1070 vec![repo.path().join("magi.toml")],
1071 "only the fixture's own file may be a layer"
1072 );
1073 if let Some(real) = dirs::config_dir() {
1074 let machine = real.join("magi").join("config.toml");
1075 assert!(
1076 !layers.contains(&machine),
1077 "the operator's {} must not be a layer in a test build",
1078 machine.display()
1079 );
1080 }
1081 }
1082
1083 #[test]
1084 fn starter_toml_loads_through_teravars() {
1085 let dir = tempfile::tempdir().unwrap();
1086 let path = dir.path().join("magi.toml");
1087 std::fs::write(&path, Config::starter_toml()).unwrap();
1088 let parsed = Config::load(&path).expect("starter config must load");
1089 assert_eq!(parsed.graph.candidates, 3);
1090 assert_eq!(parsed.merge.mode, MergeMode::None);
1091 assert!(parsed.graph.sessions);
1092 assert_eq!(parsed.update.mode, UpdateMode::Notify);
1093 }
1094
1095 #[test]
1096 fn later_layers_win_and_vars_render() {
1097 let dir = tempfile::tempdir().unwrap();
1098 let machine = dir.path().join("machine.toml");
1099 let project = dir.path().join("magi.toml");
1100 // The machine layer owns the roster...
1101 std::fs::write(
1102 &machine,
1103 "[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n\
1104 [graph]\ncandidates = 3\nmax_parallel = 8\n",
1105 )
1106 .unwrap();
1107 // ...and the project layer only states what is repo-specific, plus a
1108 // `[vars]` value interpolated into a command.
1109 std::fs::write(
1110 &project,
1111 "[vars]\ncache = \"/shared\"\n\n\
1112 [graph]\ncandidates = 2\n\n\
1113 [verify]\ngate = [\"CARGO_TARGET_DIR={{ vars.cache }}/t cargo test\"]\n",
1114 )
1115 .unwrap();
1116
1117 let cfg = Config::load_layers(&[machine, project]).expect("layered load");
1118 assert_eq!(cfg.agents.len(), 1, "roster comes from the machine layer");
1119 assert_eq!(cfg.graph.candidates, 2, "project layer wins");
1120 assert_eq!(cfg.graph.max_parallel, 8, "machine layer survives");
1121 assert_eq!(
1122 cfg.verify.gate,
1123 ["CARGO_TARGET_DIR=/shared/t cargo test".to_owned()]
1124 );
1125 }
1126
1127 #[test]
1128 fn env_is_available_to_templates_with_a_default() {
1129 let dir = tempfile::tempdir().unwrap();
1130 let path = dir.path().join("magi.toml");
1131 // teravars ships no `env`; magi adds it, and the `default` filter has
1132 // to cover the unset case or every machine would need the variable.
1133 //
1134 // Deliberately no named variable: `env` is keyed by the exact spelling
1135 // the OS reports, and Windows says `Path` where POSIX says `PATH`, so a
1136 // test asserting `env.PATH` passes on one runner and fails on another.
1137 // The map's non-emptiness is the platform-neutral claim.
1138 std::fs::write(
1139 &path,
1140 "[verify]\n\
1141 gate = [\"cache={{ env.MAGI_TEST_UNSET_XYZ | default(value='fallback') }}\", \
1142 \"populated={{ env | length > 0 }}\"]\n",
1143 )
1144 .unwrap();
1145 let cfg = Config::load(&path).expect("env lookup must render");
1146 assert_eq!(cfg.verify.gate[0], "cache=fallback");
1147 assert_eq!(cfg.verify.gate[1], "populated=true");
1148 }
1149
1150 #[test]
1151 fn a_broken_template_names_the_file() {
1152 let dir = tempfile::tempdir().unwrap();
1153 let path = dir.path().join("magi.toml");
1154 std::fs::write(&path, "[graph]\nlanguage = \"{{ nope.\"\n").unwrap();
1155 let err = Config::load(&path).expect_err("must not silently ignore");
1156 assert!(err.to_string().contains("teravars"), "{err}");
1157 }
1158
1159 #[test]
1160 fn tera_syntax_in_comments_is_inert() {
1161 // teravars >= 0.2.2 strips `#` comments before Tera sees the file, so a
1162 // comment may quote template syntax without rendering. Before 0.2.2 this
1163 // load failed: the commented-out braces reached the template parser.
1164 let dir = tempfile::tempdir().unwrap();
1165 let path = dir.path().join("magi.toml");
1166 std::fs::write(
1167 &path,
1168 "# a comment may quote templates: `{{ env.NOPE | default(value='x') }}` and `{% if %}`\n\
1169 [graph]\ncandidates = 2\n",
1170 )
1171 .unwrap();
1172 let cfg = Config::load(&path).expect("comments must be inert, not rendered");
1173 assert_eq!(cfg.graph.candidates, 2);
1174 }
1175
1176 #[test]
1177 fn opencode_defaults_to_file_delivery() {
1178 let mut s = spec("oc");
1179 s.kind = AgentKind::Opencode;
1180 assert_eq!(s.delivery(), Delivery::File);
1181 s.prompt_delivery = Some(Delivery::Argv);
1182 assert_eq!(s.delivery(), Delivery::Argv);
1183 }
1184 #[test]
1185 fn the_land_loop_is_on_but_it_cannot_merge_without_being_asked() {
1186 // Both default on, and that pair is the safety property: `land` takes
1187 // over the watching an operator was doing by hand, `land_approval`
1188 // keeps the irreversible step a human decision. An unattended merge
1189 // needs BOTH flipped, which has to be chosen deliberately twice.
1190 let g = Graph::default();
1191 assert!(
1192 g.land,
1193 "stopping at an open PR left the watching to a human"
1194 );
1195 assert!(
1196 g.land_approval,
1197 "on-by-default land is only defensible while this is also on"
1198 );
1199 assert!(g.land_rounds > 0, "a loop with no budget never terminates");
1200 }
1201 #[test]
1202 fn an_array_declared_in_two_layers_is_refused_instead_of_concatenated() {
1203 // teravars appends arrays. For an ordered list of seats, or an argv,
1204 // the concatenation is something neither file says - and the operator
1205 // pays for the extra seats by the token.
1206 let dir = tempfile::tempdir().unwrap();
1207 let machine = dir.path().join("machine.toml");
1208 let repo = dir.path().join("magi.toml");
1209 std::fs::write(&machine, "[roles]\nimplementers = [\"a\", \"b\"]\n").unwrap();
1210 std::fs::write(&repo, "[roles]\nimplementers = [\"oc\"]\n").unwrap();
1211
1212 let err = Config::load_layers(&[machine.clone(), repo.clone()])
1213 .expect_err("two layers naming one array must not merge silently")
1214 .to_string();
1215 assert!(err.contains("roles.implementers"), "{err}");
1216 // Both files are named: the fix is to delete one of them, and the
1217 // operator has to know which two to choose between.
1218 assert!(err.contains("machine.toml"), "{err}");
1219 assert!(err.contains("magi.toml"), "{err}");
1220 }
1221
1222 #[test]
1223 fn a_scalar_in_one_layer_and_an_array_in_another_still_merges() {
1224 // The split the layering exists for: state a preference machine-wide,
1225 // let the repository own its own lists.
1226 let dir = tempfile::tempdir().unwrap();
1227 let machine = dir.path().join("machine.toml");
1228 let repo = dir.path().join("magi.toml");
1229 std::fs::write(&machine, "[roles]\nplanner = \"opus\"\n").unwrap();
1230 std::fs::write(
1231 &repo,
1232 "[[agents]]\nid = \"oc\"\nkind = \"opencode\"\n\n\
1233 [roles]\nimplementers = [\"oc\"]\n",
1234 )
1235 .unwrap();
1236
1237 let cfg = Config::load_layers(&[machine, repo]).expect("layers merge");
1238 assert_eq!(cfg.roles.planner.as_deref(), Some("opus"));
1239 assert_eq!(cfg.roles.implementers, ["oc"]);
1240 assert_eq!(cfg.agents.len(), 1, "the roster is not doubled");
1241 }
1242}