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    /// OpenAI Codex CLI (`codex exec`). The one roster member with a real
31    /// read-only mode: `--sandbox read-only` is enforced by the CLI, not by
32    /// the prompt.
33    Codex,
34    /// Arbitrary command. The escape hatch, and what the test suite drives.
35    Command,
36}
37
38impl AgentKind {
39    /// Executable that must be on `PATH` for this kind, if any.
40    pub fn program(self) -> Option<&'static str> {
41        match self {
42            Self::Claude => Some("claude"),
43            Self::Opencode => Some("opencode"),
44            Self::Antigravity => Some("agy"),
45            Self::Codex => Some("codex"),
46            Self::Command => None,
47        }
48    }
49
50    /// Lowercase name as written in the config file.
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::Claude => "claude",
54            Self::Opencode => "opencode",
55            Self::Antigravity => "antigravity",
56            Self::Codex => "codex",
57            Self::Command => "command",
58        }
59    }
60}
61
62/// How the prompt reaches the agent process.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
64#[serde(rename_all = "lowercase")]
65pub enum Delivery {
66    /// Piped on stdin.
67    Stdin,
68    /// Passed as a positional argument. Beware OS command-line limits.
69    Argv,
70    /// Written to a file; the agent is told to read it. No length limit.
71    File,
72}
73
74/// One addressable agent in the roster.
75#[derive(Debug, Clone, Deserialize, Serialize)]
76#[serde(deny_unknown_fields)]
77pub struct AgentSpec {
78    /// Stable identifier used by `[roles]` and by the stats tables.
79    pub id: String,
80    /// Which CLI to drive.
81    pub kind: AgentKind,
82    /// Model passed through to the CLI (`--model` / `-m`). CLI default if unset.
83    #[serde(default)]
84    pub model: Option<String>,
85    /// `kind = "command"` only: argv. Supports `{prompt_file}`, `{cwd}`,
86    /// `{label}`, `{session}` placeholders.
87    #[serde(default)]
88    pub command: Vec<String>,
89    /// Extra arguments appended to the built command line.
90    #[serde(default)]
91    pub extra_args: Vec<String>,
92    /// Extra environment variables for the child process.
93    #[serde(default)]
94    pub env: BTreeMap<String, String>,
95    /// Override the per-kind prompt delivery default.
96    #[serde(default)]
97    pub prompt_delivery: Option<Delivery>,
98}
99
100impl AgentSpec {
101    /// Default prompt delivery for this agent.
102    ///
103    /// `opencode` and `agy` take the prompt as an argument, which on Windows
104    /// caps out around 32 KiB — well under a judging prompt carrying three
105    /// patches — so both get a file instead.
106    pub fn delivery(&self) -> Delivery {
107        self.prompt_delivery.unwrap_or(match self.kind {
108            AgentKind::Claude | AgentKind::Command => Delivery::Stdin,
109            // `codex exec -` reads the prompt from stdin, so the whole
110            // instruction arrives without an argv length limit and without a
111            // tool round-trip to open a file.
112            AgentKind::Codex => Delivery::Stdin,
113            AgentKind::Opencode | AgentKind::Antigravity => Delivery::File,
114        })
115    }
116
117    /// Human-facing label, e.g. `opus (claude:opus)`.
118    pub fn display(&self) -> String {
119        match &self.model {
120            Some(m) => format!("{} ({}:{m})", self.id, self.kind.as_str()),
121            None => format!("{} ({})", self.id, self.kind.as_str()),
122        }
123    }
124}
125
126/// Explicit role assignment. Empty lists are filled in by
127/// [`Config::resolve_roles`] by rotating the roster.
128#[derive(Debug, Clone, Default, Deserialize, Serialize)]
129#[serde(deny_unknown_fields, default)]
130pub struct Roles {
131    /// Agents that implement the task, one worktree each.
132    pub implementers: Vec<String>,
133    /// Agents that rank the candidates blind.
134    pub judges: Vec<String>,
135    /// Agents that review the winning patch.
136    pub reviewers: Vec<String>,
137    /// Agent that applies review findings. Defaults to the winner's author.
138    pub fixer: Option<String>,
139    /// Agent that answers the standing chat's turns (`src/talk.rs`).
140    ///
141    /// Unset picks a `claude` seat, else the first runnable agent in roster
142    /// order (see [`crate::agent::pick`]) - which is roster *order*, not a
143    /// judgement about who converses well. Naming one here matters once an
144    /// agent is also sitting as a judge: the chat is opened far more often
145    /// than any single competition, and every open competes with that judge
146    /// seat for the same account's concurrency. A timeout on an ordinary chat
147    /// turn traced to exactly this - `opus` triple-booked as chatter and judge
148    /// - is what this field exists to let an operator break apart.
149    pub chatter: Option<String>,
150}
151
152/// Graph shape and limits.
153#[derive(Debug, Clone, Deserialize, Serialize)]
154#[serde(deny_unknown_fields, default)]
155pub struct Graph {
156    /// Parallel implementations of the same task. **One by default.**
157    ///
158    /// Competition is the thing magi is for, and it is still here - it is just
159    /// no longer what every task buys without being asked. Three days and 13
160    /// runs on this repository, which is the workload these numbers are drawn
161    /// from:
162    ///
163    /// - **0 of 13** competed runs reached a merge. Everything that landed in
164    ///   that window went through `magi review` - the cheap half, no
165    ///   competition - and passed on the first try.
166    /// - The judges' first choices **split 73% of the time** (8 of 11
167    ///   tallies). Candidates that close together make the ranking a weak
168    ///   signal for what it costs to produce.
169    /// - One run's own breakdown: implement 60min, judge 40min, fix 40min,
170    ///   review 28min, **verify 5min** - and verify is the node that caught a
171    ///   defect every reviewer had passed as clean. The cheapest step is the
172    ///   one that earns its place every time.
173    ///
174    /// It is not worthless: `oc` won 3 of those tallies against `sonnet`, so a
175    /// single-seat default would have shipped the worse implementation in
176    /// roughly a quarter of them. That is exactly why this is a *default* and
177    /// not a removal - `magi run --candidates N` and a per-task seat count are
178    /// how a task that deserves a competition gets one.
179    ///
180    /// A single-candidate run needs no special case: `Runner::review`'s doc
181    /// records that `execute` already degrades to implement -> review -> gate
182    /// -> merge, because `judge` skips a one-candidate field, `deliberate` has
183    /// no two first choices to reconcile and `vote` returns early.
184    pub candidates: usize,
185    /// Independent judges.
186    pub judges: usize,
187    /// Deliberation rounds when the judges' first choices disagree.
188    pub deliberate_rounds: usize,
189    /// Reviewers per review round. **Three by default** - the smallest panel
190    /// a lens cycle (see [`crate::prompt::Lens`]) covers exactly once, so the
191    /// default panel reads the patch for spec compliance, regressions, and
192    /// simplicity without repeating an angle. Review is also the one stage
193    /// [`Self::candidates`]'s doc describes as running on every task
194    /// regardless of competition, which is what makes a panel worth its cost
195    /// here even though `candidates` itself defaults to one.
196    pub reviewers: usize,
197    /// Maximum review+fix rounds before the run is declared blocked.
198    pub review_rounds: usize,
199    /// Maximum agent processes running at once.
200    pub max_parallel: usize,
201    /// Language for the prose the agents write (`en` / `ja` / any language name).
202    pub language: String,
203    /// Keep one CLI conversation per seat, so a judge remembers its own
204    /// argument across deliberation rounds and the fixer remembers its own
205    /// implementation across review rounds.
206    ///
207    /// Sessions are scoped to a *seat*, never to an agent id: the same model
208    /// sitting as implementer and as judge gets two unrelated conversations,
209    /// which is what keeps blind judging blind.
210    pub sessions: bool,
211    /// Per-node timeouts, seconds.
212    pub timeout_implement: u64,
213    /// Per-node timeouts, seconds.
214    pub timeout_judge: u64,
215    /// Per-node timeouts, seconds.
216    pub timeout_review: u64,
217    /// Per-node timeouts, seconds.
218    pub timeout_fix: u64,
219    /// Wall-clock limit for one turn of [`crate::talk`]'s standing
220    /// conversation, seconds.
221    ///
222    /// An hour: the operator is not watching this turn resolve in real time,
223    /// so the budget can match what the work - reading files, running
224    /// commands, checking their output - actually needs rather than what a
225    /// person waiting on a phone can tolerate.
226    pub timeout_talk: u64,
227    /// Retries for an agent invocation that fails or returns nothing usable.
228    pub retries: usize,
229    /// Root for candidate / judge worktrees. Defaults to `~/wt/magi`.
230    pub worktree_root: Option<PathBuf>,
231    /// After the pull request is open, keep going: watch its checks and
232    /// reviews, run a fix round when they are unhappy, and ask to merge.
233    ///
234    /// On, because stopping at an open pull request left the operator doing
235    /// the watching by hand - six times in the session this was built in - and
236    /// that is the work the loop exists to take. It only engages for
237    /// `merge = "pr"`; every other merge mode ends the run as before.
238    ///
239    /// Turning this on does **not** hand magi the merge button:
240    /// [`Graph::land_approval`] is on too, and nothing merges without an
241    /// explicit answer. Setting both to their non-defaults is the only way to
242    /// get an unattended merge, and it has to be chosen twice.
243    pub land: bool,
244    /// Land rounds - watch, fix, push - before the run is left for a human.
245    pub land_rounds: usize,
246    /// Ask the owner before merging, showing what is about to land.
247    ///
248    /// On, and it is what makes `land` safe to have on: the question carries a
249    /// rendered panel - the diffstat, the patch, the checks, the review
250    /// comments that were addressed, and the subject the squash will use - so
251    /// the decision is made on evidence rather than on trust, from wherever
252    /// the operator happens to be.
253    ///
254    /// Silence is a hold. An unanswered approval never merges, and neither
255    /// does any answer other than the word `merge`.
256    pub land_approval: bool,
257    /// How long to wait for an owner to answer a question before the run is
258    /// abandoned, seconds. A parked run costs nothing, so this is generous;
259    /// it exists so a forgotten question cannot pin a worktree forever.
260    pub answer_timeout: u64,
261    /// What a round does when one or more reviewer seats never answered
262    /// (timeout, crash, unparsable output).
263    pub incomplete_review: IncompleteReviewPolicy,
264}
265
266impl Default for Graph {
267    fn default() -> Self {
268        Self {
269            candidates: 1,
270            judges: 3,
271            deliberate_rounds: 1,
272            reviewers: 3,
273            review_rounds: 6,
274            max_parallel: 4,
275            language: "en".to_owned(),
276            sessions: true,
277            timeout_implement: 3600,
278            timeout_judge: 1200,
279            timeout_review: 1200,
280            timeout_fix: 1800,
281            timeout_talk: 3600,
282            retries: 1,
283            worktree_root: None,
284            land: true,
285            land_rounds: 4,
286            land_approval: true,
287            answer_timeout: 86_400,
288            incomplete_review: IncompleteReviewPolicy::Block,
289        }
290    }
291}
292
293/// What a review round does when a reviewer seat never answered.
294///
295/// A round where half the panel timed out is not evidence of a clean patch —
296/// it is evidence of nothing. The default refuses to call that clean; `warn`
297/// exists for an operator who would rather keep a flaky seat from stalling
298/// every run, and accepts that the gap is on them to read in the report.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
300#[serde(rename_all = "lowercase")]
301pub enum IncompleteReviewPolicy {
302    /// A round with a missing seat is never `clean`: with nothing raised to
303    /// fix, the round is re-reviewed instead of gating; with the max rounds
304    /// exhausted, the run is left `Blocked` rather than declared ready.
305    Block,
306    /// A round with a missing seat can still gate as clean, once every seat
307    /// that *did* answer raised nothing blocking and verification is green.
308    /// The record keeps the gap visible (`magi show`, `magi stats`) even
309    /// though the run does not wait on it.
310    Warn,
311}
312
313/// What to do when vendor-identifying text is found in material shown to judges.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
315#[serde(rename_all = "lowercase")]
316pub enum LeakPolicy {
317    /// Record the leak, show the patch unmodified.
318    Warn,
319    /// Replace the token with `[REDACTED]` in the presented patch.
320    Redact,
321    /// Abort the run.
322    Fail,
323}
324
325/// Blindness policy.
326///
327/// Commit messages and candidate summaries are *always* stripped of
328/// attribution trailers and redacted — that is where signatures actually
329/// appear. [`Blind::on_leak`] governs the patch body only, where blanket
330/// redaction would corrupt the artifact under judgement.
331#[derive(Debug, Clone, Deserialize, Serialize)]
332#[serde(deny_unknown_fields, default)]
333pub struct Blind {
334    /// Install a per-worktree `commit-msg` hook that deletes attribution
335    /// trailers before they can land in a candidate's history.
336    pub commit_msg_hook: bool,
337    /// Literal, case-insensitive substrings. A line containing any of them is
338    /// dropped from commit messages and summaries; the `commit-msg` hook is
339    /// generated from the same list.
340    pub strip_lines: Vec<String>,
341    /// Case-insensitive substrings that identify a vendor or model.
342    pub vendor_tokens: Vec<String>,
343    /// Policy for vendor tokens found in the patch body.
344    pub on_leak: LeakPolicy,
345    /// Seed for label assignment and per-judge presentation order. Derived from
346    /// the run id when unset; set it to make a run reproducible.
347    pub seed: Option<u64>,
348}
349
350impl Default for Blind {
351    fn default() -> Self {
352        Self {
353            commit_msg_hook: true,
354            strip_lines: [
355                "Co-Authored-By:",
356                "Signed-off-by:",
357                "Assisted-by:",
358                "Generated-by:",
359                "Generated with",
360                "\u{1f916}",
361            ]
362            .iter()
363            .map(|s| (*s).to_owned())
364            .collect(),
365            vendor_tokens: [
366                "claude",
367                "anthropic",
368                "codex",
369                "openai",
370                "chatgpt",
371                "gemini",
372                "grok",
373                "xai",
374                "copilot",
375                "opencode",
376                "qoder",
377                "cursor",
378                "\u{1f916}",
379            ]
380            .iter()
381            .map(|s| (*s).to_owned())
382            .collect(),
383            on_leak: LeakPolicy::Warn,
384            seed: None,
385        }
386    }
387}
388
389/// Shell commands that gate the winner.
390#[derive(Debug, Clone, Default, Deserialize, Serialize)]
391#[serde(deny_unknown_fields, default)]
392pub struct Verify {
393    /// Run in the winner's worktree once per review round. Its output is fed
394    /// back to the fixer. This is the "real machine" leg of the review.
395    pub e2e: Vec<String>,
396    /// Final gate. Must all exit 0 before a merge is attempted.
397    pub gate: Vec<String>,
398    /// Shell used to run the commands above. Defaults to `sh -c`, or
399    /// `cmd /C` when `sh` is not on `PATH`.
400    pub shell: Option<Vec<String>>,
401}
402
403impl Verify {
404    /// The `CARGO_TARGET_DIR=` value of the first rendered command that sets
405    /// one, if any. See [`crate::disk::extract_cargo_target_dir`] for the shape
406    /// this reads back. One rendering is enough - they all set the same
407    /// rendered `{{ vars.cache }}` path via the same shell - and the first e2e
408    /// command is checked before the gate because the e2e rebuilds the crate.
409    pub fn cache_dir(&self) -> Option<PathBuf> {
410        self.e2e
411            .iter()
412            .chain(self.gate.iter())
413            .find_map(|cmd| crate::disk::extract_cargo_target_dir(cmd))
414    }
415}
416
417/// Disk hygiene: how hard magi is allowed to press on the machine's free space.
418///
419/// The numbers below come from one incident, not from theory: a machine with
420/// 951.8 GB free ran a few competitions and plans and best read 6.7 GB free.
421/// Three multi-gigabyte classes of junk accumulated side by side - per-run
422/// worktrees that end as `Merged`/`Ready`/`Failed`, a shared build cache whose
423/// each verify round and each implementation wave recompiles the derived
424/// section of the project into, and the outputs of runs that were removed but
425/// whose folders nobody deleted.
426#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
427#[serde(deny_unknown_fields, default)]
428pub struct Disk {
429    /// Free space, in bytes, below which no new run may start: the daemon and
430    /// the `magi run` gate answer with "the disk is full" instead of letting
431    /// the graph fill it the rest of the way. `0` turns the gate off.
432    ///
433    /// Default 8 GiB. The incident ran down to 6.7 GB free of 951.8 GB total
434    /// before anybody noticed; 8 GiB is enough headroom for the compile a fresh
435    /// competition triggers and small enough that a 1 TB disk with 100 GB free
436    /// is nowhere near the threshold.
437    pub min_free_bytes: u64,
438    /// Fold finished runs without being asked. `Merged`, `Ready` and `Failed`
439    /// runs older than [`fold_grace_secs`](Self::fold_grace_secs) have their
440    /// worktrees removed. `0` turns the janitor off.
441    ///
442    /// Default true.
443    pub auto_fold: bool,
444    /// How old a finished run must be before the janitor folds it, seconds.
445    ///
446    /// Default 6 hours. A run that `Ready` at 8am is the operator's answer; a
447    /// run that `Ready` a week ago is worktrees holding a compile each. Six
448    /// hours is long enough that nobody loses an answer in the gap between
449    /// reading the report and starting from it, and short enough that a backlog
450    /// cannot pile up across two nights.
451    pub fold_grace_secs: u64,
452    /// Ceiling for the shared build cache (`CARGO_TARGET_DIR` in the rendered
453    /// verify commands), in bytes. When the janitor runs and the cache is over
454    /// it, files are dropped oldest-first until it is not. `0` turns pruning
455    /// off - the cache then only ever grows, which is the operator's call.
456    ///
457    /// Default 10 GiB. This is what the incident measured: 30.61 GB sat in the
458    /// shared cache on top of ~16 GB in the primary target directory and 6.7-
459    /// 11.15 GB in each of four per-worktree targets. 10 GiB holds a healthy
460    /// stack of prebuilt dependencies (cargo's per-file fingerprinting means
461    /// pruning only costs the rebuild of the dropped files, not of the world)
462    /// without letting one addled cache swallow the machine.
463    pub cache_limit_bytes: u64,
464}
465
466impl Default for Disk {
467    fn default() -> Self {
468        Self {
469            min_free_bytes: 8 * 1024 * 1024 * 1024,
470            auto_fold: true,
471            fold_grace_secs: 6 * 60 * 60,
472            cache_limit_bytes: 10 * 1024 * 1024 * 1024,
473        }
474    }
475}
476
477/// What to do with the winning branch.
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
479#[serde(rename_all = "lowercase")]
480pub enum MergeMode {
481    /// Leave the branch alone and print the merge command matching
482    /// [`Merge::style`].
483    None,
484    /// Merge into the base branch in the primary worktree, using
485    /// [`Merge::style`].
486    Local,
487    /// Push the branch and open a PR with `gh pr create`. Landing this PR
488    /// (`[graph] land`) always squashes — see `land`'s module doc — so
489    /// [`Merge::style`] does not apply here.
490    Pr,
491}
492
493/// How the winning branch is attached to the base branch: what
494/// `mode = "local"` runs, and what `mode = "none"`'s printed guidance tells
495/// the operator to run by hand.
496///
497/// Read from configuration rather than asked of the repository at run time
498/// (e.g. `gh api repos/{owner}/{repo}/rulesets`) for two reasons: it keeps
499/// `mode = "none"`'s guidance a pure function of `RunState`, assertable in a
500/// unit test the same way `land::decide` is kept pure (see that module's
501/// doc), and it works for a base branch that is not hosted on GitHub, or not
502/// reachable at all, at the moment the report is rendered. An operator whose
503/// base branch enforces a ruleset already knows what it allows; declaring it
504/// once here is cheaper than magi re-discovering it, with a network call, on
505/// every render.
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
507#[serde(rename_all = "lowercase")]
508pub enum MergeStyle {
509    /// `git merge --no-ff`: every candidate commit lands, plus a merge
510    /// commit that records the merge as its own event in history. Rejected
511    /// by a base branch whose ruleset requires linear history or forbids
512    /// merge commits outright.
513    #[default]
514    Merge,
515    /// `git merge --squash` followed by a commit under an explicit message:
516    /// every candidate commit folds into one, and none of the candidate's
517    /// own placeholder subjects (`magi: candidate A (uncommitted work)`)
518    /// reach the base branch. No merge commit, so this satisfies a linear-
519    /// history ruleset.
520    Squash,
521    /// A fast-forward-only merge: every candidate commit lands verbatim, in
522    /// order, with no merge commit. Only succeeds because the winner was
523    /// already rebased onto the tracked base tip before this runs (see
524    /// `Runner::sync_to_base`) — equivalent to GitHub's "rebase and merge"
525    /// once that has happened.
526    Rebase,
527}
528
529/// Merge policy.
530#[derive(Debug, Clone, Deserialize, Serialize)]
531#[serde(deny_unknown_fields, default)]
532pub struct Merge {
533    /// Default is [`MergeMode::None`]: magi never touches your base branch
534    /// unless you ask it to.
535    pub mode: MergeMode,
536    /// Base branch. Defaults to the branch checked out when the run started.
537    pub base: Option<String>,
538    /// How the winner is attached to `base`; see [`MergeStyle`]. Ignored by
539    /// `mode = "pr"`.
540    pub style: MergeStyle,
541    /// Remote for `mode = "pr"`.
542    pub remote: String,
543    /// After a `mode = "pr"` run lands, open a `chore/release-vX.Y.Z` pull
544    /// request sized to the change by an agent's own judgement, so a version
545    /// bump does not depend on a human remembering to cut one.
546    ///
547    /// On by default. **Turning this off means a merge landed from the phone
548    /// never becomes a release**, so `POST /api/upgrade` keeps reporting
549    /// "already on the newest release" against a `main` that has moved past
550    /// it - the same gap this feature exists to close. Only for a repository
551    /// that wants to keep cutting releases by hand.
552    pub release_bump: bool,
553}
554
555impl Default for Merge {
556    fn default() -> Self {
557        Self {
558            mode: MergeMode::None,
559            base: None,
560            style: MergeStyle::default(),
561            remote: "origin".to_owned(),
562            release_bump: true,
563        }
564    }
565}
566
567/// How magi keeps itself current.
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
569#[serde(rename_all = "lowercase")]
570pub enum UpdateMode {
571    /// Never check.
572    Off,
573    /// Check in the background and print a one-line banner when a newer
574    /// release exists.
575    Notify,
576    /// Check and install silently.
577    Install,
578}
579
580/// Self-update policy.
581#[derive(Debug, Clone, Deserialize, Serialize)]
582#[serde(deny_unknown_fields, default)]
583pub struct Update {
584    /// Default is [`UpdateMode::Notify`]: magi tells you, and lets you decide.
585    pub mode: UpdateMode,
586    /// Minimum time between checks, e.g. `24h`. kaishin's default when unset.
587    pub interval: Option<String>,
588}
589
590impl Default for Update {
591    fn default() -> Self {
592        Self {
593            mode: UpdateMode::Notify,
594            interval: None,
595        }
596    }
597}
598
599/// Top-level configuration.
600#[derive(Debug, Clone, Default, Deserialize, Serialize)]
601#[serde(deny_unknown_fields, default)]
602pub struct Config {
603    /// Agent roster.
604    pub agents: Vec<AgentSpec>,
605    /// Role assignment.
606    pub roles: Roles,
607    /// Graph shape.
608    pub graph: Graph,
609    /// Blindness policy.
610    pub blind: Blind,
611    /// Verification commands.
612    pub verify: Verify,
613    /// Disk hygiene.
614    pub disk: Disk,
615    /// Merge policy.
616    pub merge: Merge,
617    /// Self-update policy.
618    pub update: Update,
619    /// Project-specific text appended to the node prompts.
620    pub prompts: Prompts,
621    /// How the operator is told a run is waiting on them.
622    pub notify: Notify,
623    /// Local repositories the plan surface can start or derive a conversation
624    /// against.
625    pub repos: Repos,
626    /// Policy for the standing conversation ([`crate::talk`]).
627    pub talk: Talk,
628    /// How many runs `magi serve`'s own loop drives at once.
629    pub daemon: Daemon,
630}
631
632/// How the daemon loop itself behaves, as opposed to what one run does.
633///
634/// Machine-layer material in the same sense [`Repos::roots`] is: how many
635/// competitions this machine's own loop is willing to babysit at once is a
636/// fact about the machine running `magi serve`, not about any one
637/// repository's task, so it belongs in `<config_dir>/magi/config.toml`
638/// rather than a repository's own `magi.toml` - though, like `Repos::roots`,
639/// nothing stops a repository from setting it too, since a scalar field
640/// takes whichever layer has the highest precedence.
641#[derive(Debug, Clone, Deserialize, Serialize)]
642#[serde(deny_unknown_fields, default)]
643pub struct Daemon {
644    /// How many runs `magi serve` may have actively in flight at once.
645    /// **One by default** - today's behaviour, one run at a time.
646    ///
647    /// This is a different knob from [`Graph::max_parallel`], and the two
648    /// must not be confused: `max_parallel` bounds how many agent *processes*
649    /// one run starts inside itself (implementers, judges, reviewers -
650    /// candidates competing on a single task); this field bounds how many
651    /// *runs* - whole competitions, each with its own `max_parallel` budget -
652    /// the loop drives side by side, possibly across different tasks and
653    /// different repositories. Raising `max_parallel` buys a bigger panel for
654    /// one task; raising this buys more tasks worked at once. A config file
655    /// that meant one and wrote the other would either starve a competition
656    /// of judges or leave the rest of the backlog waiting for no reason.
657    ///
658    /// A run parked waiting on the operator's land-merge approval - see
659    /// [`crate::land`] - does not hold one of these slots while it waits: the
660    /// whole point of parking there is to let the loop spend the slot on
661    /// something runnable instead of sitting on a decision only a human can
662    /// make. So even at the default of `1`, an approval that comes back does
663    /// not queue behind whatever else the loop happens to be running.
664    pub max_concurrent_runs: usize,
665}
666
667impl Default for Daemon {
668    fn default() -> Self {
669        Self {
670            max_concurrent_runs: 1,
671        }
672    }
673}
674
675/// Where `magi repos` and `GET /api/repos` look for local checkouts.
676///
677/// `roots` is one of the array keys [`array_merge_policy`] marks as
678/// append-across-layers: which checkouts exist in general is a *machine*
679/// fact in the same way the agent roster is - a repository's own `magi.toml`
680/// cannot state where its siblings live before magi has resolved which
681/// repository to read that file from in the first place - but a repository
682/// that genuinely has an extra root worth scanning is not forced to choose
683/// between an error and losing the machine's roots outright. Both layers'
684/// roots are scanned; see [`Config::refuse_split_arrays`] for the keys that
685/// are still refused.
686#[derive(Debug, Clone, Deserialize, Serialize)]
687#[serde(deny_unknown_fields, default)]
688pub struct Repos {
689    /// Roots to scan for a ghq-layout checkout: `<root>/<host>/<owner>/<repo>`
690    /// with a `.git` directory. Empty by default - nothing is scanned unless
691    /// asked to be.
692    pub roots: Vec<PathBuf>,
693    /// How long a scan is trusted before the next request re-scans it,
694    /// seconds. `0` means never trust it: scan on every request. Defaults to
695    /// a day, the same order of magnitude as [`Graph::answer_timeout`] for
696    /// the same reason - a checkout does not usually appear or vanish inside
697    /// a session, so there is little to gain from scanning more often than
698    /// that, and an explicit refresh exists for the moment one does.
699    pub scan_ttl: u64,
700}
701
702impl Default for Repos {
703    fn default() -> Self {
704        Self {
705            roots: Vec::new(),
706            scan_ttl: 86_400,
707        }
708    }
709}
710
711/// Project-specific text appended to each node's prompt.
712///
713/// **Additive by construction.** These fields cannot replace magi's prompts,
714/// only extend them, and that restriction is the whole design. The built-in
715/// prompts carry the invariants the competition rests on: a judging prompt
716/// names no authors, every structured answer must arrive as one fenced `json`
717/// block, and a judge is told not to speculate about who wrote what. A config
718/// that could overwrite them would let a typo silently un-blind the panel or
719/// break the parser, and the symptom would be "the judges got worse" rather
720/// than an error.
721///
722/// Repository-wide context belongs in `AGENTS.md`, which every agent already
723/// reads from the checkout. Use these fields for the things a *magi node*
724/// needs to know and a repository file cannot say - for instance that
725/// reviewers here should ignore formatting because a hook owns it.
726#[derive(Debug, Clone, Default, Deserialize, Serialize)]
727#[serde(deny_unknown_fields, default)]
728pub struct Prompts {
729    /// Appended to every node's prompt.
730    pub all: String,
731    /// Appended for implementers.
732    pub implement: String,
733    /// Appended for judges, both ranking and voting.
734    pub judge: String,
735    /// Appended for reviewers.
736    pub review: String,
737    /// Appended for the fixer.
738    pub fix: String,
739}
740
741impl Prompts {
742    /// The overlay for one node, or `None` when nothing is configured.
743    ///
744    /// `node` is the graph's own node name, so a new node gets no overlay
745    /// rather than the wrong one.
746    pub fn overlay(&self, node: &str) -> Option<String> {
747        let specific = match node {
748            "implement" => &self.implement,
749            "judge" | "vote" | "deliberate" => &self.judge,
750            "review" => &self.review,
751            "fix" => &self.fix,
752            _ => "",
753        };
754        let mut parts: Vec<&str> = Vec::new();
755        for p in [self.all.trim(), specific.trim()] {
756            if !p.is_empty() {
757                parts.push(p);
758            }
759        }
760        if parts.is_empty() {
761            return None;
762        }
763        Some(parts.join("\n\n"))
764    }
765}
766
767/// How the operator is told that a run is waiting on them.
768///
769/// A command rather than a built-in integration: magi is one binary with no
770/// network dependencies, and every operator's notification path is different -
771/// ntfy, a Slack webhook, a Windows toast, an SSH to a machine that beeps.
772/// Shelling out keeps all of them possible and none of them magi's problem.
773#[derive(Debug, Clone, Default, Deserialize, Serialize)]
774#[serde(deny_unknown_fields, default)]
775pub struct Notify {
776    /// Command and arguments. `{summary}`, `{run}` and `{url}` are replaced.
777    /// Empty means no notification - the web UI is then the only surface.
778    pub command: Vec<String>,
779}
780
781/// Policy for [`crate::talk`], the standing conversation.
782#[derive(Debug, Clone, Default, Deserialize, Serialize)]
783#[serde(deny_unknown_fields, default)]
784pub struct Talk {
785    /// Let the conversation's agent edit files in the repository instead of
786    /// filing a task for one. **Off by default** - see
787    /// [`crate::talk`]'s module doc for why an edit made mid-conversation is
788    /// an edit no run and no review can be attributed to, which is exactly
789    /// the property a repository entered in a competition depends on. A
790    /// repository that is never judged - dotfiles, a personal config
791    /// checkout - has nothing to lose by turning this on in its own
792    /// `magi.toml`, and a one-line fix stops costing a queued task to get.
793    pub allow_write: bool,
794}
795
796/// Roles resolved to concrete agent specs for one run.
797#[derive(Debug, Clone)]
798pub struct ResolvedRoles {
799    /// One per candidate.
800    pub implementers: Vec<AgentSpec>,
801    /// One per judge.
802    pub judges: Vec<AgentSpec>,
803    /// One per reviewer slot.
804    pub reviewers: Vec<AgentSpec>,
805    /// Explicit fixer, if configured.
806    pub fixer: Option<AgentSpec>,
807}
808
809/// Every array-valued key in a config table, as a dotted path.
810///
811/// Dotted so the error names `roles.implementers` rather than `implementers`:
812/// an operator with three config files needs to know which key, not just that
813/// there was one. `vars` is skipped because it is teravars' own input, merged
814/// on purpose and never deserialised into `Config`.
815fn array_keys(table: &toml::value::Table, prefix: &str) -> Vec<String> {
816    let mut out = Vec::new();
817    for (k, v) in table {
818        if prefix.is_empty() && k == "vars" {
819            continue;
820        }
821        let path = if prefix.is_empty() {
822            k.clone()
823        } else {
824            format!("{prefix}.{k}")
825        };
826        match v {
827            toml::Value::Array(_) => out.push(path),
828            toml::Value::Table(t) => out.extend(array_keys(t, &path)),
829            _ => {}
830        }
831    }
832    out
833}
834
835/// How an array key behaves when two config layers both declare it.
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837enum ArrayMerge {
838    /// Two layers may both declare it; the composed value is the
839    /// low-to-high-priority concatenation teravars already produces (see
840    /// [`Config::load_layers`]'s doc for why that order and no dedup).
841    Append,
842    /// Two layers declaring it is refused; see
843    /// [`Config::refuse_split_arrays`].
844    Replace,
845}
846
847/// The single place that decides, for a dotted array key (as returned by
848/// [`array_keys`]), whether declaring it in two config layers is a
849/// concatenation the operator asked for or a silent accident.
850///
851/// Kept as one match so the whole policy is visible in one place - the same
852/// reason `claude_quota` and `dropped_stream` close their own classification
853/// in one spot elsewhere in this codebase. Anything not listed defaults to
854/// [`ArrayMerge::Replace`]: refusing is the safe default for a key nobody has
855/// reasoned about yet, and a new array key added later has to be added here
856/// deliberately to become appendable.
857///
858/// - `verify.e2e` / `verify.gate` — a "run all of these, all must exit 0"
859///   gate. Concatenating two of them is exactly the checks both layers
860///   wanted, which is what lets a common gate (e.g. `editorconfig-checker`)
861///   live in a shared layer while a repository's own layer adds its own
862///   command, instead of every repository copying the shared command into
863///   its own file.
864/// - `repos.roots` — a set of directories to scan for checkouts. A
865///   repository adding its own root on top of the machine's is additive by
866///   nature, not a replacement of where the machine looks; see
867///   [`Repos::roots`].
868///
869/// Left on the refuse side, and why:
870/// - `roles.implementers` / `roles.judges` / `roles.reviewers` — an ordered
871///   list of *seats*, not a set. A machine's two implementers plus a
872///   repository's one is three seats nobody asked for and nobody is paying
873///   for on purpose.
874/// - `notify.command` — an argv. Concatenating two argvs does not produce a
875///   program that runs; it produces `["ntfy", "publish", "curl", "-X"]`.
876/// - `blind.strip_lines` — technically safe to concatenate (each entry is
877///   matched as an independent substring, so a longer list only strips
878///   *more*), but left on the refuse side anyway: the same list also drives
879///   `commit_msg_hook`'s generated `sed` addresses, where position matters,
880///   and a silent three-layer merge is exactly the kind of surprise
881///   `refuse_split_arrays` exists to catch rather than to reason about
882///   case-by-case. A repository that wants one more stripped phrase restates
883///   the whole list; that restatement is visible in review, an accidental
884///   concatenation would not be.
885fn array_merge_policy(key: &str) -> ArrayMerge {
886    match key {
887        "verify.e2e" | "verify.gate" | "repos.roots" => ArrayMerge::Append,
888        _ => ArrayMerge::Replace,
889    }
890}
891
892impl Config {
893    /// Load one file through teravars: Tera rendering, `[vars]` resolution,
894    /// and the `include = [...]` directive.
895    pub fn load(path: &Path) -> Result<Self> {
896        Self::load_layers(&[path.to_path_buf()])
897    }
898
899    /// The Tera render context shared by every layer: `system.*` (from
900    /// teravars), `env` (magi's own addition - a config that names a shared
901    /// build-cache directory or a machine-specific path needs
902    /// `{{ env.NAME | default(value='...') }}`), and `repo` / `repo_name`
903    /// derived from the last (highest-priority) path's parent directory.
904    ///
905    /// Factored out so [`Config::array_provenance`] can re-render a single
906    /// layer under the exact same context [`Config::load_layers`] uses for
907    /// the joint render, rather than drifting from it by accident.
908    fn render_ctx(paths: &[PathBuf]) -> teravars::Context {
909        let mut ctx = teravars::system_context();
910        let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
911        ctx.insert("env", &env);
912        if let Some(last) = paths.last()
913            && let Some(dir) = last.parent()
914        {
915            ctx.insert("repo", &dir.to_string_lossy());
916            ctx.insert(
917                "repo_name",
918                &dir.file_name().unwrap_or_default().to_string_lossy(),
919            );
920        }
921        ctx
922    }
923
924    /// Load and deep-merge a stack of config files, later files winning.
925    ///
926    /// This is why the config is TOML-through-teravars rather than plain serde:
927    /// the roster is a *machine* fact (which CLIs and plans you pay for) while
928    /// the gate is a *repository* fact (`cargo make check` here, `pnpm test`
929    /// there). Picking one file and ignoring the other would force every repo
930    /// to restate the roster.
931    pub fn load_layers(paths: &[PathBuf]) -> Result<Self> {
932        let mut engine = teravars::Engine::default();
933        let ctx = Self::render_ctx(paths);
934        if paths.len() > 1 {
935            Self::refuse_split_arrays(paths, &mut engine, &ctx)?;
936        }
937        let merged = teravars::load_merged(paths, &mut engine, &ctx).with_context(|| {
938            format!(
939                "rendering config via teravars: {}",
940                paths
941                    .iter()
942                    .map(|p| p.display().to_string())
943                    .collect::<Vec<_>>()
944                    .join(", ")
945            )
946        })?;
947        let mut table = merged.config;
948        // `[vars]` is teravars' own input, already resolved into the render
949        // context; `deny_unknown_fields` must not trip over it.
950        table.remove("vars");
951        toml::Value::Table(table)
952            .try_into()
953            .context("deserializing magi config")
954    }
955
956    /// Refuse an array that two layers both declare, unless
957    /// [`array_merge_policy`] says that key is meant to accumulate.
958    ///
959    /// teravars **appends** arrays when it merges layers, and that is wrong for
960    /// most arrays magi has: `implementers` is an ordered list of seats,
961    /// `notify.command` is an argv. Concatenating two of them yields something
962    /// nobody wrote - three implementers out of a machine's two and a
963    /// repository's one, or an argv of `["ntfy", "publish", "curl", "-X"]`.
964    ///
965    /// Replacing instead would be the right merge rule for those keys, but the
966    /// rule lives in teravars, which several other projects depend on;
967    /// changing it there is a decision for that crate, not something to fake
968    /// here by re-reading the files with different semantics and hoping the
969    /// two paths agree.
970    ///
971    /// So magi refuses the ambiguity rather than resolving it silently, for
972    /// every array key except the short, deliberate list
973    /// [`array_merge_policy`] marks [`ArrayMerge::Append`] - for those, the
974    /// concatenation teravars already produces *is* what both files say, so
975    /// there is nothing to refuse. The cost of guessing wrong on the refused
976    /// keys is a roster the operator did not ask for and is paying for by the
977    /// token; the append keys carry no such risk because every element runs
978    /// (or every directory is scanned) regardless of order.
979    fn refuse_split_arrays(
980        paths: &[PathBuf],
981        engine: &mut teravars::Engine,
982        ctx: &teravars::Context,
983    ) -> Result<()> {
984        let mut seen: std::collections::BTreeMap<String, PathBuf> = Default::default();
985        for path in paths {
986            let one = teravars::load_merged([path], engine, ctx)
987                .with_context(|| format!("rendering {}", path.display()))?;
988            for key in array_keys(&one.config, "") {
989                if array_merge_policy(&key) == ArrayMerge::Append {
990                    continue;
991                }
992                if let Some(first) = seen.get(&key) {
993                    bail!(
994                        "`{key}` is an array declared in two config layers:\n  \
995                         {}\n  {}\nteravars appends arrays when it merges, so \
996                         magi would run the concatenation of both - which is \
997                         not what either file says. Declare `{key}` in exactly \
998                         one of them.",
999                        first.display(),
1000                        path.display()
1001                    );
1002                }
1003                seen.insert(key, path.clone());
1004            }
1005        }
1006        Ok(())
1007    }
1008
1009    /// Which layers contributed to a composed, appendable array key (e.g.
1010    /// `"verify.gate"`), in the same low-to-high-priority order
1011    /// [`Config::load_layers`] concatenates them in. Layers that do not
1012    /// declare `key` at all are omitted.
1013    ///
1014    /// This is a **display aid for `magi doctor` only.** The command list
1015    /// that actually runs always comes from the one joint
1016    /// [`teravars::load_merged`] call in `load_layers`, never from this
1017    /// function - the exact hazard [`Config::refuse_split_arrays`] warns
1018    /// about is two merge paths that might disagree, so this function must
1019    /// never become a second source of the *composed* value, only of which
1020    /// file wrote which line in it.
1021    ///
1022    /// Re-rendering each layer alone can, in principle, resolve a
1023    /// `{{ vars.x }}` differently than the joint render would, if `x` is
1024    /// defined in one layer and referenced in another - the same caveat
1025    /// `refuse_split_arrays`'s structural, key-only check already lives with.
1026    /// None of magi's own gate commands cross that line, and a doctor listing
1027    /// is read by a human who can compare it against the joint one printed
1028    /// alongside it, so this is judged worth the simplicity of not
1029    /// threading provenance through the real load path.
1030    pub fn array_provenance(paths: &[PathBuf], key: &str) -> Vec<(PathBuf, Vec<String>)> {
1031        let mut engine = teravars::Engine::default();
1032        let ctx = Self::render_ctx(paths);
1033        let mut out = Vec::new();
1034        for path in paths {
1035            let Ok(one) = teravars::load_merged([path], &mut engine, &ctx) else {
1036                continue;
1037            };
1038            let mut cur = &one.config;
1039            let mut found = None;
1040            let parts: Vec<&str> = key.split('.').collect();
1041            for (i, part) in parts.iter().enumerate() {
1042                match cur.get(*part) {
1043                    Some(toml::Value::Array(a)) if i == parts.len() - 1 => {
1044                        found = Some(a);
1045                        break;
1046                    }
1047                    Some(toml::Value::Table(t)) => cur = t,
1048                    _ => break,
1049                }
1050            }
1051            let Some(values) = found else { continue };
1052            let strings: Vec<String> = values
1053                .iter()
1054                .filter_map(|v| v.as_str().map(str::to_owned))
1055                .collect();
1056            if !strings.is_empty() {
1057                out.push((path.clone(), strings));
1058            }
1059        }
1060        out
1061    }
1062
1063    /// Render a composed command list for `magi doctor`: the joined command
1064    /// line the run actually uses, plus - only when more than one layer
1065    /// contributed - which layer wrote which line.
1066    ///
1067    /// A single contributing layer (the common case today) stays the plain
1068    /// one-line summary magi has always printed, `empty` included: that
1069    /// honest "(none — ...)" is what caught a real gate-composition gap
1070    /// before this array could compose at all, and composition should not
1071    /// make the common case noisier.
1072    pub fn describe_composed(
1073        paths: &[PathBuf],
1074        commands: &[String],
1075        key: &str,
1076        empty: &str,
1077    ) -> String {
1078        if commands.is_empty() {
1079            return empty.to_owned();
1080        }
1081        let joined = commands.join(" && ");
1082        let provenance = Self::array_provenance(paths, key);
1083        if provenance.len() <= 1 {
1084            return joined;
1085        }
1086        let mut out = joined;
1087        for (path, cmds) in &provenance {
1088            out.push_str(&format!("\n    [{}] {}", path.display(), cmds.join(" && ")));
1089        }
1090        out
1091    }
1092
1093    /// Resolve the config for `repo`, honouring an explicit `--config` path.
1094    ///
1095    /// Returns the config and the layers it came from, empty for built-in
1096    /// defaults.
1097    pub fn discover(repo: &Path, explicit: Option<&Path>) -> Result<(Self, Vec<PathBuf>)> {
1098        if let Some(p) = explicit {
1099            let paths = vec![p.to_path_buf()];
1100            return Ok((Self::load_layers(&paths)?, paths));
1101        }
1102        let paths = Self::layers(repo);
1103        if paths.is_empty() {
1104            return Ok((Self::autodetected(), paths));
1105        }
1106        Ok((Self::load_layers(&paths)?, paths))
1107    }
1108    /// Environment variable that relocates the machine-wide config layer.
1109    ///
1110    /// Set it to a directory and magi reads `<dir>/magi/config.toml` instead
1111    /// of the one under [`dirs::config_dir`]; set it to the empty string and
1112    /// magi reads no machine layer at all.
1113    ///
1114    /// This exists because the machine layer is otherwise unavoidable, and a
1115    /// test that builds a config fixture is not asking for the operator's
1116    /// preferences to be merged into it. Adding `[repos] roots` to the real
1117    /// machine config on a development box turned two passing tests red -
1118    /// `repos_list_returns_name_and_path_for_every_configured_root` and
1119    /// `repos_list_only_rescans_within_the_ttl_when_asked_to`, whose fixtures
1120    /// declare `[repos] roots` of their own, which [`Config::layers`] then
1121    /// found in two layers and [`Config::refuse_split_arrays`] correctly
1122    /// refused. CI never saw it: a runner has no machine config, so the suite
1123    /// was green there and red only where somebody actually uses magi.
1124    ///
1125    /// An operator gets the same escape hatch for free: a second machine
1126    /// config, or none, without moving files about.
1127    pub const CONFIG_DIR_ENV: &str = "MAGI_CONFIG_DIR";
1128
1129    /// Every config layer that applies to `repo`, in increasing precedence.
1130    ///
1131    /// The machine layer is whatever [`Config::machine_layer`] resolves to,
1132    /// which is nothing at all in a test build.
1133    pub fn layers(repo: &Path) -> Vec<PathBuf> {
1134        let mut paths = Vec::new();
1135        paths.extend(Self::machine_layer());
1136        paths.push(repo.join(".magi").join("config.toml"));
1137        paths.push(repo.join("magi.toml"));
1138        paths.retain(|p| p.is_file());
1139        paths
1140    }
1141
1142    /// The machine-wide layer's path, when there is one.
1143    ///
1144    /// **A test build has none unless it names one.** A fixture is a complete
1145    /// statement of the config under test, and the operator's own preferences
1146    /// have no business being merged into it - least of all silently, on one
1147    /// machine, in a suite that is green everywhere else.
1148    #[cfg(test)]
1149    fn machine_layer() -> Option<PathBuf> {
1150        std::env::var(Self::CONFIG_DIR_ENV)
1151            .ok()
1152            .filter(|dir| !dir.trim().is_empty())
1153            .map(|dir| PathBuf::from(dir).join("magi").join("config.toml"))
1154    }
1155
1156    /// The machine-wide layer's path, when there is one.
1157    #[cfg(not(test))]
1158    fn machine_layer() -> Option<PathBuf> {
1159        match std::env::var(Self::CONFIG_DIR_ENV) {
1160            // Named, and empty on purpose: no machine layer.
1161            Ok(dir) if dir.trim().is_empty() => None,
1162            Ok(dir) => Some(PathBuf::from(dir).join("magi").join("config.toml")),
1163            Err(_) => dirs::config_dir().map(|dir| dir.join("magi").join("config.toml")),
1164        }
1165    }
1166
1167    /// Built-in config whose roster is the agent CLIs found on `PATH`.
1168    pub fn autodetected() -> Self {
1169        let mut cfg = Self::default();
1170        for (kind, id, model) in [
1171            (AgentKind::Claude, "opus", Some("opus")),
1172            (AgentKind::Claude, "sonnet", Some("sonnet")),
1173            (AgentKind::Antigravity, "antigravity", None),
1174            (AgentKind::Opencode, "opencode", None),
1175            (AgentKind::Codex, "codex", None),
1176        ] {
1177            if kind.program().is_some_and(which) && !cfg.agents.iter().any(|a| a.id == id) {
1178                cfg.agents.push(AgentSpec {
1179                    id: id.to_owned(),
1180                    kind,
1181                    model: model.map(str::to_owned),
1182                    command: Vec::new(),
1183                    extra_args: Vec::new(),
1184                    env: BTreeMap::new(),
1185                    prompt_delivery: None,
1186                });
1187            }
1188        }
1189        cfg
1190    }
1191
1192    /// The shared build cache the verify commands and the agents both build
1193    /// into, when the config declares one. See [`Verify::cache_dir`].
1194    pub fn cache_dir(&self) -> Option<PathBuf> {
1195        self.verify.cache_dir()
1196    }
1197
1198    /// Look an agent up by id.
1199    pub fn agent(&self, id: &str) -> Result<&AgentSpec> {
1200        self.agents
1201            .iter()
1202            .find(|a| a.id == id)
1203            .with_context(|| format!("no agent with id `{id}` in the roster"))
1204    }
1205
1206    /// Rotate `count` seats out of `ids`, or out of the whole roster at
1207    /// `offset` when `ids` is empty.
1208    ///
1209    /// The one rotation rule - explicit ids cycle, an empty list rotates the
1210    /// roster - shared by every seat count [`Config::resolve_roles`] fills in,
1211    /// rather than each seat reimplementing it and drifting apart.
1212    fn rotate(&self, ids: &[String], count: usize, offset: usize) -> Result<Vec<AgentSpec>> {
1213        let mut out = Vec::with_capacity(count);
1214        for i in 0..count {
1215            let spec = if ids.is_empty() {
1216                self.agents[(i + offset) % self.agents.len()].clone()
1217            } else {
1218                self.agent(&ids[i % ids.len()])?.clone()
1219            };
1220            out.push(spec);
1221        }
1222        Ok(out)
1223    }
1224
1225    /// Fill the roles out to the configured widths.
1226    ///
1227    /// An empty role list rotates through the whole roster, so a three-agent
1228    /// roster with `candidates = 3` gives one implementation per agent, and
1229    /// `judges = 3` rotates the judge seats by one so that judge *i* is not the
1230    /// author of candidate *i* whenever the roster has more than one agent.
1231    pub fn resolve_roles(&self) -> Result<ResolvedRoles> {
1232        if self.agents.is_empty() {
1233            bail!(
1234                "agent roster is empty: no agent CLI found on PATH and no \
1235                 [[agents]] in the config. Run `magi init` to write a starter \
1236                 magi.toml."
1237            );
1238        }
1239        Ok(ResolvedRoles {
1240            implementers: self.rotate(&self.roles.implementers, self.graph.candidates, 0)?,
1241            judges: self.rotate(&self.roles.judges, self.graph.judges, 1)?,
1242            reviewers: self.rotate(&self.roles.reviewers, self.graph.reviewers, 0)?,
1243            fixer: self
1244                .roles
1245                .fixer
1246                .as_deref()
1247                .map(|f| self.agent(f).cloned())
1248                .transpose()?,
1249        })
1250    }
1251
1252    /// Shell prefix for [`Verify`] commands.
1253    pub fn shell(&self) -> Vec<String> {
1254        if let Some(s) = &self.verify.shell {
1255            return s.clone();
1256        }
1257        if which("sh") {
1258            vec!["sh".to_owned(), "-c".to_owned()]
1259        } else {
1260            vec!["cmd".to_owned(), "/C".to_owned()]
1261        }
1262    }
1263
1264    /// Starter config, as written by `magi init`.
1265    pub fn starter_toml() -> String {
1266        let detected = Self::autodetected();
1267        let mut s = String::from(
1268            "# magi — blind multi-agent implementation competition.\n\
1269             # `magi run \"<task>\"` walks: implement (N parallel worktrees)\n\
1270             #   -> blind judging -> deliberation -> private final vote\n\
1271             #   -> fold losers -> review + E2E loop -> gate -> merge.\n\
1272             #\n\
1273             # Rendered by teravars: a `[vars]` table, env\n\
1274             # and system lookups, and `include = [...]` all work. Tera\n\
1275             # braces are live everywhere in this file, but comments are\n\
1276             # stripped before rendering (teravars >= 0.2.2), so a comment\n\
1277             # may quote `{{ ... }}` freely.\n\
1278             #\n\
1279             # Layers deep-merge in increasing\n\
1280             # precedence, so the roster can live once per machine in\n\
1281             # <config_dir>/magi/config.toml and each repo only states its own\n\
1282             # gate:\n\
1283             #   <config_dir>/magi/config.toml  <  .magi/config.toml  <  magi.toml\n\n\
1284             [vars]\n\
1285             # Reference it as vars.cache inside Tera braces, anywhere below.\n\
1286             # Single quotes inside the braces: teravars renders the raw file\n\
1287             # text, so TOML's own \\\" escaping never reaches Tera.\n\
1288             cache = \"{{ env.MAGI_CACHE | default(value='/tmp') }}\"\n\n",
1289        );
1290        if detected.agents.is_empty() {
1291            s.push_str(
1292                "# No agent CLI was found on PATH. Fill this in by hand.\n\
1293                 # kind = claude | opencode | antigravity | codex | command\n\
1294                 [[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n",
1295            );
1296        } else {
1297            for a in &detected.agents {
1298                s.push_str("[[agents]]\n");
1299                s.push_str(&format!("id = {:?}\n", a.id));
1300                s.push_str(&format!("kind = {:?}\n", a.kind.as_str()));
1301                if let Some(m) = &a.model {
1302                    s.push_str(&format!("model = {m:?}\n"));
1303                }
1304                s.push('\n');
1305            }
1306        }
1307        s.push_str(
1308            "# Leave a role list empty to rotate through the roster.\n\
1309             [roles]\n\
1310             implementers = []\n\
1311             judges = []\n\
1312             reviewers = []\n\n\
1313             [graph]\n\
1314             candidates = 3\n\
1315             judges = 3\n\
1316             deliberate_rounds = 1\n\
1317             reviewers = 3\n\
1318             review_rounds = 6\n\
1319             max_parallel = 4\n\
1320             language = \"en\"\n\
1321             # One CLI conversation per seat: judges keep their own argument\n\
1322             # across deliberation, the fixer keeps its implementation context.\n\
1323             sessions = true\n\n\
1324             [verify]\n\
1325             # Run once per review round in the winner's worktree; failures are\n\
1326             # fed back to the fixer.\n\
1327             e2e = []\n\
1328             # Final gate. Every command must exit 0 before a merge.\n\
1329             gate = []\n\n\
1330             [merge]\n\
1331             # none | local | pr\n\
1332             mode = \"none\"\n\n\
1333             [update]\n\
1334             # off | notify | install — checked in the background, throttled.\n\
1335             mode = \"notify\"\n\
1336             # interval = \"24h\"\n",
1337        );
1338        s
1339    }
1340}
1341
1342/// Is `program` on `PATH`?
1343pub fn which(program: &str) -> bool {
1344    let Some(paths) = std::env::var_os("PATH") else {
1345        return false;
1346    };
1347    let exts: Vec<String> = std::env::var("PATHEXT")
1348        .map(|v| v.split(';').map(|e| e.to_lowercase()).collect())
1349        .unwrap_or_default();
1350    std::env::split_paths(&paths).any(|dir| {
1351        let direct = dir.join(program);
1352        if direct.is_file() {
1353            return true;
1354        }
1355        exts.iter().any(|ext| {
1356            let mut name = program.to_owned();
1357            name.push_str(ext);
1358            dir.join(name).is_file()
1359        })
1360    })
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365    use super::*;
1366
1367    fn spec(id: &str) -> AgentSpec {
1368        AgentSpec {
1369            id: id.to_owned(),
1370            kind: AgentKind::Command,
1371            model: None,
1372            command: vec!["true".to_owned()],
1373            extra_args: Vec::new(),
1374            env: BTreeMap::new(),
1375            prompt_delivery: None,
1376        }
1377    }
1378
1379    #[test]
1380    fn empty_roles_rotate_judges_off_their_own_candidate() {
1381        // Three seats, said out loud: this is a test about *rotation*, and it
1382        // has nothing to say about how many candidates a task buys by default.
1383        let cfg = Config {
1384            agents: vec![spec("a"), spec("b"), spec("c")],
1385            graph: Graph {
1386                candidates: 3,
1387                ..Graph::default()
1388            },
1389            ..Config::default()
1390        };
1391        let roles = cfg.resolve_roles().unwrap();
1392        let impls: Vec<&str> = roles.implementers.iter().map(|a| a.id.as_str()).collect();
1393        let judges: Vec<&str> = roles.judges.iter().map(|a| a.id.as_str()).collect();
1394        assert_eq!(impls, ["a", "b", "c"]);
1395        assert_eq!(judges, ["b", "c", "a"]);
1396        for (i, j) in judges.iter().enumerate() {
1397            assert_ne!(*j, impls[i], "judge {i} must not sit on its own candidate");
1398        }
1399    }
1400
1401    #[test]
1402    fn single_agent_roster_fills_every_seat() {
1403        let cfg = Config {
1404            agents: vec![spec("solo")],
1405            graph: Graph {
1406                candidates: 3,
1407                ..Graph::default()
1408            },
1409            ..Config::default()
1410        };
1411        let roles = cfg.resolve_roles().unwrap();
1412        assert_eq!(roles.implementers.len(), 3);
1413        assert!(roles.judges.iter().all(|a| a.id == "solo"));
1414    }
1415
1416    #[test]
1417    fn explicit_roles_win() {
1418        let cfg = Config {
1419            agents: vec![spec("a"), spec("b")],
1420            roles: Roles {
1421                implementers: vec!["b".to_owned()],
1422                judges: vec!["a".to_owned()],
1423                reviewers: Vec::new(),
1424                fixer: Some("a".to_owned()),
1425                ..Roles::default()
1426            },
1427            ..Config::default()
1428        };
1429        let roles = cfg.resolve_roles().unwrap();
1430        assert!(roles.implementers.iter().all(|a| a.id == "b"));
1431        assert!(roles.judges.iter().all(|a| a.id == "a"));
1432        assert_eq!(roles.fixer.unwrap().id, "a");
1433    }
1434
1435    #[test]
1436    fn unknown_agent_id_is_an_error() {
1437        let cfg = Config {
1438            agents: vec![spec("a")],
1439            roles: Roles {
1440                judges: vec!["nope".to_owned()],
1441                ..Roles::default()
1442            },
1443            ..Config::default()
1444        };
1445        assert!(cfg.resolve_roles().is_err());
1446    }
1447
1448    #[test]
1449    fn empty_roster_is_an_error() {
1450        assert!(Config::default().resolve_roles().is_err());
1451    }
1452
1453    #[test]
1454    fn repos_default_to_no_roots_and_a_day_of_trust() {
1455        assert_eq!(Config::default().repos.roots, Vec::<PathBuf>::new());
1456        assert_eq!(Config::default().repos.scan_ttl, 86_400);
1457    }
1458
1459    #[test]
1460    fn a_config_file_with_no_repos_table_still_loads() {
1461        let dir = tempfile::tempdir().unwrap();
1462        let path = dir.path().join("magi.toml");
1463        std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
1464        let cfg = Config::load(&path).expect("must load without [repos]");
1465        assert_eq!(cfg.repos.roots, Vec::<PathBuf>::new());
1466        assert_eq!(cfg.repos.scan_ttl, 86_400);
1467    }
1468
1469    /// A fixture is the whole config under test.
1470    ///
1471    /// `layers` used to reach for `dirs::config_dir()` unconditionally, so on
1472    /// a machine where somebody had written `<config_dir>/magi/config.toml`
1473    /// the suite silently loaded it as the lowest layer. Adding `[repos]
1474    /// roots` there turned two web tests red - their fixtures declare
1475    /// `[repos] roots` too, and `refuse_split_arrays` rightly refuses one
1476    /// array key spread across two layers. CI stayed green throughout,
1477    /// because a runner has no such file: the suite failed only where magi is
1478    /// actually used.
1479    ///
1480    /// So a test build has no machine layer unless it asks for one, and this
1481    /// is that promise. Written against a real file at the real location so
1482    /// it fails if `machine_layer` starts reading it again.
1483    #[test]
1484    fn a_test_build_does_not_read_the_operators_machine_config() {
1485        let repo = tempfile::tempdir().unwrap();
1486        std::fs::write(repo.path().join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();
1487
1488        let layers = Config::layers(repo.path());
1489        assert_eq!(
1490            layers,
1491            vec![repo.path().join("magi.toml")],
1492            "only the fixture's own file may be a layer"
1493        );
1494        if let Some(real) = dirs::config_dir() {
1495            let machine = real.join("magi").join("config.toml");
1496            assert!(
1497                !layers.contains(&machine),
1498                "the operator's {} must not be a layer in a test build",
1499                machine.display()
1500            );
1501        }
1502    }
1503
1504    #[test]
1505    fn starter_toml_loads_through_teravars() {
1506        let dir = tempfile::tempdir().unwrap();
1507        let path = dir.path().join("magi.toml");
1508        std::fs::write(&path, Config::starter_toml()).unwrap();
1509        let parsed = Config::load(&path).expect("starter config must load");
1510        assert_eq!(parsed.graph.candidates, 3);
1511        assert_eq!(parsed.merge.mode, MergeMode::None);
1512        assert_eq!(parsed.merge.style, MergeStyle::Merge);
1513        assert!(parsed.graph.sessions);
1514        assert_eq!(parsed.update.mode, UpdateMode::Notify);
1515    }
1516
1517    /// A repository whose ruleset forbids merge commits declares that once,
1518    /// here, rather than magi asking GitHub about it on every render (see
1519    /// [`MergeStyle`]'s own doc for why).
1520    #[test]
1521    fn a_repository_can_declare_a_linear_history_merge_style() {
1522        let dir = tempfile::tempdir().unwrap();
1523        let path = dir.path().join("magi.toml");
1524        std::fs::write(&path, "[merge]\nmode = \"none\"\nstyle = \"squash\"\n").unwrap();
1525        let parsed = Config::load(&path).expect("config must load");
1526        assert_eq!(parsed.merge.style, MergeStyle::Squash);
1527    }
1528
1529    #[test]
1530    fn later_layers_win_and_vars_render() {
1531        let dir = tempfile::tempdir().unwrap();
1532        let machine = dir.path().join("machine.toml");
1533        let project = dir.path().join("magi.toml");
1534        // The machine layer owns the roster...
1535        std::fs::write(
1536            &machine,
1537            "[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n\
1538             [graph]\ncandidates = 3\nmax_parallel = 8\n",
1539        )
1540        .unwrap();
1541        // ...and the project layer only states what is repo-specific, plus a
1542        // `[vars]` value interpolated into a command.
1543        std::fs::write(
1544            &project,
1545            "[vars]\ncache = \"/shared\"\n\n\
1546             [graph]\ncandidates = 2\n\n\
1547             [verify]\ngate = [\"CARGO_TARGET_DIR={{ vars.cache }}/t cargo test\"]\n",
1548        )
1549        .unwrap();
1550
1551        let cfg = Config::load_layers(&[machine, project]).expect("layered load");
1552        assert_eq!(cfg.agents.len(), 1, "roster comes from the machine layer");
1553        assert_eq!(cfg.graph.candidates, 2, "project layer wins");
1554        assert_eq!(cfg.graph.max_parallel, 8, "machine layer survives");
1555        assert_eq!(
1556            cfg.verify.gate,
1557            ["CARGO_TARGET_DIR=/shared/t cargo test".to_owned()]
1558        );
1559        // The rendered command is where the cache path is read back from.
1560        assert_eq!(cfg.cache_dir(), Some(PathBuf::from("/shared/t")));
1561    }
1562
1563    #[test]
1564    fn talk_defaults_to_an_hour_and_an_unwritten_config_still_gets_it() {
1565        // An operator who writes no `[graph]` timeout keys at all must still
1566        // land on the hour, not on the five/fifteen minutes this turn used
1567        // to hardcode before it read from config.
1568        let g = Graph::default();
1569        assert_eq!(g.timeout_talk, 3600);
1570
1571        let dir = tempfile::tempdir().unwrap();
1572        let path = dir.path().join("magi.toml");
1573        std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
1574        let cfg = Config::load(&path).expect("must load without timeout_talk set");
1575        assert_eq!(cfg.graph.timeout_talk, 3600);
1576    }
1577
1578    #[test]
1579    fn an_overridden_talk_timeout_reaches_the_loaded_config() {
1580        let dir = tempfile::tempdir().unwrap();
1581        let path = dir.path().join("magi.toml");
1582        std::fs::write(&path, "[graph]\ntimeout_talk = 120\n").unwrap();
1583        let cfg = Config::load(&path).expect("must load");
1584        assert_eq!(cfg.graph.timeout_talk, 120);
1585    }
1586
1587    #[test]
1588    fn the_disk_defaults_are_the_measurements_made_up_front() {
1589        let cfg = Config::default();
1590        assert_eq!(cfg.disk.min_free_bytes, 8 * 1024 * 1024 * 1024);
1591        assert!(cfg.disk.auto_fold);
1592        assert_eq!(cfg.disk.fold_grace_secs, 6 * 60 * 60);
1593        assert_eq!(cfg.disk.cache_limit_bytes, 10 * 1024 * 1024 * 1024);
1594    }
1595
1596    #[test]
1597    fn an_unset_disk_section_is_the_safe_default() {
1598        let dir = tempfile::tempdir().unwrap();
1599        std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 1\n").unwrap();
1600        let cfg = Config::load(&dir.path().join("magi.toml")).expect("load");
1601        assert_eq!(cfg.disk, Disk::default());
1602    }
1603
1604    #[test]
1605    fn env_is_available_to_templates_with_a_default() {
1606        let dir = tempfile::tempdir().unwrap();
1607        let path = dir.path().join("magi.toml");
1608        // teravars ships no `env`; magi adds it, and the `default` filter has
1609        // to cover the unset case or every machine would need the variable.
1610        //
1611        // Deliberately no named variable: `env` is keyed by the exact spelling
1612        // the OS reports, and Windows says `Path` where POSIX says `PATH`, so a
1613        // test asserting `env.PATH` passes on one runner and fails on another.
1614        // The map's non-emptiness is the platform-neutral claim.
1615        std::fs::write(
1616            &path,
1617            "[verify]\n\
1618             gate = [\"cache={{ env.MAGI_TEST_UNSET_XYZ | default(value='fallback') }}\", \
1619             \"populated={{ env | length > 0 }}\"]\n",
1620        )
1621        .unwrap();
1622        let cfg = Config::load(&path).expect("env lookup must render");
1623        assert_eq!(cfg.verify.gate[0], "cache=fallback");
1624        assert_eq!(cfg.verify.gate[1], "populated=true");
1625    }
1626
1627    #[test]
1628    fn a_broken_template_names_the_file() {
1629        let dir = tempfile::tempdir().unwrap();
1630        let path = dir.path().join("magi.toml");
1631        std::fs::write(&path, "[graph]\nlanguage = \"{{ nope.\"\n").unwrap();
1632        let err = Config::load(&path).expect_err("must not silently ignore");
1633        assert!(err.to_string().contains("teravars"), "{err}");
1634    }
1635
1636    #[test]
1637    fn tera_syntax_in_comments_is_inert() {
1638        // teravars >= 0.2.2 strips `#` comments before Tera sees the file, so a
1639        // comment may quote template syntax without rendering. Before 0.2.2 this
1640        // load failed: the commented-out braces reached the template parser.
1641        let dir = tempfile::tempdir().unwrap();
1642        let path = dir.path().join("magi.toml");
1643        std::fs::write(
1644            &path,
1645            "# a comment may quote templates: `{{ env.NOPE | default(value='x') }}` and `{% if %}`\n\
1646             [graph]\ncandidates = 2\n",
1647        )
1648        .unwrap();
1649        let cfg = Config::load(&path).expect("comments must be inert, not rendered");
1650        assert_eq!(cfg.graph.candidates, 2);
1651    }
1652
1653    #[test]
1654    fn opencode_defaults_to_file_delivery() {
1655        let mut s = spec("oc");
1656        s.kind = AgentKind::Opencode;
1657        assert_eq!(s.delivery(), Delivery::File);
1658        s.prompt_delivery = Some(Delivery::Argv);
1659        assert_eq!(s.delivery(), Delivery::Argv);
1660    }
1661    #[test]
1662    fn the_land_loop_is_on_but_it_cannot_merge_without_being_asked() {
1663        // Both default on, and that pair is the safety property: `land` takes
1664        // over the watching an operator was doing by hand, `land_approval`
1665        // keeps the irreversible step a human decision. An unattended merge
1666        // needs BOTH flipped, which has to be chosen deliberately twice.
1667        let g = Graph::default();
1668        assert!(
1669            g.land,
1670            "stopping at an open PR left the watching to a human"
1671        );
1672        assert!(
1673            g.land_approval,
1674            "on-by-default land is only defensible while this is also on"
1675        );
1676        assert!(g.land_rounds > 0, "a loop with no budget never terminates");
1677    }
1678    #[test]
1679    fn an_array_declared_in_two_layers_is_refused_instead_of_concatenated() {
1680        // teravars appends arrays. For an ordered list of seats, or an argv,
1681        // the concatenation is something neither file says - and the operator
1682        // pays for the extra seats by the token.
1683        let dir = tempfile::tempdir().unwrap();
1684        let machine = dir.path().join("machine.toml");
1685        let repo = dir.path().join("magi.toml");
1686        std::fs::write(&machine, "[roles]\nimplementers = [\"a\", \"b\"]\n").unwrap();
1687        std::fs::write(&repo, "[roles]\nimplementers = [\"oc\"]\n").unwrap();
1688
1689        let err = Config::load_layers(&[machine.clone(), repo.clone()])
1690            .expect_err("two layers naming one array must not merge silently")
1691            .to_string();
1692        assert!(err.contains("roles.implementers"), "{err}");
1693        // Both files are named: the fix is to delete one of them, and the
1694        // operator has to know which two to choose between.
1695        assert!(err.contains("machine.toml"), "{err}");
1696        assert!(err.contains("magi.toml"), "{err}");
1697    }
1698
1699    #[test]
1700    fn a_scalar_in_one_layer_and_an_array_in_another_still_merges() {
1701        // The split the layering exists for: state a preference machine-wide,
1702        // let the repository own its own lists.
1703        let dir = tempfile::tempdir().unwrap();
1704        let machine = dir.path().join("machine.toml");
1705        let repo = dir.path().join("magi.toml");
1706        std::fs::write(&machine, "[roles]\nchatter = \"opus\"\n").unwrap();
1707        std::fs::write(
1708            &repo,
1709            "[[agents]]\nid = \"oc\"\nkind = \"opencode\"\n\n\
1710             [roles]\nimplementers = [\"oc\"]\n",
1711        )
1712        .unwrap();
1713
1714        let cfg = Config::load_layers(&[machine, repo]).expect("layers merge");
1715        assert_eq!(cfg.roles.chatter.as_deref(), Some("opus"));
1716        assert_eq!(cfg.roles.implementers, ["oc"]);
1717        assert_eq!(cfg.agents.len(), 1, "the roster is not doubled");
1718    }
1719
1720    #[test]
1721    fn two_layers_declaring_verify_gate_run_both_in_priority_order() {
1722        // The `editorconfig-checker` distribution problem: a shared layer
1723        // wants to add a gate command without erasing the repository's own.
1724        let dir = tempfile::tempdir().unwrap();
1725        let machine = dir.path().join("machine.toml");
1726        let repo = dir.path().join("magi.toml");
1727        std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
1728        std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
1729
1730        let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
1731        assert_eq!(
1732            cfg.verify.gate,
1733            [
1734                "editorconfig-checker".to_owned(),
1735                "cargo make check".to_owned()
1736            ],
1737            "low-priority (machine) command first, high-priority (repo) command after"
1738        );
1739    }
1740
1741    #[test]
1742    fn two_layers_declaring_verify_e2e_run_both_in_priority_order() {
1743        let dir = tempfile::tempdir().unwrap();
1744        let machine = dir.path().join("machine.toml");
1745        let repo = dir.path().join("magi.toml");
1746        std::fs::write(&machine, "[verify]\ne2e = [\"shared-smoke-test\"]\n").unwrap();
1747        std::fs::write(&repo, "[verify]\ne2e = [\"cargo test\"]\n").unwrap();
1748
1749        let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
1750        assert_eq!(
1751            cfg.verify.e2e,
1752            ["shared-smoke-test".to_owned(), "cargo test".to_owned()]
1753        );
1754    }
1755
1756    #[test]
1757    fn two_layers_declaring_repos_roots_are_both_scanned() {
1758        let dir = tempfile::tempdir().unwrap();
1759        let machine = dir.path().join("machine.toml");
1760        let repo = dir.path().join("magi.toml");
1761        std::fs::write(&machine, "[repos]\nroots = [\"/machine/root\"]\n").unwrap();
1762        std::fs::write(&repo, "[repos]\nroots = [\"/repo/root\"]\n").unwrap();
1763
1764        let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
1765        assert_eq!(
1766            cfg.repos.roots,
1767            [PathBuf::from("/machine/root"), PathBuf::from("/repo/root")]
1768        );
1769    }
1770
1771    #[test]
1772    fn duplicate_gate_commands_across_layers_both_run() {
1773        // Dropping the duplicate would be a silent surprise; the operator
1774        // sees a slower gate, never a missing one.
1775        let dir = tempfile::tempdir().unwrap();
1776        let machine = dir.path().join("machine.toml");
1777        let repo = dir.path().join("magi.toml");
1778        std::fs::write(&machine, "[verify]\ngate = [\"same-command\"]\n").unwrap();
1779        std::fs::write(&repo, "[verify]\ngate = [\"same-command\"]\n").unwrap();
1780
1781        let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
1782        assert_eq!(
1783            cfg.verify.gate,
1784            ["same-command".to_owned(), "same-command".to_owned()]
1785        );
1786    }
1787
1788    #[test]
1789    fn notify_command_is_still_refused_across_two_layers() {
1790        // An argv, not a set: concatenating two of them is not a program.
1791        let dir = tempfile::tempdir().unwrap();
1792        let machine = dir.path().join("machine.toml");
1793        let repo = dir.path().join("magi.toml");
1794        std::fs::write(&machine, "[notify]\ncommand = [\"ntfy\", \"publish\"]\n").unwrap();
1795        std::fs::write(&repo, "[notify]\ncommand = [\"curl\", \"-X\"]\n").unwrap();
1796
1797        let err = Config::load_layers(&[machine.clone(), repo.clone()])
1798            .expect_err("an argv split across layers must not concatenate")
1799            .to_string();
1800        assert!(err.contains("notify.command"), "{err}");
1801        assert!(err.contains("machine.toml"), "{err}");
1802        assert!(err.contains("magi.toml"), "{err}");
1803    }
1804
1805    #[test]
1806    fn one_layer_declaring_verify_gate_runs_unchanged() {
1807        // The classification must not change behaviour for the configuration
1808        // this very repository has today: exactly one layer names the gate.
1809        let dir = tempfile::tempdir().unwrap();
1810        let path = dir.path().join("magi.toml");
1811        std::fs::write(&path, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
1812
1813        let cfg = Config::load(&path).expect("single layer must still load");
1814        assert_eq!(cfg.verify.gate, ["cargo make check".to_owned()]);
1815    }
1816
1817    #[test]
1818    fn describe_composed_names_the_contributing_layers_only_when_there_are_two() {
1819        let dir = tempfile::tempdir().unwrap();
1820        let machine = dir.path().join("machine.toml");
1821        let repo = dir.path().join("magi.toml");
1822        std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
1823        std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
1824        let paths = vec![machine.clone(), repo.clone()];
1825
1826        let cfg = Config::load_layers(&paths).expect("appendable arrays must merge");
1827        let described =
1828            Config::describe_composed(&paths, &cfg.verify.gate, "verify.gate", "(none)");
1829        assert!(described.contains("editorconfig-checker && cargo make check"));
1830        assert!(
1831            described.contains(&machine.display().to_string()),
1832            "{described}"
1833        );
1834        assert!(
1835            described.contains(&repo.display().to_string()),
1836            "{described}"
1837        );
1838
1839        // A single contributing layer stays the plain one-line summary.
1840        let single = vec![repo.clone()];
1841        let solo_cfg = Config::load_layers(&single).expect("single layer loads");
1842        let solo_described =
1843            Config::describe_composed(&single, &solo_cfg.verify.gate, "verify.gate", "(none)");
1844        assert_eq!(solo_described, "cargo make check");
1845    }
1846}