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