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.
269///
270/// The default is not timidity. Acting on a comment means a commit pushed to a
271/// branch because somebody typed a sentence, and `authorAssociation` is one
272/// field GitHub already returns on every comment endpoint that says whether
273/// they can write to this repository at all. Everybody is still answered in
274/// words either way; this governs only whether a comment can produce a commit.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "lowercase")]
277pub enum Trust {
278    /// Anybody GitHub says can write here: OWNER, MEMBER, COLLABORATOR.
279    Write,
280    /// Anybody at all. Both agents still have to agree before anything changes.
281    Anyone,
282}
283
284impl std::fmt::Display for Trust {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.write_str(match self {
287            Trust::Write => "write",
288            Trust::Anyone => "anyone",
289        })
290    }
291}
292
293impl Trust {
294    /// Whether a comment from somebody with this association may produce a
295    /// commit.
296    pub fn may_act_on(self, association: &str) -> bool {
297        match self {
298            Trust::Anyone => true,
299            Trust::Write => matches!(
300                association.trim().to_uppercase().as_str(),
301                "OWNER" | "MEMBER" | "COLLABORATOR"
302            ),
303        }
304    }
305}
306
307/// Which call is about to be made.
308///
309/// The schedule used to be two keys, `round_1` and `rest`, chosen by a round
310/// number that most of these calls do not have. Triage of the whole queue, the
311/// implement call, the follow-up screen, the check-in judge and the split
312/// proposer were all "round 1", so `round_1 = "ultra"` put every one of them at
313/// ultra, which is neither what the name says nor what anybody wants to pay
314/// for.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum Call {
317    /// Both agents, over the whole queue, once per wave.
318    Triage,
319    /// Writing the first draft.
320    Implement,
321    /// A review round. The first is the deep one.
322    Review(u32),
323    /// The author answering a review.
324    Respond(u32),
325    /// The closing merge-safety pass.
326    Close,
327    /// The follow-up screen, one call for the whole queue.
328    Screen,
329    /// A check-in call: the judge is round 1, the checker round 2.
330    Checkin(u32),
331    /// A split call: the proposer is round 1, the checker round 2.
332    Split(u32),
333    /// A brainstorm round: both agents diverge in round 1, cross in the
334    /// middle rounds, and converge in the last.
335    Brainstorm(u32),
336}
337
338impl Call {
339    /// The schedule key this call reads.
340    pub fn key(self) -> &'static str {
341        match self {
342            Call::Triage => "triage",
343            Call::Implement => "implement",
344            Call::Review(round) if round <= 1 => "review_1",
345            Call::Review(_) => "review_rest",
346            Call::Respond(_) => "respond",
347            Call::Close => "close",
348            Call::Screen => "screen",
349            Call::Checkin(_) => "checkin",
350            Call::Split(_) => "split",
351            Call::Brainstorm(_) => "brainstorm",
352        }
353    }
354
355    /// What the two key schedule would have used, so a config written before
356    /// the keys existed behaves exactly as it did.
357    fn legacy_round(self) -> u32 {
358        match self {
359            Call::Triage | Call::Implement | Call::Screen => 1,
360            Call::Review(round) | Call::Respond(round) => round,
361            Call::Checkin(round) | Call::Split(round) | Call::Brainstorm(round) => round,
362            Call::Close => 2,
363        }
364    }
365
366    /// Every kind, for `doctor` to check a configuration against.
367    pub fn every() -> [Call; 10] {
368        [
369            Call::Triage,
370            Call::Implement,
371            Call::Review(1),
372            Call::Review(2),
373            Call::Respond(1),
374            Call::Close,
375            Call::Screen,
376            Call::Checkin(1),
377            Call::Split(1),
378            Call::Brainstorm(1),
379        ]
380    }
381}
382
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct EffortSchedule {
386    /// The deep first review.
387    ///
388    /// Kept, and still read by every call that has no key of its own, so an
389    /// existing config keeps working exactly as it did.
390    pub round_1: Option<String>,
391    /// Every round after the first, and the closing pass. Both are asked a
392    /// narrower question than the first review, so neither buys its depth again.
393    pub rest: Option<String>,
394    /// Both agents, over the whole queue. The most expensive call in a run, and
395    /// the one the two key schedule never mentioned.
396    pub triage: Option<String>,
397    pub implement: Option<String>,
398    pub review_1: Option<String>,
399    pub review_rest: Option<String>,
400    pub respond: Option<String>,
401    pub close: Option<String>,
402    pub screen: Option<String>,
403    pub checkin: Option<String>,
404    pub split: Option<String>,
405    /// Every brainstorm round. Diverging is the deep call and the later rounds
406    /// answer narrower questions, but they read the same key: a brainstorm is
407    /// cheap next to a run, and one word for it is enough.
408    pub brainstorm: Option<String>,
409}
410
411impl EffortSchedule {
412    /// What this schedule says about one call, if anything.
413    pub fn get(&self, call: Call) -> Option<String> {
414        let value = match call.key() {
415            "triage" => &self.triage,
416            "implement" => &self.implement,
417            "review_1" => &self.review_1,
418            "review_rest" => &self.review_rest,
419            "respond" => &self.respond,
420            "close" => &self.close,
421            "screen" => &self.screen,
422            "checkin" => &self.checkin,
423            "brainstorm" => &self.brainstorm,
424            _ => &self.split,
425        };
426        value.clone().filter(|s| !s.trim().is_empty())
427    }
428
429    /// What the old two key schedule would have said.
430    fn legacy(&self, call: Call) -> Option<String> {
431        let value = if call.legacy_round() <= 1 {
432            &self.round_1
433        } else {
434            &self.rest
435        };
436        value.clone().filter(|s| !s.trim().is_empty())
437    }
438}
439
440/// A command spar runs itself, before it pushes.
441///
442/// A string is split on whitespace, which covers `cargo test` and `npm test`.
443/// A list is taken as it is, for anything that needs an argument with a space
444/// in it. No shell either way: spar runs the program, so nothing here is
445/// interpreted.
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(untagged)]
448pub enum CheckCommand {
449    Line(String),
450    Argv(Vec<String>),
451}
452
453impl Default for CheckCommand {
454    /// An empty list, which is both "nothing to run" and a value that survives
455    /// a round trip through TOML, unlike a unit variant.
456    fn default() -> Self {
457        CheckCommand::Argv(Vec::new())
458    }
459}
460
461impl CheckCommand {
462    pub fn argv(&self) -> Vec<String> {
463        match self {
464            CheckCommand::Line(line) => line.split_whitespace().map(str::to_string).collect(),
465            CheckCommand::Argv(argv) => argv
466                .iter()
467                .map(|a| a.trim().to_string())
468                .filter(|a| !a.is_empty())
469                .collect(),
470        }
471    }
472
473    pub fn is_set(&self) -> bool {
474        !self.argv().is_empty()
475    }
476
477    /// How the command reads in a log line or a pull request body.
478    pub fn describe(&self) -> String {
479        self.argv().join(" ")
480    }
481}
482
483/// Every field takes its value from `LoopCfg::default()` when a config does not
484/// mention it, rather than from a per-field function saying the same thing in a
485/// second place. Two places is how a default goes stale.
486#[derive(Debug, Clone, Serialize, Deserialize)]
487#[serde(default, deny_unknown_fields)]
488pub struct LoopCfg {
489    /// Review rounds one invocation may spend asking for changes. A run that
490    /// spends the full budget may use one closing pass, which is not a round.
491    pub max_rounds: u32,
492    pub auto_merge: bool,
493    pub first_implementor: Option<String>,
494    pub base_branch: String,
495    pub worktrees: bool,
496    pub keep_worktrees: bool,
497    pub state_store: StateStore,
498    pub branch_prefix: String,
499    pub followups: Followups,
500    /// File a non-blocking finding as a follow-up.
501    ///
502    /// Off by default, and this is the setting that stops a run breeding. A
503    /// thorough reviewer always finds improvements, and turning each one into a
504    /// tracker item made a single issue spawn ten, which spawned more: mean
505    /// offspring above one never terminates. Not gating a merge is not the same
506    /// as being worth somebody's triage queue.
507    pub file_non_blocking: bool,
508    /// Most follow-ups one run may record before it stops and says what it
509    /// dropped. A backstop, not a target.
510    pub max_followups: usize,
511    /// Most parts `spar split` will make out of one issue or pull request.
512    ///
513    /// A backstop, not a target, in the shape of `max_followups`. There is no
514    /// setting to turn splitting off: it is a command somebody types, and a
515    /// setting to disable a command nobody has to run only ever confuses. There
516    /// is no mechanical threshold either, because a floor on files changed
517    /// would split a forty file rename and hold back a three file mess.
518    pub max_split_parts: usize,
519    /// Nits stay in the PR thread by default. A filed nit is somebody else's
520    /// notification: a run on a production codebase once opened an issue titled
521    /// "Log wording".
522    pub file_nits: bool,
523    /// Close an issue that both agents independently declined, after posting
524    /// the shared reasoning. One agent's opinion is never enough.
525    pub close_skipped: bool,
526    /// Ask both agents to triage at the same time. They only read during
527    /// triage, so there is nothing to serialise.
528    pub parallel_triage: bool,
529    /// Ignore issues and pull requests numbered below this when spar is picking
530    /// for itself. 0 is no floor.
531    ///
532    /// A repository that has been going a while carries a tail of old issues
533    /// nobody is going to reach, and since spar takes the lowest numbered open
534    /// items it walks straight into them. A number you name explicitly is still
535    /// honoured: naming it is the point.
536    pub min_number: i64,
537    /// Waves of newly filed follow-ups to fold back into the same run, rather
538    /// than leaving them for the next one.
539    ///
540    /// Off by default because it multiplies what a run costs, and because each
541    /// wave can file follow-ups of its own. Every wave is triaged like any
542    /// other issue, so both agents still have to agree it is worth doing.
543    pub absorb_new_issues: u32,
544    /// Turn the checklist in a tracking issue into issues, and work them.
545    ///
546    /// Off by default, for the reason `file_non_blocking` is: a tracker exists
547    /// to contain many things, so this is the shape of feature that multiplies.
548    /// `max_tracker_children` caps it, a child that triage calls a tracker is
549    /// held rather than decomposed in turn, and both agents still gate every
550    /// child at triage.
551    ///
552    /// The trigger is the checklist, never a judgement about what the parts
553    /// are. Writing `- [ ]` lines is something a person does on purpose, which
554    /// makes this opt in per issue as well as per repository.
555    pub decompose_trackers: bool,
556    /// Most items from one tracker's checklist that one run may take on. A cap,
557    /// not a target, and what it left is named out loud.
558    pub max_tracker_children: usize,
559    /// Whether a pull request spar opens starts as a draft.
560    pub drafts: Drafts,
561    /// Extra instructions handed to both agents with every request.
562    ///
563    /// For what a person wants of this repository that the code cannot say and
564    /// spar has no setting for: how far to go, what not to touch, what not to
565    /// wait on. A CLI reads its own conventions file already, CLAUDE.md or
566    /// AGENTS.md, but each reads only its own, and two agents given different
567    /// standing instructions are not the pair this design rests on.
568    ///
569    /// Subordinate to the request and to the schema, which is said in the
570    /// header they arrive under: they change how the work is done, never what
571    /// was asked for or the shape of the answer.
572    pub instructions: String,
573    /// The most of one issue body that reaches a prompt.
574    ///
575    /// Sized so that no issue a person wrote is ever cut. It was 2000 for
576    /// triage and 6000 for implement, silently, and both were small enough to
577    /// clip an ordinary bug report: an agent given half an issue judges and
578    /// implements the half it saw and has no way to know the rest existed.
579    /// When this does fire it is said out loud, in the log and in the prompt.
580    pub max_issue_chars: usize,
581    /// The most every issue body together may add to one triage prompt.
582    ///
583    /// Triage reads the whole queue at once, so the only unbounded thing here
584    /// is the queue. Past this, whole issues are left for the next run rather
585    /// than every issue being shortened: a verdict is posted on the issue and
586    /// can close it, so judging one on part of its body is worse than not
587    /// reaching it yet.
588    pub max_triage_chars: usize,
589    /// Whose comments `spar checkin` will act on.
590    pub checkin_trust: Trust,
591    /// Mark a review thread resolved when spar made the change it asked for.
592    ///
593    /// A thread spar disagreed with is left open whatever this says: the person
594    /// who raised it has not had their say yet, and it is their thread.
595    pub checkin_resolve: bool,
596    /// Most unanswered comments spar answers on one pull request in a run.
597    ///
598    /// A backstop against a long argument being read back to somebody, not a
599    /// target. What it held back is said out loud.
600    pub max_checkin_comments: usize,
601    /// A command spar runs in the worktree after an editing call committed and
602    /// before the branch is pushed. Unset means nothing is run.
603    ///
604    /// The design's rule is that a prompt is not a permission and every claim
605    /// an agent makes is checked mechanically where it can be. "I ran the
606    /// tests" was the one claim nothing checked: the schema records it, and
607    /// spar pushed and opened the pull request on the agent's word. This is not
608    /// waiting on CI, which is expensive and somebody else's machine. It is one
609    /// command, before the push, that fails closed.
610    pub check: CheckCommand,
611    pub effort_schedule: EffortSchedule,
612}
613
614impl Default for LoopCfg {
615    fn default() -> Self {
616        Self {
617            max_rounds: 3,
618            auto_merge: false,
619            first_implementor: None,
620            base_branch: "main".into(),
621            worktrees: true,
622            keep_worktrees: false,
623            state_store: StateStore::Local,
624            branch_prefix: String::new(),
625            followups: Followups::Local,
626            file_non_blocking: false,
627            max_followups: 5,
628            max_split_parts: 4,
629            file_nits: false,
630            close_skipped: true,
631            parallel_triage: true,
632            min_number: 0,
633            absorb_new_issues: 0,
634            decompose_trackers: false,
635            max_tracker_children: 5,
636            drafts: Drafts::Never,
637            instructions: String::new(),
638            max_issue_chars: 60_000,
639            max_triage_chars: 200_000,
640            checkin_trust: Trust::Write,
641            checkin_resolve: true,
642            max_checkin_comments: 20,
643            check: CheckCommand::default(),
644            effort_schedule: EffortSchedule::default(),
645        }
646    }
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize)]
650#[serde(default, deny_unknown_fields)]
651pub struct StyleCfg {
652    pub ban_em_dash: bool,
653    pub ban_ai_attribution: bool,
654    pub terse: bool,
655    pub max_detail_chars: usize,
656    pub max_summary_chars: usize,
657    pub max_body_chars: usize,
658    /// A filed issue's body. Far larger than a PR comment's on purpose: a
659    /// comment is read with the diff in front of you, an issue is picked up
660    /// cold months later by somebody who needs the whole story.
661    pub max_issue_body_chars: usize,
662    pub max_title_chars: usize,
663    pub pr_comments: PrComments,
664}
665
666impl Default for StyleCfg {
667    /// Taken from `Style`, which is where the budgets are decided, rather than
668    /// written out again here.
669    ///
670    /// They were written out again here, and they drifted. The functions
671    /// supplying them to serde were still named `d90`, `d200`, `d320`, `d900`
672    /// and `d4000` while returning 140, 2000, 6000, 8000 and 20000, and the
673    /// config `spar init` generated offered the old numbers as though they
674    /// were current. Uncommenting one of those lines to see what it did cut
675    /// every comment spar posts to a fifth of its length.
676    fn default() -> Self {
677        let style = Style::default();
678        Self {
679            ban_em_dash: style.ban_em_dash,
680            ban_ai_attribution: style.ban_ai_attribution,
681            terse: style.terse,
682            max_detail_chars: style.max_detail_chars,
683            max_summary_chars: style.max_summary_chars,
684            max_body_chars: style.max_body_chars,
685            max_issue_body_chars: style.max_issue_body_chars,
686            max_title_chars: style.max_title_chars,
687            pr_comments: style.pr_comments,
688        }
689    }
690}
691
692impl StyleCfg {
693    pub fn to_style(&self) -> Style {
694        Style {
695            ban_em_dash: self.ban_em_dash,
696            ban_ai_attribution: self.ban_ai_attribution,
697            terse: self.terse,
698            max_detail_chars: self.max_detail_chars,
699            max_summary_chars: self.max_summary_chars,
700            max_body_chars: self.max_body_chars,
701            max_issue_body_chars: self.max_issue_body_chars,
702            max_title_chars: self.max_title_chars,
703            pr_comments: self.pr_comments,
704        }
705    }
706}
707
708// ---------------------------------------------------------------------------
709// The whole config
710// ---------------------------------------------------------------------------
711
712#[derive(Debug, Clone)]
713pub struct Config {
714    /// In declaration order, which is what `first_implementor` defaults to.
715    pub agents: Vec<AgentSpec>,
716    pub loop_cfg: LoopCfg,
717    pub style: Style,
718    /// Resolved: never empty, always one of the configured agents. Under the
719    /// alternating policy this is who goes first on the first issue.
720    pub first_implementor: String,
721    /// Whether the first implementor swaps from issue to issue.
722    pub alternate_first: bool,
723    /// Where this config was read from, for error messages.
724    pub source: Option<PathBuf>,
725}
726
727impl Config {
728    pub fn agent_names(&self) -> Vec<String> {
729        self.agents.iter().map(|a| a.name.clone()).collect()
730    }
731
732    pub fn has_agent(&self, name: &str) -> bool {
733        self.agents.iter().any(|a| a.name == name)
734    }
735
736    pub fn spec(&self, name: &str) -> Result<&AgentSpec> {
737        self.agents.iter().find(|a| a.name == name).ok_or_else(|| {
738            spar_err!(
739                "no agent named '{name}' ({})",
740                self.agent_names().join(", ")
741            )
742        })
743    }
744
745    /// The other agent. With exactly two configured, custody alternates by
746    /// definition.
747    pub fn other(&self, name: &str) -> String {
748        let names = self.agent_names();
749        if names.first().map(String::as_str) == Some(name) {
750            names.get(1).cloned().unwrap_or_else(|| name.to_string())
751        } else {
752            names.first().cloned().unwrap_or_else(|| name.to_string())
753        }
754    }
755
756    /// What effort one agent is asked for on one call.
757    ///
758    /// Four places, most specific first: this agent's own schedule, the shared
759    /// schedule, the old `round_1`/`rest` pair, and the agent's plain `effort`.
760    /// The per agent layer is what makes a schedule expressible at all when the
761    /// two CLIs do not share a vocabulary: `ultra` is not a value the claude
762    /// preset accepts, and `max` means different things to each.
763    pub fn effort_for(&self, spec: &AgentSpec, call: Call) -> Option<String> {
764        spec.effort_schedule
765            .get(call)
766            .or_else(|| self.loop_cfg.effort_schedule.get(call))
767            .or_else(|| self.loop_cfg.effort_schedule.legacy(call))
768            .or_else(|| spec.effort.clone())
769    }
770
771    /// Round 1 gets the deep pass; later rounds only see a small delta, and a
772    /// full ultra review of a three line delta is money on fire.
773    pub fn effort_for_round(&self, spec: &AgentSpec, round: u32) -> Option<String> {
774        self.effort_for(spec, Call::Review(round))
775    }
776
777    /// Who writes the first draft of the nth issue this run works.
778    ///
779    /// Fixed unless the policy is to alternate, in which case it swaps every
780    /// issue, so both models author and both models review first over a queue.
781    pub fn first_implementor_for(&self, nth: usize) -> String {
782        if !self.alternate_first || nth % 2 == 0 {
783            return self.first_implementor.clone();
784        }
785        self.other(&self.first_implementor)
786    }
787
788    /// What to print when somebody asks who goes first.
789    pub fn first_implementor_policy(&self) -> String {
790        if self.alternate_first {
791            format!("alternating, {} first", self.first_implementor)
792        } else {
793            self.first_implementor.clone()
794        }
795    }
796
797    pub fn base_branch(&self) -> &str {
798        &self.loop_cfg.base_branch
799    }
800}
801
802#[derive(Debug, Deserialize)]
803#[serde(deny_unknown_fields)]
804struct RawConfig {
805    #[serde(default)]
806    agents: toml::Table,
807    #[serde(default)]
808    #[serde(rename = "loop")]
809    loop_cfg: Option<LoopCfg>,
810    #[serde(default)]
811    style: Option<StyleCfg>,
812}
813
814// ---------------------------------------------------------------------------
815// Presets
816// ---------------------------------------------------------------------------
817
818/// Directories searched for preset overrides, nearest first.
819///
820/// Deliberately *not* a bare `presets/`. spar runs from inside the user's own
821/// repository, where `presets/` is a perfectly ordinary directory name for
822/// something unrelated (sampler configs, prompt libraries, editor themes), and
823/// a stray `presets/claude.toml` shadowing the built in preset produces a
824/// baffling failure: `spar init` reports Claude Code as missing while it sits
825/// on PATH. Overrides live somewhere that names spar.
826pub fn preset_dirs() -> Vec<PathBuf> {
827    let mut dirs = Vec::new();
828    if let Some(custom) = std::env::var_os("SPAR_PRESET_DIR") {
829        dirs.push(PathBuf::from(custom));
830    }
831    dirs.push(PathBuf::from(".spar").join("presets"));
832    if let Some(home) = home_dir() {
833        dirs.push(home.join(".config").join("spar").join("presets"));
834    }
835    dirs
836}
837
838/// Every preset name available, built in and on disk, sorted.
839pub fn available_presets() -> Vec<String> {
840    let mut names: Vec<String> = BUILTIN_PRESETS.iter().map(|(n, _)| n.to_string()).collect();
841    for dir in preset_dirs() {
842        if let Ok(entries) = std::fs::read_dir(&dir) {
843            for entry in entries.flatten() {
844                let path = entry.path();
845                if path.extension().and_then(|e| e.to_str()) == Some("toml") {
846                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
847                        names.push(stem.to_string());
848                    }
849                }
850            }
851        }
852    }
853    names.sort();
854    names.dedup();
855    names
856}
857
858/// Parse a whole TOML document into a `Value`.
859///
860/// `"...".parse::<Value>()` parses a single TOML *value*, not a document, so it
861/// rejects the first comment line of every preset.
862fn parse_document(text: &str, what: &str) -> Result<Value> {
863    let table: toml::Table =
864        toml::from_str(text).map_err(|e| spar_err!("{what} is not valid TOML: {e}"))?;
865    Ok(Value::Table(table))
866}
867
868/// Load a preset. A file on disk wins over the built in copy of the same name,
869/// so a drifting CLI can be corrected without waiting for a release.
870pub fn load_preset(name: &str) -> Result<Value> {
871    for dir in preset_dirs() {
872        let path = dir.join(format!("{name}.toml"));
873        if path.is_file() {
874            let text = std::fs::read_to_string(&path)
875                .map_err(|e| spar_err!("could not read preset {}: {e}", path.display()))?;
876            return parse_document(&text, &format!("preset {}", path.display()));
877        }
878    }
879    for (builtin, text) in BUILTIN_PRESETS {
880        if *builtin == name {
881            return parse_document(text, &format!("built in preset {name}"));
882        }
883    }
884    Err(spar_err!(
885        "unknown preset '{name}'. Available: {}",
886        available_presets().join(", ")
887    ))
888}
889
890/// Deep merge, with `over` winning. Used so a config block can override one
891/// field of a preset without restating the whole command template.
892fn merge(base: &Value, over: &Value) -> Value {
893    match (base, over) {
894        (Value::Table(b), Value::Table(o)) => {
895            let mut out = b.clone();
896            for (key, value) in o {
897                let merged = match out.get(key) {
898                    Some(existing) => merge(existing, value),
899                    None => value.clone(),
900                };
901                out.insert(key.clone(), merged);
902            }
903            Value::Table(out)
904        }
905        _ => over.clone(),
906    }
907}
908
909fn build_spec(name: &str, raw: &Value) -> Result<AgentSpec> {
910    let table = raw
911        .as_table()
912        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
913
914    let merged = match table.get("preset").and_then(Value::as_str) {
915        Some(preset) => merge(&load_preset(preset)?, raw),
916        None => raw.clone(),
917    };
918
919    let mut merged_table = merged
920        .as_table()
921        .cloned()
922        .ok_or_else(|| spar_err!("agent '{name}' must be a table"))?;
923    merged_table.remove("preset");
924    // Lifted out before the spec is deserialized: a fallback is a whole agent,
925    // preset and all, and only this function knows how to resolve a preset.
926    let fallback_raw = merged_table.remove("fallback");
927
928    if !merged_table.contains_key("command") {
929        bail!(
930            "agent '{name}' has no command and no preset. Set one of them, or pick a preset: {}",
931            available_presets().join(", ")
932        );
933    }
934
935    let mut spec: AgentSpec = Value::Table(merged_table)
936        .try_into()
937        .map_err(|e| spar_err!("agent '{name}': {e}"))?;
938    spec.name = name.to_string();
939
940    if spec.command.is_empty() {
941        bail!("agent '{name}' has an empty command");
942    }
943    if matches!(spec.command.first(), Some(CommandPart::Group(_))) {
944        bail!("agent '{name}': the first command element must be the program name, not a group");
945    }
946    if spec.output == OutputMode::Jsonl && spec.message_path.as_deref().unwrap_or("").is_empty() {
947        bail!(
948            "agent '{name}': output = \"jsonl\" needs a message_path saying where the answer lives"
949        );
950    }
951
952    if let Some(raw) = fallback_raw {
953        if !raw.is_table() {
954            bail!(
955                "agent '{name}': fallback is a whole agent, so write it as a table:\n                   [agents.{name}.fallback]\n  preset = \"cursor\""
956            );
957        }
958        // Named for the env override it answers to, SPAR_<NAME>_FALLBACK_BIN,
959        // and so a log line says which agent stood in for which.
960        let backup = build_spec(&format!("{name}-fallback"), &raw)?;
961        if backup.fallback.is_some() {
962            bail!(
963                "agent '{name}': a fallback may not have a fallback of its own. Each one costs \
964                 another full timeout on a call that has already failed once."
965            );
966        }
967        spec.fallback = Some(Box::new(backup));
968    }
969
970    Ok(spec)
971}
972
973// ---------------------------------------------------------------------------
974// Loading
975// ---------------------------------------------------------------------------
976
977/// One option the parser accepts, with the value it takes when unset.
978#[derive(Debug, Clone)]
979pub struct OptionInfo {
980    pub section: &'static str,
981    pub key: String,
982    pub default: String,
983}
984
985/// Every option a config file may set, with its default.
986///
987/// Derived from the defaults themselves rather than written out by hand, so an
988/// option added to the code cannot go missing here. That is what lets `doctor`
989/// tell somebody upgrading which settings are new since they wrote their file.
990pub fn known_options() -> Vec<OptionInfo> {
991    fn lines<T: Serialize>(section: &'static str, value: &T) -> Vec<OptionInfo> {
992        toml::to_string(value)
993            .unwrap_or_default()
994            .lines()
995            .filter_map(|line| line.split_once(" = "))
996            .map(|(key, default)| OptionInfo {
997                section,
998                key: key.trim().to_string(),
999                default: default.trim().to_string(),
1000            })
1001            .collect()
1002    }
1003    let mut out = lines("loop", &LoopCfg::default());
1004    out.extend(lines("style", &StyleCfg::default()));
1005    out.extend(lines(
1006        "loop.effort_schedule",
1007        &EffortSchedule {
1008            round_1: Some("high".into()),
1009            rest: Some("low".into()),
1010            triage: Some("low".into()),
1011            implement: Some("high".into()),
1012            review_1: Some("high".into()),
1013            review_rest: Some("low".into()),
1014            respond: Some("high".into()),
1015            close: Some("low".into()),
1016            screen: Some("low".into()),
1017            checkin: Some("high".into()),
1018            split: Some("high".into()),
1019            brainstorm: Some("high".into()),
1020        },
1021    ));
1022    out
1023}
1024
1025/// Whether a config file mentions an option at all, set or commented out.
1026pub fn mentions(config_text: &str, key: &str) -> bool {
1027    config_text.lines().any(|line| {
1028        let bare = line.trim_start().trim_start_matches('#').trim_start();
1029        bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1030    })
1031}
1032
1033/// Options this config file has never heard of, which is what somebody who
1034/// upgraded wants to know.
1035pub fn unmentioned_options(config_text: &str) -> Vec<OptionInfo> {
1036    known_options()
1037        .into_iter()
1038        .filter(|o| !mentions(config_text, &o.key))
1039        .collect()
1040}
1041
1042pub const CONFIG_NAMES: &[&str] = &["spar.toml", ".spar.toml"];
1043
1044/// Find a config: an explicit path, then the working directory, then
1045/// `~/.config/spar/spar.toml`.
1046pub fn find_config(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1047    if let Some(path) = explicit {
1048        if !path.is_file() {
1049            bail!("config not found: {}", path.display());
1050        }
1051        return Ok(Some(path.to_path_buf()));
1052    }
1053    for name in CONFIG_NAMES {
1054        let path = PathBuf::from(name);
1055        if path.is_file() {
1056            return Ok(Some(path));
1057        }
1058    }
1059    if let Some(home) = home_dir() {
1060        let path = home.join(".config").join("spar").join("spar.toml");
1061        if path.is_file() {
1062            return Ok(Some(path));
1063        }
1064    }
1065    Ok(None)
1066}
1067
1068pub fn load(explicit: Option<&Path>) -> Result<Config> {
1069    let Some(path) = find_config(explicit)? else {
1070        bail!(
1071            "no spar.toml found. Run `spar init` to generate one from the CLIs you have installed."
1072        );
1073    };
1074    let text = std::fs::read_to_string(&path)
1075        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1076    let mut cfg = parse(&text).map_err(|e| spar_err!("{}: {e}", path.display()))?;
1077    cfg.source = Some(path);
1078    Ok(cfg)
1079}
1080
1081pub fn parse(text: &str) -> Result<Config> {
1082    let raw: RawConfig = toml::from_str(text)?;
1083
1084    if raw.agents.len() != 2 {
1085        bail!(
1086            "spar needs exactly two agents, found {}. The whole design is one reviewing the other.",
1087            raw.agents.len()
1088        );
1089    }
1090
1091    let mut agents = Vec::new();
1092    for (name, value) in raw.agents.iter() {
1093        agents.push(build_spec(name, value)?);
1094    }
1095
1096    let loop_cfg = raw.loop_cfg.unwrap_or_default();
1097    let style = raw.style.unwrap_or_default().to_style();
1098
1099    if loop_cfg.max_rounds == 0 {
1100        bail!("max_rounds must be at least 1");
1101    }
1102    // A draft cannot be merged, so merging one means promoting it first, which
1103    // is the one thing `always` asks spar not to do. Refusing is better than
1104    // picking a winner: either setting alone is coherent and only somebody who
1105    // set both can say which they meant.
1106    if loop_cfg.auto_merge && loop_cfg.drafts == Drafts::Always {
1107        bail!(
1108            "auto_merge cannot be on with drafts = \"always\": merging a draft means marking it \
1109             ready, which is what \"always\" asks spar not to do. Use drafts = \"until_approved\" \
1110             to have it promoted when the review converges, or turn auto_merge off."
1111        );
1112    }
1113
1114    // "alternate" is not an agent name, it is a policy: whoever is not the
1115    // agent that wrote the last first draft. Roles alternate within a pull
1116    // request and never between them, so over a queue of twenty issues one
1117    // model writes every first draft and the other reviews every one of them.
1118    // A model's blind spots as an author are systematic, and the ones the pair
1119    // shares go through every time.
1120    let alternate = loop_cfg
1121        .first_implementor
1122        .as_deref()
1123        .is_some_and(|name| name.trim().eq_ignore_ascii_case("alternate"));
1124    let first = match &loop_cfg.first_implementor {
1125        Some(name) if !name.trim().is_empty() && !alternate => name.trim().to_string(),
1126        _ => agents[0].name.clone(),
1127    };
1128    if !agents.iter().any(|a| a.name == first) {
1129        bail!(
1130            "first_implementor '{first}' is not a configured agent ({})",
1131            agents
1132                .iter()
1133                .map(|a| a.name.as_str())
1134                .collect::<Vec<_>>()
1135                .join(", ")
1136        );
1137    }
1138
1139    Ok(Config {
1140        agents,
1141        loop_cfg,
1142        style,
1143        first_implementor: first,
1144        alternate_first: alternate,
1145        source: None,
1146    })
1147}
1148
1149/// Resolve a configured search path, expanding a leading `~`.
1150pub fn resolve_search_path(raw: &str) -> PathBuf {
1151    expand_tilde(raw)
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    const TWO_AGENTS: &str = r#"
1159[agents.claude]
1160preset = "claude"
1161model = "fable"
1162
1163[agents.codex]
1164preset = "codex"
1165model = "gpt-5.6-sol"
1166"#;
1167
1168    // -- fallback --------------------------------------------------------
1169
1170    #[test]
1171    fn a_fallback_is_a_whole_agent_with_its_own_preset() {
1172        let text = format!(
1173            "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\nmodel = \"kimi-k3\"\n"
1174        );
1175        let cfg = parse(&text).expect("parses");
1176        // Still a pair. A backup is not a third opinion.
1177        assert_eq!(2, cfg.agents.len());
1178        let codex = cfg.spec("codex").expect("codex");
1179        let backup = codex.fallback.as_ref().expect("fallback");
1180        assert_eq!("codex-fallback", backup.name);
1181        assert_eq!(Some("kimi-k3"), backup.model.as_deref());
1182        assert_eq!(
1183            Some(&CommandPart::One("cursor-agent".into())),
1184            backup.command.first()
1185        );
1186    }
1187
1188    #[test]
1189    fn the_agent_without_a_fallback_does_not_grow_one() {
1190        let cfg = parse(TWO_AGENTS).expect("parses");
1191        assert!(cfg.agents.iter().all(|a| a.fallback.is_none()));
1192    }
1193
1194    #[test]
1195    fn a_fallback_may_not_have_one_of_its_own() {
1196        let text = format!(
1197            "{TWO_AGENTS}\n[agents.codex.fallback]\npreset = \"cursor\"\n\
1198             [agents.codex.fallback.fallback]\npreset = \"gemini\"\n"
1199        );
1200        let err = parse(&text).expect_err("rejected");
1201        assert!(err.message().contains("may not have a fallback"), "{err}");
1202    }
1203
1204    #[test]
1205    fn a_fallback_written_as_a_string_says_what_it_should_be() {
1206        let text = "[agents.claude]\npreset = \"claude\"\n\n\
1207                    [agents.codex]\npreset = \"codex\"\nfallback = \"cursor\"\n";
1208        let err = parse(text).expect_err("rejected");
1209        assert!(err.message().contains("[agents.codex.fallback]"), "{err}");
1210    }
1211
1212    /// A block that names one setting keeps the defaults for every setting it
1213    /// did not name. That is what the container level serde default buys: each
1214    /// field used to carry its own default function repeating a number that
1215    /// also lived in `Default`, and the two copies stopped agreeing.
1216    #[test]
1217    fn a_partial_block_keeps_the_defaults_it_did_not_name() {
1218        let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 9\n\n[style]\nterse = false\n");
1219        let cfg = parse(&text).expect("parses");
1220
1221        assert_eq!(9, cfg.loop_cfg.max_rounds);
1222        assert_eq!(LoopCfg::default().followups, cfg.loop_cfg.followups);
1223        assert_eq!(LoopCfg::default().close_skipped, cfg.loop_cfg.close_skipped);
1224
1225        assert!(!cfg.style.terse);
1226        assert_eq!(Style::default().max_body_chars, cfg.style.max_body_chars);
1227        assert_eq!(Style::default().max_title_chars, cfg.style.max_title_chars);
1228    }
1229
1230    /// The budgets are decided in `Style` and read from there by the config
1231    /// layer. When they were written out in both places they drifted, and the
1232    /// generated config offered the older set for months.
1233    #[test]
1234    fn the_config_layer_does_not_keep_its_own_copy_of_the_budgets() {
1235        assert_eq!(Style::default(), StyleCfg::default().to_style());
1236    }
1237
1238    // -- drafts ------------------------------------------------------------
1239
1240    #[test]
1241    fn pull_requests_are_not_drafts_unless_asked_for() {
1242        assert_eq!(Drafts::Never, parse(TWO_AGENTS).unwrap().loop_cfg.drafts);
1243    }
1244
1245    #[test]
1246    fn each_draft_setting_parses() {
1247        for (text, want) in [
1248            ("never", Drafts::Never),
1249            ("until_approved", Drafts::UntilApproved),
1250            ("always", Drafts::Always),
1251        ] {
1252            let cfg = parse(&format!("{TWO_AGENTS}\n[loop]\ndrafts = \"{text}\"\n"))
1253                .unwrap_or_else(|e| panic!("{text}: {e}"));
1254            assert_eq!(want, cfg.loop_cfg.drafts, "{text}");
1255        }
1256    }
1257
1258    /// Merging a draft means marking it ready, which is the one thing `always`
1259    /// asks spar not to do. Either setting alone is coherent, so refusing beats
1260    /// picking a winner between them.
1261    #[test]
1262    fn auto_merge_and_a_permanent_draft_are_refused_together() {
1263        let text = format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"always\"\n");
1264        let err = parse(&text).expect_err("refused");
1265        assert!(err.message().contains("auto_merge"), "{err}");
1266        assert!(
1267            err.message().contains("until_approved"),
1268            "says the way out: {err}"
1269        );
1270    }
1271
1272    /// The pairing that does make sense: the draft clears when the review
1273    /// converges, and then it can merge.
1274    #[test]
1275    fn auto_merge_is_fine_with_a_draft_that_clears() {
1276        let text =
1277            format!("{TWO_AGENTS}\n[loop]\nauto_merge = true\ndrafts = \"until_approved\"\n");
1278        assert!(parse(&text).is_ok());
1279    }
1280
1281    #[test]
1282    fn every_builtin_preset_parses() {
1283        for (name, _) in BUILTIN_PRESETS {
1284            let value = load_preset(name).unwrap_or_else(|e| panic!("{name}: {e}"));
1285            assert!(value.get("command").is_some(), "{name} has no command");
1286        }
1287    }
1288
1289    #[test]
1290    fn every_builtin_preset_builds_a_spec() {
1291        for (name, _) in BUILTIN_PRESETS {
1292            let raw = parse_document(&format!("preset = \"{name}\""), "test").unwrap();
1293            build_spec(name, &raw).unwrap_or_else(|e| panic!("{name}: {e}"));
1294        }
1295    }
1296
1297    /// `--allowedTools` is variadic, so the separate form swallows the
1298    /// following positional prompt unless another flag happens to sit between
1299    /// them. The equals form is not cosmetic.
1300    #[test]
1301    fn claude_preset_uses_the_equals_form_for_allowed_tools() {
1302        let spec = build_spec(
1303            "claude",
1304            &parse_document("preset = \"claude\"", "test").unwrap(),
1305        )
1306        .unwrap();
1307        let flat: Vec<&String> = spec.command.iter().flat_map(|p| p.args()).collect();
1308        assert!(flat.iter().any(|a| a.starts_with("--allowedTools=")));
1309        assert!(!flat.iter().any(|a| a.as_str() == "--allowedTools"));
1310    }
1311
1312    #[test]
1313    fn codex_preset_declares_where_its_answer_lives() {
1314        let spec = build_spec(
1315            "codex",
1316            &parse_document("preset = \"codex\"", "test").unwrap(),
1317        )
1318        .unwrap();
1319        assert_eq!(OutputMode::Jsonl, spec.output);
1320        assert_eq!(Some("item.text"), spec.message_path.as_deref());
1321        assert!(!spec.message_match.is_empty());
1322    }
1323
1324    #[test]
1325    fn agent_order_follows_declaration_order() {
1326        let cfg = parse(TWO_AGENTS).unwrap();
1327        assert_eq!(vec!["claude", "codex"], cfg.agent_names());
1328        assert_eq!("claude", cfg.first_implementor);
1329    }
1330
1331    #[test]
1332    fn other_alternates() {
1333        let cfg = parse(TWO_AGENTS).unwrap();
1334        assert_eq!("codex", cfg.other("claude"));
1335        assert_eq!("claude", cfg.other("codex"));
1336    }
1337
1338    #[test]
1339    fn a_config_block_overrides_one_preset_field() {
1340        let cfg = parse(TWO_AGENTS).unwrap();
1341        let claude = cfg.spec("claude").unwrap();
1342        assert_eq!(Some("fable"), claude.model.as_deref());
1343        assert!(claude.command.len() > 1, "the preset command survived");
1344    }
1345
1346    #[test]
1347    fn exactly_two_agents_are_required() {
1348        let one = "[agents.claude]\npreset = \"claude\"\n";
1349        assert!(parse(one).unwrap_err().to_string().contains("exactly two"));
1350    }
1351
1352    #[test]
1353    fn an_unknown_agent_option_is_named() {
1354        let text = "[agents.a]\ncommand = [\"x\"]\nwidget = 3\n[agents.b]\ncommand = [\"y\"]\n";
1355        let err = parse(text).unwrap_err().to_string();
1356        assert!(err.contains("widget"), "{err}");
1357    }
1358
1359    #[test]
1360    fn an_unknown_loop_option_is_named() {
1361        let text = format!("{TWO_AGENTS}\n[loop]\nmax_round = 4\n");
1362        let err = parse(&text).unwrap_err().to_string();
1363        assert!(err.contains("max_round"), "{err}");
1364    }
1365
1366    #[test]
1367    fn an_agent_with_no_command_and_no_preset_is_rejected() {
1368        let text = "[agents.a]\nmodel = \"x\"\n[agents.b]\ncommand = [\"y\"]\n";
1369        let err = parse(text).unwrap_err().to_string();
1370        assert!(err.contains("no command and no preset"), "{err}");
1371    }
1372
1373    #[test]
1374    fn jsonl_without_a_message_path_is_rejected() {
1375        let text =
1376            "[agents.a]\ncommand = [\"x\"]\noutput = \"jsonl\"\n[agents.b]\ncommand = [\"y\"]\n";
1377        let err = parse(text).unwrap_err().to_string();
1378        assert!(err.contains("message_path"), "{err}");
1379    }
1380
1381    /// One agent wrote every first draft, so half the pair's diversity went
1382    /// unused: a model's blind spots as an author are systematic, and the ones
1383    /// the two share go through every time.
1384    /// The one claim a report makes that nothing else checks.
1385    #[test]
1386    fn a_check_is_read_as_an_argv_either_way_it_is_written() {
1387        let line = format!("{TWO_AGENTS}\n[loop]\ncheck = \"cargo test --all\"\n");
1388        let cfg = parse(&line).unwrap();
1389        assert!(cfg.loop_cfg.check.is_set());
1390        assert_eq!(vec!["cargo", "test", "--all"], cfg.loop_cfg.check.argv());
1391        assert_eq!("cargo test --all", cfg.loop_cfg.check.describe());
1392
1393        let listed =
1394            format!("{TWO_AGENTS}\n[loop]\ncheck = [\"npm\", \"run\", \"test -- --ci\"]\n");
1395        let cfg = parse(&listed).unwrap();
1396        assert_eq!(
1397            vec!["npm", "run", "test -- --ci"],
1398            cfg.loop_cfg.check.argv(),
1399            "a list is taken as it is, spaces and all"
1400        );
1401
1402        // Unset is the default, and runs nothing.
1403        let bare = parse(TWO_AGENTS).unwrap();
1404        assert!(!bare.loop_cfg.check.is_set());
1405        assert!(bare.loop_cfg.check.argv().is_empty());
1406    }
1407
1408    #[test]
1409    fn alternating_swaps_the_author_from_issue_to_issue() {
1410        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"alternate\"\n");
1411        let cfg = parse(&text).unwrap();
1412
1413        assert!(cfg.alternate_first);
1414        assert_eq!(
1415            "claude", cfg.first_implementor,
1416            "declaration order still leads"
1417        );
1418        assert_eq!("claude", cfg.first_implementor_for(0));
1419        assert_eq!("codex", cfg.first_implementor_for(1));
1420        assert_eq!("claude", cfg.first_implementor_for(2));
1421        assert!(cfg.first_implementor_policy().contains("alternating"));
1422    }
1423
1424    /// Naming an agent still pins it, which is what --first does for a run.
1425    #[test]
1426    fn a_named_first_implementor_never_swaps() {
1427        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"codex\"\n");
1428        let cfg = parse(&text).unwrap();
1429        assert!(!cfg.alternate_first);
1430        for nth in 0..4 {
1431            assert_eq!("codex", cfg.first_implementor_for(nth));
1432        }
1433        assert_eq!("codex", cfg.first_implementor_policy());
1434    }
1435
1436    #[test]
1437    fn first_implementor_must_name_a_configured_agent() {
1438        let text = format!("{TWO_AGENTS}\n[loop]\nfirst_implementor = \"nobody\"\n");
1439        let err = parse(&text).unwrap_err().to_string();
1440        assert!(err.contains("not a configured agent"), "{err}");
1441    }
1442
1443    #[test]
1444    fn defaults_are_the_conservative_ones() {
1445        let cfg = parse(TWO_AGENTS).unwrap();
1446        assert!(
1447            !cfg.loop_cfg.auto_merge,
1448            "auto_merge must be off by default"
1449        );
1450        assert!(cfg.loop_cfg.worktrees);
1451        assert!(
1452            !cfg.loop_cfg.file_nits,
1453            "a filed nit is somebody else's triage queue"
1454        );
1455        assert_eq!(3, cfg.loop_cfg.max_rounds);
1456        assert_eq!(
1457            Followups::Local,
1458            cfg.loop_cfg.followups,
1459            "the tracker is somebody's queue; the default must not write to it"
1460        );
1461        assert!(
1462            !cfg.loop_cfg.file_non_blocking,
1463            "a suggestion is not a tracker item"
1464        );
1465        assert_eq!(StateStore::Local, cfg.loop_cfg.state_store);
1466        assert!(cfg.style.terse);
1467    }
1468
1469    #[test]
1470    fn effort_schedule_splits_round_one_from_the_rest() {
1471        let text =
1472            format!("{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"high\"\n");
1473        let cfg = parse(&text).unwrap();
1474        let spec = cfg.spec("claude").unwrap();
1475        assert_eq!(Some("ultra".into()), cfg.effort_for_round(spec, 1));
1476        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 2));
1477        assert_eq!(Some("high".into()), cfg.effort_for_round(spec, 9));
1478    }
1479
1480    /// The schedule is one string handed to whichever agent holds the round,
1481    /// and the two CLIs do not share a vocabulary.
1482    ///
1483    /// The README's own example pairs round_1 = "ultra" with a claude agent at
1484    /// effort = "high", and ultra is not a value the claude preset accepts, so
1485    /// the first review failed at the CLI whenever claude reviewed first.
1486    #[test]
1487    fn an_agent_can_carry_its_own_schedule_in_its_own_words() {
1488        let text = format!(
1489            "{TWO_AGENTS}\n[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
1490             [loop.effort_schedule]\nreview_1 = \"ultra\"\n"
1491        );
1492        let cfg = parse(&text).unwrap();
1493        assert_eq!(
1494            Some("xhigh".into()),
1495            cfg.effort_for(cfg.spec("claude").unwrap(), Call::Review(1)),
1496            "the agent's own schedule has to win"
1497        );
1498        assert_eq!(
1499            Some("ultra".into()),
1500            cfg.effort_for(cfg.spec("codex").unwrap(), Call::Review(1))
1501        );
1502    }
1503
1504    /// Every call used to read round_1 or rest by a round number most of them
1505    /// do not have, so round_1 = "ultra" put triage over the whole queue, the
1506    /// implement call and the follow-up screen at ultra too.
1507    #[test]
1508    fn each_kind_of_call_reads_its_own_key_and_falls_back_to_the_old_pair() {
1509        let text = format!(
1510            "{TWO_AGENTS}\n[loop.effort_schedule]\nround_1 = \"ultra\"\nrest = \"low\"\n\
1511             triage = \"minimal\"\n"
1512        );
1513        let cfg = parse(&text).unwrap();
1514        let spec = cfg.spec("claude").unwrap();
1515
1516        assert_eq!(Some("minimal".into()), cfg.effort_for(spec, Call::Triage));
1517        // No key of its own: exactly what the two key schedule did.
1518        for call in [Call::Implement, Call::Review(1), Call::Screen] {
1519            assert_eq!(Some("ultra".into()), cfg.effort_for(spec, call), "{call:?}");
1520        }
1521        for call in [Call::Review(2), Call::Close, Call::Respond(3)] {
1522            assert_eq!(Some("low".into()), cfg.effort_for(spec, call), "{call:?}");
1523        }
1524    }
1525
1526    #[test]
1527    fn effort_falls_back_to_the_agents_own_setting() {
1528        let text = format!("{TWO_AGENTS}effort = \"low\"\n");
1529        let cfg = parse(&text).unwrap();
1530        let spec = cfg.spec("codex").unwrap();
1531        assert_eq!(Some("low".into()), cfg.effort_for_round(spec, 1));
1532    }
1533
1534    #[test]
1535    fn an_unset_model_and_an_empty_model_normalise_the_same() {
1536        let a = AgentSpec {
1537            name: "a".into(),
1538            command: vec![CommandPart::One("x".into())],
1539            model: None,
1540            effort: None,
1541            effort_schedule: EffortSchedule::default(),
1542            output: OutputMode::Text,
1543            message_match: BTreeMap::new(),
1544            message_path: None,
1545            search_paths: vec![],
1546            system_via: SystemVia::Prompt,
1547            timeout: 60,
1548            fallback: None,
1549            models: vec![],
1550            efforts: vec![],
1551            options_note: None,
1552        };
1553        let b = AgentSpec {
1554            model: Some("  ".into()),
1555            ..a.clone()
1556        };
1557        assert_eq!(a.model_key(), b.model_key());
1558    }
1559
1560    #[test]
1561    fn max_rounds_zero_is_rejected() {
1562        let text = format!("{TWO_AGENTS}\n[loop]\nmax_rounds = 0\n");
1563        assert!(parse(&text).is_err());
1564    }
1565
1566    #[test]
1567    fn an_inline_command_needs_no_preset() {
1568        let text = r#"
1569[agents.custom]
1570command = ["mytool", ["-m", "{model}"], "--prompt", "{prompt}"]
1571output = "text"
1572
1573[agents.other]
1574command = ["othertool", "{prompt}"]
1575"#;
1576        let cfg = parse(text).unwrap();
1577        assert_eq!(4, cfg.spec("custom").unwrap().command.len());
1578    }
1579
1580    #[test]
1581    fn style_budgets_are_configurable() {
1582        let text = format!("{TWO_AGENTS}\n[style]\nterse = false\nmax_detail_chars = 40\n");
1583        let cfg = parse(&text).unwrap();
1584        assert!(!cfg.style.terse);
1585        assert_eq!(40, cfg.style.max_detail_chars);
1586    }
1587}