Skip to main content

spar/
config.rs

1//! Configuration, and the agent presets that make a new CLI a data change
2//! rather than a code change.
3//!
4//! Presets are compiled into the binary. That is not an optimisation: a
5//! `cargo install`ed binary has no source tree beside it, so a preset read from
6//! a relative path would work for the author and fail for everyone else. Files
7//! on disk still win over the built in copies, so a preset can be overridden or
8//! a new one added without rebuilding.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14use toml::Value;
15
16use crate::error::Result;
17use crate::proc::{expand_tilde, home_dir};
18use crate::style::Style;
19use crate::{bail, spar_err};
20
21/// Presets that ship inside the binary.
22pub const BUILTIN_PRESETS: &[(&str, &str)] = &[
23    ("aider", include_str!("../presets/aider.toml")),
24    ("claude", include_str!("../presets/claude.toml")),
25    ("codex", include_str!("../presets/codex.toml")),
26    ("cursor", include_str!("../presets/cursor.toml")),
27    ("gemini", include_str!("../presets/gemini.toml")),
28];
29
30// ---------------------------------------------------------------------------
31// Agents
32// ---------------------------------------------------------------------------
33
34/// One element of a command template: a bare argument, or a group that is
35/// dropped whole when its placeholder is unset.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(untagged)]
38pub enum CommandPart {
39    One(String),
40    Group(Vec<String>),
41}
42
43impl CommandPart {
44    pub fn args(&self) -> &[String] {
45        match self {
46            CommandPart::One(s) => std::slice::from_ref(s),
47            CommandPart::Group(v) => v,
48        }
49    }
50}
51
52/// How to read the answer out of what a CLI printed.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum OutputMode {
56    /// Everything on stdout is the answer.
57    Text,
58    /// Same as text; named separately because it reads better in a preset.
59    Json,
60    /// An event stream, one JSON object per line.
61    Jsonl,
62}
63
64/// Where the style rules go when a CLI has a system prompt flag.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum SystemVia {
68    /// Prepended to the prompt.
69    Prompt,
70    /// Passed through the `{system}` placeholder.
71    Placeholder,
72}
73
74fn default_timeout() -> u64 {
75    crate::proc::DEFAULT_TIMEOUT_SECS
76}
77
78fn default_output() -> OutputMode {
79    OutputMode::Text
80}
81
82fn default_system_via() -> SystemVia {
83    SystemVia::Prompt
84}
85
86/// Everything needed to drive one CLI. An agent is data, not a class:
87/// supporting a new tool is a preset file.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct AgentSpec {
91    #[serde(skip)]
92    pub name: String,
93    pub command: Vec<CommandPart>,
94    #[serde(default)]
95    pub model: Option<String>,
96    #[serde(default)]
97    pub effort: Option<String>,
98    /// This agent's own schedule, which wins over the shared one.
99    ///
100    /// Effort words are each CLI's own vocabulary, so one string for both
101    /// agents is a value that has to be legal in two languages at once. This is
102    /// where a schedule says `ultra` to codex and `high` to claude.
103    #[serde(default)]
104    pub effort_schedule: EffortSchedule,
105    #[serde(default = "default_output")]
106    pub output: OutputMode,
107    /// For `jsonl`: the event fields that identify the agent's own message.
108    #[serde(default)]
109    pub message_match: BTreeMap<String, String>,
110    /// For `jsonl`: the dotted path to the text inside a matching event.
111    #[serde(default)]
112    pub message_path: Option<String>,
113    /// Extra places to look for the binary when it is not on PATH.
114    #[serde(default)]
115    pub search_paths: Vec<String>,
116    #[serde(default = "default_system_via")]
117    pub system_via: SystemVia,
118    #[serde(default = "default_timeout")]
119    pub timeout: u64,
120    /// A stand in for when this agent cannot answer at all: a CLI that is down,
121    /// out of quota, or refusing the request on policy grounds.
122    ///
123    /// Declared as a nested table rather than by naming a third agent, because
124    /// spar takes exactly two and a backup is not a third opinion. It never
125    /// reviews alongside the pair, it only answers in place of the one that
126    /// failed, so the alternation the design rests on is unchanged.
127    ///
128    /// Built by `build_spec` from the `[agents.NAME.fallback]` table, never
129    /// deserialized directly, so a preset of its own still resolves.
130    #[serde(skip)]
131    pub fallback: Option<Box<AgentSpec>>,
132
133    // -- hints, inert at runtime -----------------------------------------
134    //
135    // Written into a generated config as comments so nobody has to guess what
136    // to put in `model` or `effort`. Deliberately never validated against: a
137    // CLI's options drift, and a stale allow list that refuses a model which
138    // actually works would be worse than no hint at all.
139    /// Model names this CLI is known to accept.
140    #[serde(default)]
141    pub models: Vec<String>,
142    /// Effort levels this CLI is known to accept.
143    #[serde(default)]
144    pub efforts: Vec<String>,
145    /// Where to check the current list, when spar cannot enumerate it.
146    #[serde(default)]
147    pub options_note: Option<String>,
148}
149
150impl AgentSpec {
151    /// The model as configured, normalised. An unset and an empty value both
152    /// mean "let the CLI pick", because `render` drops the flag either way.
153    pub fn model_key(&self) -> String {
154        self.model.as_deref().unwrap_or("").trim().to_string()
155    }
156
157    pub fn describe(&self) -> String {
158        format!(
159            "{}/{}",
160            self.model.as_deref().unwrap_or("default model"),
161            self.effort.as_deref().unwrap_or("default effort")
162        )
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Loop and style blocks
168// ---------------------------------------------------------------------------
169
170/// Where an out of scope finding goes. On your own repository an issue is the
171/// right home. On a large repository that is not yours it is somebody else's
172/// notification and somebody else's triage queue.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "lowercase")]
175pub enum Followups {
176    Issues,
177    Local,
178    None,
179}
180
181impl std::fmt::Display for Followups {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.write_str(match self {
184            Followups::Issues => "issues",
185            Followups::Local => "local",
186            Followups::None => "none",
187        })
188    }
189}
190
191/// Whether a pull request spar opens starts as a draft, and when it stops being
192/// one.
193///
194/// A draft says the work is not for a person yet, which is exactly true while
195/// two agents are still arguing about it. `UntilApproved` makes that state mean
196/// something and clear itself: the loop marks the pull request ready the moment
197/// it has no blocking findings left. `Always` is for somebody who promotes
198/// every pull request by hand and wants spar to keep out of it.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum Drafts {
202    /// Open ordinary pull requests. The default, and what spar has always done.
203    Never,
204    /// Open as a draft, and mark it ready when the review converges.
205    UntilApproved,
206    /// Open as a draft and leave it that way.
207    Always,
208}
209
210impl std::fmt::Display for Drafts {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.write_str(match self {
213            Drafts::Never => "never",
214            Drafts::UntilApproved => "until_approved",
215            Drafts::Always => "always",
216        })
217    }
218}
219
220/// How much of its own working spar narrates into a pull request thread.
221///
222/// The agents never read the PR: they receive findings through their prompts,
223/// so nothing in the loop depends on any of this being posted. It exists purely
224/// for the person who reads the thread later, which is why the default is the
225/// outcome rather than the play by play.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "lowercase")]
228pub enum PrComments {
229    /// One comment when the run finishes, and only if it has something to say.
230    Outcome,
231    /// A comment per review and per response, as it happens. An audit trail,
232    /// at the cost of a thread nobody wants to read.
233    Rounds,
234    /// Never comment on a pull request. Everything goes to the terminal.
235    None,
236}
237
238impl std::fmt::Display for PrComments {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.write_str(match self {
241            PrComments::Outcome => "outcome",
242            PrComments::Rounds => "rounds",
243            PrComments::None => "none",
244        })
245    }
246}
247
248/// Where resume state lives. Local keeps the PR clean and costs no API calls;
249/// writing to the PR only buys anything if a run might be resumed from a
250/// different checkout.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "lowercase")]
253pub enum StateStore {
254    Local,
255    Pr,
256    Both,
257}
258
259impl StateStore {
260    pub fn writes_local(self) -> bool {
261        matches!(self, StateStore::Local | StateStore::Both)
262    }
263    pub fn writes_pr(self) -> bool {
264        matches!(self, StateStore::Pr | StateStore::Both)
265    }
266}
267
268/// Whose comments `spar checkin` will act on, and whose comments under an issue
269/// reach a prompt.
270///
271/// The default is not timidity. Acting on a comment means a commit pushed to a
272/// branch because somebody typed a sentence, and `authorAssociation` is one
273/// field GitHub already returns on every comment endpoint that says whether
274/// they can write to this repository at all. Everybody is still answered in
275/// words either way; this governs only whether a comment can produce a commit.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "lowercase")]
278pub enum Trust {
279    /// Anybody GitHub says can write here: OWNER, MEMBER, COLLABORATOR.
280    Write,
281    /// Anybody at all. Both agents still have to agree before anything changes.
282    Anyone,
283}
284
285impl std::fmt::Display for Trust {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        f.write_str(match self {
288            Trust::Write => "write",
289            Trust::Anyone => "anyone",
290        })
291    }
292}
293
294impl Trust {
295    /// Whether a comment from somebody with this association may produce a
296    /// commit.
297    pub fn may_act_on(self, association: &str) -> bool {
298        match self {
299            Trust::Anyone => true,
300            Trust::Write => matches!(
301                association.trim().to_uppercase().as_str(),
302                "OWNER" | "MEMBER" | "COLLABORATOR"
303            ),
304        }
305    }
306}
307
308/// Which call is about to be made.
309///
310/// The schedule used to be two keys, `round_1` and `rest`, chosen by a round
311/// number that most of these calls do not have. Triage of the whole queue, the
312/// implement call, the follow-up screen, the check-in judge and the split
313/// proposer were all "round 1", so `round_1 = "ultra"` put every one of them at
314/// ultra, which is neither what the name says nor what anybody wants to pay
315/// for.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum Call {
318    /// Drafting one issue from a project prompt.
319    Code,
320    /// Both agents, over the whole queue, once per wave.
321    Triage,
322    /// Writing the first draft.
323    Implement,
324    /// A review round. The first is the deep one.
325    Review(u32),
326    /// The author answering a review.
327    Respond(u32),
328    /// The closing merge-safety pass.
329    Close,
330    /// The follow-up screen, one call for the whole queue.
331    Screen,
332    /// A check-in call: the judge is round 1, the checker round 2.
333    Checkin(u32),
334    /// A split call: the proposer is round 1, the checker round 2.
335    Split(u32),
336    /// A brainstorm round: both agents diverge in round 1, cross in the
337    /// middle rounds, and converge in the last.
338    Brainstorm(u32),
339    /// A second look at a reviewed pull request after commits or replies.
340    Recheck,
341    /// Answering what an issue asks of you: the drafter is round 1, the
342    /// checker round 2.
343    Answer(u32),
344}
345
346impl Call {
347    /// The schedule key this call reads.
348    pub fn key(self) -> &'static str {
349        match self {
350            Call::Code => "code",
351            Call::Triage => "triage",
352            Call::Implement => "implement",
353            Call::Review(round) if round <= 1 => "review_1",
354            Call::Review(_) => "review_rest",
355            Call::Respond(_) => "respond",
356            Call::Close => "close",
357            Call::Screen => "screen",
358            Call::Checkin(_) => "checkin",
359            Call::Split(_) => "split",
360            Call::Brainstorm(_) => "brainstorm",
361            Call::Recheck => "recheck",
362            Call::Answer(_) => "answer",
363        }
364    }
365
366    /// What the two key schedule would have used, so a config written before
367    /// the keys existed behaves exactly as it did.
368    fn legacy_round(self) -> u32 {
369        match self {
370            Call::Code | Call::Triage | Call::Implement | Call::Screen => 1,
371            Call::Review(round) | Call::Respond(round) => round,
372            Call::Checkin(round)
373            | Call::Split(round)
374            | Call::Brainstorm(round)
375            | Call::Answer(round) => round,
376            Call::Close | Call::Recheck => 2,
377        }
378    }
379
380    /// Every kind, for `doctor` to check a configuration against.
381    pub fn every() -> [Call; 13] {
382        [
383            Call::Code,
384            Call::Triage,
385            Call::Implement,
386            Call::Review(1),
387            Call::Review(2),
388            Call::Recheck,
389            Call::Respond(1),
390            Call::Close,
391            Call::Screen,
392            Call::Checkin(1),
393            Call::Split(1),
394            Call::Brainstorm(1),
395            Call::Answer(1),
396        ]
397    }
398}
399
400#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401#[serde(deny_unknown_fields)]
402pub struct EffortSchedule {
403    /// Drafting an issue for `spar code`.
404    pub code: Option<String>,
405    /// The deep first review.
406    ///
407    /// Kept, and still read by every call that has no key of its own, so an
408    /// existing config keeps working exactly as it did.
409    pub round_1: Option<String>,
410    /// Every round after the first, and the closing pass. Both are asked a
411    /// narrower question than the first review, so neither buys its depth again.
412    pub rest: Option<String>,
413    /// Both agents, over the whole queue. The most expensive call in a run, and
414    /// the one the two key schedule never mentioned.
415    pub triage: Option<String>,
416    pub implement: Option<String>,
417    pub review_1: Option<String>,
418    pub review_rest: Option<String>,
419    pub respond: Option<String>,
420    pub close: Option<String>,
421    pub screen: Option<String>,
422    pub checkin: Option<String>,
423    pub split: Option<String>,
424    /// Every brainstorm round. Diverging is the deep call and the later rounds
425    /// answer narrower questions, but they read the same key: a brainstorm is
426    /// cheap next to a run, and one word for it is enough.
427    pub brainstorm: Option<String>,
428    /// A second look at a reviewed pull request: a delta and a list of claims
429    /// to check, so it sits with the later review rounds and reads
430    /// `review_rest` when it has no word of its own.
431    pub recheck: Option<String>,
432    /// Answering what an issue asks of you, both the draft and the check.
433    pub answer: Option<String>,
434}
435
436/// A schedule entry that was actually written, blank meaning unset.
437fn set(value: &Option<String>) -> Option<String> {
438    value.clone().filter(|s| !s.trim().is_empty())
439}
440
441impl EffortSchedule {
442    /// What this schedule says about one call, if anything.
443    pub fn get(&self, call: Call) -> Option<String> {
444        let value = match call.key() {
445            "code" => &self.code,
446            "triage" => &self.triage,
447            "implement" => &self.implement,
448            "review_1" => &self.review_1,
449            "review_rest" => &self.review_rest,
450            "respond" => &self.respond,
451            "close" => &self.close,
452            "screen" => &self.screen,
453            "checkin" => &self.checkin,
454            "brainstorm" => &self.brainstorm,
455            "answer" => &self.answer,
456            "recheck" => {
457                return set(&self.recheck).or_else(|| set(&self.review_rest));
458            }
459            _ => &self.split,
460        };
461        set(value)
462    }
463
464    /// What the old two key schedule would have said.
465    fn legacy(&self, call: Call) -> Option<String> {
466        let value = if call.legacy_round() <= 1 {
467            &self.round_1
468        } else {
469            &self.rest
470        };
471        value.clone().filter(|s| !s.trim().is_empty())
472    }
473}
474
475/// A command spar runs itself: the check before a push, or the setup of a new
476/// worktree.
477///
478/// A string is split on whitespace, which covers `cargo test` and `npm test`.
479/// A list is taken as it is, for anything that needs an argument with a space
480/// in it. No shell either way: spar runs the program, so nothing here is
481/// interpreted.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(untagged)]
484pub enum CheckCommand {
485    Line(String),
486    Argv(Vec<String>),
487}
488
489impl Default for CheckCommand {
490    /// An empty list, which is both "nothing to run" and a value that survives
491    /// a round trip through TOML, unlike a unit variant.
492    fn default() -> Self {
493        CheckCommand::Argv(Vec::new())
494    }
495}
496
497impl CheckCommand {
498    pub fn argv(&self) -> Vec<String> {
499        match self {
500            CheckCommand::Line(line) => line.split_whitespace().map(str::to_string).collect(),
501            CheckCommand::Argv(argv) => argv
502                .iter()
503                .map(|a| a.trim().to_string())
504                .filter(|a| !a.is_empty())
505                .collect(),
506        }
507    }
508
509    pub fn is_set(&self) -> bool {
510        !self.argv().is_empty()
511    }
512
513    /// How the command reads in a log line or a pull request body.
514    pub fn describe(&self) -> String {
515        self.argv().join(" ")
516    }
517}
518
519/// Every field takes its value from `LoopCfg::default()` when a config does not
520/// mention it, rather than from a per-field function saying the same thing in a
521/// second place. Two places is how a default goes stale.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523#[serde(default, deny_unknown_fields)]
524pub struct LoopCfg {
525    /// Review rounds one invocation may spend asking for changes. A run that
526    /// spends the full budget may use one closing pass, which is not a round.
527    pub max_rounds: u32,
528    pub auto_merge: bool,
529    pub first_implementor: Option<String>,
530    pub base_branch: String,
531    pub worktrees: bool,
532    pub keep_worktrees: bool,
533    pub state_store: StateStore,
534    pub branch_prefix: String,
535    pub followups: Followups,
536    /// File a non-blocking finding as a follow-up.
537    ///
538    /// Off by default, and this is the setting that stops a run breeding. A
539    /// thorough reviewer always finds improvements, and turning each one into a
540    /// tracker item made a single issue spawn ten, which spawned more: mean
541    /// offspring above one never terminates. Not gating a merge is not the same
542    /// as being worth somebody's triage queue.
543    pub file_non_blocking: bool,
544    /// Most follow-ups one run may record before it stops and says what it
545    /// dropped. A backstop, not a target.
546    pub max_followups: usize,
547    /// Most parts `spar split` will make out of one issue or pull request.
548    ///
549    /// A backstop, not a target, in the shape of `max_followups`. There is no
550    /// setting to turn splitting off: it is a command somebody types, and a
551    /// setting to disable a command nobody has to run only ever confuses. There
552    /// is no mechanical threshold either, because a floor on files changed
553    /// would split a forty file rename and hold back a three file mess.
554    pub max_split_parts: usize,
555    /// Nits stay in the PR thread by default. A filed nit is somebody else's
556    /// notification: a run on a production codebase once opened an issue titled
557    /// "Log wording".
558    pub file_nits: bool,
559    /// Close an issue that both agents independently declined, after posting
560    /// the shared reasoning. One agent's opinion is never enough.
561    pub close_skipped: bool,
562    /// Ask both agents to triage at the same time. They only read during
563    /// triage, so there is nothing to serialise.
564    pub parallel_triage: bool,
565    /// Ignore issues and pull requests numbered below this when spar is picking
566    /// for itself. 0 is no floor.
567    ///
568    /// A repository that has been going a while carries a tail of old issues
569    /// nobody is going to reach, and since spar takes the lowest numbered open
570    /// items it walks straight into them. A number you name explicitly is still
571    /// honoured: naming it is the point.
572    pub min_number: i64,
573    /// Waves of newly filed follow-ups to fold back into the same run, rather
574    /// than leaving them for the next one.
575    ///
576    /// Off by default because it multiplies what a run costs, and because each
577    /// wave can file follow-ups of its own. Every wave is triaged like any
578    /// other issue, so both agents still have to agree it is worth doing.
579    pub absorb_new_issues: u32,
580    /// Turn the checklist in a tracking issue into issues, and work them.
581    ///
582    /// Off by default, for the reason `file_non_blocking` is: a tracker exists
583    /// to contain many things, so this is the shape of feature that multiplies.
584    /// `max_tracker_children` caps it, a child that triage calls a tracker is
585    /// held rather than decomposed in turn, and both agents still gate every
586    /// child at triage.
587    ///
588    /// The trigger is the checklist, never a judgement about what the parts
589    /// are. Writing `- [ ]` lines is something a person does on purpose, which
590    /// makes this opt in per issue as well as per repository.
591    pub decompose_trackers: bool,
592    /// Most items from one tracker's checklist that one run may take on. A cap,
593    /// not a target, and what it left is named out loud.
594    pub max_tracker_children: usize,
595    /// Tick an acceptance criterion on the issue once spar merges a pull
596    /// request the signing review ruled met it.
597    ///
598    /// On by default, unlike `decompose_trackers`, because nothing here
599    /// multiplies: a tick follows only a merge spar performed and only the
600    /// ruling of the agent that did not write the head, and a box is never
601    /// unchecked.
602    pub tick_criteria: bool,
603    /// Whether a pull request spar opens starts as a draft.
604    pub drafts: Drafts,
605    /// Publish a new branch from the authenticated user's fork when origin
606    /// refuses the push, and open the pull request from there.
607    ///
608    /// On by default because the alternative is a run that implements an
609    /// issue, then fails at the push and throws the work away. A fork is how
610    /// anybody without write access contributes, and asking GitHub for one
611    /// returns the existing fork when there already is one.
612    pub fork_when_denied: bool,
613    /// Extra instructions handed to both agents with every request.
614    ///
615    /// For what a person wants of this repository that the code cannot say and
616    /// spar has no setting for: how far to go, what not to touch, what not to
617    /// wait on. A CLI reads its own conventions file already, CLAUDE.md or
618    /// AGENTS.md, but each reads only its own, and two agents given different
619    /// standing instructions are not the pair this design rests on.
620    ///
621    /// Subordinate to the request and to the schema, which is said in the
622    /// header they arrive under: they change how the work is done, never what
623    /// was asked for or the shape of the answer.
624    pub instructions: String,
625    /// The most of one issue, its body and the comments on it, that reaches a
626    /// prompt. Past it the oldest comments go first, then the body's tail.
627    ///
628    /// Sized so that no issue a person wrote is ever cut. It was 2000 for
629    /// triage and 6000 for implement, silently, and both were small enough to
630    /// clip an ordinary bug report: an agent given half an issue judges and
631    /// implements the half it saw and has no way to know the rest existed.
632    /// When this does fire it is said out loud, in the log and in the prompt.
633    pub max_issue_chars: usize,
634    /// The most every issue together, bodies and comments, may add to one
635    /// triage prompt.
636    ///
637    /// Triage reads the whole queue at once, so the only unbounded thing here
638    /// is the queue. Past this, whole issues are left for the next run rather
639    /// than every issue being shortened: a verdict is posted on the issue and
640    /// can close it, so judging one on part of its body is worse than not
641    /// reaching it yet.
642    pub max_triage_chars: usize,
643    /// Whose comments `spar checkin` will act on, and whose comments under an
644    /// issue reach the prompts that implement, triage, and review it.
645    ///
646    /// One setting, because both are the same question: whether what somebody
647    /// typed can become a commit. The person who filed an issue is read under
648    /// it whatever this says, since they wrote the body. The authenticated
649    /// GitHub user is also read under an issue regardless of write access.
650    pub checkin_trust: Trust,
651    /// Mark a review thread resolved when spar made the change it asked for.
652    ///
653    /// A thread spar disagreed with is left open whatever this says: the person
654    /// who raised it has not had their say yet, and it is their thread.
655    pub checkin_resolve: bool,
656    /// Most unanswered comments spar answers on one pull request in a run.
657    ///
658    /// A backstop against a long argument being read back to somebody, not a
659    /// target. What it held back is said out loud.
660    pub max_checkin_comments: usize,
661    /// A command spar runs in every worktree it creates, before any agent works
662    /// there or the check runs. Unset means nothing is run.
663    ///
664    /// A worktree holds only what git tracks, so a project that needs
665    /// `npm ci` or `uv sync` before it builds got a checkout that could not.
666    /// Whether it ever built then rested on an agent happening to install
667    /// dependencies on its own: an issue whose agent did passed the check, and
668    /// the next one failed it twice over types it could not find, spending the
669    /// one fix on a tree it was never asked to repair. The shared checkout is
670    /// left alone, since it is the person's own and already set up.
671    pub setup: CheckCommand,
672    /// A command spar runs in the worktree after an editing call committed and
673    /// before the branch is pushed. Unset means nothing is run.
674    ///
675    /// The design's rule is that a prompt is not a permission and every claim
676    /// an agent makes is checked mechanically where it can be. "I ran the
677    /// tests" was the one claim nothing checked: the schema records it, and
678    /// spar pushed and opened the pull request on the agent's word. This is not
679    /// waiting on CI, which is expensive and somebody else's machine. It is one
680    /// command, before the push, that fails closed.
681    pub check: CheckCommand,
682    pub effort_schedule: EffortSchedule,
683}
684
685impl Default for LoopCfg {
686    fn default() -> Self {
687        Self {
688            max_rounds: 3,
689            auto_merge: false,
690            first_implementor: None,
691            base_branch: "main".into(),
692            worktrees: true,
693            keep_worktrees: false,
694            state_store: StateStore::Local,
695            branch_prefix: String::new(),
696            followups: Followups::Local,
697            file_non_blocking: false,
698            max_followups: 5,
699            max_split_parts: 4,
700            file_nits: false,
701            close_skipped: true,
702            parallel_triage: true,
703            min_number: 0,
704            absorb_new_issues: 0,
705            decompose_trackers: false,
706            max_tracker_children: 5,
707            tick_criteria: true,
708            drafts: Drafts::Never,
709            fork_when_denied: true,
710            instructions: String::new(),
711            max_issue_chars: 60_000,
712            max_triage_chars: 200_000,
713            checkin_trust: Trust::Write,
714            checkin_resolve: true,
715            max_checkin_comments: 20,
716            setup: CheckCommand::default(),
717            check: CheckCommand::default(),
718            effort_schedule: EffortSchedule::default(),
719        }
720    }
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize)]
724#[serde(default, deny_unknown_fields)]
725pub struct StyleCfg {
726    pub ban_em_dash: bool,
727    pub ban_ai_attribution: bool,
728    pub terse: bool,
729    pub max_detail_chars: usize,
730    pub max_summary_chars: usize,
731    pub max_body_chars: usize,
732    /// A filed issue's body. Far larger than a PR comment's on purpose: a
733    /// comment is read with the diff in front of you, an issue is picked up
734    /// cold months later by somebody who needs the whole story.
735    pub max_issue_body_chars: usize,
736    pub max_title_chars: usize,
737    pub pr_comments: PrComments,
738    pub inline_comments: bool,
739}
740
741impl Default for StyleCfg {
742    /// Taken from `Style`, which is where the budgets are decided, rather than
743    /// written out again here.
744    ///
745    /// They were written out again here, and they drifted. The functions
746    /// supplying them to serde were still named `d90`, `d200`, `d320`, `d900`
747    /// and `d4000` while returning 140, 2000, 6000, 8000 and 20000, and the
748    /// config `spar init` generated offered the old numbers as though they
749    /// were current. Uncommenting one of those lines to see what it did cut
750    /// every comment spar posts to a fifth of its length.
751    fn default() -> Self {
752        let style = Style::default();
753        Self {
754            ban_em_dash: style.ban_em_dash,
755            ban_ai_attribution: style.ban_ai_attribution,
756            terse: style.terse,
757            max_detail_chars: style.max_detail_chars,
758            max_summary_chars: style.max_summary_chars,
759            max_body_chars: style.max_body_chars,
760            max_issue_body_chars: style.max_issue_body_chars,
761            max_title_chars: style.max_title_chars,
762            pr_comments: style.pr_comments,
763            inline_comments: style.inline_comments,
764        }
765    }
766}
767
768impl StyleCfg {
769    pub fn to_style(&self) -> Style {
770        Style {
771            ban_em_dash: self.ban_em_dash,
772            ban_ai_attribution: self.ban_ai_attribution,
773            terse: self.terse,
774            max_detail_chars: self.max_detail_chars,
775            max_summary_chars: self.max_summary_chars,
776            max_body_chars: self.max_body_chars,
777            max_issue_body_chars: self.max_issue_body_chars,
778            max_title_chars: self.max_title_chars,
779            pr_comments: self.pr_comments,
780            inline_comments: self.inline_comments,
781        }
782    }
783}
784
785// ---------------------------------------------------------------------------
786// Listening
787// ---------------------------------------------------------------------------
788
789/// What `spar listen` does with a pull request somebody put in front of you.
790#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(rename_all = "lowercase")]
792pub enum ReviewOrIgnore {
793    /// Review it, with a verdict when `verdicts` is on.
794    Review,
795    /// Say so in the log and do nothing.
796    Ignore,
797}
798
799impl std::fmt::Display for ReviewOrIgnore {
800    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801        f.write_str(match self {
802            ReviewOrIgnore::Review => "review",
803            ReviewOrIgnore::Ignore => "ignore",
804        })
805    }
806}
807
808/// What `spar listen` does when somebody speaks on a pull request you wrote.
809/// GitHub will not let the same account approve it, so the choice is between
810/// `spar checkin` with and without the power to push.
811#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(rename_all = "lowercase")]
813pub enum OwnPr {
814    /// `spar checkin`: a change both agents agree on is pushed, behind
815    /// `checkin_trust`.
816    Checkin,
817    /// `spar checkin --reply-only`: answered in words, nothing pushed.
818    Reply,
819    /// Say so in the log and do nothing.
820    Ignore,
821}
822
823impl std::fmt::Display for OwnPr {
824    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
825        f.write_str(match self {
826            OwnPr::Checkin => "checkin",
827            OwnPr::Reply => "reply",
828            OwnPr::Ignore => "ignore",
829        })
830    }
831}
832
833/// What `spar listen` does when somebody mentions you on an issue with no pull
834/// request spar reviewed.
835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(rename_all = "lowercase")]
837pub enum OnMention {
838    /// `spar answer`: what both agents agree on and can check is posted, and
839    /// anything that asks you to decide is left for you.
840    Answer,
841    /// The same, saved for `spar post` rather than posted.
842    Draft,
843    /// Say so in the log and do nothing.
844    Ignore,
845}
846
847impl std::fmt::Display for OnMention {
848    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
849        f.write_str(match self {
850            OnMention::Answer => "answer",
851            OnMention::Draft => "draft",
852            OnMention::Ignore => "ignore",
853        })
854    }
855}
856
857/// What `spar listen` watches for and what it does about it. As with `LoopCfg`,
858/// every field takes its value from `ListenCfg::default()` when a config does
859/// not mention it.
860#[derive(Debug, Clone, Serialize, Deserialize)]
861#[serde(default, deny_unknown_fields)]
862pub struct ListenCfg {
863    /// Seconds between polls of GitHub's notifications for the repository.
864    /// GitHub says how often it will answer, and the larger of the two is used.
865    pub interval: u64,
866    /// Seconds between sweeps of the open pull requests. The sweep sees what a
867    /// notification can miss: a review asked for again, an assignment, a
868    /// close, or a push, which raises no notification for a reviewer.
869    pub sweep: u64,
870    /// Seconds an item has to stay quiet before spar acts on it, so a review
871    /// request and three comments in the same minute cost one recheck rather
872    /// than four.
873    pub settle: u64,
874    /// Most seconds an item may keep settling before it is acted on anyway.
875    pub max_wait: u64,
876    /// Rechecks one pull request gets before a person has to run `spar review`
877    /// on it again. A cap against two bots arguing, not a target. 0 turns
878    /// automatic rechecks off.
879    pub max_rechecks: u32,
880    pub on_review_requested: ReviewOrIgnore,
881    pub on_assigned: ReviewOrIgnore,
882    pub on_own_pr: OwnPr,
883    /// Whether a review `spar listen` starts submits a formal verdict, approve
884    /// or request changes, rather than a comment. A recheck always does.
885    pub verdicts: bool,
886    pub on_mention: OnMention,
887    /// Times `spar listen` runs `spar answer` on one issue before a person has
888    /// to run it by hand. A cap against two bots talking to each other, not a
889    /// target. 0 turns automatic answers off.
890    pub max_answers: u32,
891}
892
893impl Default for ListenCfg {
894    fn default() -> Self {
895        Self {
896            interval: 60,
897            sweep: 300,
898            settle: 120,
899            max_wait: 900,
900            max_rechecks: 5,
901            on_review_requested: ReviewOrIgnore::Review,
902            on_assigned: ReviewOrIgnore::Review,
903            on_own_pr: OwnPr::Checkin,
904            verdicts: true,
905            on_mention: OnMention::Answer,
906            max_answers: 3,
907        }
908    }
909}
910
911// ---------------------------------------------------------------------------
912// The whole config
913// ---------------------------------------------------------------------------
914
915#[derive(Debug, Clone)]
916pub struct Config {
917    /// In declaration order, which is what `first_implementor` defaults to.
918    pub agents: Vec<AgentSpec>,
919    pub loop_cfg: LoopCfg,
920    pub style: Style,
921    pub listen: ListenCfg,
922    /// Resolved: never empty, always one of the configured agents. Under the
923    /// alternating policy this is who goes first on the first issue.
924    pub first_implementor: String,
925    /// Whether the first implementor swaps from issue to issue.
926    pub alternate_first: bool,
927    /// Where this config was read from, for error messages.
928    pub source: Option<PathBuf>,
929}
930
931impl Config {
932    pub fn agent_names(&self) -> Vec<String> {
933        self.agents.iter().map(|a| a.name.clone()).collect()
934    }
935
936    pub fn has_agent(&self, name: &str) -> bool {
937        self.agents.iter().any(|a| a.name == name)
938    }
939
940    pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
941        self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
942            spar_err!(
943                "no agent named '{name}' ({})",
944                self.agent_names().join(", ")
945            )
946        })
947    }
948
949    /// The other agent. With exactly two configured, custody alternates by
950    /// definition.
951    pub fn other(&self, name: &str) -> String {
952        let names = self.agent_names();
953        if names.first().map(String::as_str) == Some(name) {
954            names.get(1).cloned().unwrap_or_else(|| name.to_string())
955        } else {
956            names.first().cloned().unwrap_or_else(|| name.to_string())
957        }
958    }
959
960    /// What effort one agent is asked for on one call.
961    ///
962    /// Four places, most specific first: this agent's own schedule, the shared
963    /// schedule, the old `round_1`/`rest` pair, and the agent's plain `effort`.
964    /// The per agent layer is what makes a schedule expressible at all when the
965    /// two CLIs do not share a vocabulary: `ultra` is not a value the claude
966    /// preset accepts, and `max` means different things to each.
967    pub fn effort_for(&self, spec: &AgentSpec, call: Call) -> Option<String> {
968        spec.effort_schedule
969            .get(call)
970            .or_else(|| self.loop_cfg.effort_schedule.get(call))
971            .or_else(|| self.loop_cfg.effort_schedule.legacy(call))
972            .or_else(|| spec.effort.clone())
973    }
974
975    /// Round 1 gets the deep pass; later rounds only see a small delta, and a
976    /// full ultra review of a three line delta is money on fire.
977    pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
978        self.effort_for(spec, Call::Review(round))
979    }
980
981    /// Who writes the first draft of the nth issue this run works.
982    ///
983    /// Fixed unless the policy is to alternate, in which case it swaps every
984    /// issue, so both models author and both models review first over a queue.
985    pub fn first_implementor_for(&self, nth: usize) -> String {
986        if !self.alternate_first || nth % 2 == 0 {
987            return self.first_implementor.clone();
988        }
989        self.other(&self.first_implementor)
990    }
991
992    /// What to print when somebody asks who goes first.
993    pub fn first_implementor_policy(&self) -> String {
994        if self.alternate_first {
995            format!("alternating, {} first", self.first_implementor)
996        } else {
997            self.first_implementor.clone()
998        }
999    }
1000
1001    pub fn base_branch(&self) -> &str {
1002        &self.loop_cfg.base_branch
1003    }
1004}
1005
1006#[derive(Debug, Deserialize)]
1007#[serde(deny_unknown_fields)]
1008struct RawConfig {
1009    #[serde(default)]
1010    agents: toml::Table,
1011    #[serde(default)]
1012    #[serde(rename = "loop")]
1013    loop_cfg: Option<LoopCfg>,
1014    #[serde(default)]
1015    style: Option<StyleCfg>,
1016    #[serde(default)]
1017    listen: Option<ListenCfg>,
1018}
1019
1020// ---------------------------------------------------------------------------
1021// Presets
1022// ---------------------------------------------------------------------------
1023
1024/// Directories searched for preset overrides, nearest first.
1025///
1026/// Deliberately *not* a bare `presets/`. spar runs from inside the user's own
1027/// repository, where `presets/` is a perfectly ordinary directory name for
1028/// something unrelated (sampler configs, prompt libraries, editor themes), and
1029/// a stray `presets/claude.toml` shadowing the built in preset produces a
1030/// baffling failure: `spar init` reports Claude Code as missing while it sits
1031/// on PATH. Overrides live somewhere that names spar.
1032pub fn preset_dirs() -> Vec<PathBuf> {
1033    let mut dirs = Vec::new();
1034    if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
1035        dirs.push(PathBuf::from(custom));
1036    }
1037    dirs.push(PathBuf::from(".spar").join("presets"));
1038    if let Some(home) = home_dir() {
1039        dirs.push(home.join(".config").join("spar").join("presets"));
1040    }
1041    dirs
1042}
1043
1044/// Every preset name available, built in and on disk, sorted.
1045pub fn available_presets() -> Vec<String> {
1046    let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
1047    for dir in preset_dirs() {
1048        if let Ok(entries) = std::fs::read_dir(&dir) {
1049            for entry in entries.flatten() {
1050                let path = entry.path();
1051                if path.extension().and_then(|e| e.to_str()) == Some("toml") {
1052                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
1053                        names.push(stem.to_string());
1054                    }
1055                }
1056            }
1057        }
1058    }
1059    names.sort();
1060    names.dedup();
1061    names
1062}
1063
1064/// Parse a whole TOML document into a `Value`.
1065///
1066/// `"...".parse::<Value>()` parses a single TOML *value*, not a document, so it
1067/// rejects the first comment line of every preset.
1068fn parse_document(text: &str, what: &str) -> Result<Value> {
1069    let table: toml::Table =
1070        toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
1071    Ok(Value::Table(table))
1072}
1073
1074/// Load a preset. A file on disk wins over the built in copy of the same name,
1075/// so a drifting CLI can be corrected without waiting for a release.
1076pub fn load_preset(name: &str) -> Result<Value> {
1077    for dir in preset_dirs() {
1078        let path = dir.join(format!("{name}.toml"));
1079        if path.is_file() {
1080            let text = std::fs::read_to_string(&path)
1081                .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
1082            return parse_document(&text, &format!("preset {}", path.display()));
1083        }
1084    }
1085    for (builtin, text) in BUILTIN_PRESETS {
1086        if *builtin == name {
1087            return parse_document(text, &format!("built in preset {name}"));
1088        }
1089    }
1090    Err(spar_err!(
1091        "unknown preset '{name}'. Available: {}",
1092        available_presets().join(", ")
1093    ))
1094}
1095
1096/// Deep merge, with `over` winning. Used so a config block can override one
1097/// field of a preset without restating the whole command template.
1098fn merge(base: &Value, over: &Value) -> Value {
1099    match (base, over) {
1100        (Value::Table(b), Value::Table(o)) => {
1101            let mut out = b.clone();
1102            for (key, value) in o {
1103                let merged = match out.get(key) {
1104                    Some(existing) => merge(existing, value),
1105                    None => value.clone(),
1106                };
1107                out.insert(key.clone(), merged);
1108            }
1109            Value::Table(out)
1110        }
1111        _ => over.clone(),
1112    }
1113}
1114
1115fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
1116    let table = raw
1117        .as_table()
1118        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
1119
1120    let merged = match table.get("preset").and_then(Value::as_str) {
1121        Some(preset) => merge(&load_preset(preset)?, raw),
1122        None => raw.clone(),
1123    };
1124
1125    let mut merged_table = merged
1126        .as_table()
1127        .cloned()
1128        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
1129    merged_table.remove("preset");
1130    // Lifted out before the spec is deserialized: a fallback is a whole agent,
1131    // preset and all, and only this function knows how to resolve a preset.
1132    let fallback_raw = merged_table.remove("fallback");
1133
1134    if !merged_table.contains_key("command") {
1135        bail!(
1136            "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
1137            available_presets().join(", ")
1138        );
1139    }
1140
1141    let mut spec: AgentSpec = Value::Table(merged_table)
1142        .try_into()
1143        .map_err(|e| spar_err!("agent '{name}': {e}"))?;
1144    spec.name = name.to_string();
1145
1146    if spec.command.is_empty() {
1147        bail!("agent '{name}' has an empty command");
1148    }
1149    if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
1150        bail!("agent '{name}': the first command element must be the program name, not a group");
1151    }
1152    if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
1153        bail!(
1154            "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
1155        );
1156    }
1157
1158    if let Some(raw) = fallback_raw {
1159        if !raw.is_table() {
1160            bail!(
1161                "agent '{name}': fallback is a whole agent, so write it as a table:\n                   [agents.{name}.fallback]\n  preset = \"cursor\""
1162            );
1163        }
1164        // Named for the env override it answers to, SPAR_<NAME>_FALLBACK_BIN,
1165        // and so a log line says which agent stood in for which.
1166        let backup = build_spec(&format!("{name}-fallback"), &raw)?;
1167        if backup.fallback.is_some() {
1168            bail!(
1169                "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
1170                 another full timeout on a call that has already failed once."
1171            );
1172        }
1173        spec.fallback = Some(Box::new(backup));
1174    }
1175
1176    Ok(spec)
1177}
1178
1179// ---------------------------------------------------------------------------
1180// Loading
1181// ---------------------------------------------------------------------------
1182
1183/// One option the parser accepts, with the value it takes when unset.
1184#[derive(Debug, Clone)]
1185pub struct OptionInfo {
1186    pub section: &'static str,
1187    pub key: String,
1188    pub default: String,
1189}
1190
1191/// Every option a config file may set, with its default.
1192///
1193/// Derived from the defaults themselves rather than written out by hand, so an
1194/// option added to the code cannot go missing here. That is what lets `doctor`
1195/// tell somebody upgrading which settings are new since they wrote their file.
1196pub fn known_options() -> Vec<OptionInfo> {
1197    fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
1198        toml::to_string(value)
1199            .unwrap_or_default()
1200            .lines()
1201            .filter_map(|line| line.split_once(" = "))
1202            .map(|(key, default)| OptionInfo {
1203                section,
1204                key: key.trim().to_string(),
1205                default: default.trim().to_string(),
1206            })
1207            .collect()
1208    }
1209    let mut out = lines("loop", &LoopCfg::default());
1210    out.extend(lines("style", &StyleCfg::default()));
1211    out.extend(lines(
1212        "loop.effort_schedule",
1213        &EffortSchedule {
1214            round_1: Some("high".into()),
1215            rest: Some("low".into()),
1216            code: Some("high".into()),
1217            triage: Some("low".into()),
1218            implement: Some("high".into()),
1219            review_1: Some("high".into()),
1220            review_rest: Some("low".into()),
1221            respond: Some("high".into()),
1222            close: Some("low".into()),
1223            screen: Some("low".into()),
1224            checkin: Some("high".into()),
1225            split: Some("high".into()),
1226            brainstorm: Some("high".into()),
1227            recheck: Some("low".into()),
1228            answer: Some("high".into()),
1229        },
1230    ));
1231    out.extend(lines("listen", &ListenCfg::default()));
1232    out
1233}
1234
1235/// Whether a config file mentions an option at all, set or commented out.
1236pub fn mentions(config_text: &str, key: &str) -> bool {
1237    config_text.lines().any(|line| {
1238        let bare = line.trim_start().trim_start_matches('#').trim_start();
1239        bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1240    })
1241}
1242
1243/// Options this config file has never heard of, which is what somebody who
1244/// upgraded wants to know.
1245pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
1246    known_options()
1247        .into_iter()
1248        .filter(|o| !mentions(config_text, &o.key))
1249        .collect()
1250}
1251
1252pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
1253
1254/// Find a config: an explicit path, then the working directory, then
1255/// `~/.config/spar/spar.toml`.
1256pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1257    if let Some(path) = explicit {
1258        if !path.is_file() {
1259            bail!("config not found: {}", path.display());
1260        }
1261        return Ok(Some(path.to_path_buf()));
1262    }
1263    for name in CONFIG_NAMES {
1264        let path = PathBuf::from(name);
1265        if path.is_file() {
1266            return Ok(Some(path));
1267        }
1268    }
1269    if let Some(home) = home_dir() {
1270        let path = home.join(".config").join("spar").join("spar.toml");
1271        if path.is_file() {
1272            return Ok(Some(path));
1273        }
1274    }
1275    Ok(None)
1276}
1277
1278pub fn load(explicit: Option<&Path>) -> Result<Config> {
1279    let Some(path) = find_config(explicit)? else {
1280        bail!(
1281            "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
1282        );
1283    };
1284    let text = std::fs::read_to_string(&path)
1285        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1286    let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
1287    cfg.source = Some(path);
1288    Ok(cfg)
1289}
1290
1291pub fn parse(text: &str) -> Result<Config> {
1292    let raw: RawConfig = toml::from_str(text)?;
1293
1294    if raw.agents.len() != 2 {
1295        bail!(
1296            "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
1297            raw.agents.len()
1298        );
1299    }
1300
1301    let mut agents = Vec::new();
1302    for (name, value) in raw.agents.iter() {
1303        agents.push(build_spec(name, value)?);
1304    }
1305
1306    let loop_cfg = raw.loop_cfg.unwrap_or_default();
1307    let style = raw.style.unwrap_or_default().to_style();
1308    let listen = raw.listen.unwrap_or_default();
1309
1310    if loop_cfg.max_rounds == 0 {
1311        bail!("max_rounds must be at least 1");
1312    }
1313    if listen.settle > listen.max_wait {
1314        bail!(
1315            "[listen] settle ({}) cannot be longer than max_wait ({}): an item that kept \
1316             changing would never be acted on. Raise max_wait or lower settle.",
1317            listen.settle,
1318            listen.max_wait
1319        );
1320    }
1321    if listen.interval == 0 || listen.sweep == 0 {
1322        bail!(
1323            "[listen] interval and sweep are seconds between polls and must be at least 1. \
1324             Polling never goes below 15 seconds whatever they say."
1325        );
1326    }
1327    // A draft cannot be merged, so merging one means promoting it first, which
1328    // is the one thing `always` asks spar not to do. Refusing is better than
1329    // picking a winner: either setting alone is coherent and only somebody who
1330    // set both can say which they meant.
1331    if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
1332        bail!(
1333            "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
1334             ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
1335             to have it promoted when the review converges, or turn auto_merge off."
1336        );
1337    }
1338
1339    // "alternate" is not an agent name, it is a policy: whoever is not the
1340    // agent that wrote the last first draft. Roles alternate within a pull
1341    // request and never between them, so over a queue of twenty issues one
1342    // model writes every first draft and the other reviews every one of them.
1343    // A model's blind spots as an author are systematic, and the ones the pair
1344    // shares go through every time.
1345    let alternate = loop_cfg
1346        .first_implementor
1347        .as_deref()
1348        .is_some_and(|name| name.trim().eq_ignore_ascii_case("alternate"));
1349    let first = match &loop_cfg.first_implementor {
1350        Some(name) if !name.trim().is_empty() && !alternate => name.trim().to_string(),
1351        _ => agents[0].name.clone(),
1352    };
1353    if !agents.iter().any(|a| a.name == first) {
1354        bail!(
1355            "first_implementor '{first}' is not a configured agent ({})",
1356            agents
1357                .iter()
1358                .map(|a| a.name.as_str())
1359                .collect::<Vec<_>>()
1360                .join(", ")
1361        );
1362    }
1363
1364    Ok(Config {
1365        agents,
1366        loop_cfg,
1367        style,
1368        listen,
1369        first_implementor: first,
1370        alternate_first: alternate,
1371        source: None,
1372    })
1373}
1374
1375/// Resolve a configured search path, expanding a leading `~`.
1376pub fn resolve_search_path(raw: &str) -> PathBuf {
1377    expand_tilde(raw)
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382    use super::*;
1383
1384    const TWO_AGENTS: &str = r#"
1385[agents.claude]
1386preset = "claude"
1387model = "fable"
1388
1389[agents.codex]
1390preset = "codex"
1391model = "gpt-5.6-sol"
1392"#;
1393
1394    // -- fallback --------------------------------------------------------
1395
1396    #[test]
1397    fn a_fallback_is_a_whole_agent_with_its_own_preset() {
1398        let text = format!(
1399            "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
1400        );
1401        let cfg = parse(&text).expect("parses");
1402        // Still a pair. A backup is not a third opinion.
1403        assert_eq!(2, cfg.agents.len());
1404        let codex = cfg.spec("codex").expect("codex");
1405        let backup = codex.fallback.as_ref().expect("fallback");
1406        assert_eq!("codex-fallback", backup.name);
1407        assert_eq!(Some("kimi-k3"), backup.model.as_deref());
1408        assert_eq!(
1409            Some(&CommandPart::One("cursor-agent".into())),
1410            backup.command.first()
1411        );
1412    }
1413
1414    #[test]
1415    fn the_agent_without_a_fallback_does_not_grow_one() {
1416        let cfg = parse(TWO_AGENTS).expect("parses");
1417        assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
1418    }
1419
1420    #[test]
1421    fn a_fallback_may_not_have_one_of_its_own() {
1422        let text = format!(
1423            "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
1424             [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
1425        );
1426        let err = parse(&text).expect_err("rejected");
1427        assert!(err.message().contains("may not have a fallback"), "{err}");
1428    }
1429
1430    #[test]
1431    fn a_fallback_written_as_a_string_says_what_it_should_be() {
1432        let text = "[agents.claude]\npreset = \"claude\"\n\n\
1433                    [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
1434        let err = parse(text).expect_err("rejected");
1435        assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
1436    }
1437
1438    /// A block that names one setting keeps the defaults for every setting it
1439    /// did not name. That is what the container level serde default buys: each
1440    /// field used to carry its own default function repeating a number that
1441    /// also lived in `Default`, and the two copies stopped agreeing.
1442    #[test]
1443    fn a_partial_block_keeps_the_defaults_it_did_not_name() {
1444        let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
1445        let cfg = parse(&text).expect("parses");
1446
1447        assert_eq!(9, cfg.loop_cfg.max_rounds);
1448        assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
1449        assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
1450
1451        assert!(!cfg.style.terse);
1452        assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
1453        assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
1454    }
1455
1456    /// The budgets are decided in `Style` and read from there by the config
1457    /// layer. When they were written out in both places they drifted, and the
1458    /// generated config offered the older set for months.
1459    #[test]
1460    fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
1461        assert_eq!(Style::default(), StyleCfg::default().to_style());
1462    }
1463
1464    // -- drafts ------------------------------------------------------------
1465
1466    #[test]
1467    fn pull_requests_are_not_drafts_unless_asked_for() {
1468        assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1469    }
1470
1471    #[test]
1472    fn each_draft_setting_parses() {
1473        for (text, want) in [
1474            ("never", Drafts::Never),
1475            ("until_approved", Drafts::UntilApproved),
1476            ("always", Drafts::Always),
1477        ] {
1478            let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1479                .unwrap_or_else(|e| panic!("{text}: {e}"));
1480            assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1481        }
1482    }
1483
1484    /// Merging a draft means marking it ready, which is the one thing `always`
1485    /// asks spar not to do. Either setting alone is coherent, so refusing beats
1486    /// picking a winner between them.
1487    #[test]
1488    fn auto_merge_and_a_permanent_draft_are_refused_together() {
1489        let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1490        let err = parse(&text).expect_err("refused");
1491        assert!(err.message().contains("auto_merge"), "{err}");
1492        assert!(
1493            err.message().contains("until_approved"),
1494            "says the way out: {err}"
1495        );
1496    }
1497
1498    /// The pairing that does make sense: the draft clears when the review
1499    /// converges, and then it can merge.
1500    #[test]
1501    fn auto_merge_is_fine_with_a_draft_that_clears() {
1502        let text =
1503            format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1504        assert!(parse(&text).is_ok());
1505    }
1506
1507    #[test]
1508    fn every_builtin_preset_parses() {
1509        for (name, _) in BUILTIN_PRESETS {
1510            let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1511            assert!(value.get("command").is_some(), "{name} has no command");
1512        }
1513    }
1514
1515    #[test]
1516    fn every_builtin_preset_builds_a_spec() {
1517        for (name, _) in BUILTIN_PRESETS {
1518            let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1519            build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1520        }
1521    }
1522
1523    /// `--allowedTools` is variadic, so the separate form swallows the
1524    /// following positional prompt unless another flag happens to sit between
1525    /// them. The equals form is not cosmetic.
1526    #[test]
1527    fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1528        let spec = build_spec(
1529            "claude",
1530            &parse_document("preset = \"claude\"", "test").unwrap(),
1531        )
1532        .unwrap();
1533        let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1534        assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1535        assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1536    }
1537
1538    #[test]
1539    fn codex_preset_declares_where_its_answer_lives() {
1540        let spec = build_spec(
1541            "codex",
1542            &parse_document("preset = \"codex\"", "test").unwrap(),
1543        )
1544        .unwrap();
1545        assert_eq!(OutputMode::Jsonl, spec.output);
1546        assert_eq!(Some("item.text"), spec.message_path.as_deref());
1547        assert!(!spec.message_match.is_empty());
1548    }
1549
1550    /// ChatGPT 26.924 moved the bundled CLI from `Resources/codex` to
1551    /// `Resources/codex-cli/bin/codex`. An app not yet updated still has the
1552    /// old one, so both are searched, the current layout first.
1553    #[test]
1554    fn codex_preset_looks_where_either_chatgpt_layout_keeps_the_cli() {
1555        let spec = build_spec(
1556            "codex",
1557            &parse_document("preset = \"codex\"", "test").unwrap(),
1558        )
1559        .unwrap();
1560        let at = |path: &str| spec.search_paths.iter().position(|p| p == path);
1561        let current = at("/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin");
1562        let older = at("/Applications/ChatGPT.app/Contents/Resources");
1563        assert!(current.is_some(), "{:?}", spec.search_paths);
1564        assert!(older.is_some(), "{:?}", spec.search_paths);
1565        assert!(current < older, "{:?}", spec.search_paths);
1566    }
1567
1568    #[test]
1569    fn agent_order_follows_declaration_order() {
1570        let cfg = parse(TWO_AGENTS).unwrap();
1571        assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1572        assert_eq!("claude", cfg.first_implementor);
1573    }
1574
1575    #[test]
1576    fn other_alternates() {
1577        let cfg = parse(TWO_AGENTS).unwrap();
1578        assert_eq!("codex", cfg.other("claude"));
1579        assert_eq!("claude", cfg.other("codex"));
1580    }
1581
1582    #[test]
1583    fn a_config_block_overrides_one_preset_field() {
1584        let cfg = parse(TWO_AGENTS).unwrap();
1585        let claude = cfg.spec("claude").unwrap();
1586        assert_eq!(Some("fable"), claude.model.as_deref());
1587        assert!(claude.command.len() > 1, "the preset command survived");
1588    }
1589
1590    #[test]
1591    fn exactly_two_agents_are_required() {
1592        let one = "[agents.claude]\npreset = \"claude\"\n";
1593        assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1594    }
1595
1596    #[test]
1597    fn an_unknown_agent_option_is_named() {
1598        let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1599        let err = parse(text).unwrap_err().to_string();
1600        assert!(err.contains("widget"), "{err}");
1601    }
1602
1603    #[test]
1604    fn an_unknown_loop_option_is_named() {
1605        let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1606        let err = parse(&text).unwrap_err().to_string();
1607        assert!(err.contains("max_round"), "{err}");
1608    }
1609
1610    #[test]
1611    fn an_unknown_listen_option_is_named() {
1612        let text = format!("{TWO_AGENTS}\n[listen]\ninterval_secs = 30\n");
1613        let err = parse(&text).unwrap_err().to_string();
1614        assert!(err.contains("interval_secs"), "{err}");
1615    }
1616
1617    #[test]
1618    fn a_listen_block_keeps_the_defaults_it_did_not_name() {
1619        let text = format!("{TWO_AGENTS}\n[listen]\nsweep = 30\n");
1620        let cfg = parse(&text).expect("parses");
1621        assert_eq!(30, cfg.listen.sweep);
1622        assert_eq!(ListenCfg::default().interval, cfg.listen.interval);
1623        assert_eq!(ListenCfg::default().settle, cfg.listen.settle);
1624        assert_eq!(OwnPr::Checkin, cfg.listen.on_own_pr);
1625        assert!(cfg.listen.verdicts);
1626
1627        let bare = parse(TWO_AGENTS).unwrap();
1628        assert_eq!(60, bare.listen.interval);
1629        assert_eq!(300, bare.listen.sweep);
1630        assert_eq!(120, bare.listen.settle);
1631        assert_eq!(900, bare.listen.max_wait);
1632        assert_eq!(5, bare.listen.max_rechecks);
1633        assert_eq!(ReviewOrIgnore::Review, bare.listen.on_review_requested);
1634        assert_eq!(ReviewOrIgnore::Review, bare.listen.on_assigned);
1635    }
1636
1637    #[test]
1638    fn settle_longer_than_max_wait_is_refused_with_a_reason() {
1639        let text = format!("{TWO_AGENTS}\n[listen]\nsettle = 1000\nmax_wait = 900\n");
1640        let err = parse(&text).unwrap_err().to_string();
1641        assert!(err.contains("settle"), "{err}");
1642        assert!(err.contains("Raise max_wait"), "{err}");
1643
1644        let text = format!("{TWO_AGENTS}\n[listen]\ninterval = 0\n");
1645        let err = parse(&text).unwrap_err().to_string();
1646        assert!(err.contains("at least 1"), "{err}");
1647    }
1648
1649    #[test]
1650    fn each_listen_action_word_parses() {
1651        for (text, want) in [
1652            ("review", ReviewOrIgnore::Review),
1653            ("ignore", ReviewOrIgnore::Ignore),
1654        ] {
1655            let cfg = parse(&format!(
1656                "{TWO_AGENTS}\n[listen]\non_review_requested = \"{text}\"\non_assigned = \"{text}\"\n"
1657            ))
1658            .unwrap();
1659            assert_eq!(want, cfg.listen.on_review_requested);
1660            assert_eq!(want, cfg.listen.on_assigned);
1661            assert_eq!(text, want.to_string());
1662        }
1663        for (text, want) in [
1664            ("checkin", OwnPr::Checkin),
1665            ("reply", OwnPr::Reply),
1666            ("ignore", OwnPr::Ignore),
1667        ] {
1668            let cfg = parse(&format!("{TWO_AGENTS}\n[listen]\non_own_pr = \"{text}\"\n")).unwrap();
1669            assert_eq!(want, cfg.listen.on_own_pr);
1670            assert_eq!(text, want.to_string());
1671        }
1672        for (text, want) in [
1673            ("answer", OnMention::Answer),
1674            ("draft", OnMention::Draft),
1675            ("ignore", OnMention::Ignore),
1676        ] {
1677            let cfg = parse(&format!(
1678                "{TWO_AGENTS}\n[listen]\non_mention = \"{text}\"\n"
1679            ))
1680            .unwrap();
1681            assert_eq!(want, cfg.listen.on_mention);
1682            assert_eq!(text, want.to_string());
1683        }
1684        assert_eq!(OnMention::Answer, ListenCfg::default().on_mention);
1685        assert_eq!(3, ListenCfg::default().max_answers);
1686        let err = parse(&format!("{TWO_AGENTS}\n[listen]\non_own_pr = \"push\"\n"))
1687            .unwrap_err()
1688            .to_string();
1689        assert!(err.contains("on_own_pr") || err.contains("push"), "{err}");
1690    }
1691
1692    #[test]
1693    fn a_recheck_reads_its_own_key_before_review_rest_and_falls_back_to_it() {
1694        let text = format!(
1695            "{TWO_AGENTS}\n[loop.effort_schedule]\nreview_rest = \"low\"\nrecheck = \"high\"\n"
1696        );
1697        let cfg = parse(&text).unwrap();
1698        let spec = cfg.spec("claude").unwrap();
1699        assert_eq!(
1700            Some("high".to_string()),
1701            cfg.effort_for(spec, Call::Recheck)
1702        );
1703
1704        let text = format!("{TWO_AGENTS}\n[loop.effort_schedule]\nreview_rest = \"low\"\n");
1705        let cfg = parse(&text).unwrap();
1706        let spec = cfg.spec("claude").unwrap();
1707        assert_eq!(Some("low".to_string()), cfg.effort_for(spec, Call::Recheck));
1708
1709        let text = format!(
1710            "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"medium\"\n"
1711        );
1712        let cfg = parse(&text).unwrap();
1713        let spec = cfg.spec("claude").unwrap();
1714        assert_eq!(
1715            Some("medium".to_string()),
1716            cfg.effort_for(spec, Call::Recheck)
1717        );
1718    }
1719
1720    #[test]
1721    fn every_call_kind_is_in_every() {
1722        let every = Call::every();
1723        assert_eq!(13, every.len());
1724        assert!(every.contains(&Call::Code));
1725        assert!(every.contains(&Call::Recheck));
1726        assert!(every.contains(&Call::Answer(1)));
1727        assert_eq!("recheck", Call::Recheck.key());
1728        assert_eq!("answer", Call::Answer(2).key());
1729    }
1730
1731    #[test]
1732    fn an_agent_with_no_command_and_no_preset_is_rejected() {
1733        let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1734        let err = parse(text).unwrap_err().to_string();
1735        assert!(err.contains("no command and no preset"), "{err}");
1736    }
1737
1738    #[test]
1739    fn jsonl_without_a_message_path_is_rejected() {
1740        let text =
1741            "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1742        let err = parse(text).unwrap_err().to_string();
1743        assert!(err.contains("message_path"), "{err}");
1744    }
1745
1746    /// One agent wrote every first draft, so half the pair's diversity went
1747    /// unused: a model's blind spots as an author are systematic, and the ones
1748    /// the two share go through every time.
1749    /// The one claim a report makes that nothing else checks.
1750    #[test]
1751    fn a_check_is_read_as_an_argv_either_way_it_is_written() {
1752        let line = format!("{TWO_AGENTS}\n[loop]\ncheck = \"cargo test --all\"\n");
1753        let cfg = parse(&line).unwrap();
1754        assert!(cfg.loop_cfg.check.is_set());
1755        assert_eq!(vec!["cargo", "test", "--all"], cfg.loop_cfg.check.argv());
1756        assert_eq!("cargo test --all", cfg.loop_cfg.check.describe());
1757
1758        let listed =
1759            format!("{TWO_AGENTS}\n[loop]\ncheck = [\"npm\", \"run\", \"test -- --ci\"]\n");
1760        let cfg = parse(&listed).unwrap();
1761        assert_eq!(
1762            vec!["npm", "run", "test -- --ci"],
1763            cfg.loop_cfg.check.argv(),
1764            "a list is taken as it is, spaces and all"
1765        );
1766
1767        // Unset is the default, and runs nothing.
1768        let bare = parse(TWO_AGENTS).unwrap();
1769        assert!(!bare.loop_cfg.check.is_set());
1770        assert!(bare.loop_cfg.check.argv().is_empty());
1771    }
1772
1773    #[test]
1774    fn a_setup_is_read_like_a_check_and_runs_nothing_unset() {
1775        let line = format!("{TWO_AGENTS}\n[loop]\nsetup = \"npm ci --no-audit\"\n");
1776        let cfg = parse(&line).unwrap();
1777        assert_eq!(vec!["npm", "ci", "--no-audit"], cfg.loop_cfg.setup.argv());
1778        assert!(
1779            !cfg.loop_cfg.check.is_set(),
1780            "setting up a worktree is not checking it"
1781        );
1782
1783        let bare = parse(TWO_AGENTS).unwrap();
1784        assert!(!bare.loop_cfg.setup.is_set());
1785    }
1786
1787    #[test]
1788    fn alternating_swaps_the_author_from_issue_to_issue() {
1789        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"alternate\"\n");
1790        let cfg = parse(&text).unwrap();
1791
1792        assert!(cfg.alternate_first);
1793        assert_eq!(
1794            "claude", cfg.first_implementor,
1795            "declaration order still leads"
1796        );
1797        assert_eq!("claude", cfg.first_implementor_for(0));
1798        assert_eq!("codex", cfg.first_implementor_for(1));
1799        assert_eq!("claude", cfg.first_implementor_for(2));
1800        assert!(cfg.first_implementor_policy().contains("alternating"));
1801    }
1802
1803    /// Naming an agent still pins it, which is what --first does for a run.
1804    #[test]
1805    fn a_named_first_implementor_never_swaps() {
1806        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"codex\"\n");
1807        let cfg = parse(&text).unwrap();
1808        assert!(!cfg.alternate_first);
1809        for nth in 0..4 {
1810            assert_eq!("codex", cfg.first_implementor_for(nth));
1811        }
1812        assert_eq!("codex", cfg.first_implementor_policy());
1813    }
1814
1815    #[test]
1816    fn first_implementor_must_name_a_configured_agent() {
1817        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1818        let err = parse(&text).unwrap_err().to_string();
1819        assert!(err.contains("not a configured agent"), "{err}");
1820    }
1821
1822    #[test]
1823    fn defaults_are_the_conservative_ones() {
1824        let cfg = parse(TWO_AGENTS).unwrap();
1825        assert!(
1826            !cfg.loop_cfg.auto_merge,
1827            "auto_merge must be off by default"
1828        );
1829        assert!(cfg.loop_cfg.worktrees);
1830        assert!(
1831            !cfg.loop_cfg.file_nits,
1832            "a filed nit is somebody else's triage queue"
1833        );
1834        assert_eq!(3, cfg.loop_cfg.max_rounds);
1835        assert_eq!(
1836            Followups::Local,
1837            cfg.loop_cfg.followups,
1838            "the tracker is somebody's queue; the default must not write to it"
1839        );
1840        assert!(
1841            !cfg.loop_cfg.file_non_blocking,
1842            "a suggestion is not a tracker item"
1843        );
1844        assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1845        assert!(cfg.style.terse);
1846    }
1847
1848    #[test]
1849    fn effort_schedule_splits_round_one_from_the_rest() {
1850        let text =
1851            format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1852        let cfg = parse(&text).unwrap();
1853        let spec = cfg.spec("claude").unwrap();
1854        assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1855        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1856        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1857    }
1858
1859    /// The schedule is one string handed to whichever agent holds the round,
1860    /// and the two CLIs do not share a vocabulary.
1861    ///
1862    /// The README's own example pairs round_1 = "ultra" with a claude agent at
1863    /// effort = "high", and ultra is not a value the claude preset accepts, so
1864    /// the first review failed at the CLI whenever claude reviewed first.
1865    #[test]
1866    fn an_agent_can_carry_its_own_schedule_in_its_own_words() {
1867        let text = format!(
1868            "{TWO_AGENTS}\n[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
1869             [loop.effort_schedule]\nreview_1 = \"ultra\"\n"
1870        );
1871        let cfg = parse(&text).unwrap();
1872        assert_eq!(
1873            Some("xhigh".into()),
1874            cfg.effort_for(cfg.spec("claude").unwrap(), Call::Review(1)),
1875            "the agent's own schedule has to win"
1876        );
1877        assert_eq!(
1878            Some("ultra".into()),
1879            cfg.effort_for(cfg.spec("codex").unwrap(), Call::Review(1))
1880        );
1881    }
1882
1883    /// Every call used to read round_1 or rest by a round number most of them
1884    /// do not have, so round_1 = "ultra" put triage over the whole queue, the
1885    /// implement call and the follow-up screen at ultra too.
1886    #[test]
1887    fn each_kind_of_call_reads_its_own_key_and_falls_back_to_the_old_pair() {
1888        let text = format!(
1889            "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"low\"\n\
1890             triage = \"minimal\"\n"
1891        );
1892        let cfg = parse(&text).unwrap();
1893        let spec = cfg.spec("claude").unwrap();
1894
1895        assert_eq!(Some("minimal".into()), cfg.effort_for(spec, Call::Triage));
1896        // No key of its own: exactly what the two key schedule did.
1897        for call in [Call::Implement, Call::Review(1), Call::Screen] {
1898            assert_eq!(Some("ultra".into()), cfg.effort_for(spec, call), "{call:?}");
1899        }
1900        for call in [Call::Review(2), Call::Close, Call::Respond(3)] {
1901            assert_eq!(Some("low".into()), cfg.effort_for(spec, call), "{call:?}");
1902        }
1903    }
1904
1905    #[test]
1906    fn effort_falls_back_to_the_agents_own_setting() {
1907        let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1908        let cfg = parse(&text).unwrap();
1909        let spec = cfg.spec("codex").unwrap();
1910        assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1911    }
1912
1913    #[test]
1914    fn an_unset_model_and_an_empty_model_normalise_the_same() {
1915        let a = AgentSpec {
1916            name: "a".into(),
1917            command: vec![CommandPart::One("x".into())],
1918            model: None,
1919            effort: None,
1920            effort_schedule: EffortSchedule::default(),
1921            output: OutputMode::Text,
1922            message_match: BTreeMap::new(),
1923            message_path: None,
1924            search_paths: vec![],
1925            system_via: SystemVia::Prompt,
1926            timeout: 60,
1927            fallback: None,
1928            models: vec![],
1929            efforts: vec![],
1930            options_note: None,
1931        };
1932        let b = AgentSpec {
1933            model: Some("  ".into()),
1934            ..a.clone()
1935        };
1936        assert_eq!(a.model_key(), b.model_key());
1937    }
1938
1939    #[test]
1940    fn max_rounds_zero_is_rejected() {
1941        let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1942        assert!(parse(&text).is_err());
1943    }
1944
1945    #[test]
1946    fn an_inline_command_needs_no_preset() {
1947        let text = r#"
1948[agents.custom]
1949command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1950output = "text"
1951
1952[agents.other]
1953command = ["othertool", "{prompt}"]
1954"#;
1955        let cfg = parse(text).unwrap();
1956        assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1957    }
1958
1959    #[test]
1960    fn style_budgets_are_configurable() {
1961        let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1962        let cfg = parse(&text).unwrap();
1963        assert!(!cfg.style.terse);
1964        assert_eq!(40, cfg.style.max_detail_chars);
1965    }
1966}