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, "setup", "A command spar runs in every worktree it creates, before any agent works there or the check runs, as an argv list or a string split on whitespace: `npm ci`, `uv sync`, `bundle install`. Unset runs nothing. A worktree holds only what git tracks, so without this a project's dependencies are there only if an agent happens to install them. A failure stops that issue before any agent call, with the output, and the next run rebuilds the worktree. The shared checkout is never touched."),
2155    (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."),
2156    (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
2157    (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."),
2158    (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
2159    (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."),
2160    (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."),
2161    (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."),
2162    (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
2163    (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
2164    (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."),
2165    (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
2166    (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."),
2167    (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."),
2168    (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."),
2169    (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."),
2170    (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."),
2171    (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."),
2172    (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."),
2173    (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."),
2174];
2175
2176const STYLE_OPTIONS: &[Setting] = &[
2177    (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2178    (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2179    (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2180    (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."),
2181    (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."),
2182    (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2183    (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2184    (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2185    (true, "max_body_chars", "A pull request body."),
2186    (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."),
2187];
2188
2189const LISTEN_OPTIONS: &[Setting] = &[
2190    (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."),
2191    (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."),
2192    (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."),
2193    (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2194    (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."),
2195    (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2196    (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2197    (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."),
2198    (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2199];
2200
2201/// The `[loop]` and `[style]` blocks of a generated config.
2202///
2203/// Safety valves, not editors: the length budgets here are sized so real
2204/// content is never touched, which is why they read as large numbers.
2205fn settings_block(first_implementor: &str) -> String {
2206    let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2207        .into_iter()
2208        .map(|option| (option.key, option.default))
2209        .collect();
2210    // first_implementor has no default: it is whichever agent was written
2211    // first, and until there is a config there is no answer to give.
2212    let value = |key: &str| match key {
2213        "first_implementor" => format!("\"{first_implementor}\""),
2214        other => defaults.get(other).cloned().unwrap_or_default(),
2215    };
2216
2217    let mut out = String::from("[loop]\n");
2218    out.push_str(&option_lines(LOOP_OPTIONS, &value));
2219    out.push_str(concat!(
2220        "\n[loop.effort_schedule]\n",
2221        "# One key per kind of call, so a value means what its name says. Values\n",
2222        "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2223        "# examples rather than defaults. Left out, a call falls back to\n",
2224        "# round_1 or rest, and then to the effort the agent's own block asked\n",
2225        "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2226        "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2227        "# code        = \"high\"   # drafting an issue from a project prompt\n",
2228        "# triage      = \"low\"    # both agents, over the whole queue, per wave\n",
2229        "# implement   = \"high\"   # writing the first draft\n",
2230        "# review_1    = \"high\"   # the deep first review\n",
2231        "# review_rest = \"low\"    # later rounds, which see a small delta\n",
2232        "# respond     = \"high\"   # the author answering a review\n",
2233        "# close       = \"low\"    # the closing merge safety pass\n",
2234        "# screen      = \"low\"    # the follow-up screen, one call for the queue\n",
2235        "# checkin     = \"high\"   # judging comments other people left\n",
2236        "# split       = \"high\"   # proposing and checking a split\n",
2237        "# brainstorm  = \"high\"   # every brainstorm round\n",
2238        "# recheck     = \"low\"    # a second look after commits or replies\n",
2239        "# round_1     = \"high\"   # read by any call above with no key set\n",
2240        "# rest        = \"low\"    # the same, for later rounds and the close\n\n",
2241    ));
2242    out.push_str("[style]\n");
2243    out.push_str(&option_lines(STYLE_OPTIONS, &value));
2244    out.push_str("\n[listen]\n");
2245    out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2246    out
2247}
2248
2249/// Option lines with their notes lined up in a column, a long note wrapping
2250/// onto continuation lines that stay in the column rather than running off the
2251/// edge or restarting at the margin.
2252fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2253    let mut out = String::new();
2254    for (commented, key, note) in options {
2255        if !out.is_empty() {
2256            out.push('\n');
2257        }
2258        out.push_str(&wrap_comment(note));
2259        let lead = if *commented { "# " } else { "" };
2260        out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2261    }
2262    out
2263}
2264
2265/// One prerequisite check: a label and something that either reports a version
2266/// or explains what is missing.
2267type Probe = Box<dyn Fn() -> Result<String>>;
2268
2269/// One agent's block, with the options commented out beside a working value.
2270///
2271/// The values come from the preset rather than from here, so a CLI that adds a
2272/// model is a file edit. They are hints only: nothing validates against them,
2273/// because a stale list that refused a model which actually works would be
2274/// worse than no hint at all.
2275fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2276    let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2277
2278    // Only what the preset has hints for. An option with none used to be
2279    // written as `# effort = "..."`, and a placeholder is not a working value:
2280    // the line fails the moment somebody takes the file at its word and
2281    // uncomments it. Cursor has no effort setting at all, so for that agent the
2282    // line should not exist rather than exist and be wrong.
2283    //
2284    // The first entry of each list is the one written as the suggested value,
2285    // which is why the presets put the sensible default there rather than in
2286    // whatever order a CLI's help happens to print.
2287    // Once, above both. The preset's note is about the pair, and repeating it
2288    // under each put a sentence about models underneath the effort line.
2289    if let Some(extra) = &spec.options_note {
2290        if !spec.models.is_empty() || !spec.efforts.is_empty() {
2291            out.push('\n');
2292            out.push_str(&wrap_comment(extra));
2293        }
2294    }
2295    for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2296        let Some(suggested) = choices.first() else {
2297            continue;
2298        };
2299        let mut note = format!("Omit {key} to use the CLI's own default.");
2300        if choices.len() > 1 {
2301            note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2302        }
2303        out.push('\n');
2304        out.push_str(&wrap_comment(&note));
2305        out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2306    }
2307
2308    // The value from the spec, not a number typed here, for the reason the
2309    // [loop] block learned: a second copy of a default is the one that goes
2310    // stale.
2311    out.push('\n');
2312    out.push_str(&wrap_comment(
2313        "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2314         never retried, so err long.",
2315    ));
2316    out.push_str(&format!("# timeout = {}\n", spec.timeout));
2317
2318    // Anything but this agent's own preset: a CLI that has just refused is not
2319    // a stand in for itself.
2320    let backup = if name == "cursor" { "gemini" } else { "cursor" };
2321    out.push('\n');
2322    out.push_str(&wrap_comment(
2323        "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2324         of this agent, never alongside it.",
2325    ));
2326    out.push_str(&format!(
2327        "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2328    ));
2329
2330    // The rest of what an agent block takes defines a CLI rather than tunes
2331    // one, so it is pointed at rather than offered: a generated file that
2332    // invites somebody to edit `command` or `output` on a working preset is
2333    // offering them a way to break it.
2334    out.push('\n');
2335    out.push_str(&wrap_comment(
2336        "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2337         that has no preset.",
2338    ));
2339    out.push('\n');
2340    out
2341}
2342
2343/// What `spar init` says about an option, for `--update` to say too.
2344///
2345/// Empty for one with nothing written about it, and for the effort schedule,
2346/// whose two keys are examples rather than settings and are described by the
2347/// stanza they sit in rather than one at a time.
2348fn note_for(key: &str) -> &'static str {
2349    LOOP_OPTIONS
2350        .iter()
2351        .chain(STYLE_OPTIONS)
2352        .chain(LISTEN_OPTIONS)
2353        .find(|(_, name, _)| *name == key)
2354        .map(|(_, _, note)| *note)
2355        .unwrap_or("")
2356}
2357
2358/// Wrap a note across comment lines so a long one does not run off the edge.
2359fn wrap_comment(text: &str) -> String {
2360    const WIDTH: usize = 76;
2361    let mut out = String::new();
2362    let mut line = String::from("#");
2363    for word in text.split_whitespace() {
2364        if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2365            out.push_str(&line);
2366            out.push('\n');
2367            line = String::from("#");
2368        }
2369        line.push(' ');
2370        line.push_str(word);
2371    }
2372    if line.len() > 1 {
2373        out.push_str(&line);
2374        out.push('\n');
2375    }
2376    out
2377}
2378
2379fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2380    let mut ok = true;
2381
2382    let probes: Vec<(&str, Probe)> = vec![
2383        (
2384            "git",
2385            Box::new(|| {
2386                proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2387                    .map(|s| first_line(&s))
2388            }),
2389        ),
2390        (
2391            "gh",
2392            Box::new(|| {
2393                proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2394                    .map(|s| first_line(&s))
2395            }),
2396        ),
2397        (
2398            "gh auth",
2399            Box::new(|| {
2400                let out = proc::exec(
2401                    &["gh".into(), "auth".into(), "status".into()],
2402                    &ExecOpts::new().check(false).timeout_secs(60),
2403                )?;
2404                let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2405                if out.ok() {
2406                    Ok(first_line(&text))
2407                } else {
2408                    Err(spar_err!("not authenticated. Run `gh auth login`."))
2409                }
2410            }),
2411        ),
2412    ];
2413
2414    for (label, probe) in probes {
2415        match probe() {
2416            Ok(detail) => println!("  ok    {label:12} {detail}"),
2417            Err(e) => {
2418                println!("  FAIL  {label:12} {}", e.first_line());
2419                ok = false;
2420            }
2421        }
2422    }
2423
2424    let found = config::find_config(config_path)?;
2425    let Some(path) = found else {
2426        println!("\n  no spar.toml found. Run `spar init` to generate one.");
2427        println!(
2428            "  presets available: {}",
2429            config::available_presets().join(", ")
2430        );
2431        return Ok(if ok { 0 } else { 1 });
2432    };
2433
2434    println!("\n  config: {}", path.display());
2435    let cfg = match config::load(Some(&path)) {
2436        Ok(cfg) => cfg,
2437        Err(e) => {
2438            println!("  FAIL  config       {e}");
2439            return Ok(1);
2440        }
2441    };
2442
2443    // Kept apart from `ok`: a missing gh says nothing about whether the two
2444    // agents are the same CLI, and must not silence the warning below.
2445    let mut resolved = Vec::new();
2446    for spec in &cfg.agents {
2447        let agent = Agent::new(spec.clone());
2448        match agent.resolve_bin() {
2449            Ok(bin) => {
2450                println!(
2451                    "  ok    {:12} {}  ({})",
2452                    spec.name,
2453                    bin.display(),
2454                    spec.describe()
2455                );
2456                report_fallback(&agent);
2457                resolved.push(agent);
2458            }
2459            Err(e) => {
2460                println!("  FAIL  {:12} {}", spec.name, e.first_line());
2461                ok = false;
2462            }
2463        }
2464    }
2465
2466    if resolved.len() == cfg.agents.len() {
2467        if let Some(warning) = agent::correlation_warning(&resolved) {
2468            println!("\n  WARNING  {warning}");
2469        }
2470    }
2471
2472    for line in unknown_effort_values(&cfg) {
2473        println!("\n  WARNING  {line}");
2474    }
2475
2476    println!(
2477        "\n  settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2478        cfg.first_implementor_policy(),
2479        cfg.loop_cfg.max_rounds,
2480        cfg.loop_cfg.auto_merge,
2481        cfg.loop_cfg.worktrees,
2482        cfg.loop_cfg.followups,
2483        cfg.style.terse
2484    );
2485    // What somebody upgrading wants to know. `spar init` refuses to touch an
2486    // existing config, so without this there is no way to learn that a release
2487    // added a setting short of reading the source.
2488    if let Ok(text) = std::fs::read_to_string(&path) {
2489        let unset = config::unmentioned_options(&text);
2490        if !unset.is_empty() {
2491            println!(
2492                "\n  {} setting(s) this config does not mention, all at their defaults:",
2493                unset.len()
2494            );
2495            for option in &unset {
2496                println!(
2497                    "      [{}] {} = {}",
2498                    option.section, option.key, option.default
2499                );
2500            }
2501            println!(
2502                "  `spar init --update {}` appends them as comments.",
2503                path.display()
2504            );
2505        }
2506    }
2507
2508    println!(
2509        "{}",
2510        if ok {
2511            "\nready"
2512        } else {
2513            "\nmissing prerequisites"
2514        }
2515    );
2516    Ok(if ok { 0 } else { 1 })
2517}
2518
2519/// Scheduled effort values the agent that would receive them is not known to
2520/// accept.
2521///
2522/// A warning rather than a refusal, for the reason the hints themselves carry:
2523/// a CLI's options drift, and a stale allow list that refused a value which
2524/// actually works would be worse than no hint at all. But `round_1 = "ultra"`
2525/// against a claude agent is a call that fails at the CLI, and nothing said so
2526/// until it ran.
2527fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2528    let mut out = Vec::new();
2529    for spec in &cfg.agents {
2530        if spec.efforts.is_empty() {
2531            continue;
2532        }
2533        let mut said: Vec<String> = Vec::new();
2534        for call in config::Call::every() {
2535            let Some(value) = cfg.effort_for(spec, call) else {
2536                continue;
2537            };
2538            let known = spec
2539                .efforts
2540                .iter()
2541                .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2542            if known || said.contains(&value) {
2543                continue;
2544            }
2545            said.push(value.clone());
2546            out.push(format!(
2547                "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2548                 to accept ({}). Put this agent's values under \
2549                 [agents.{}.effort_schedule].",
2550                spec.name,
2551                call.key(),
2552                spec.efforts.join(" | "),
2553                spec.name
2554            ));
2555        }
2556    }
2557    out
2558}
2559
2560fn first_line(text: &str) -> String {
2561    text.trim().lines().next().unwrap_or("").trim().to_string()
2562}
2563
2564// ---------------------------------------------------------------------------
2565// Reporting
2566// ---------------------------------------------------------------------------
2567
2568fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2569    report_with(results, cfg, repo, &[], &[])
2570}
2571
2572/// The same, plus what needs a person: waves that never ran, and the issues
2573/// triage parked or declined.
2574///
2575/// `stopped` is work that did not happen and makes the run non-zero. `parked`
2576/// is an outcome rather than a failure, and is the record of what is waiting on
2577/// a decision, which used to reach a person only through plan.json.
2578fn report_with(
2579    results: &[IssueRun],
2580    cfg: &Config,
2581    repo: &Repo,
2582    stopped: &[String],
2583    parked: &[String],
2584) -> i32 {
2585    // What the run spent, recorded as it happened. Kept beside the run so a
2586    // resume adds to it rather than starting the count again.
2587    let spent = spend::taken();
2588    if let Err(e) = repo.record_spend(&spent) {
2589        logdim!("could not record what this run spent: {e}");
2590    }
2591    println!("\n{}", "=".repeat(60));
2592    for r in results {
2593        print_outcome(r, &spent);
2594    }
2595    println!("{}", "=".repeat(60));
2596
2597    if cfg.alternate_first && results.len() > 1 {
2598        println!(
2599            "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2600             .spar/state/implementors.json.",
2601            cfg.first_implementor
2602        );
2603    }
2604    if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2605        println!("\nApproved PRs are waiting on you to merge.");
2606    }
2607    let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2608    if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2609        println!(
2610            "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2611             Set followups = \"issues\" to file them."
2612        );
2613    }
2614    if let Some(summary) = spend::summary(&spent) {
2615        println!("\n{summary}");
2616    }
2617    for lines in [parked, stopped] {
2618        if lines.is_empty() {
2619            continue;
2620        }
2621        println!();
2622        for line in lines {
2623            println!("{line}");
2624        }
2625    }
2626    let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2627    issue_exit.max(report_writes(repo))
2628}
2629
2630/// One item's outcome, as the report prints it.
2631pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2632    println!(
2633        "#{:<5} {:<10} rounds={} {}",
2634        r.issue,
2635        r.status.to_string(),
2636        r.rounds,
2637        r.pr.as_deref().unwrap_or("")
2638    );
2639    for note in &r.notes {
2640        println!("       {}", first_line(note));
2641    }
2642    for url in &r.filed {
2643        println!("       filed {url}");
2644    }
2645    for dispute in &r.disputes {
2646        println!(
2647            "       disputed: {}",
2648            report_item(&dispute.title, &dispute.file)
2649        );
2650    }
2651    for finding in &r.noted {
2652        println!(
2653            "       noted, not blocking: {}",
2654            report_item(&finding.title, &finding.file)
2655        );
2656    }
2657    if let Some(cost) = spend::for_subject(spent, r.issue) {
2658        println!("       {cost}");
2659    }
2660}
2661
2662/// A failed write makes the final status non-zero, including a partial failure.
2663/// Independent writes still finish first, so one failure does not discard work
2664/// that another item can land. The non-zero status lets automation retry what
2665/// was missed.
2666pub(crate) fn report_writes(repo: &Repo) -> i32 {
2667    let writes = repo.write_summary();
2668    if let Some(line) = write_summary_line(writes) {
2669        println!("\n{line}");
2670    }
2671    write_exit_code(writes)
2672}
2673
2674fn write_summary_line(writes: WriteSummary) -> Option<String> {
2675    (writes.attempted > 0).then(|| {
2676        format!(
2677            "writes: {} attempted, {} succeeded, {} failed",
2678            writes.attempted,
2679            writes.succeeded(),
2680            writes.failed
2681        )
2682    })
2683}
2684
2685fn write_exit_code(writes: WriteSummary) -> i32 {
2686    i32::from(writes.failed > 0)
2687}
2688
2689fn report_item(title: &str, file: &str) -> String {
2690    match file.trim() {
2691        "" => title.to_string(),
2692        location => format!("{title} ({location})"),
2693    }
2694}
2695
2696#[cfg(test)]
2697mod tests {
2698    use super::*;
2699    use clap::CommandFactory;
2700
2701    fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2702        PlanItem {
2703            issue,
2704            title: format!("issue {issue}"),
2705            complexity: crate::model::Complexity::S,
2706            risk: crate::model::Risk::Low,
2707            depends_on,
2708            reason: "worth doing".into(),
2709        }
2710    }
2711
2712    /// Ordering by dependency did half a job: it sorted, and then every
2713    /// worktree was still built from origin/<base>.
2714    ///
2715    /// With auto_merge off, the default and the recommended setting, a
2716    /// dependency that ends approved is not on the base when the dependent is
2717    /// implemented. The implementor then fails, re-implements the dependency,
2718    /// or declares the issue not worth doing, having spent a full issue's
2719    /// budget to find that out.
2720    #[test]
2721    fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2722        let plan = Plan {
2723            order: vec![planned(9, vec![]), planned(10, vec![9])],
2724            ..Plan::default()
2725        };
2726        let mut landed = BTreeSet::new();
2727
2728        let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2729        assert!(why.contains("#9"), "{why}");
2730        assert!(why.contains("has not merged"), "{why}");
2731        assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2732
2733        // Merged is the only thing that puts the work on the base.
2734        landed.insert(9);
2735        assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2736    }
2737
2738    /// The case the ordering stayed quietest about: a dependency nobody is
2739    /// going to do.
2740    #[test]
2741    fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2742        let declined = Plan {
2743            order: vec![planned(10, vec![9])],
2744            skipped: vec![crate::model::SkippedItem {
2745                issue: 9,
2746                title: "issue 9".into(),
2747                reasons: Default::default(),
2748                tracker: false,
2749            }],
2750            ..Plan::default()
2751        };
2752        let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2753        assert!(why.contains("declined by both reviewers"), "{why}");
2754
2755        let contested = Plan {
2756            order: vec![planned(10, vec![9])],
2757            contested: vec![crate::model::ContestedItem {
2758                issue: 9,
2759                title: "issue 9".into(),
2760                positions: Default::default(),
2761                reasons: Default::default(),
2762                note: None,
2763            }],
2764            ..Plan::default()
2765        };
2766        let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2767        assert!(why.contains("disagree about #9"), "{why}");
2768    }
2769
2770    /// A dependency that is not in this run at all is somebody else's business:
2771    /// it may well be merged already, and holding on it would stop work that is
2772    /// perfectly buildable. It is logged by `report_dependencies` instead.
2773    #[test]
2774    fn a_dependency_outside_the_run_does_not_hold_anything() {
2775        let plan = Plan {
2776            order: vec![planned(10, vec![900])],
2777            ..Plan::default()
2778        };
2779        assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2780    }
2781
2782    /// A disagreement is the one triage outcome that needs a person, and it
2783    /// reached them least reliably: a log line `--quiet` suppresses, an entry
2784    /// in plan.json, and nothing in the report at all.
2785    /// `round_1 = "ultra"` against a claude agent is a call that fails at the
2786    /// CLI, and nothing said so until it ran.
2787    #[test]
2788    fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2789        let text = "\
2790[agents.claude]
2791preset = \"claude\"
2792
2793[agents.codex]
2794preset = \"codex\"
2795
2796[loop.effort_schedule]
2797review_1 = \"ultra\"
2798";
2799        let cfg = config::parse(text).expect("parses");
2800        let said = unknown_effort_values(&cfg);
2801        assert_eq!(1, said.len(), "{said:?}");
2802        assert!(said[0].starts_with("claude"), "{}", said[0]);
2803        assert!(said[0].contains("ultra"), "{}", said[0]);
2804        assert!(said[0].contains("review_1"), "{}", said[0]);
2805        assert!(
2806            said[0].contains("[agents.claude.effort_schedule]"),
2807            "it has to say where to put the right value: {}",
2808            said[0]
2809        );
2810
2811        // With the value in each agent's own words, nothing is said.
2812        let quiet = text.replace(
2813            "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2814            "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2815             [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2816        );
2817        assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2818    }
2819
2820    #[test]
2821    fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2822        let mut positions_map = std::collections::BTreeMap::new();
2823        positions_map.insert("claude".to_string(), "do".to_string());
2824        positions_map.insert("codex".to_string(), "skip".to_string());
2825        let mut reasons = std::collections::BTreeMap::new();
2826        reasons.insert(
2827            "claude".to_string(),
2828            "the retry path is still wrong".to_string(),
2829        );
2830        reasons.insert(
2831            "codex".to_string(),
2832            "fixed in the rewrite last month".to_string(),
2833        );
2834
2835        let line = positions(&positions_map, &reasons);
2836        assert!(line.contains("claude says do"), "{line}");
2837        assert!(line.contains("codex says skip"), "{line}");
2838        assert!(line.contains("the retry path is still wrong"), "{line}");
2839        assert!(line.contains("fixed in the rewrite"), "{line}");
2840
2841        // A verdict with no reason still names who said what.
2842        let bare = positions(&positions_map, &Default::default());
2843        assert!(bare.contains("claude says do"), "{bare}");
2844    }
2845
2846    #[test]
2847    fn report_items_include_their_location() {
2848        assert_eq!(
2849            "Same title (src/a.rs:10)",
2850            report_item("Same title", "src/a.rs:10")
2851        );
2852        assert_eq!("General point", report_item("General point", ""));
2853    }
2854
2855    #[test]
2856    fn every_attempted_write_failing_is_a_failed_run() {
2857        let writes = WriteSummary {
2858            attempted: 3,
2859            failed: 3,
2860        };
2861
2862        assert_eq!(1, write_exit_code(writes));
2863        assert_eq!(
2864            Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2865            write_summary_line(writes)
2866        );
2867    }
2868
2869    #[test]
2870    fn a_partial_write_failure_is_reported_after_the_run() {
2871        let writes = WriteSummary {
2872            attempted: 3,
2873            failed: 1,
2874        };
2875
2876        assert_eq!(1, write_exit_code(writes));
2877        assert_eq!(2, writes.succeeded());
2878    }
2879
2880    #[test]
2881    fn the_parser_is_internally_consistent() {
2882        Cli::command().debug_assert();
2883    }
2884
2885    #[test]
2886    fn quiet_is_accepted_before_or_after_the_subcommand() {
2887        for argv in [
2888            vec!["spar", "--quiet", "run", "42"],
2889            vec!["spar", "run", "42", "--quiet"],
2890            vec!["spar", "resume", "--quiet"],
2891            vec!["spar", "init", "-q"],
2892        ] {
2893            assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2894        }
2895        assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2896    }
2897
2898    #[test]
2899    fn several_issue_numbers_are_accepted() {
2900        let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2901        match cli.command {
2902            Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2903            other => panic!("{other:?}"),
2904        }
2905    }
2906
2907    #[test]
2908    fn issue_numbers_and_flags_can_be_interleaved() {
2909        let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2910        match cli.command {
2911            Command::Run {
2912                issues, loop_flags, ..
2913            } => {
2914                assert_eq!(vec![42, 51], issues);
2915                assert!(loop_flags.auto_merge);
2916            }
2917            other => panic!("{other:?}"),
2918        }
2919    }
2920
2921    #[test]
2922    fn every_command_that_reads_a_config_accepts_one() {
2923        for argv in [
2924            vec!["spar", "run", "42"],
2925            vec!["spar", "triage"],
2926            vec!["spar", "resume"],
2927            vec!["spar", "followup"],
2928            vec!["spar", "checkin"],
2929            vec!["spar", "split"],
2930            vec!["spar", "brainstorm"],
2931            vec!["spar", "listen"],
2932            vec!["spar", "clean"],
2933            vec!["spar", "doctor"],
2934        ] {
2935            let mut full = argv.clone();
2936            full.extend(["--config", "other.toml"]);
2937            let cli = Cli::parse_from(&full);
2938            let config = match cli.command {
2939                Command::Run { common, .. }
2940                | Command::Triage { common, .. }
2941                | Command::Resume { common, .. }
2942                | Command::Followup { common, .. }
2943                | Command::Split { common, .. }
2944                | Command::Brainstorm { common, .. }
2945                | Command::Listen { common, .. }
2946                | Command::Checkin { common, .. } => common.config,
2947                Command::Clean { config, .. } | Command::Doctor { config } => config,
2948                other => panic!("{other:?}"),
2949            };
2950            assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2951        }
2952    }
2953
2954    #[test]
2955    fn auto_merge_is_off_unless_asked_for() {
2956        let cli = Cli::parse_from(["spar", "run"]);
2957        match cli.command {
2958            Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2959            other => panic!("{other:?}"),
2960        }
2961    }
2962
2963    /// The flag reaches resume as well as run, which is what makes "stop, edit
2964    /// the config, carry on with something extra to say" a thing you can do.
2965    #[test]
2966    fn every_command_that_reads_a_config_takes_instructions() {
2967        // `followup` is given no number, because it takes none: its entries
2968        // have no identity a person could type.
2969        for argv in [
2970            vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2971            vec![
2972                "spar",
2973                "triage",
2974                "7",
2975                "--instructions",
2976                "Do not wait for CI.",
2977            ],
2978            vec![
2979                "spar",
2980                "resume",
2981                "7",
2982                "--instructions",
2983                "Do not wait for CI.",
2984            ],
2985            vec![
2986                "spar",
2987                "review",
2988                "7",
2989                "--instructions",
2990                "Do not wait for CI.",
2991            ],
2992            vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2993            vec![
2994                "spar",
2995                "checkin",
2996                "7",
2997                "--instructions",
2998                "Do not wait for CI.",
2999            ],
3000            vec![
3001                "spar",
3002                "split",
3003                "7",
3004                "--instructions",
3005                "Do not wait for CI.",
3006            ],
3007            vec![
3008                "spar",
3009                "brainstorm",
3010                "a",
3011                "subject",
3012                "--instructions",
3013                "Do not wait for CI.",
3014            ],
3015            vec![
3016                "spar",
3017                "listen",
3018                "7",
3019                "--instructions",
3020                "Do not wait for CI.",
3021            ],
3022        ] {
3023            let parsed = Cli::parse_from(&argv);
3024            let common = match parsed.command {
3025                Command::Run { common, .. }
3026                | Command::Triage { common, .. }
3027                | Command::Resume { common, .. }
3028                | Command::Review { common, .. }
3029                | Command::Followup { common, .. }
3030                | Command::Split { common, .. }
3031                | Command::Brainstorm { common, .. }
3032                | Command::Listen { common, .. }
3033                | Command::Checkin { common, .. } => common,
3034                other => panic!("{other:?}"),
3035            };
3036            assert_eq!(
3037                Some("Do not wait for CI."),
3038                common.instructions.as_deref(),
3039                "{argv:?}"
3040            );
3041        }
3042    }
3043
3044    #[test]
3045    fn the_two_close_skipped_flags_are_mutually_exclusive() {
3046        assert!(
3047            Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
3048        );
3049    }
3050
3051    /// Only `run` triages, so only `run` can decline an issue. Accepting the
3052    /// flag on `resume` would silently do nothing.
3053    #[test]
3054    fn close_skipped_is_offered_only_where_it_means_something() {
3055        assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
3056        assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
3057        // `followup` triages what it files, so it can decline it too.
3058        assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
3059        assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
3060        assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
3061        assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
3062    }
3063
3064    /// The subject is prose, so it is taken as words rather than one quoted
3065    /// string, and none at all means the repository itself.
3066    #[test]
3067    fn brainstorm_takes_words_or_nothing() {
3068        match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
3069            Command::Brainstorm { topic, ideas, .. } => {
3070                assert_eq!(vec!["novel", "uses", "of", "script"], topic);
3071                assert_eq!(5, ideas);
3072            }
3073            other => panic!("{other:?}"),
3074        }
3075        match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
3076            Command::Brainstorm {
3077                topic,
3078                ideas,
3079                max_rounds,
3080                ..
3081            } => {
3082                assert!(topic.is_empty());
3083                assert_eq!(3, ideas);
3084                assert_eq!(Some(2), max_rounds);
3085            }
3086            other => panic!("{other:?}"),
3087        }
3088    }
3089
3090    /// `--from` files what a saved session says and runs nothing, so it means
3091    /// nothing without `--file-issues`, and there is no session to write with
3092    /// `--out`.
3093    #[test]
3094    fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
3095        assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
3096        assert!(
3097            Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3098        );
3099        assert!(Cli::try_parse_from([
3100            "spar",
3101            "brainstorm",
3102            "--from",
3103            "s.md",
3104            "--file-issues",
3105            "--out",
3106            "t.md"
3107        ])
3108        .is_err());
3109        assert!(
3110            Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3111                .is_ok()
3112        );
3113    }
3114
3115    /// An entry in the follow-up queue has no number and its title is prose, so
3116    /// a number on the command line could only be silently ignored.
3117    #[test]
3118    fn followup_takes_no_numbers() {
3119        assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3120        assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3121    }
3122
3123    /// `--screen-only` stops before `--file-only` does, so asking for both says
3124    /// nothing about where to stop.
3125    #[test]
3126    fn the_two_stopping_points_are_mutually_exclusive() {
3127        assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3128        assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3129        assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3130    }
3131
3132    #[test]
3133    fn the_close_skipped_pair_resolves_to_a_tristate() {
3134        let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3135            Command::Run { triage_flags, .. } => {
3136                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3137                    (true, _) => Some(true),
3138                    (_, true) => Some(false),
3139                    _ => None,
3140                }
3141            }
3142            other => panic!("{other:?}"),
3143        };
3144        assert_eq!(None, read(&["spar", "run"]));
3145        assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3146        assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3147    }
3148
3149    #[test]
3150    fn the_default_limit_is_twenty() {
3151        let cli = Cli::parse_from(["spar", "run"]);
3152        match cli.command {
3153            Command::Run { common, .. } => assert_eq!(20, common.limit),
3154            other => panic!("{other:?}"),
3155        }
3156    }
3157
3158    #[test]
3159    fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3160        assert!(matches!(
3161            Cli::parse_from(["spar", "scrub-filter"]).command,
3162            Command::ScrubFilter
3163        ));
3164        let help = Cli::command().render_long_help().to_string();
3165        assert!(
3166            !help.contains("scrub-filter"),
3167            "it is plumbing, not a command"
3168        );
3169    }
3170
3171    #[test]
3172    fn review_takes_pr_numbers_and_a_dry_run() {
3173        let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3174        match cli.command {
3175            Command::Review { items, dry_run, .. } => {
3176                assert_eq!(vec![101, 102], items);
3177                assert!(dry_run);
3178            }
3179            other => panic!("{other:?}"),
3180        }
3181    }
3182
3183    #[test]
3184    fn review_posts_unless_told_not_to() {
3185        match Cli::parse_from(["spar", "review", "101"]).command {
3186            Command::Review { dry_run, .. } => assert!(!dry_run),
3187            other => panic!("{other:?}"),
3188        }
3189    }
3190
3191    #[test]
3192    fn review_with_no_numbers_is_allowed() {
3193        match Cli::parse_from(["spar", "review"]).command {
3194            Command::Review { items, .. } => assert!(items.is_empty()),
3195            other => panic!("{other:?}"),
3196        }
3197    }
3198
3199    #[test]
3200    fn review_takes_its_own_round_budget() {
3201        match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3202            Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3203            other => panic!("{other:?}"),
3204        }
3205    }
3206
3207    #[test]
3208    fn checkin_takes_pr_numbers_and_a_dry_run() {
3209        match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3210            Command::Checkin { items, dry_run, .. } => {
3211                assert_eq!(vec![108, 112], items);
3212                assert!(dry_run);
3213            }
3214            other => panic!("{other:?}"),
3215        }
3216        match Cli::parse_from(["spar", "checkin"]).command {
3217            Command::Checkin { items, dry_run, .. } => {
3218                assert!(items.is_empty());
3219                assert!(!dry_run);
3220            }
3221            other => panic!("{other:?}"),
3222        }
3223    }
3224
3225    /// `--auto-merge` must not exist on the one command whose input is written
3226    /// by somebody else, and `--max-rounds` would be worse than useless: the
3227    /// judgement is two passes by construction, so `--max-rounds 1` could only
3228    /// mean "let one agent decide alone", which removes the one thing standing
3229    /// between a stranger's comment and a push.
3230    #[test]
3231    fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3232        assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3233        assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3234        assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3235        assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3236        // What it does offer.
3237        assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3238        assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3239        assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3240        assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3241    }
3242
3243    #[test]
3244    fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3245        match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3246            Command::Review {
3247                items,
3248                verdict,
3249                recheck,
3250                ..
3251            } => {
3252                assert_eq!(vec![108], items);
3253                assert!(verdict);
3254                assert!(recheck);
3255            }
3256            other => panic!("{other:?}"),
3257        }
3258        match Cli::parse_from(["spar", "review", "108"]).command {
3259            Command::Review {
3260                verdict, recheck, ..
3261            } => {
3262                assert!(!verdict);
3263                assert!(!recheck);
3264            }
3265            other => panic!("{other:?}"),
3266        }
3267        assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3268    }
3269
3270    #[test]
3271    fn listen_takes_numbers_once_dry_run_and_since() {
3272        match Cli::parse_from([
3273            "spar",
3274            "listen",
3275            "108",
3276            "42",
3277            "--once",
3278            "--dry-run",
3279            "--since",
3280            "2h",
3281        ])
3282        .command
3283        {
3284            Command::Listen {
3285                items,
3286                once,
3287                dry_run,
3288                since,
3289                ..
3290            } => {
3291                assert_eq!(vec![108, 42], items);
3292                assert!(once);
3293                assert!(dry_run);
3294                assert_eq!(Some("2h".to_string()), since);
3295            }
3296            other => panic!("{other:?}"),
3297        }
3298        match Cli::parse_from(["spar", "listen"]).command {
3299            Command::Listen {
3300                items,
3301                once,
3302                dry_run,
3303                since,
3304                common,
3305            } => {
3306                assert!(items.is_empty());
3307                assert!(!once);
3308                assert!(!dry_run);
3309                assert_eq!(None, since);
3310                assert_eq!(20, common.limit);
3311            }
3312            other => panic!("{other:?}"),
3313        }
3314    }
3315
3316    #[test]
3317    fn resume_takes_a_next_override() {
3318        let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3319        match cli.command {
3320            Command::Resume {
3321                prs, next_actor, ..
3322            } => {
3323                assert_eq!(vec![108], prs);
3324                assert_eq!(Some("codex".to_string()), next_actor);
3325            }
3326            other => panic!("{other:?}"),
3327        }
3328    }
3329}
3330
3331#[cfg(test)]
3332mod absorb_tests {
3333    use super::*;
3334
3335    #[test]
3336    fn absorb_is_off_unless_asked_for() {
3337        match Cli::parse_from(["spar", "run"]).command {
3338            Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3339            other => panic!("{other:?}"),
3340        }
3341    }
3342
3343    #[test]
3344    fn absorb_takes_a_wave_count() {
3345        match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3346            Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3347            other => panic!("{other:?}"),
3348        }
3349    }
3350
3351    #[test]
3352    fn absorb_is_only_offered_where_issues_are_worked() {
3353        assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3354        assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3355        assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3356        // `split` decomposes and stops, so there is nothing for a wave of newly
3357        // filed issues to be absorbed into.
3358        assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3359    }
3360}
3361
3362#[cfg(test)]
3363mod split_tests {
3364    use super::*;
3365
3366    #[test]
3367    fn split_takes_numbers_of_either_kind() {
3368        match Cli::parse_from(["spar", "split", "10", "12"]).command {
3369            Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3370            other => panic!("{other:?}"),
3371        }
3372    }
3373
3374    /// The bare form means both kinds, which no other command does, so it has
3375    /// to be allowed to take nothing.
3376    #[test]
3377    fn split_with_no_numbers_is_allowed() {
3378        match Cli::parse_from(["spar", "split"]).command {
3379            Command::Split {
3380                items,
3381                dry_run,
3382                again,
3383                ..
3384            } => {
3385                assert!(items.is_empty());
3386                assert!(!dry_run);
3387                assert!(!again);
3388            }
3389            other => panic!("{other:?}"),
3390        }
3391    }
3392
3393    /// A wrong split is N issues to close, a checklist to strip out of
3394    /// somebody's body, and branches and pull requests to delete, so the read
3395    /// only half is not optional.
3396    #[test]
3397    fn split_can_be_told_to_write_nothing() {
3398        match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3399            Command::Split { dry_run, .. } => assert!(dry_run),
3400            other => panic!("{other:?}"),
3401        }
3402    }
3403
3404    /// The flag `checkin` established for exactly this: do it again on
3405    /// something spar already dealt with.
3406    #[test]
3407    fn split_can_be_told_to_split_something_it_already_split() {
3408        match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3409            Command::Split { again, .. } => assert!(again),
3410            other => panic!("{other:?}"),
3411        }
3412    }
3413
3414    /// It picks for itself when given nothing, so it takes both of the flags
3415    /// that govern picking, and its config like everything else.
3416    #[test]
3417    fn split_takes_the_flags_that_govern_picking() {
3418        match Cli::parse_from([
3419            "spar",
3420            "split",
3421            "--limit",
3422            "5",
3423            "--min-number",
3424            "480",
3425            "--config",
3426            "other.toml",
3427        ])
3428        .command
3429        {
3430            Command::Split { common, .. } => {
3431                assert_eq!(5, common.limit);
3432                assert_eq!(Some(480), common.min_number);
3433                assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3434            }
3435            other => panic!("{other:?}"),
3436        }
3437    }
3438
3439    /// `split` decomposes and stops. A flag that implied it works, reviews, or
3440    /// merges anything would be a flag that does nothing.
3441    #[test]
3442    fn split_offers_no_flag_that_would_make_it_a_second_run() {
3443        for flag in [
3444            vec!["--auto-merge"],
3445            vec!["--max-rounds", "2"],
3446            vec!["--close-skipped"],
3447            vec!["--no-close-skipped"],
3448            vec!["--keep-worktrees"],
3449        ] {
3450            let mut argv = vec!["spar", "split", "10"];
3451            argv.extend(flag.iter().copied());
3452            assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3453        }
3454    }
3455}
3456
3457#[cfg(test)]
3458mod min_number_tests {
3459    use super::*;
3460
3461    fn read(argv: &[&str]) -> Option<i64> {
3462        match Cli::parse_from(argv).command {
3463            Command::Run { common, .. }
3464            | Command::Triage { common, .. }
3465            | Command::Resume { common, .. }
3466            | Command::Review { common, .. }
3467            | Command::Followup { common, .. }
3468            | Command::Split { common, .. }
3469            | Command::Listen { common, .. }
3470            | Command::Checkin { common, .. } => common.min_number,
3471            other => panic!("{other:?}"),
3472        }
3473    }
3474
3475    #[test]
3476    fn there_is_no_floor_unless_one_is_asked_for() {
3477        assert_eq!(None, read(&["spar", "run"]));
3478    }
3479
3480    #[test]
3481    fn every_command_that_picks_for_itself_accepts_a_floor() {
3482        for cmd in [
3483            "run", "triage", "resume", "review", "checkin", "split", "listen",
3484        ] {
3485            assert_eq!(
3486                Some(480),
3487                read(&["spar", cmd, "--min-number", "480"]),
3488                "{cmd}"
3489            );
3490        }
3491    }
3492}
3493
3494#[cfg(test)]
3495mod run_targets_tests {
3496    use super::*;
3497
3498    fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3499        pairs.iter().copied().collect()
3500    }
3501
3502    /// The run this comes from: `spar run 786 783 784 771`, where #786 is the
3503    /// pull request an earlier run opened for #772. Naming it is how a person
3504    /// says "finish this one", and it used to be worked last, behind three
3505    /// issues' full implement and review loops.
3506    #[test]
3507    fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3508        let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3509
3510        assert_eq!(vec![783, 784], kept);
3511    }
3512
3513    #[test]
3514    fn an_issue_no_named_pull_request_closes_is_still_worked() {
3515        let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3516
3517        assert_eq!(vec![783, 784, 771], kept);
3518    }
3519
3520    /// A pull request whose closing issues could not be read covers nothing,
3521    /// so nothing is dropped on a guess.
3522    #[test]
3523    fn an_unreadable_pull_request_drops_no_issue() {
3524        let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3525
3526        assert_eq!(vec![772, 783], kept);
3527    }
3528
3529    /// One pull request can close several issues, and all of them belong to it.
3530    #[test]
3531    fn every_issue_one_pull_request_closes_is_taken_by_it() {
3532        let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3533
3534        assert_eq!(vec![783], kept);
3535    }
3536}
3537
3538#[cfg(test)]
3539mod code_tests {
3540    use super::*;
3541
3542    #[test]
3543    fn code_requires_a_prompt_and_exclusive_preview_modes() {
3544        assert!(Cli::try_parse_from(["spar", "code"]).is_err());
3545        assert!(
3546            Cli::try_parse_from(["spar", "code", "Add export", "--dry-run", "--file-only"])
3547                .is_err()
3548        );
3549        assert!(Cli::try_parse_from(["spar", "code", "Add export", "--dry-run"]).is_ok());
3550        assert!(Cli::try_parse_from(["spar", "code", "Add export", "--file-only"]).is_ok());
3551    }
3552
3553    #[test]
3554    fn code_takes_words_and_the_normal_run_options() {
3555        let cli = Cli::parse_from([
3556            "spar",
3557            "code",
3558            "Add",
3559            "CSV",
3560            "export",
3561            "--repo",
3562            "/tmp/project",
3563            "--config",
3564            "project.toml",
3565            "--first",
3566            "b",
3567            "--base",
3568            "develop",
3569            "--instructions",
3570            "Keep the format stable",
3571            "--max-rounds",
3572            "5",
3573            "--auto-merge",
3574            "--absorb",
3575            "1",
3576            "--keep-worktrees",
3577            "--no-worktrees",
3578            "--no-close-skipped",
3579            "--retriage",
3580            "--plan-out",
3581            "code-plan.json",
3582        ]);
3583        match cli.command {
3584            Command::Code {
3585                prompt,
3586                common,
3587                loop_flags,
3588                triage_flags,
3589                plan_out,
3590                no_worktrees,
3591                dry_run,
3592                file_only,
3593            } => {
3594                assert_eq!(prompt, ["Add", "CSV", "export"]);
3595                assert_eq!(common.repo, PathBuf::from("/tmp/project"));
3596                assert_eq!(common.config, Some(PathBuf::from("project.toml")));
3597                assert_eq!(common.first.as_deref(), Some("b"));
3598                assert_eq!(common.base.as_deref(), Some("develop"));
3599                assert_eq!(
3600                    common.instructions.as_deref(),
3601                    Some("Keep the format stable")
3602                );
3603                assert_eq!(loop_flags.max_rounds, Some(5));
3604                assert_eq!(loop_flags.absorb, Some(1));
3605                assert!(loop_flags.auto_merge && loop_flags.keep_worktrees);
3606                assert!(triage_flags.no_close_skipped && triage_flags.retriage);
3607                assert_eq!(plan_out, PathBuf::from("code-plan.json"));
3608                assert!(no_worktrees);
3609                assert!(!dry_run && !file_only);
3610            }
3611            other => panic!("{other:?}"),
3612        }
3613    }
3614}
3615
3616#[cfg(test)]
3617mod settings_block_tests {
3618    use super::*;
3619
3620    /// The value a config line offers, with its trailing note removed. Quote
3621    /// aware, since a note is free to contain a `#` and several do.
3622    fn written(line: &str) -> String {
3623        let after = line.split_once('=').expect("an assignment").1;
3624        let mut quoted = false;
3625        for (i, c) in after.char_indices() {
3626            match c {
3627                '"' => quoted = !quoted,
3628                '#' if !quoted => return after[..i].trim().to_string(),
3629                _ => {}
3630            }
3631        }
3632        after.trim().to_string()
3633    }
3634
3635    fn line_for(text: &str, key: &str) -> String {
3636        text.lines()
3637            .find(|l| {
3638                let bare = l.trim_start().trim_start_matches('#').trim_start();
3639                bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3640            })
3641            .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3642            .to_string()
3643    }
3644
3645    /// The guard that was missing. Every number in the generated config used to
3646    /// be typed in beside its comment, which is a second copy of a default that
3647    /// lives in the code, and the copies stopped agreeing: it offered a title
3648    /// budget of 90 against a real 140, a body of 900 against 8000, and three
3649    /// more like it. Uncommenting one to see what it did cut every comment spar
3650    /// posts to a fifth of its length.
3651    #[test]
3652    fn every_value_it_offers_is_the_default_it_actually_has() {
3653        let text = settings_block("claude");
3654        for option in config::known_options() {
3655            // The effort words are per CLI, so the schedule's are examples of
3656            // what one accepts rather than defaults. There is no default
3657            // effort: an agent that names none uses its own CLI's.
3658            if option.section == "loop.effort_schedule" {
3659                continue;
3660            }
3661            let line = line_for(&text, &option.key);
3662            assert_eq!(
3663                option.default,
3664                written(&line),
3665                "the generated config offers `{}`, but the default is {}",
3666                line.trim(),
3667                option.default
3668            );
3669        }
3670    }
3671
3672    /// `doctor` reports what a config does not mention, so a generated one
3673    /// should send nobody to that list on the day it was written. pr_comments
3674    /// was missing from it for exactly that long.
3675    #[test]
3676    fn it_offers_every_option_the_parser_knows_about() {
3677        let text = settings_block("claude");
3678        let missing: Vec<String> = config::unmentioned_options(&text)
3679            .into_iter()
3680            .map(|o| format!("[{}] {}", o.section, o.key))
3681            .collect();
3682        assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3683    }
3684
3685    /// The strongest of these: every line the file suggests has to be a line
3686    /// that works. A commented option is an invitation to uncomment it, and one
3687    /// that then fails to load is worse than never having offered it.
3688    #[test]
3689    fn every_option_it_offers_can_be_uncommented_and_still_load() {
3690        let mut text = String::from(
3691            "[agents.claude]\ncommand = [\"claude\"]\n\n\
3692             [agents.codex]\ncommand = [\"codex\"]\n\n",
3693        );
3694        for line in settings_block("claude").lines() {
3695            text.push_str(uncomment(line).unwrap_or(line));
3696            text.push('\n');
3697        }
3698        let cfg = config::parse(&text).expect("a config of its own suggestions");
3699        assert_eq!("claude", cfg.first_implementor);
3700    }
3701
3702    /// A commented assignment with its `#` removed, or None for a line of
3703    /// prose, which stays a comment.
3704    fn uncomment(line: &str) -> Option<&str> {
3705        let bare = line.trim_start().strip_prefix('#')?.trim_start();
3706        // An assignment, not a wrapped note that happens to contain an `=`:
3707        // the key has to be one bare word.
3708        let key = bare.split_once('=')?.0.trim();
3709        let named = !key.is_empty()
3710            && key
3711                .chars()
3712                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3713        named.then_some(bare)
3714    }
3715
3716    #[test]
3717    fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3718        assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3719    }
3720
3721    /// Every line starts at the margin, and an option's note sits above the
3722    /// option rather than beside it. Beside meant three different columns in
3723    /// one file, and a note that wrapped left the reader tracking indentation
3724    /// to work out which setting it belonged to.
3725    #[test]
3726    fn a_note_sits_above_the_option_it_describes() {
3727        let text = settings_block("claude");
3728        assert!(
3729            text.lines().all(|l| l.chars().count() <= 80),
3730            "a line runs off the edge:\n{text}"
3731        );
3732        assert!(
3733            text.lines().all(|l| !l.starts_with(' ')),
3734            "a line is indented, so the columns are back:\n{text}"
3735        );
3736
3737        // The line before an option carries its note, not another option.
3738        let lines: Vec<&str> = text.lines().collect();
3739        let at = lines
3740            .iter()
3741            .position(|l| l.starts_with("max_rounds"))
3742            .expect("max_rounds");
3743        assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3744        assert!(
3745            lines[at - 1].contains("lifetime cap"),
3746            "the note above it is the end of its own note: {:?}",
3747            lines[at - 1]
3748        );
3749    }
3750
3751    /// A note reads as a sentence now that it leads rather than trails.
3752    #[test]
3753    fn every_note_starts_as_a_sentence() {
3754        for (_, key, note) in LOOP_OPTIONS
3755            .iter()
3756            .chain(STYLE_OPTIONS)
3757            .chain(LISTEN_OPTIONS)
3758        {
3759            let first = note.chars().next().expect("a note");
3760            assert!(
3761                first.is_uppercase(),
3762                "{key} reads as a margin scribble rather than a sentence: {note}"
3763            );
3764        }
3765    }
3766}
3767
3768#[cfg(test)]
3769mod agent_block_tests {
3770    use super::*;
3771
3772    fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3773        let mut spec: config::AgentSpec =
3774            toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3775                .try_into()
3776                .expect("builds");
3777        spec.models = models.iter().map(|s| s.to_string()).collect();
3778        spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3779        spec
3780    }
3781
3782    /// A placeholder is not a working value. `# effort = "..."` was written for
3783    /// every preset that lists no efforts, and taking the file at its word by
3784    /// uncommenting it passes `...` to the CLI as a real setting.
3785    #[test]
3786    fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3787        let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3788        assert!(!block.contains("..."), "{block}");
3789        assert!(!block.contains("effort"), "{block}");
3790        assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3791    }
3792
3793    /// Each option is introduced by its own line, so a block with no effort
3794    /// setting never mentions one.
3795    #[test]
3796    fn only_the_options_that_follow_are_introduced() {
3797        let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3798        assert!(model_only.contains("Omit model to use"), "{model_only}");
3799        assert!(!model_only.contains("Omit effort"), "{model_only}");
3800
3801        let both = agent_block("claude", &spec(&["fable"], &["high"]));
3802        assert!(both.contains("Omit model to use"), "{both}");
3803        assert!(both.contains("Omit effort to use"), "{both}");
3804    }
3805
3806    /// A preset with no hints at all still has to produce a loadable block,
3807    /// which is every preset that has never listed any: gemini and aider.
3808    #[test]
3809    fn a_preset_with_no_hints_still_writes_a_usable_block() {
3810        let block = agent_block("gemini", &spec(&[], &[]));
3811        assert!(!block.contains("..."), "{block}");
3812        assert!(!block.contains("Omit"), "{block}");
3813        assert!(
3814            block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3815            "{block}"
3816        );
3817        // What the block does still carry is unaffected.
3818        assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3819        assert!(block.contains("# timeout = "), "{block}");
3820    }
3821
3822    /// The timeout comes from the spec rather than a number typed into the
3823    /// generator, for the reason the [loop] block learned the hard way.
3824    #[test]
3825    fn the_timeout_offered_is_the_one_the_agent_would_use() {
3826        let mut spec = spec(&["a"], &[]);
3827        spec.timeout = 7200;
3828        assert!(
3829            agent_block("custom", &spec).contains("# timeout = 7200"),
3830            "the generator kept its own copy"
3831        );
3832    }
3833
3834    /// Alternatives are named, and a single choice is not dressed up as one.
3835    #[test]
3836    fn alternatives_are_listed_only_when_there_are_any() {
3837        assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3838        let single = agent_block("b", &spec(&["only"], &[]));
3839        assert!(!single.contains("One of:"), "{single}");
3840    }
3841
3842    /// The preset's own note is about the pair, so it appears once above them
3843    /// rather than under each, which put a sentence about models beneath the
3844    /// effort line.
3845    #[test]
3846    fn the_presets_note_is_said_once() {
3847        let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3848        spec.options_note = Some("Check the current sets with: mytool --help".into());
3849        let block = agent_block("mytool", &spec);
3850        assert_eq!(
3851            1,
3852            block.matches("Check the current sets").count(),
3853            "{block}"
3854        );
3855    }
3856}