Skip to main content

spar/
cli.rs

1//! The command line.
2
3use std::collections::{BTreeSet, VecDeque};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use clap::{Args, Parser, Subcommand};
8
9use crate::agent::{self, Agent};
10use crate::checkin;
11use crate::config::{self, Config};
12use crate::error::Result;
13use crate::followups;
14use crate::model::{Issue, IssueRun, ItemKind, Plan, Status};
15use crate::proc::{self, ExecOpts};
16use crate::repo::Repo;
17use crate::review;
18use crate::review_only;
19use crate::split;
20use crate::style;
21use crate::tracker;
22use crate::triage;
23use crate::{bail, log, logdim, logging, logwarn, spar_err};
24
25pub const VERSION: &str = env!("CARGO_PKG_VERSION");
26
27#[derive(Parser, Debug)]
28#[command(
29    name = "spar",
30    version = VERSION,
31    about = "Two coding agents alternate implementing and reviewing GitHub issues.",
32    long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
33                  pull request converges. Neither agent reviews its own most recent edit.\n\n\
34                  Arguments are issue numbers for `run` and `triage`, and pull request numbers \
35                  for `resume`, `review`, and `checkin`. `split` takes either. Omit them and \
36                  spar takes everything open, up to --limit. `split` applies that limit once to \
37                  issues and once to pull requests. `followup` takes none: it works the queue in \
38                  .spar/followups.md, and an entry there has no number to name.",
39    max_term_width = 96
40)]
41pub struct Cli {
42    /// Suppress progress logging. Warnings, errors, and the final summary still print.
43    #[arg(short, long, global = true)]
44    pub quiet: bool,
45
46    #[command(subcommand)]
47    pub command: Command,
48}
49
50#[derive(Subcommand, Debug)]
51pub enum Command {
52    /// Triage the issues, then work them in dependency order.
53    Run {
54        /// Issue numbers. Omit to take every open issue, up to --limit.
55        issues: Vec<i64>,
56        #[command(flatten)]
57        common: Common,
58        #[command(flatten)]
59        loop_flags: LoopFlags,
60        #[command(flatten)]
61        triage_flags: TriageFlags,
62        /// Where to write the triage plan.
63        #[arg(long, default_value = "plan.json")]
64        plan_out: PathBuf,
65        /// Work in the main checkout instead of an isolated worktree per issue.
66        #[arg(long)]
67        no_worktrees: bool,
68    },
69
70    /// Work the follow-ups recorded in .spar/followups.md.
71    ///
72    /// One agent reads every entry against the current checkout and rules on
73    /// it: still there, already fixed, not worth it, or a duplicate. What
74    /// survives is filed as an issue and worked like any other, which means
75    /// both agents still triage it before anything is implemented. An entry
76    /// that was filed or dropped leaves the queue and is kept in
77    /// .spar/followups.done.md.
78    ///
79    /// Takes no numbers: an entry has no number a person could type. --limit
80    /// caps how many are taken, and --min-number does nothing here.
81    Followup {
82        #[command(flatten)]
83        common: Common,
84        #[command(flatten)]
85        loop_flags: LoopFlags,
86        #[command(flatten)]
87        triage_flags: TriageFlags,
88        /// Read this instead of .spar/followups.md.
89        #[arg(long, value_name = "PATH")]
90        file: Option<PathBuf>,
91        /// Print the verdicts and stop. Nothing is filed and no file is touched.
92        #[arg(long)]
93        screen_only: bool,
94        /// File the issues and stop, leaving them for a later `spar run`.
95        #[arg(long, conflicts_with = "screen_only")]
96        file_only: bool,
97        /// Where to write the triage plan.
98        #[arg(long, default_value = "plan.json")]
99        plan_out: PathBuf,
100        /// Work in the main checkout instead of an isolated worktree per issue.
101        #[arg(long)]
102        no_worktrees: bool,
103    },
104
105    /// Triage only. Writes the plan and touches nothing else.
106    Triage {
107        /// Issue numbers. Omit to take every open issue, up to --limit.
108        issues: Vec<i64>,
109        #[command(flatten)]
110        common: Common,
111        #[arg(long, default_value = "plan.json")]
112        plan_out: PathBuf,
113    },
114
115    /// Continue the review loop on existing PRs, including ones spar did not create.
116    Resume {
117        /// Pull request numbers. Omit to take every open PR, up to --limit.
118        prs: Vec<i64>,
119        #[command(flatten)]
120        common: Common,
121        #[command(flatten)]
122        loop_flags: LoopFlags,
123        /// Which agent reviews next, overriding saved custody after the PR head changed.
124        #[arg(long = "next", value_name = "AGENT")]
125        next_actor: Option<String>,
126    },
127
128    /// Answer the comments on a pull request, and act on the ones worth acting on.
129    ///
130    /// Reads every comment somebody else left that has not been answered. Both
131    /// agents judge each one. A change they both agree is right and belongs
132    /// here is made, pushed, answered in its own thread, and the thread marked
133    /// resolved. One they both judge wrong gets the reason and the thread is
134    /// left open for you. One they disagree about is parked.
135    Checkin {
136        /// Pull request numbers. An issue number resolves to its open PR.
137        /// Omit to take every open PR, up to --limit.
138        items: Vec<i64>,
139        #[command(flatten)]
140        common: Common,
141        /// Print every reply and every change instead of posting or pushing.
142        #[arg(long)]
143        dry_run: bool,
144        /// Answer in words only. Nothing is committed, pushed, or resolved.
145        #[arg(long)]
146        reply_only: bool,
147        /// Act on a comment from anyone, not only from somebody who can write
148        /// to this repository.
149        #[arg(long)]
150        any_author: bool,
151        /// Answer comments spar already answered, ignoring what it recorded.
152        #[arg(long)]
153        again: bool,
154        /// Leave worktrees in place afterwards, for inspection.
155        #[arg(long)]
156        keep_worktrees: bool,
157    },
158
159    /// Review pull requests without changing them, including from a fork.
160    ///
161    /// Both agents review independently, then rule on each other's findings,
162    /// then answer the objections. Nothing is committed, pushed, or merged.
163    Review {
164        /// Pull request numbers. An issue number resolves to its open PR.
165        /// Omit to take every open PR, up to --limit.
166        items: Vec<i64>,
167        #[command(flatten)]
168        common: Common,
169        /// Print the review instead of posting it.
170        #[arg(long)]
171        dry_run: bool,
172        /// Adjudication passes. 1 is two independent reviews with no
173        /// cross-checking, 2 adds it, 3 adds a rebuttal on what they dispute.
174        #[arg(long)]
175        max_rounds: Option<u32>,
176    },
177
178    /// Break an issue or a pull request into smaller ones.
179    ///
180    /// One agent proposes the parts with the code open, the other rules on the
181    /// proposal: accept, reject, or accept with named parts struck.
182    /// Disagreement resolves toward not splitting.
183    ///
184    /// An issue's parts are filed as issues and the parent is rewritten into a
185    /// checklist that points at them. A pull request's parts each get their own
186    /// branch and their own pull request, and the original is left open and
187    /// otherwise untouched: split branches use create-only pushes, and the
188    /// parent is never closed or rebased. A pull request from a fork is proposed
189    /// in a comment rather than split.
190    ///
191    /// It decomposes and stops. Nothing is triaged, implemented, or merged. Run
192    /// the reported child numbers next, or enable `decompose_trackers` and run
193    /// the issue parent.
194    Split {
195        /// Issue or pull request numbers. Omit to go through every open issue
196        /// and every open pull request, and split what is worth splitting.
197        items: Vec<i64>,
198        #[command(flatten)]
199        common: Common,
200        /// Print the proposal and write nothing.
201        #[arg(long)]
202        dry_run: bool,
203        /// Start a separate split even when one is recorded or retained. Does
204        /// not resume retained branches.
205        #[arg(long)]
206        again: bool,
207    },
208
209    /// Post a review a dry run produced, without running the agents again.
210    ///
211    /// `spar review <pr> --dry-run` saves what it produced. Read it, edit the
212    /// file if you like, then post exactly that.
213    Post {
214        /// Pull request numbers whose saved review should be posted.
215        #[arg(required = true)]
216        prs: Vec<i64>,
217        #[arg(long, default_value = ".")]
218        repo: PathBuf,
219        #[arg(long)]
220        config: Option<PathBuf>,
221        /// Post this file instead of the saved review.
222        #[arg(long, value_name = "PATH")]
223        file: Option<PathBuf>,
224        /// Print what would be posted and stop.
225        #[arg(long)]
226        dry_run: bool,
227    },
228
229    /// Detect installed agent CLIs and write a spar.toml.
230    ///
231    /// On an existing config, `--update` appends any settings it does not
232    /// mention, which is how to pick up options added by a newer release.
233    Init {
234        #[arg(long, default_value = "spar.toml")]
235        out: PathBuf,
236        /// Overwrite an existing config.
237        #[arg(long)]
238        force: bool,
239        /// Append settings the existing config does not mention, as comments.
240        /// Nothing already in the file is changed.
241        #[arg(long, conflicts_with = "force")]
242        update: bool,
243    },
244
245    /// Remove worktrees, branches, and state whose PR is merged or closed.
246    Clean {
247        #[arg(long, default_value = ".")]
248        repo: PathBuf,
249        #[arg(long)]
250        config: Option<PathBuf>,
251        /// Remove every local worktree and local branch spar recorded, even for
252        /// open PRs.
253        #[arg(long)]
254        all: bool,
255        /// Also delete state comments left on finished PRs.
256        #[arg(long)]
257        pr_state: bool,
258    },
259
260    /// Check prerequisites and resolve each configured agent.
261    Doctor {
262        #[arg(long)]
263        config: Option<PathBuf>,
264    },
265
266    /// Read a commit message on stdin and write the scrubbed version to stdout.
267    ///
268    /// Used by `git filter-branch`, not by people.
269    #[command(hide = true)]
270    ScrubFilter,
271}
272
273#[derive(Args, Debug, Clone)]
274pub struct Common {
275    /// Path to the git repository.
276    #[arg(long, default_value = ".")]
277    pub repo: PathBuf,
278    /// Path to spar.toml.
279    #[arg(long)]
280    pub config: Option<PathBuf>,
281    /// Base branch. Defaults to whatever origin/HEAD points at.
282    #[arg(long)]
283    pub base: Option<String>,
284    /// Which agent implements first. A key from the `[agents]` table.
285    #[arg(long)]
286    pub first: Option<String>,
287    /// Cap on open items of each kind when none are named. Bare `split` may take
288    /// this many issues and this many pull requests.
289    #[arg(long, default_value_t = 20)]
290    pub limit: usize,
291    /// Ignore issues and pull requests numbered below this when picking for
292    /// itself. A number you name explicitly is always honoured.
293    #[arg(long, value_name = "N")]
294    pub min_number: Option<i64>,
295    /// Extra instructions for both agents, for this run only. Added to any
296    /// already in the config rather than replacing them.
297    #[arg(long, value_name = "TEXT")]
298    pub instructions: Option<String>,
299}
300
301#[derive(Args, Debug, Clone)]
302pub struct LoopFlags {
303    /// Review rounds this run may spend asking for changes. A closing pass,
304    /// when needed, is not one of them. Resuming grants a fresh budget.
305    #[arg(long)]
306    pub max_rounds: Option<u32>,
307    /// Merge when no blocking findings remain. Off by default, deliberately.
308    #[arg(long)]
309    pub auto_merge: bool,
310    /// Leave worktrees in place after a run, for inspection.
311    #[arg(long)]
312    pub keep_worktrees: bool,
313    /// Waves of newly filed follow-ups to fold back into this run instead of
314    /// leaving them for the next one. Each wave is triaged like any issue.
315    #[arg(long, value_name = "N")]
316    pub absorb: Option<u32>,
317}
318
319/// Only `run` triages, so only `run` can decline an issue. Offering these on
320/// `resume` would accept a flag that does nothing.
321#[derive(Args, Debug, Clone)]
322pub struct TriageFlags {
323    /// Close an issue both agents declined, after posting the reasoning.
324    #[arg(long, conflicts_with = "no_close_skipped")]
325    pub close_skipped: bool,
326    /// Comment on a declined issue but leave it open.
327    #[arg(long)]
328    pub no_close_skipped: bool,
329}
330
331// ---------------------------------------------------------------------------
332// Entry
333// ---------------------------------------------------------------------------
334
335pub fn main() -> i32 {
336    let cli = Cli::parse();
337    logging::init_color();
338    logging::set_quiet(cli.quiet);
339
340    match dispatch(cli) {
341        Ok(code) => code,
342        Err(e) => {
343            logging::error(e.to_string());
344            2
345        }
346    }
347}
348
349fn dispatch(cli: Cli) -> Result<i32> {
350    match cli.command {
351        Command::ScrubFilter => cmd_scrub_filter(),
352        Command::Doctor { config } => cmd_doctor(config.as_deref()),
353        Command::Review {
354            items,
355            common,
356            dry_run,
357            max_rounds,
358        } => {
359            let overrides = Overrides {
360                max_rounds,
361                ..Overrides::default()
362            };
363            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
364            let numbers = if items.is_empty() {
365                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
366                if found.is_empty() {
367                    log!("no open PRs");
368                    return Ok(0);
369                }
370                log!("no PRs given, reviewing {} open", found.len());
371                found
372            } else {
373                items
374            };
375            let sorted = classify(&repo, &numbers)?;
376            let mut targets = sorted.prs;
377            for number in sorted.issues {
378                match repo.open_pr_for_issue(number) {
379                    Some(pr) => {
380                        log!("#{number} is an issue; reviewing its open PR {}", pr.url);
381                        targets.push(pr.number);
382                    }
383                    None => logwarn!("#{number} is an issue with no open pull request to review"),
384                }
385            }
386            let mut results = Vec::new();
387            for number in targets {
388                results.push(review_only::review_pr(
389                    &agents, &cfg, &repo, number, dry_run,
390                ));
391            }
392            if results.is_empty() {
393                return Ok(0);
394            }
395            Ok(report(&results, &cfg))
396        }
397
398        Command::Checkin {
399            items,
400            common,
401            dry_run,
402            reply_only,
403            any_author,
404            again,
405            keep_worktrees,
406        } => {
407            let overrides = Overrides {
408                keep_worktrees: keep_worktrees.then_some(true),
409                ..Overrides::default()
410            };
411            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
412            let mode = checkin::Mode {
413                dry_run,
414                reply_only,
415                trust: if any_author {
416                    crate::config::Trust::Anyone
417                } else {
418                    cfg.loop_cfg.checkin_trust
419                },
420                again,
421                resolve: cfg.loop_cfg.checkin_resolve,
422                posts: checkin::posts(&cfg),
423            };
424            let numbers = if items.is_empty() {
425                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
426                if found.is_empty() {
427                    log!("no open PRs");
428                    return Ok(0);
429                }
430                log!(
431                    "no PRs given, checking in on {} open: {}",
432                    found.len(),
433                    found
434                        .iter()
435                        .map(|n| format!("#{n}"))
436                        .collect::<Vec<_>>()
437                        .join(", ")
438                );
439                found
440            } else {
441                items
442            };
443            let sorted = classify(&repo, &numbers)?;
444            let mut results = Vec::new();
445            for number in sorted.prs {
446                results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
447            }
448            // A change request left on the issue is exactly what this exists to
449            // catch, and when the issue has work open there is a branch to act
450            // on, so route to it rather than refusing.
451            for number in sorted.issues {
452                match repo.open_pr_for_issue(number) {
453                    Some(pr) => {
454                        log!(
455                            "#{number} is an issue; checking in on its open PR {}",
456                            pr.url
457                        );
458                        results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
459                    }
460                    None => {
461                        results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
462                    }
463                }
464            }
465            if results.is_empty() {
466                return Ok(0);
467            }
468            Ok(report(&results, &cfg))
469        }
470
471        Command::Split {
472            items,
473            common,
474            dry_run,
475            again,
476        } => {
477            let (cfg, repo, agents) = prepare(&common, None)?;
478            let mode = split::Mode { dry_run, again };
479            let picked = if items.is_empty() {
480                pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
481            } else {
482                let sorted = classify(&repo, &items)?;
483                let mut out: Vec<(i64, ItemKind)> = sorted
484                    .issues
485                    .iter()
486                    .map(|n| (*n, ItemKind::Issue))
487                    .collect();
488                out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
489                out
490            };
491
492            let mut results = Vec::new();
493            let mut seen: BTreeSet<i64> = BTreeSet::new();
494            for (number, kind) in picked {
495                // An issue whose work is half done produces children describing
496                // work that already exists on a branch, so it routes to the
497                // branch instead, the way `checkin` and `review` already do.
498                let target = match kind {
499                    ItemKind::Pr => Some(number),
500                    ItemKind::Issue => repo.open_pr_for_issue(number).map(|pr| {
501                        log!("#{number} is an issue; splitting its open PR {}", pr.url);
502                        pr.number
503                    }),
504                };
505                // An issue and the pull request it routes to can both be named,
506                // and the queue can hold both. Splitting one pull request twice
507                // makes two sets of branches and pull requests out of it, which
508                // `--again` would not stop.
509                if !seen.insert(target.unwrap_or(number)) {
510                    logdim!("#{number} was already covered by this run, skipping it");
511                    continue;
512                }
513                results.push(match target {
514                    Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
515                    None => split::split_issue(&agents, &cfg, &repo, number, &mode),
516                });
517            }
518            if results.is_empty() {
519                return Ok(0);
520            }
521            Ok(report(&results, &cfg))
522        }
523
524        Command::Post {
525            prs,
526            repo: repo_path,
527            config,
528            file,
529            dry_run,
530        } => cmd_post(
531            &prs,
532            &repo_path,
533            config.as_deref(),
534            file.as_deref(),
535            dry_run,
536        ),
537
538        Command::Init { out, force, update } => {
539            if update {
540                cmd_init_update(&out)
541            } else {
542                cmd_init(&out, force)
543            }
544        }
545        Command::Clean {
546            repo,
547            config,
548            all,
549            pr_state,
550        } => cmd_clean(&repo, config.as_deref(), all, pr_state),
551        Command::Triage {
552            issues,
553            common,
554            plan_out,
555        } => {
556            let (cfg, repo, agents) = prepare(&common, None)?;
557            let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
558            if numbers.is_empty() {
559                return Ok(0);
560            }
561            let sorted = classify(&repo, &numbers)?;
562            for number in &sorted.prs {
563                log!("#{number} is a pull request, nothing to triage");
564            }
565            if sorted.issues.is_empty() {
566                log!("no issues to triage");
567                return Ok(0);
568            }
569            let issues = repo.fetch_issues(&sorted.issues)?;
570            // Deliberately no act_on_plan here. `triage` is the command you
571            // reach for to look before leaping, and a preview that comments on
572            // and closes issues is a trap.
573            let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
574            // A preview that files issues and rewrites somebody's issue body is
575            // that same trap, so this prints the decomposition and writes none
576            // of it. It is where the first few real trackers should be checked.
577            if cfg.loop_cfg.decompose_trackers {
578                for item in plan.skipped.iter().filter(|i| i.tracker) {
579                    tracker::preview(&cfg, &repo, item.issue);
580                }
581            }
582            Ok(0)
583        }
584        Command::Run {
585            issues,
586            common,
587            loop_flags,
588            triage_flags,
589            plan_out,
590            no_worktrees,
591        } => {
592            let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
593            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
594            let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
595            if numbers.is_empty() {
596                return Ok(0);
597            }
598            let sorted = classify(&repo, &numbers)?;
599            let mut results = Vec::new();
600            work_issues(
601                &agents,
602                &cfg,
603                &repo,
604                sorted.issues.clone(),
605                &plan_out,
606                &mut results,
607            )?;
608
609            for number in sorted.prs {
610                results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
611            }
612
613            if results.is_empty() {
614                log!("nothing scheduled");
615                return Ok(0);
616            }
617            Ok(report(&results, &cfg))
618        }
619        Command::Followup {
620            common,
621            loop_flags,
622            triage_flags,
623            file,
624            screen_only,
625            file_only,
626            plan_out,
627            no_worktrees,
628        } => {
629            let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
630            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
631            let path = file.unwrap_or_else(|| repo.followups_path());
632            let mode = match (screen_only, file_only) {
633                (true, _) => followups::Mode::ScreenOnly,
634                (_, true) => followups::Mode::FileOnly,
635                _ => followups::Mode::Work,
636            };
637            let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
638
639            let wave = followups::wave(&outcome);
640            if mode != followups::Mode::Work || wave.is_empty() {
641                return Ok(outcome.exit_code());
642            }
643            let mut results = Vec::new();
644            work_issues(&agents, &cfg, &repo, wave, &plan_out, &mut results)?;
645            if results.is_empty() {
646                log!("nothing scheduled");
647                return Ok(outcome.exit_code());
648            }
649            Ok(report(&results, &cfg).max(outcome.exit_code()))
650        }
651
652        Command::Resume {
653            prs,
654            common,
655            loop_flags,
656            next_actor,
657        } => {
658            let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
659            if let Some(name) = &next_actor {
660                if !cfg.has_agent(name) {
661                    bail!("--next must be one of: {}", cfg.agent_names().join(", "));
662                }
663            }
664            let numbers = if prs.is_empty() {
665                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
666                if found.is_empty() {
667                    log!("no open PRs");
668                    return Ok(0);
669                }
670                log!(
671                    "no PRs given, taking {} open: {}",
672                    found.len(),
673                    found
674                        .iter()
675                        .map(|n| format!("#{n}"))
676                        .collect::<Vec<_>>()
677                        .join(", ")
678                );
679                found
680            } else {
681                prs
682            };
683            let sorted = classify(&repo, &numbers)?;
684            let mut results = Vec::new();
685            for number in sorted.prs {
686                results.push(review::resume_pr(
687                    &agents,
688                    &cfg,
689                    &repo,
690                    number,
691                    next_actor.as_deref(),
692                ));
693            }
694            // An issue number handed to `resume` is not a mistake worth
695            // refusing over. If work is already open for it, continue that.
696            for number in sorted.issues {
697                match repo.open_pr_for_issue(number) {
698                    Some(pr) => {
699                        log!("#{number} is an issue; continuing its open PR {}", pr.url);
700                        results.push(review::resume_pr(
701                            &agents,
702                            &cfg,
703                            &repo,
704                            pr.number,
705                            next_actor.as_deref(),
706                        ));
707                    }
708                    None => logwarn!(
709                        "#{number} is an issue with no open pull request. Use `spar run {number}` \
710                         to implement it."
711                    ),
712                }
713            }
714            if results.is_empty() {
715                return Ok(0);
716            }
717            Ok(report(&results, &cfg))
718        }
719    }
720}
721
722// ---------------------------------------------------------------------------
723// Shared setup
724// ---------------------------------------------------------------------------
725
726#[derive(Debug, Default, Clone)]
727struct Overrides {
728    max_rounds: Option<u32>,
729    auto_merge: Option<bool>,
730    keep_worktrees: Option<bool>,
731    worktrees: Option<bool>,
732    close_skipped: Option<bool>,
733    absorb: Option<u32>,
734}
735
736impl From<&LoopFlags> for Overrides {
737    fn from(flags: &LoopFlags) -> Self {
738        Self {
739            max_rounds: flags.max_rounds,
740            auto_merge: flags.auto_merge.then_some(true),
741            keep_worktrees: flags.keep_worktrees.then_some(true),
742            worktrees: None,
743            close_skipped: None,
744            absorb: flags.absorb,
745        }
746    }
747}
748
749impl Overrides {
750    /// What `run` and `followup` share past the loop flags: both triage, so
751    /// both can decline, and both work issues in a worktree apiece.
752    fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
753        let mut over = Overrides::from(loop_flags);
754        over.worktrees = if no_worktrees { Some(false) } else { None };
755        over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
756            (true, _) => Some(true),
757            (_, true) => Some(false),
758            _ => None,
759        };
760        over
761    }
762}
763
764/// Triage a set of issues, work them in dependency order, and fold each wave of
765/// newly filed follow-ups back in as the absorb budget allows.
766///
767/// Shared by `run` and `followup`, which differ only in where the first wave
768/// comes from: `run` takes it from the tracker, `followup` from the issues it
769/// just filed out of the local queue. Everything after that is the same
770/// pipeline, and it has to stay the same. An issue spar filed for itself gets
771/// no easier a ride through triage than one a person opened, which is the whole
772/// reason the screening pass is one agent and this is two.
773fn work_issues(
774    agents: &[Agent],
775    cfg: &Config,
776    repo: &Repo,
777    first_wave: Vec<i64>,
778    plan_out: &Path,
779    results: &mut Vec<IssueRun>,
780) -> Result<()> {
781    let mut handled: BTreeSet<i64> = BTreeSet::new();
782    let mut leftover: BTreeSet<i64> = BTreeSet::new();
783    let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
784    let mut plans_written = 0usize;
785
786    // The first wave is what was asked for. A wave of follow-ups the previous
787    // one filed is folded back in as the absorb budget allows, and a wave of
788    // children extracted from a tracker's checklist joins the same run because
789    // it is named work rather than picked work. Every wave is triaged like
790    // anything else, so both agents still have to agree each one is worth
791    // doing.
792    while let Some(mut wave) = queue.pop_front() {
793        wave.numbers.retain(|n| !handled.contains(n));
794        if wave.numbers.is_empty() {
795            continue;
796        }
797        match wave.parent {
798            Some(tracker) => log!(
799                "working {} item(s) from the checklist in #{tracker}: {}",
800                wave.numbers.len(),
801                numbers(&wave.numbers)
802            ),
803            None if wave.absorbed > 0 => log!(
804                "absorbing {} newly filed issue(s): {}",
805                wave.numbers.len(),
806                numbers(&wave.numbers)
807            ),
808            None => {}
809        }
810        handled.extend(wave.numbers.iter().copied());
811
812        let fetched = match repo.fetch_issues(&wave.numbers) {
813            Ok(fetched) => fetched,
814            Err(e) => {
815                logdim!("could not read the next wave: {e}");
816                continue;
817            }
818        };
819        // Named for the order the plans were written in, so the first is
820        // plan.json exactly as before and no two waves overwrite each other.
821        let plan_path = if plans_written == 0 {
822            plan_out.to_path_buf()
823        } else {
824            plan_out.with_extension(format!("wave{plans_written}.json"))
825        };
826        plans_written += 1;
827        let plan = make_plan(agents, cfg, repo, &fetched, &plan_path)?;
828        act_on_plan(cfg, repo, &plan);
829
830        // No recursion: a child that triage calls a tracker in its turn is
831        // commented on and held like any other. `file_non_blocking` records
832        // what happened the last time this codebase let something multiply.
833        if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
834            for item in plan.skipped.iter().filter(|i| i.tracker) {
835                let children = tracker::decompose(cfg, repo, item.issue);
836                if !children.is_empty() {
837                    queue.push_back(wave.child(item.issue, children));
838                }
839            }
840        }
841
842        let before = results.len();
843        for item in &plan.order {
844            let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
845                continue;
846            };
847            results.push(review::run_issue(agents, cfg, repo, item, issue));
848        }
849
850        // Whatever this wave filed becomes the next one, budget allowing.
851        let filed: Vec<i64> = results[before..]
852            .iter()
853            .flat_map(|r| r.filed.iter())
854            .filter_map(|url| review::filed_issue_number(url))
855            .filter(|n| !handled.contains(n))
856            .collect::<BTreeSet<_>>()
857            .into_iter()
858            .collect();
859        if filed.is_empty() {
860            continue;
861        }
862        match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
863            true => queue.push_back(wave.absorb(filed)),
864            false => leftover.extend(filed),
865        }
866    }
867    if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
868        log!(
869            "{} issue(s) filed in the last wave were left for a later run: {}",
870            leftover.len(),
871            numbers(&leftover.iter().copied().collect::<Vec<_>>())
872        );
873    }
874    Ok(())
875}
876
877/// One pass of the pipeline, and where it came from.
878struct Wave {
879    numbers: Vec<i64>,
880    /// Absorb rounds spent to reach it. A tracker's children inherit their
881    /// parent's, because extracting named work is not absorbing a follow-up:
882    /// `absorb_new_issues` is off by default, and spending it here would make
883    /// `decompose_trackers` silently do nothing.
884    absorbed: u32,
885    /// The tracker whose checklist this wave came out of.
886    parent: Option<i64>,
887}
888
889impl Wave {
890    fn first(numbers: Vec<i64>) -> Self {
891        Self {
892            numbers,
893            absorbed: 0,
894            parent: None,
895        }
896    }
897
898    fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
899        Self {
900            numbers,
901            absorbed: self.absorbed,
902            parent: Some(tracker),
903        }
904    }
905
906    fn absorb(&self, numbers: Vec<i64>) -> Self {
907        Self {
908            numbers,
909            absorbed: self.absorbed + 1,
910            parent: None,
911        }
912    }
913}
914
915fn numbers(items: &[i64]) -> String {
916    items
917        .iter()
918        .map(|n| format!("#{n}"))
919        .collect::<Vec<_>>()
920        .join(", ")
921}
922
923fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
924    let mut cfg = config::load(common.config.as_deref())?;
925
926    if let Some(first) = &common.first {
927        if !cfg.has_agent(first) {
928            bail!("--first must be one of: {}", cfg.agent_names().join(", "));
929        }
930        cfg.first_implementor = first.clone();
931    }
932    if let Some(base) = &common.base {
933        cfg.loop_cfg.base_branch = base.clone();
934    }
935    if let Some(min) = common.min_number {
936        cfg.loop_cfg.min_number = min;
937    }
938    // Added to the config's, not in place of them. One is what this repository
939    // always wants and the other is what today wants, and a flag that silenced
940    // the standing set would be a trap: you would notice it the run after.
941    if let Some(extra) = common.instructions.as_deref().map(str::trim) {
942        if !extra.is_empty() {
943            let standing = cfg.loop_cfg.instructions.trim();
944            cfg.loop_cfg.instructions = if standing.is_empty() {
945                extra.to_string()
946            } else {
947                format!("{standing}\n{extra}")
948            };
949        }
950    }
951    if let Some(over) = overrides {
952        if let Some(v) = over.max_rounds {
953            if v == 0 {
954                bail!("--max-rounds must be at least 1");
955            }
956            cfg.loop_cfg.max_rounds = v;
957        }
958        if let Some(v) = over.auto_merge {
959            cfg.loop_cfg.auto_merge = v;
960        }
961        if let Some(v) = over.keep_worktrees {
962            cfg.loop_cfg.keep_worktrees = v;
963        }
964        if let Some(v) = over.worktrees {
965            cfg.loop_cfg.worktrees = v;
966        }
967        if let Some(v) = over.close_skipped {
968            cfg.loop_cfg.close_skipped = v;
969        }
970        if let Some(v) = over.absorb {
971            cfg.loop_cfg.absorb_new_issues = v;
972        }
973    }
974
975    let repo = Repo::open(&common.repo, &cfg)?;
976    if common.base.is_none() {
977        cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
978    }
979
980    let agents = agent::build(&cfg)?;
981    if let Some(warning) = agent::correlation_warning(&agents) {
982        logging::warn(warning);
983    }
984
985    // Sweep finished worktrees before starting, so they cannot accumulate.
986    for stale in repo.prune_worktrees(false) {
987        let what = if stale.starts_with("branch ") {
988            stale
989        } else {
990            format!("worktree {stale}")
991        };
992        logdim!("cleaned up finished {what}");
993    }
994
995    log!("repo {} base {}", repo.root().display(), cfg.base_branch());
996    log!(
997        "agents: {}",
998        agents
999            .iter()
1000            .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1001            .collect::<Vec<_>>()
1002            .join(", ")
1003    );
1004    Ok((cfg, repo, agents))
1005}
1006
1007fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1008    if !given.is_empty() {
1009        // Naming a number is the point, so a floor never overrides it.
1010        if min_number > 0 {
1011            let below: Vec<String> = given
1012                .iter()
1013                .filter(|n| **n < min_number)
1014                .map(|n| format!("#{n}"))
1015                .collect();
1016            if !below.is_empty() {
1017                logdim!(
1018                    "{} below the #{min_number} floor, taking them because you named them",
1019                    below.join(", ")
1020                );
1021            }
1022        }
1023        return Ok(given);
1024    }
1025    let found = repo.list_open_issues(limit, min_number)?;
1026    if found.is_empty() {
1027        log!("no open issues");
1028        return Ok(found);
1029    }
1030    log!(
1031        "no issues given, taking {} open: {}",
1032        found.len(),
1033        found
1034            .iter()
1035            .map(|n| format!("#{n}"))
1036            .collect::<Vec<_>>()
1037            .join(", ")
1038    );
1039    Ok(found)
1040}
1041
1042/// The bare `spar split`: every open issue and every open pull request, then
1043/// one screening call over both.
1044///
1045/// The first command that means both kinds when given nothing, so it says so
1046/// out loud. `--limit` applies to each list rather than to the two together: a
1047/// single budget shared across both would fill up on issues and starve the pull
1048/// requests. `min_number` applies because this is picked work rather than named
1049/// work.
1050///
1051/// One agent rules on the whole list in one call rather than two calls per
1052/// item, because two agent calls per item across a whole queue is the wrong
1053/// price for a question whose answer is usually no.
1054fn pick_for_split(
1055    agents: &[Agent],
1056    cfg: &Config,
1057    repo: &Repo,
1058    limit: usize,
1059    mode: &split::Mode,
1060) -> Result<Vec<(i64, ItemKind)>> {
1061    let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1062    let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1063    log!(
1064        "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1065        issues.len(),
1066        prs.len()
1067    );
1068    if issues.is_empty() && prs.is_empty() {
1069        return Ok(Vec::new());
1070    }
1071
1072    let issue_rows = repo.open_issue_rows();
1073    let pr_rows = repo.open_pr_rows();
1074    let mut candidates: Vec<split::Candidate> = Vec::new();
1075    let mut already = 0usize;
1076
1077    for number in &issues {
1078        let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1079            logdim!("could not read #{number}, leaving it alone");
1080            continue;
1081        };
1082        // Free here, because the body is already in hand. A pull request that
1083        // has been split is caught by `split_pr`, which reads its comments.
1084        if split::already_split(row.body_text()) && !mode.again {
1085            already += 1;
1086            continue;
1087        }
1088        candidates.push(split::Candidate::from_issue(row));
1089    }
1090    for number in &prs {
1091        match pr_rows.iter().find(|p| p.number == *number) {
1092            Some(row) => candidates.push(split::Candidate::from_pr(row)),
1093            None => logdim!("could not read PR #{number}, leaving it alone"),
1094        }
1095    }
1096    if already > 0 {
1097        log!("{already} issue(s) already split, skipped. --again reopens them.");
1098    }
1099    if candidates.is_empty() {
1100        return Ok(Vec::new());
1101    }
1102
1103    let agent = agent::find(agents, &cfg.first_implementor)?;
1104    log!(
1105        "screening {} item(s) with {}",
1106        candidates.len(),
1107        agent.name()
1108    );
1109    let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1110
1111    let mut picked = Vec::new();
1112    for candidate in &candidates {
1113        match verdicts.iter().find(|v| v.item == candidate.number) {
1114            Some(v) if v.split => {
1115                log!("  split #{}: {}", candidate.number, v.reason.trim());
1116                picked.push((candidate.number, candidate.kind));
1117            }
1118            // Not a warning: no is the expected answer, and one line per item
1119            // saying so is the whole screen printed twice.
1120            Some(v) => logdim!("  leave #{} whole: {}", candidate.number, v.reason.trim()),
1121            None => logdim!("  no verdict for #{}, leaving it whole", candidate.number),
1122        }
1123    }
1124    if picked.is_empty() {
1125        log!("nothing worth splitting");
1126    }
1127    Ok(picked)
1128}
1129
1130/// Numbers split by what they actually name.
1131///
1132/// Issues and pull requests share one number sequence per repository, so a
1133/// person should not have to remember which command takes which. Both `run` and
1134/// `resume` sort the numbers themselves and route each one.
1135#[derive(Debug, Default)]
1136struct Sorted {
1137    issues: Vec<i64>,
1138    prs: Vec<i64>,
1139}
1140
1141fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1142    let mut sorted = Sorted::default();
1143    for number in numbers {
1144        match repo.item_kind(*number)? {
1145            ItemKind::Issue => sorted.issues.push(*number),
1146            ItemKind::Pr => sorted.prs.push(*number),
1147        }
1148    }
1149    if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1150        log!(
1151            "{} issue(s) and {} pull request(s) given",
1152            sorted.issues.len(),
1153            sorted.prs.len()
1154        );
1155    }
1156    Ok(sorted)
1157}
1158
1159fn make_plan(
1160    agents: &[Agent],
1161    cfg: &Config,
1162    repo: &Repo,
1163    issues: &[Issue],
1164    plan_out: &Path,
1165) -> Result<Plan> {
1166    let plan = triage::triage(agents, cfg, repo, issues)?;
1167
1168    std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1169        .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1170    log!("plan written to {}", plan_out.display());
1171
1172    for item in &plan.order {
1173        log!(
1174            "  do   #{} [{}/{}] {}",
1175            item.issue,
1176            item.complexity,
1177            item.risk,
1178            item.title
1179        );
1180    }
1181    for item in &plan.skipped {
1182        if item.tracker {
1183            log!(
1184                "  hold #{} (both reviewers: tracks work filed elsewhere)",
1185                item.issue
1186            );
1187        } else {
1188            log!("  skip #{} (both reviewers: not worth doing)", item.issue);
1189        }
1190    }
1191    for item in &plan.contested {
1192        log!("  ??   #{} contested, parked for you to decide", item.issue);
1193    }
1194    Ok(plan)
1195}
1196
1197/// Post the shared reasoning on every issue both agents declined, and close it
1198/// when the config says so. Contested issues are never touched.
1199///
1200/// A tracker is never closed, whatever `close_skipped` says. Declining to open
1201/// a pull request for an umbrella is right, and closing it does not follow from
1202/// that: its parts are still open, and the shared context and the alternatives
1203/// somebody recorded against are the reason the issue exists. spar closed a
1204/// real one as "not planned" while all three of its subtasks were open, which
1205/// is what this exists to stop.
1206fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1207    for item in &plan.skipped {
1208        let body = review::skip_comment(item, &repo.style);
1209        let close = cfg.loop_cfg.close_skipped && !item.tracker;
1210        let outcome = if close {
1211            repo.close_issue(item.issue, &body)
1212        } else {
1213            repo.comment_issue(item.issue, &body)
1214        };
1215        match outcome {
1216            Ok(()) if close => log!("  closed #{}", item.issue),
1217            Ok(()) if item.tracker => {
1218                log!(
1219                    "  left #{} open, it tracks work filed elsewhere",
1220                    item.issue
1221                )
1222            }
1223            Ok(()) => {}
1224            Err(e) => logdim!("could not update #{}: {e}", item.issue),
1225        }
1226    }
1227}
1228
1229// ---------------------------------------------------------------------------
1230// Subcommands
1231// ---------------------------------------------------------------------------
1232
1233fn cmd_scrub_filter() -> Result<i32> {
1234    let mut input = String::new();
1235    std::io::stdin()
1236        .read_to_string(&mut input)
1237        .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1238    let out = style::scrub(&input, &crate::repo::style_from_env());
1239    let mut stdout = std::io::stdout();
1240    stdout
1241        .write_all(out.as_bytes())
1242        .and_then(|_| stdout.write_all(b"\n"))
1243        .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1244    Ok(0)
1245}
1246
1247fn cmd_clean(
1248    repo_path: &Path,
1249    config_path: Option<&Path>,
1250    all: bool,
1251    pr_state: bool,
1252) -> Result<i32> {
1253    let cfg = config::load(config_path)?;
1254    let repo = Repo::open(repo_path, &cfg)?;
1255    let mut removed = repo.prune_worktrees(all);
1256    removed.extend(repo.prune_state());
1257    if pr_state {
1258        removed.extend(repo.prune_pr_state(None));
1259    }
1260    if removed.is_empty() {
1261        println!("nothing to clean");
1262    } else {
1263        for item in removed {
1264            println!("removed {item}");
1265        }
1266    }
1267    Ok(0)
1268}
1269
1270/// Post a review that was produced earlier and not sent.
1271fn cmd_post(
1272    prs: &[i64],
1273    repo_path: &Path,
1274    config_path: Option<&Path>,
1275    file: Option<&Path>,
1276    dry_run: bool,
1277) -> Result<i32> {
1278    let cfg = config::load(config_path)?;
1279    let repo = Repo::open(repo_path, &cfg)?;
1280
1281    if file.is_some() && prs.len() > 1 {
1282        bail!("--file posts one review, so give it one pull request number");
1283    }
1284
1285    let mut failed = false;
1286    for number in prs {
1287        let text = match file {
1288            Some(path) => std::fs::read_to_string(path)
1289                .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1290            None => match repo.read_pending_comment(*number) {
1291                Some(text) => text,
1292                None => {
1293                    logging::error(format!(
1294                        "no saved review for PR #{number}. `spar review {number} --dry-run` \
1295                         produces one, or pass --file."
1296                    ));
1297                    failed = true;
1298                    continue;
1299                }
1300            },
1301        };
1302        if text.trim().is_empty() {
1303            logging::error(format!("the saved review for PR #{number} is empty"));
1304            failed = true;
1305            continue;
1306        }
1307        if dry_run {
1308            println!("\n{}\n", text.trim());
1309            log!("would post the above to PR #{number}");
1310            continue;
1311        }
1312        // Through the style gate like anything else spar sends, so an edit that
1313        // reintroduces a banned dash is caught rather than published.
1314        match repo.comment_pr(*number, &text) {
1315            Ok(()) => log!("posted to PR #{number}"),
1316            Err(e) => {
1317                logging::error(format!("could not post to PR #{number}: {e}"));
1318                failed = true;
1319            }
1320        }
1321    }
1322    Ok(if failed { 1 } else { 0 })
1323}
1324
1325/// Append the settings a config does not mention, commented out.
1326///
1327/// Append only by design. Rewriting somebody's config to insert options would
1328/// take their comments and their ordering with it, and `--force` already exists
1329/// for anyone who wants the generated file back.
1330fn cmd_init_update(out: &Path) -> Result<i32> {
1331    let text = std::fs::read_to_string(out)
1332        .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1333    // Refuse to append to something that does not parse, rather than making a
1334    // broken config longer.
1335    config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1336
1337    let unset = config::unmentioned_options(&text);
1338    if unset.is_empty() {
1339        println!("{} already mentions every setting.", out.display());
1340        return Ok(0);
1341    }
1342
1343    let mut block = String::new();
1344    if !text.ends_with('\n') {
1345        block.push('\n');
1346    }
1347    block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1348    block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1349    let mut section = "";
1350    for option in &unset {
1351        if option.section != section {
1352            section = option.section;
1353            block.push_str(&format!("\n# [{section}]\n"));
1354        }
1355        // With the same note `spar init` writes. A config that gained a setting
1356        // this way used to gain a bare line and nothing saying what it was for,
1357        // which is the half of the setting that matters when you are reading it
1358        // for the first time.
1359        block.push('\n');
1360        block.push_str(&wrap_comment(note_for(&option.key)));
1361        block.push_str(&format!("# {} = {}\n", option.key, option.default));
1362    }
1363
1364    use std::io::Write;
1365    std::fs::OpenOptions::new()
1366        .append(true)
1367        .open(out)
1368        .and_then(|mut f| f.write_all(block.as_bytes()))
1369        .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1370
1371    println!(
1372        "added {} setting(s) to {} as comments",
1373        unset.len(),
1374        out.display()
1375    );
1376    Ok(0)
1377}
1378
1379fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1380    if out.exists() && !force {
1381        logging::error(format!(
1382            "{} already exists. `--update` appends any settings it does not mention, \
1383             `--force` overwrites it.",
1384            out.display()
1385        ));
1386        return Ok(1);
1387    }
1388
1389    let presets = config::available_presets();
1390    if presets.is_empty() {
1391        bail!("no presets available, which should be impossible in a released build");
1392    }
1393
1394    let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1395    for name in &presets {
1396        let raw = config::load_preset(name)?;
1397        // A preset that will not build is a broken preset, not an uninstalled
1398        // CLI. Skipping it silently reported it as "missing" and sent people
1399        // looking for an install problem that was not there.
1400        let mut spec: config::AgentSpec = match raw
1401            .as_table()
1402            .cloned()
1403            .ok_or_else(|| spar_err!("not a table"))
1404            .and_then(|t| {
1405                toml::Value::Table(t)
1406                    .try_into()
1407                    .map_err(|e| spar_err!("{e}"))
1408            }) {
1409            Ok(spec) => spec,
1410            Err(e) => {
1411                println!("  BROKEN   {name:10} {}", e.first_line());
1412                continue;
1413            }
1414        };
1415        spec.name = name.clone();
1416        match Agent::new(spec.clone()).resolve_bin() {
1417            Ok(path) => {
1418                println!("  found    {name:10} {}", path.display());
1419                found.push((name.clone(), path.to_path_buf(), spec));
1420            }
1421            Err(_) => println!("  missing  {name}"),
1422        }
1423    }
1424
1425    if found.len() < 2 {
1426        logging::error(format!(
1427            "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1428             presets as a reference.",
1429            found.len(),
1430            out.display()
1431        ));
1432        return Ok(1);
1433    }
1434
1435    // Prefer a pair that cannot share blind spots, if one is available.
1436    let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1437    if found.len() > 2 {
1438        log!(
1439            "{} agents available, picking {} and {}. Edit {} to change.",
1440            found.len(),
1441            chosen[0].0,
1442            chosen[1].0,
1443            out.display()
1444        );
1445    }
1446
1447    let mut text = String::from(
1448        "# Generated by `spar init`. Each agent inherits a command template from a\n\
1449         # built in preset; anything set here overrides it.\n\
1450         #\n\
1451         # Commented lines are the other options, each with a working value.\n\
1452         # Uncomment one to change it.\n\n",
1453    );
1454    for (name, _, spec) in &chosen {
1455        text.push_str(&agent_block(name, spec));
1456    }
1457    text.push_str(&settings_block(&chosen[0].0));
1458
1459    std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1460    println!("\nwrote {}", out.display());
1461    println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1462    Ok(0)
1463}
1464
1465/// An agent's stand in, under the agent it stands in for.
1466///
1467/// Never counted against `doctor`'s exit code, deliberately. A fallback that is
1468/// not installed does not stop a run either, and a check that disagrees with
1469/// the runtime teaches people to ignore it.
1470fn report_fallback(agent: &Agent) {
1471    let Some(backup) = agent.fallback() else {
1472        return;
1473    };
1474    match backup.resolve_bin() {
1475        Ok(bin) => println!(
1476            "        fallback    {}  ({})",
1477            bin.display(),
1478            backup.spec.describe()
1479        ),
1480        Err(_) => println!(
1481            "        fallback    {} not found, so it will not stand in. Set {} to its path.",
1482            backup.program(),
1483            backup.env_key()
1484        ),
1485    }
1486}
1487
1488/// One settable option: whether the generated config leaves it commented out,
1489/// its key, and the note beside it.
1490///
1491/// The value is deliberately absent. Every value comes from the defaults
1492/// themselves, because a value typed in here is a second copy of a number that
1493/// lives somewhere else, and the second copy is the one that goes stale. This
1494/// one did: the generated config offered a title budget of 90, a summary of
1495/// 200, a detail of 320, a body of 900 and an issue body of 4000, long after
1496/// those became 140, 2000, 6000, 8000 and 20000. Uncommenting a line to see
1497/// what it did cut every comment spar posts to a fifth of its length.
1498type Setting = (bool, &'static str, &'static str);
1499
1500const LOOP_OPTIONS: &[Setting] = &[
1501    (false, "max_rounds", "In the custody loop, review rounds one invocation may spend asking for changes. A full-budget run may then use one extra closing pass. In `spar review`, this selects up to three phases: independent review, cross-adjudication, and rebuttal. Resuming a custody run grants a fresh budget, so this is not a lifetime cap."),
1502    (false, "auto_merge", "Merge when no blocking findings remain. Off on purpose: two models agreeing is not the same as being right, and neither carries the consequences."),
1503    (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1504    (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1505    (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1506    (false, "followups", "Where a follow-up goes. issues files them, local writes .spar/followups.md and leaves the tracker alone, none drops them. `spar followup` works that file."),
1507    (true, "file_non_blocking", "File a non-blocking finding as a follow-up. Off, because not gating a merge is not the same as deserving somebody's triage queue."),
1508    (true, "max_followups", "Most follow-ups one run may record before it stops and says what it dropped. A backstop, not a target. `spar followup` is bounded by --limit instead."),
1509    (true, "max_split_parts", "Most parts `spar split` will make out of one issue or pull request. A backstop against a queue nobody asked for, not a target, and what it holds back is said out loud. A part is never itself split, so if one is still too big, `spar split <part>` is one command away."),
1510    (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1511    (true, "min_number", "Ignore issues and pull requests numbered below this when spar picks for itself. 0 is no floor, and a number you name explicitly is always honoured."),
1512    (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1513    (true, "absorb_new_issues", "Waves of newly filed follow-ups to fold back into this run rather than leaving them for the next one. Multiplies what a run costs."),
1514    (true, "decompose_trackers", "Turn the checklist in a tracking issue into issues, link each item to the one covering it, tick an item off when its issue closes, and work the children in this run. Only ever acts on markdown task list items, so it is opt in per issue as well. `spar triage` prints what it would do without writing any of it."),
1515    (true, "max_tracker_children", "Most unchecked items from one tracker's checklist that one run will act on. A cap, not a target, and what it left is named out loud."),
1516    (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1517    (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1518    (true, "branch_prefix", "Namespace the branches spar creates, for example \"spar/\". Without it they are issue-N, pr-N, and split-N-I with a suffix on repeated split names."),
1519    (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1520    (true, "drafts", "Whether a pull request starts as a draft. until_approved opens one and marks it ready when the review converges, which is what the draft was saying while two agents were still arguing about it. always opens one and leaves it, and cannot be combined with auto_merge."),
1521    (true, "instructions", "Extra instructions handed to both agents with every request, for what this repository always wants that spar has no setting for. --instructions adds to this for one run."),
1522    (true, "max_issue_chars", "Most of one issue body that reaches a prompt. Sized so nothing a person wrote is cut, and a cut is said out loud when it happens."),
1523    (true, "max_triage_chars", "Most every issue body together may add to one triage prompt, or every recorded follow-up in one screening prompt. Past it, whole items wait for the next run rather than all of them losing their tails."),
1524    (true, "checkin_trust", "Whose comments `spar checkin` will act on. write is anybody GitHub says can write to this repository, which is the default because acting on a comment means pushing a commit to somebody's branch. anyone answers everyone, and still only changes code when both agents agree."),
1525    (true, "checkin_resolve", "Mark a review thread resolved when spar made the change it asked for. A thread spar disagreed with is left open whatever this says."),
1526    (true, "max_checkin_comments", "Most unanswered comments spar will answer on one pull request in a run. A backstop against a long argument being read back to somebody, not a target."),
1527];
1528
1529const STYLE_OPTIONS: &[Setting] = &[
1530    (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1531    (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1532    (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1533    (true, "pr_comments", "How much of its own working spar narrates into a pull request thread. outcome is one comment at the end, rounds is an audit trail, none never comments at all."),
1534    (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1535    (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1536    (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1537    (true, "max_body_chars", "A pull request body."),
1538    (true, "max_issue_body_chars", "A filed issue's body. Far larger on purpose: an issue is picked up cold months later. Fenced code blocks are never truncated and never count against it."),
1539];
1540
1541/// The `[loop]` and `[style]` blocks of a generated config.
1542///
1543/// Safety valves, not editors: the length budgets here are sized so real
1544/// content is never touched, which is why they read as large numbers.
1545fn settings_block(first_implementor: &str) -> String {
1546    let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1547        .into_iter()
1548        .map(|option| (option.key, option.default))
1549        .collect();
1550    // first_implementor has no default: it is whichever agent was written
1551    // first, and until there is a config there is no answer to give.
1552    let value = |key: &str| match key {
1553        "first_implementor" => format!("\"{first_implementor}\""),
1554        other => defaults.get(other).cloned().unwrap_or_default(),
1555    };
1556
1557    let mut out = String::from("[loop]\n");
1558    out.push_str(&option_lines(LOOP_OPTIONS, &value));
1559    out.push_str(concat!(
1560        "\n[loop.effort_schedule]\n",
1561        "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1562        "# these are examples rather than defaults. Left out, each agent uses\n",
1563        "# the effort its own block asked for.\n",
1564        "# round_1 = \"high\"   # the deep first review\n",
1565        "# rest    = \"low\"    # later rounds and the closing pass\n\n",
1566    ));
1567    out.push_str("[style]\n");
1568    out.push_str(&option_lines(STYLE_OPTIONS, &value));
1569    out
1570}
1571
1572/// Option lines with their notes lined up in a column, a long note wrapping
1573/// onto continuation lines that stay in the column rather than running off the
1574/// edge or restarting at the margin.
1575fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1576    let mut out = String::new();
1577    for (commented, key, note) in options {
1578        if !out.is_empty() {
1579            out.push('\n');
1580        }
1581        out.push_str(&wrap_comment(note));
1582        let lead = if *commented { "# " } else { "" };
1583        out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1584    }
1585    out
1586}
1587
1588/// One prerequisite check: a label and something that either reports a version
1589/// or explains what is missing.
1590type Probe = Box<dyn Fn() -> Result<String>>;
1591
1592/// One agent's block, with the options commented out beside a working value.
1593///
1594/// The values come from the preset rather than from here, so a CLI that adds a
1595/// model is a file edit. They are hints only: nothing validates against them,
1596/// because a stale list that refused a model which actually works would be
1597/// worse than no hint at all.
1598fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1599    let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1600
1601    // Only what the preset has hints for. An option with none used to be
1602    // written as `# effort = "..."`, and a placeholder is not a working value:
1603    // the line fails the moment somebody takes the file at its word and
1604    // uncomments it. Cursor has no effort setting at all, so for that agent the
1605    // line should not exist rather than exist and be wrong.
1606    //
1607    // The first entry of each list is the one written as the suggested value,
1608    // which is why the presets put the sensible default there rather than in
1609    // whatever order a CLI's help happens to print.
1610    // Once, above both. The preset's note is about the pair, and repeating it
1611    // under each put a sentence about models underneath the effort line.
1612    if let Some(extra) = &spec.options_note {
1613        if !spec.models.is_empty() || !spec.efforts.is_empty() {
1614            out.push('\n');
1615            out.push_str(&wrap_comment(extra));
1616        }
1617    }
1618    for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1619        let Some(suggested) = choices.first() else {
1620            continue;
1621        };
1622        let mut note = format!("Omit {key} to use the CLI's own default.");
1623        if choices.len() > 1 {
1624            note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1625        }
1626        out.push('\n');
1627        out.push_str(&wrap_comment(&note));
1628        out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1629    }
1630
1631    // The value from the spec, not a number typed here, for the reason the
1632    // [loop] block learned: a second copy of a default is the one that goes
1633    // stale.
1634    out.push('\n');
1635    out.push_str(&wrap_comment(
1636        "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1637         never retried, so err long.",
1638    ));
1639    out.push_str(&format!("# timeout = {}\n", spec.timeout));
1640
1641    // Anything but this agent's own preset: a CLI that has just refused is not
1642    // a stand in for itself.
1643    let backup = if name == "cursor" { "gemini" } else { "cursor" };
1644    out.push('\n');
1645    out.push_str(&wrap_comment(
1646        "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1647         of this agent, never alongside it.",
1648    ));
1649    out.push_str(&format!(
1650        "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1651    ));
1652
1653    // The rest of what an agent block takes defines a CLI rather than tunes
1654    // one, so it is pointed at rather than offered: a generated file that
1655    // invites somebody to edit `command` or `output` on a working preset is
1656    // offering them a way to break it.
1657    out.push('\n');
1658    out.push_str(&wrap_comment(
1659        "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1660         that has no preset.",
1661    ));
1662    out.push('\n');
1663    out
1664}
1665
1666/// What `spar init` says about an option, for `--update` to say too.
1667///
1668/// Empty for one with nothing written about it, and for the effort schedule,
1669/// whose two keys are examples rather than settings and are described by the
1670/// stanza they sit in rather than one at a time.
1671fn note_for(key: &str) -> &'static str {
1672    LOOP_OPTIONS
1673        .iter()
1674        .chain(STYLE_OPTIONS)
1675        .find(|(_, name, _)| *name == key)
1676        .map(|(_, _, note)| *note)
1677        .unwrap_or("")
1678}
1679
1680/// Wrap a note across comment lines so a long one does not run off the edge.
1681fn wrap_comment(text: &str) -> String {
1682    const WIDTH: usize = 76;
1683    let mut out = String::new();
1684    let mut line = String::from("#");
1685    for word in text.split_whitespace() {
1686        if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1687            out.push_str(&line);
1688            out.push('\n');
1689            line = String::from("#");
1690        }
1691        line.push(' ');
1692        line.push_str(word);
1693    }
1694    if line.len() > 1 {
1695        out.push_str(&line);
1696        out.push('\n');
1697    }
1698    out
1699}
1700
1701fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1702    let mut ok = true;
1703
1704    let probes: Vec<(&str, Probe)> = vec![
1705        (
1706            "git",
1707            Box::new(|| {
1708                proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1709                    .map(|s| first_line(&s))
1710            }),
1711        ),
1712        (
1713            "gh",
1714            Box::new(|| {
1715                proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1716                    .map(|s| first_line(&s))
1717            }),
1718        ),
1719        (
1720            "gh auth",
1721            Box::new(|| {
1722                let out = proc::exec(
1723                    &["gh".into(), "auth".into(), "status".into()],
1724                    &ExecOpts::new().check(false).timeout_secs(60),
1725                )?;
1726                let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1727                if out.ok() {
1728                    Ok(first_line(&text))
1729                } else {
1730                    Err(spar_err!("not authenticated. Run `gh auth login`."))
1731                }
1732            }),
1733        ),
1734    ];
1735
1736    for (label, probe) in probes {
1737        match probe() {
1738            Ok(detail) => println!("  ok    {label:12} {detail}"),
1739            Err(e) => {
1740                println!("  FAIL  {label:12} {}", e.first_line());
1741                ok = false;
1742            }
1743        }
1744    }
1745
1746    let found = config::find_config(config_path)?;
1747    let Some(path) = found else {
1748        println!("\n  no spar.toml found. Run `spar init` to generate one.");
1749        println!(
1750            "  presets available: {}",
1751            config::available_presets().join(", ")
1752        );
1753        return Ok(if ok { 0 } else { 1 });
1754    };
1755
1756    println!("\n  config: {}", path.display());
1757    let cfg = match config::load(Some(&path)) {
1758        Ok(cfg) => cfg,
1759        Err(e) => {
1760            println!("  FAIL  config       {e}");
1761            return Ok(1);
1762        }
1763    };
1764
1765    // Kept apart from `ok`: a missing gh says nothing about whether the two
1766    // agents are the same CLI, and must not silence the warning below.
1767    let mut resolved = Vec::new();
1768    for spec in &cfg.agents {
1769        let agent = Agent::new(spec.clone());
1770        match agent.resolve_bin() {
1771            Ok(bin) => {
1772                println!(
1773                    "  ok    {:12} {}  ({})",
1774                    spec.name,
1775                    bin.display(),
1776                    spec.describe()
1777                );
1778                report_fallback(&agent);
1779                resolved.push(agent);
1780            }
1781            Err(e) => {
1782                println!("  FAIL  {:12} {}", spec.name, e.first_line());
1783                ok = false;
1784            }
1785        }
1786    }
1787
1788    if resolved.len() == cfg.agents.len() {
1789        if let Some(warning) = agent::correlation_warning(&resolved) {
1790            println!("\n  WARNING  {warning}");
1791        }
1792    }
1793
1794    println!(
1795        "\n  settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1796        cfg.loop_cfg.max_rounds,
1797        cfg.loop_cfg.auto_merge,
1798        cfg.loop_cfg.worktrees,
1799        cfg.loop_cfg.followups,
1800        cfg.style.terse
1801    );
1802    // What somebody upgrading wants to know. `spar init` refuses to touch an
1803    // existing config, so without this there is no way to learn that a release
1804    // added a setting short of reading the source.
1805    if let Ok(text) = std::fs::read_to_string(&path) {
1806        let unset = config::unmentioned_options(&text);
1807        if !unset.is_empty() {
1808            println!(
1809                "\n  {} setting(s) this config does not mention, all at their defaults:",
1810                unset.len()
1811            );
1812            for option in &unset {
1813                println!(
1814                    "      [{}] {} = {}",
1815                    option.section, option.key, option.default
1816                );
1817            }
1818            println!(
1819                "  `spar init --update {}` appends them as comments.",
1820                path.display()
1821            );
1822        }
1823    }
1824
1825    println!(
1826        "{}",
1827        if ok {
1828            "\nready"
1829        } else {
1830            "\nmissing prerequisites"
1831        }
1832    );
1833    Ok(if ok { 0 } else { 1 })
1834}
1835
1836fn first_line(text: &str) -> String {
1837    text.trim().lines().next().unwrap_or("").trim().to_string()
1838}
1839
1840// ---------------------------------------------------------------------------
1841// Reporting
1842// ---------------------------------------------------------------------------
1843
1844fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1845    println!("\n{}", "=".repeat(60));
1846    for r in results {
1847        println!(
1848            "#{:<5} {:<10} rounds={} {}",
1849            r.issue,
1850            r.status.to_string(),
1851            r.rounds,
1852            r.pr.as_deref().unwrap_or("")
1853        );
1854        for note in &r.notes {
1855            println!("       {}", first_line(note));
1856        }
1857        for url in &r.filed {
1858            println!("       filed {url}");
1859        }
1860        for dispute in &r.disputes {
1861            println!(
1862                "       disputed: {}",
1863                report_item(&dispute.title, &dispute.file)
1864            );
1865        }
1866        for finding in &r.noted {
1867            println!(
1868                "       noted, not blocking: {}",
1869                report_item(&finding.title, &finding.file)
1870            );
1871        }
1872    }
1873    println!("{}", "=".repeat(60));
1874
1875    if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1876        println!("\nApproved PRs are waiting on you to merge.");
1877    }
1878    let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1879    if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1880        println!(
1881            "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1882             Set followups = \"issues\" to file them."
1883        );
1884    }
1885    if results.iter().all(IssueRun::succeeded) {
1886        0
1887    } else {
1888        1
1889    }
1890}
1891
1892fn report_item(title: &str, file: &str) -> String {
1893    match file.trim() {
1894        "" => title.to_string(),
1895        location => format!("{title} ({location})"),
1896    }
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901    use super::*;
1902    use clap::CommandFactory;
1903
1904    #[test]
1905    fn report_items_include_their_location() {
1906        assert_eq!(
1907            "Same title (src/a.rs:10)",
1908            report_item("Same title", "src/a.rs:10")
1909        );
1910        assert_eq!("General point", report_item("General point", ""));
1911    }
1912
1913    #[test]
1914    fn the_parser_is_internally_consistent() {
1915        Cli::command().debug_assert();
1916    }
1917
1918    #[test]
1919    fn quiet_is_accepted_before_or_after_the_subcommand() {
1920        for argv in [
1921            vec!["spar", "--quiet", "run", "42"],
1922            vec!["spar", "run", "42", "--quiet"],
1923            vec!["spar", "resume", "--quiet"],
1924            vec!["spar", "init", "-q"],
1925        ] {
1926            assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1927        }
1928        assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1929    }
1930
1931    #[test]
1932    fn several_issue_numbers_are_accepted() {
1933        let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1934        match cli.command {
1935            Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1936            other => panic!("{other:?}"),
1937        }
1938    }
1939
1940    #[test]
1941    fn issue_numbers_and_flags_can_be_interleaved() {
1942        let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1943        match cli.command {
1944            Command::Run {
1945                issues, loop_flags, ..
1946            } => {
1947                assert_eq!(vec![42, 51], issues);
1948                assert!(loop_flags.auto_merge);
1949            }
1950            other => panic!("{other:?}"),
1951        }
1952    }
1953
1954    #[test]
1955    fn every_command_that_reads_a_config_accepts_one() {
1956        for argv in [
1957            vec!["spar", "run", "42"],
1958            vec!["spar", "triage"],
1959            vec!["spar", "resume"],
1960            vec!["spar", "followup"],
1961            vec!["spar", "checkin"],
1962            vec!["spar", "split"],
1963            vec!["spar", "clean"],
1964            vec!["spar", "doctor"],
1965        ] {
1966            let mut full = argv.clone();
1967            full.extend(["--config", "other.toml"]);
1968            let cli = Cli::parse_from(&full);
1969            let config = match cli.command {
1970                Command::Run { common, .. }
1971                | Command::Triage { common, .. }
1972                | Command::Resume { common, .. }
1973                | Command::Followup { common, .. }
1974                | Command::Split { common, .. }
1975                | Command::Checkin { common, .. } => common.config,
1976                Command::Clean { config, .. } | Command::Doctor { config } => config,
1977                other => panic!("{other:?}"),
1978            };
1979            assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1980        }
1981    }
1982
1983    #[test]
1984    fn auto_merge_is_off_unless_asked_for() {
1985        let cli = Cli::parse_from(["spar", "run"]);
1986        match cli.command {
1987            Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1988            other => panic!("{other:?}"),
1989        }
1990    }
1991
1992    /// The flag reaches resume as well as run, which is what makes "stop, edit
1993    /// the config, carry on with something extra to say" a thing you can do.
1994    #[test]
1995    fn every_command_that_reads_a_config_takes_instructions() {
1996        // `followup` is given no number, because it takes none: its entries
1997        // have no identity a person could type.
1998        for argv in [
1999            vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2000            vec![
2001                "spar",
2002                "triage",
2003                "7",
2004                "--instructions",
2005                "Do not wait for CI.",
2006            ],
2007            vec![
2008                "spar",
2009                "resume",
2010                "7",
2011                "--instructions",
2012                "Do not wait for CI.",
2013            ],
2014            vec![
2015                "spar",
2016                "review",
2017                "7",
2018                "--instructions",
2019                "Do not wait for CI.",
2020            ],
2021            vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2022            vec![
2023                "spar",
2024                "checkin",
2025                "7",
2026                "--instructions",
2027                "Do not wait for CI.",
2028            ],
2029            vec![
2030                "spar",
2031                "split",
2032                "7",
2033                "--instructions",
2034                "Do not wait for CI.",
2035            ],
2036        ] {
2037            let parsed = Cli::parse_from(&argv);
2038            let common = match parsed.command {
2039                Command::Run { common, .. }
2040                | Command::Triage { common, .. }
2041                | Command::Resume { common, .. }
2042                | Command::Review { common, .. }
2043                | Command::Followup { common, .. }
2044                | Command::Split { common, .. }
2045                | Command::Checkin { common, .. } => common,
2046                other => panic!("{other:?}"),
2047            };
2048            assert_eq!(
2049                Some("Do not wait for CI."),
2050                common.instructions.as_deref(),
2051                "{argv:?}"
2052            );
2053        }
2054    }
2055
2056    #[test]
2057    fn the_two_close_skipped_flags_are_mutually_exclusive() {
2058        assert!(
2059            Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2060        );
2061    }
2062
2063    /// Only `run` triages, so only `run` can decline an issue. Accepting the
2064    /// flag on `resume` would silently do nothing.
2065    #[test]
2066    fn close_skipped_is_offered_only_where_it_means_something() {
2067        assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2068        assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2069        // `followup` triages what it files, so it can decline it too.
2070        assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2071        assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2072        assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2073        assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2074    }
2075
2076    /// An entry in the follow-up queue has no number and its title is prose, so
2077    /// a number on the command line could only be silently ignored.
2078    #[test]
2079    fn followup_takes_no_numbers() {
2080        assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2081        assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2082    }
2083
2084    /// `--screen-only` stops before `--file-only` does, so asking for both says
2085    /// nothing about where to stop.
2086    #[test]
2087    fn the_two_stopping_points_are_mutually_exclusive() {
2088        assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2089        assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2090        assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2091    }
2092
2093    #[test]
2094    fn the_close_skipped_pair_resolves_to_a_tristate() {
2095        let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2096            Command::Run { triage_flags, .. } => {
2097                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2098                    (true, _) => Some(true),
2099                    (_, true) => Some(false),
2100                    _ => None,
2101                }
2102            }
2103            other => panic!("{other:?}"),
2104        };
2105        assert_eq!(None, read(&["spar", "run"]));
2106        assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2107        assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2108    }
2109
2110    #[test]
2111    fn the_default_limit_is_twenty() {
2112        let cli = Cli::parse_from(["spar", "run"]);
2113        match cli.command {
2114            Command::Run { common, .. } => assert_eq!(20, common.limit),
2115            other => panic!("{other:?}"),
2116        }
2117    }
2118
2119    #[test]
2120    fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2121        assert!(matches!(
2122            Cli::parse_from(["spar", "scrub-filter"]).command,
2123            Command::ScrubFilter
2124        ));
2125        let help = Cli::command().render_long_help().to_string();
2126        assert!(
2127            !help.contains("scrub-filter"),
2128            "it is plumbing, not a command"
2129        );
2130    }
2131
2132    #[test]
2133    fn review_takes_pr_numbers_and_a_dry_run() {
2134        let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2135        match cli.command {
2136            Command::Review { items, dry_run, .. } => {
2137                assert_eq!(vec![101, 102], items);
2138                assert!(dry_run);
2139            }
2140            other => panic!("{other:?}"),
2141        }
2142    }
2143
2144    #[test]
2145    fn review_posts_unless_told_not_to() {
2146        match Cli::parse_from(["spar", "review", "101"]).command {
2147            Command::Review { dry_run, .. } => assert!(!dry_run),
2148            other => panic!("{other:?}"),
2149        }
2150    }
2151
2152    #[test]
2153    fn review_with_no_numbers_is_allowed() {
2154        match Cli::parse_from(["spar", "review"]).command {
2155            Command::Review { items, .. } => assert!(items.is_empty()),
2156            other => panic!("{other:?}"),
2157        }
2158    }
2159
2160    #[test]
2161    fn review_takes_its_own_round_budget() {
2162        match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2163            Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2164            other => panic!("{other:?}"),
2165        }
2166    }
2167
2168    #[test]
2169    fn checkin_takes_pr_numbers_and_a_dry_run() {
2170        match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2171            Command::Checkin { items, dry_run, .. } => {
2172                assert_eq!(vec![108, 112], items);
2173                assert!(dry_run);
2174            }
2175            other => panic!("{other:?}"),
2176        }
2177        match Cli::parse_from(["spar", "checkin"]).command {
2178            Command::Checkin { items, dry_run, .. } => {
2179                assert!(items.is_empty());
2180                assert!(!dry_run);
2181            }
2182            other => panic!("{other:?}"),
2183        }
2184    }
2185
2186    /// `--auto-merge` must not exist on the one command whose input is written
2187    /// by somebody else, and `--max-rounds` would be worse than useless: the
2188    /// judgement is two passes by construction, so `--max-rounds 1` could only
2189    /// mean "let one agent decide alone", which removes the one thing standing
2190    /// between a stranger's comment and a push.
2191    #[test]
2192    fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2193        assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2194        assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2195        assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2196        assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
2197        // What it does offer.
2198        assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
2199        assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
2200        assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
2201        assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
2202    }
2203
2204    #[test]
2205    fn resume_takes_a_next_override() {
2206        let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
2207        match cli.command {
2208            Command::Resume {
2209                prs, next_actor, ..
2210            } => {
2211                assert_eq!(vec![108], prs);
2212                assert_eq!(Some("codex".to_string()), next_actor);
2213            }
2214            other => panic!("{other:?}"),
2215        }
2216    }
2217}
2218
2219#[cfg(test)]
2220mod absorb_tests {
2221    use super::*;
2222
2223    #[test]
2224    fn absorb_is_off_unless_asked_for() {
2225        match Cli::parse_from(["spar", "run"]).command {
2226            Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
2227            other => panic!("{other:?}"),
2228        }
2229    }
2230
2231    #[test]
2232    fn absorb_takes_a_wave_count() {
2233        match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
2234            Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
2235            other => panic!("{other:?}"),
2236        }
2237    }
2238
2239    #[test]
2240    fn absorb_is_only_offered_where_issues_are_worked() {
2241        assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
2242        assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
2243        assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
2244        // `split` decomposes and stops, so there is nothing for a wave of newly
2245        // filed issues to be absorbed into.
2246        assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
2247    }
2248}
2249
2250#[cfg(test)]
2251mod split_tests {
2252    use super::*;
2253
2254    #[test]
2255    fn split_takes_numbers_of_either_kind() {
2256        match Cli::parse_from(["spar", "split", "10", "12"]).command {
2257            Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
2258            other => panic!("{other:?}"),
2259        }
2260    }
2261
2262    /// The bare form means both kinds, which no other command does, so it has
2263    /// to be allowed to take nothing.
2264    #[test]
2265    fn split_with_no_numbers_is_allowed() {
2266        match Cli::parse_from(["spar", "split"]).command {
2267            Command::Split {
2268                items,
2269                dry_run,
2270                again,
2271                ..
2272            } => {
2273                assert!(items.is_empty());
2274                assert!(!dry_run);
2275                assert!(!again);
2276            }
2277            other => panic!("{other:?}"),
2278        }
2279    }
2280
2281    /// A wrong split is N issues to close, a checklist to strip out of
2282    /// somebody's body, and branches and pull requests to delete, so the read
2283    /// only half is not optional.
2284    #[test]
2285    fn split_can_be_told_to_write_nothing() {
2286        match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
2287            Command::Split { dry_run, .. } => assert!(dry_run),
2288            other => panic!("{other:?}"),
2289        }
2290    }
2291
2292    /// The flag `checkin` established for exactly this: do it again on
2293    /// something spar already dealt with.
2294    #[test]
2295    fn split_can_be_told_to_split_something_it_already_split() {
2296        match Cli::parse_from(["spar", "split", "10", "--again"]).command {
2297            Command::Split { again, .. } => assert!(again),
2298            other => panic!("{other:?}"),
2299        }
2300    }
2301
2302    /// It picks for itself when given nothing, so it takes both of the flags
2303    /// that govern picking, and its config like everything else.
2304    #[test]
2305    fn split_takes_the_flags_that_govern_picking() {
2306        match Cli::parse_from([
2307            "spar",
2308            "split",
2309            "--limit",
2310            "5",
2311            "--min-number",
2312            "480",
2313            "--config",
2314            "other.toml",
2315        ])
2316        .command
2317        {
2318            Command::Split { common, .. } => {
2319                assert_eq!(5, common.limit);
2320                assert_eq!(Some(480), common.min_number);
2321                assert_eq!(Some(PathBuf::from("other.toml")), common.config);
2322            }
2323            other => panic!("{other:?}"),
2324        }
2325    }
2326
2327    /// `split` decomposes and stops. A flag that implied it works, reviews, or
2328    /// merges anything would be a flag that does nothing.
2329    #[test]
2330    fn split_offers_no_flag_that_would_make_it_a_second_run() {
2331        for flag in [
2332            vec!["--auto-merge"],
2333            vec!["--max-rounds", "2"],
2334            vec!["--close-skipped"],
2335            vec!["--no-close-skipped"],
2336            vec!["--keep-worktrees"],
2337        ] {
2338            let mut argv = vec!["spar", "split", "10"];
2339            argv.extend(flag.iter().copied());
2340            assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
2341        }
2342    }
2343}
2344
2345#[cfg(test)]
2346mod min_number_tests {
2347    use super::*;
2348
2349    fn read(argv: &[&str]) -> Option<i64> {
2350        match Cli::parse_from(argv).command {
2351            Command::Run { common, .. }
2352            | Command::Triage { common, .. }
2353            | Command::Resume { common, .. }
2354            | Command::Review { common, .. }
2355            | Command::Followup { common, .. }
2356            | Command::Split { common, .. }
2357            | Command::Checkin { common, .. } => common.min_number,
2358            other => panic!("{other:?}"),
2359        }
2360    }
2361
2362    #[test]
2363    fn there_is_no_floor_unless_one_is_asked_for() {
2364        assert_eq!(None, read(&["spar", "run"]));
2365    }
2366
2367    #[test]
2368    fn every_command_that_picks_for_itself_accepts_a_floor() {
2369        for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
2370            assert_eq!(
2371                Some(480),
2372                read(&["spar", cmd, "--min-number", "480"]),
2373                "{cmd}"
2374            );
2375        }
2376    }
2377}
2378
2379#[cfg(test)]
2380mod settings_block_tests {
2381    use super::*;
2382
2383    /// The value a config line offers, with its trailing note removed. Quote
2384    /// aware, since a note is free to contain a `#` and several do.
2385    fn written(line: &str) -> String {
2386        let after = line.split_once('=').expect("an assignment").1;
2387        let mut quoted = false;
2388        for (i, c) in after.char_indices() {
2389            match c {
2390                '"' => quoted = !quoted,
2391                '#' if !quoted => return after[..i].trim().to_string(),
2392                _ => {}
2393            }
2394        }
2395        after.trim().to_string()
2396    }
2397
2398    fn line_for(text: &str, key: &str) -> String {
2399        text.lines()
2400            .find(|l| {
2401                let bare = l.trim_start().trim_start_matches('#').trim_start();
2402                bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
2403            })
2404            .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
2405            .to_string()
2406    }
2407
2408    /// The guard that was missing. Every number in the generated config used to
2409    /// be typed in beside its comment, which is a second copy of a default that
2410    /// lives in the code, and the copies stopped agreeing: it offered a title
2411    /// budget of 90 against a real 140, a body of 900 against 8000, and three
2412    /// more like it. Uncommenting one to see what it did cut every comment spar
2413    /// posts to a fifth of its length.
2414    #[test]
2415    fn every_value_it_offers_is_the_default_it_actually_has() {
2416        let text = settings_block("claude");
2417        for option in config::known_options() {
2418            // The effort words are per CLI, so the schedule's are examples of
2419            // what one accepts rather than defaults. There is no default
2420            // effort: an agent that names none uses its own CLI's.
2421            if option.section == "loop.effort_schedule" {
2422                continue;
2423            }
2424            let line = line_for(&text, &option.key);
2425            assert_eq!(
2426                option.default,
2427                written(&line),
2428                "the generated config offers `{}`, but the default is {}",
2429                line.trim(),
2430                option.default
2431            );
2432        }
2433    }
2434
2435    /// `doctor` reports what a config does not mention, so a generated one
2436    /// should send nobody to that list on the day it was written. pr_comments
2437    /// was missing from it for exactly that long.
2438    #[test]
2439    fn it_offers_every_option_the_parser_knows_about() {
2440        let text = settings_block("claude");
2441        let missing: Vec<String> = config::unmentioned_options(&text)
2442            .into_iter()
2443            .map(|o| format!("[{}] {}", o.section, o.key))
2444            .collect();
2445        assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
2446    }
2447
2448    /// The strongest of these: every line the file suggests has to be a line
2449    /// that works. A commented option is an invitation to uncomment it, and one
2450    /// that then fails to load is worse than never having offered it.
2451    #[test]
2452    fn every_option_it_offers_can_be_uncommented_and_still_load() {
2453        let mut text = String::from(
2454            "[agents.claude]\ncommand = [\"claude\"]\n\n\
2455             [agents.codex]\ncommand = [\"codex\"]\n\n",
2456        );
2457        for line in settings_block("claude").lines() {
2458            text.push_str(uncomment(line).unwrap_or(line));
2459            text.push('\n');
2460        }
2461        let cfg = config::parse(&text).expect("a config of its own suggestions");
2462        assert_eq!("claude", cfg.first_implementor);
2463    }
2464
2465    /// A commented assignment with its `#` removed, or None for a line of
2466    /// prose, which stays a comment.
2467    fn uncomment(line: &str) -> Option<&str> {
2468        let bare = line.trim_start().strip_prefix('#')?.trim_start();
2469        // An assignment, not a wrapped note that happens to contain an `=`:
2470        // the key has to be one bare word.
2471        let key = bare.split_once('=')?.0.trim();
2472        let named = !key.is_empty()
2473            && key
2474                .chars()
2475                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
2476        named.then_some(bare)
2477    }
2478
2479    #[test]
2480    fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
2481        assert!(settings_block("codex").contains("first_implementor = \"codex\""));
2482    }
2483
2484    /// Every line starts at the margin, and an option's note sits above the
2485    /// option rather than beside it. Beside meant three different columns in
2486    /// one file, and a note that wrapped left the reader tracking indentation
2487    /// to work out which setting it belonged to.
2488    #[test]
2489    fn a_note_sits_above_the_option_it_describes() {
2490        let text = settings_block("claude");
2491        assert!(
2492            text.lines().all(|l| l.chars().count() <= 80),
2493            "a line runs off the edge:\n{text}"
2494        );
2495        assert!(
2496            text.lines().all(|l| !l.starts_with(' ')),
2497            "a line is indented, so the columns are back:\n{text}"
2498        );
2499
2500        // The line before an option carries its note, not another option.
2501        let lines: Vec<&str> = text.lines().collect();
2502        let at = lines
2503            .iter()
2504            .position(|l| l.starts_with("max_rounds"))
2505            .expect("max_rounds");
2506        assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
2507        assert!(
2508            lines[at - 1].contains("lifetime cap"),
2509            "the note above it is the end of its own note: {:?}",
2510            lines[at - 1]
2511        );
2512    }
2513
2514    /// A note reads as a sentence now that it leads rather than trails.
2515    #[test]
2516    fn every_note_starts_as_a_sentence() {
2517        for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
2518            let first = note.chars().next().expect("a note");
2519            assert!(
2520                first.is_uppercase(),
2521                "{key} reads as a margin scribble rather than a sentence: {note}"
2522            );
2523        }
2524    }
2525}
2526
2527#[cfg(test)]
2528mod agent_block_tests {
2529    use super::*;
2530
2531    fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
2532        let mut spec: config::AgentSpec =
2533            toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
2534                .try_into()
2535                .expect("builds");
2536        spec.models = models.iter().map(|s| s.to_string()).collect();
2537        spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
2538        spec
2539    }
2540
2541    /// A placeholder is not a working value. `# effort = "..."` was written for
2542    /// every preset that lists no efforts, and taking the file at its word by
2543    /// uncommenting it passes `...` to the CLI as a real setting.
2544    #[test]
2545    fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
2546        let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
2547        assert!(!block.contains("..."), "{block}");
2548        assert!(!block.contains("effort"), "{block}");
2549        assert!(block.contains("# model = \"composer-2.5\""), "{block}");
2550    }
2551
2552    /// Each option is introduced by its own line, so a block with no effort
2553    /// setting never mentions one.
2554    #[test]
2555    fn only_the_options_that_follow_are_introduced() {
2556        let model_only = agent_block("cursor", &spec(&["auto"], &[]));
2557        assert!(model_only.contains("Omit model to use"), "{model_only}");
2558        assert!(!model_only.contains("Omit effort"), "{model_only}");
2559
2560        let both = agent_block("claude", &spec(&["fable"], &["high"]));
2561        assert!(both.contains("Omit model to use"), "{both}");
2562        assert!(both.contains("Omit effort to use"), "{both}");
2563    }
2564
2565    /// A preset with no hints at all still has to produce a loadable block,
2566    /// which is every preset that has never listed any: gemini and aider.
2567    #[test]
2568    fn a_preset_with_no_hints_still_writes_a_usable_block() {
2569        let block = agent_block("gemini", &spec(&[], &[]));
2570        assert!(!block.contains("..."), "{block}");
2571        assert!(!block.contains("Omit"), "{block}");
2572        assert!(
2573            block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
2574            "{block}"
2575        );
2576        // What the block does still carry is unaffected.
2577        assert!(block.contains("[agents.gemini.fallback]"), "{block}");
2578        assert!(block.contains("# timeout = "), "{block}");
2579    }
2580
2581    /// The timeout comes from the spec rather than a number typed into the
2582    /// generator, for the reason the [loop] block learned the hard way.
2583    #[test]
2584    fn the_timeout_offered_is_the_one_the_agent_would_use() {
2585        let mut spec = spec(&["a"], &[]);
2586        spec.timeout = 7200;
2587        assert!(
2588            agent_block("custom", &spec).contains("# timeout = 7200"),
2589            "the generator kept its own copy"
2590        );
2591    }
2592
2593    /// Alternatives are named, and a single choice is not dressed up as one.
2594    #[test]
2595    fn alternatives_are_listed_only_when_there_are_any() {
2596        assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
2597        let single = agent_block("b", &spec(&["only"], &[]));
2598        assert!(!single.contains("One of:"), "{single}");
2599    }
2600
2601    /// The preset's own note is about the pair, so it appears once above them
2602    /// rather than under each, which put a sentence about models beneath the
2603    /// effort line.
2604    #[test]
2605    fn the_presets_note_is_said_once() {
2606        let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
2607        spec.options_note = Some("Check the current sets with: mytool --help".into());
2608        let block = agent_block("mytool", &spec);
2609        assert_eq!(
2610            1,
2611            block.matches("Check the current sets").count(),
2612            "{block}"
2613        );
2614    }
2615}