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