Skip to main content

spar/
cli.rs

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