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