Skip to main content

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}