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