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