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            wave.numbers.retain(|n| {
961                let Some(known) = waiting.get(n) else {
962                    return true;
963                };
964                log!(
965                    "#{n} was contested on an earlier run and is waiting on you: {}. Pass \
966                     --retriage to ask again.",
967                    positions_of(known)
968                );
969                parked.push(format!(
970                    "#{n} contested and parked for you: {}",
971                    positions_of(known)
972                ));
973                false
974            });
975        }
976        if wave.numbers.is_empty() {
977            continue;
978        }
979        match wave.parent {
980            Some(tracker) => log!(
981                "working {} item(s) from the checklist in #{tracker}: {}",
982                wave.numbers.len(),
983                numbers(&wave.numbers)
984            ),
985            None if wave.absorbed > 0 => log!(
986                "absorbing {} newly filed issue(s): {}",
987                wave.numbers.len(),
988                numbers(&wave.numbers)
989            ),
990            None => {}
991        }
992        handled.extend(wave.numbers.iter().copied());
993
994        let fetched = match repo.fetch_issues(&wave.numbers) {
995            Ok(fetched) => fetched,
996            Err(e) => {
997                logdim!("could not read the next wave: {e}");
998                continue;
999            }
1000        };
1001        // Named for the order the plans were written in, so the first is
1002        // plan.json exactly as before and no two waves overwrite each other.
1003        let plan_path = if plans_written == 0 {
1004            plan_out.to_path_buf()
1005        } else {
1006            plan_out.with_extension(format!("wave{plans_written}.json"))
1007        };
1008        plans_written += 1;
1009        // A triage failure is one CLI out of quota or refusing, in the cheapest
1010        // step of the next wave, on a run that may have spent an hour. It ends
1011        // the waves; it does not throw away the record of what the earlier ones
1012        // did, which is the only place a person learns what the run made.
1013        let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
1014            Ok(plan) => plan,
1015            Err(e) => {
1016                logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
1017                stopped.push(format!(
1018                    "triage failed for {}, so it was not worked: {}",
1019                    numbers(&wave.numbers),
1020                    first_line(&e.to_string())
1021                ));
1022                for later in &queue {
1023                    stopped.push(format!(
1024                        "{} was not reached after that failure",
1025                        numbers(&later.numbers)
1026                    ));
1027                }
1028                queue.clear();
1029                break;
1030            }
1031        };
1032        act_on_plan(cfg, repo, &plan);
1033        repo.remember_contested(&plan.contested);
1034        for item in &plan.contested {
1035            parked.push(format!(
1036                "#{} contested and parked for you: {}",
1037                item.issue,
1038                positions(&item.positions, &item.reasons)
1039            ));
1040        }
1041        for item in &plan.skipped {
1042            let what = if item.tracker {
1043                "left open as a tracker"
1044            } else if cfg.loop_cfg.close_skipped {
1045                "commented on and closed"
1046            } else {
1047                "commented on and left open"
1048            };
1049            parked.push(format!(
1050                "#{} skipped by both reviewers, {what}: {}",
1051                item.issue,
1052                item.reasons
1053                    .values()
1054                    .next()
1055                    .map(|r| first_line(r))
1056                    .unwrap_or_default()
1057            ));
1058        }
1059
1060        // No recursion: a child that triage calls a tracker in its turn is
1061        // commented on and held like any other. `file_non_blocking` records
1062        // what happened the last time this codebase let something multiply.
1063        if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
1064            for item in plan.skipped.iter().filter(|i| i.tracker) {
1065                let children = tracker::decompose(cfg, repo, item.issue);
1066                if !children.is_empty() {
1067                    queue.push_back(wave.child(item.issue, children));
1068                }
1069            }
1070        }
1071
1072        let before = results.len();
1073        for item in &plan.order {
1074            let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
1075                continue;
1076            };
1077            if let Some(why) = held_back(item, &plan, &landed) {
1078                log!("#{}: {why}", item.issue);
1079                let mut held = IssueRun::new(item.issue, item.title.clone());
1080                held.status = Status::Pending;
1081                held.notes.push(why);
1082                results.push(held);
1083                continue;
1084            }
1085            let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
1086            worked += 1;
1087            if run.status == Status::Merged {
1088                landed.insert(item.issue);
1089            }
1090            results.push(run);
1091        }
1092
1093        // Whatever this wave filed becomes the next one, budget allowing.
1094        let filed: Vec<i64> = results[before..]
1095            .iter()
1096            .flat_map(|r| r.filed.iter())
1097            .filter_map(|url| review::filed_issue_number(url))
1098            .filter(|n| !handled.contains(n))
1099            .collect::<BTreeSet<_>>()
1100            .into_iter()
1101            .collect();
1102        if filed.is_empty() {
1103            continue;
1104        }
1105        match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1106            true => queue.push_back(wave.absorb(filed)),
1107            false => leftover.extend(filed),
1108        }
1109    }
1110    if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1111        log!(
1112            "{} issue(s) filed in the last wave were left for a later run: {}",
1113            leftover.len(),
1114            numbers(&leftover.iter().copied().collect::<Vec<_>>())
1115        );
1116    }
1117    Ok(())
1118}
1119
1120/// Both sides of a disagreement on one line, for the report and the log.
1121fn positions(
1122    positions: &std::collections::BTreeMap<String, String>,
1123    reasons: &std::collections::BTreeMap<String, String>,
1124) -> String {
1125    if positions.is_empty() {
1126        return "no positions recorded".to_string();
1127    }
1128    positions
1129        .iter()
1130        .map(|(name, side)| match reasons.get(name) {
1131            Some(why) if !why.trim().is_empty() => {
1132                format!("{name} says {side} ({})", first_line(why))
1133            }
1134            _ => format!("{name} says {side}"),
1135        })
1136        .collect::<Vec<_>>()
1137        .join("; ")
1138}
1139
1140fn positions_of(known: &crate::model::Contested) -> String {
1141    positions(&known.positions, &known.reasons)
1142}
1143
1144/// Why an item is not being worked yet, or `None` to work it.
1145///
1146/// Triage collects `depends_on` and `order` sorts by it, and the base was never
1147/// changed: `run_issue` creates every worktree from `origin/<base>` whatever
1148/// came before it. With `auto_merge = false`, the default and the recommended
1149/// setting, a dependency that ends approved is not on the base when the
1150/// dependent is implemented, and the implementor either fails, re-implements
1151/// it, or declares the issue not worth doing.
1152///
1153/// Holding is the honest answer: the work is not there, and building on a base
1154/// that lacks it spends a full issue's budget to find that out.
1155fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1156    for dep in &item.depends_on {
1157        if landed.contains(dep) {
1158            continue;
1159        }
1160        if plan.order.iter().any(|other| other.issue == *dep) {
1161            return Some(format!(
1162                "held back: #{dep} is in this run and has not merged, so its work is not on the \
1163                 base this would be built from. Merge #{dep} and run #{} again.",
1164                item.issue
1165            ));
1166        }
1167        if plan.skipped.iter().any(|s| s.issue == *dep) {
1168            return Some(format!(
1169                "held back: #{dep}, which it depends on, was declined by both reviewers"
1170            ));
1171        }
1172        if plan.contested.iter().any(|c| c.issue == *dep) {
1173            return Some(format!(
1174                "held back: the reviewers disagree about #{dep}, which it depends on"
1175            ));
1176        }
1177    }
1178    None
1179}
1180
1181/// One pass of the pipeline, and where it came from.
1182struct Wave {
1183    numbers: Vec<i64>,
1184    /// Absorb rounds spent to reach it. A tracker's children inherit their
1185    /// parent's, because extracting named work is not absorbing a follow-up:
1186    /// `absorb_new_issues` is off by default, and spending it here would make
1187    /// `decompose_trackers` silently do nothing.
1188    absorbed: u32,
1189    /// The tracker whose checklist this wave came out of.
1190    parent: Option<i64>,
1191}
1192
1193impl Wave {
1194    fn first(numbers: Vec<i64>) -> Self {
1195        Self {
1196            numbers,
1197            absorbed: 0,
1198            parent: None,
1199        }
1200    }
1201
1202    fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1203        Self {
1204            numbers,
1205            absorbed: self.absorbed,
1206            parent: Some(tracker),
1207        }
1208    }
1209
1210    fn absorb(&self, numbers: Vec<i64>) -> Self {
1211        Self {
1212            numbers,
1213            absorbed: self.absorbed + 1,
1214            parent: None,
1215        }
1216    }
1217}
1218
1219fn numbers(items: &[i64]) -> String {
1220    items
1221        .iter()
1222        .map(|n| format!("#{n}"))
1223        .collect::<Vec<_>>()
1224        .join(", ")
1225}
1226
1227fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1228    let mut cfg = config::load(common.config.as_deref())?;
1229
1230    if let Some(first) = &common.first {
1231        if !cfg.has_agent(first) {
1232            bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1233        }
1234        cfg.first_implementor = first.clone();
1235    }
1236    if let Some(base) = &common.base {
1237        cfg.loop_cfg.base_branch = base.clone();
1238    }
1239    if let Some(min) = common.min_number {
1240        cfg.loop_cfg.min_number = min;
1241    }
1242    // Added to the config's, not in place of them. One is what this repository
1243    // always wants and the other is what today wants, and a flag that silenced
1244    // the standing set would be a trap: you would notice it the run after.
1245    if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1246        if !extra.is_empty() {
1247            let standing = cfg.loop_cfg.instructions.trim();
1248            cfg.loop_cfg.instructions = if standing.is_empty() {
1249                extra.to_string()
1250            } else {
1251                format!("{standing}\n{extra}")
1252            };
1253        }
1254    }
1255    if let Some(over) = overrides {
1256        if let Some(v) = over.max_rounds {
1257            if v == 0 {
1258                bail!("--max-rounds must be at least 1");
1259            }
1260            cfg.loop_cfg.max_rounds = v;
1261        }
1262        if let Some(v) = over.auto_merge {
1263            cfg.loop_cfg.auto_merge = v;
1264        }
1265        if let Some(v) = over.keep_worktrees {
1266            cfg.loop_cfg.keep_worktrees = v;
1267        }
1268        if let Some(v) = over.worktrees {
1269            cfg.loop_cfg.worktrees = v;
1270        }
1271        if let Some(v) = over.close_skipped {
1272            cfg.loop_cfg.close_skipped = v;
1273        }
1274        if let Some(v) = over.absorb {
1275            cfg.loop_cfg.absorb_new_issues = v;
1276        }
1277    }
1278
1279    let repo = Repo::open(&common.repo, &cfg)?;
1280    if common.base.is_none() {
1281        cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1282    }
1283
1284    let agents = agent::build(&cfg)?;
1285    if let Some(warning) = agent::correlation_warning(&agents) {
1286        logging::warn(warning);
1287    }
1288
1289    // Sweep finished worktrees before starting, so they cannot accumulate.
1290    for stale in repo.prune_worktrees(false) {
1291        let what = if stale.starts_with("branch ") {
1292            stale
1293        } else {
1294            format!("worktree {stale}")
1295        };
1296        logdim!("cleaned up finished {what}");
1297    }
1298
1299    log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1300    log!(
1301        "agents: {}, first {}",
1302        agents
1303            .iter()
1304            .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1305            .collect::<Vec<_>>()
1306            .join(", "),
1307        cfg.first_implementor_policy()
1308    );
1309    Ok((cfg, repo, agents))
1310}
1311
1312fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1313    if !given.is_empty() {
1314        // Naming a number is the point, so a floor never overrides it.
1315        if min_number > 0 {
1316            let below: Vec<String> = given
1317                .iter()
1318                .filter(|n| **n < min_number)
1319                .map(|n| format!("#{n}"))
1320                .collect();
1321            if !below.is_empty() {
1322                logdim!(
1323                    "{} below the #{min_number} floor, taking them because you named them",
1324                    below.join(", ")
1325                );
1326            }
1327        }
1328        return Ok(given);
1329    }
1330    let found = repo.list_open_issues(limit, min_number)?;
1331    if found.is_empty() {
1332        log!("no open issues");
1333        return Ok(found);
1334    }
1335    log!(
1336        "no issues given, taking {} open: {}",
1337        found.len(),
1338        found
1339            .iter()
1340            .map(|n| format!("#{n}"))
1341            .collect::<Vec<_>>()
1342            .join(", ")
1343    );
1344    Ok(found)
1345}
1346
1347/// The bare `spar split`: every open issue and every open pull request, then
1348/// one screening call over both.
1349///
1350/// The first command that means both kinds when given nothing, so it says so
1351/// out loud. `--limit` applies to each list rather than to the two together: a
1352/// single budget shared across both would fill up on issues and starve the pull
1353/// requests. `min_number` applies because this is picked work rather than named
1354/// work.
1355///
1356/// One agent rules on the whole list in one call rather than two calls per
1357/// item, because two agent calls per item across a whole queue is the wrong
1358/// price for a question whose answer is usually no.
1359fn pick_for_split(
1360    agents: &[Agent],
1361    cfg: &Config,
1362    repo: &Repo,
1363    limit: usize,
1364    mode: &split::Mode,
1365) -> Result<Vec<(i64, ItemKind)>> {
1366    let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1367    let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1368    log!(
1369        "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1370        issues.len(),
1371        prs.len()
1372    );
1373    if issues.is_empty() && prs.is_empty() {
1374        return Ok(Vec::new());
1375    }
1376
1377    let issue_rows = repo.open_issue_rows();
1378    let pr_rows = repo.open_pr_rows();
1379    let mut candidates: Vec<split::Candidate> = Vec::new();
1380    let mut already = 0usize;
1381
1382    for number in &issues {
1383        let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1384            logdim!("could not read #{number}, leaving it alone");
1385            continue;
1386        };
1387        // Free here, because the body is already in hand. A pull request that
1388        // has been split is caught by `split_pr`, which reads its comments.
1389        if split::already_split(row.body_text()) && !mode.again {
1390            already += 1;
1391            continue;
1392        }
1393        candidates.push(split::Candidate::from_issue(row));
1394    }
1395    for number in &prs {
1396        match pr_rows.iter().find(|p| p.number == *number) {
1397            Some(row) => candidates.push(split::Candidate::from_pr(row)),
1398            None => logdim!("could not read PR #{number}, leaving it alone"),
1399        }
1400    }
1401    if already > 0 {
1402        log!("{already} issue(s) already split, skipped. --again reopens them.");
1403    }
1404    if candidates.is_empty() {
1405        return Ok(Vec::new());
1406    }
1407
1408    let agent = agent::find(agents, &cfg.first_implementor)?;
1409    log!(
1410        "screening {} item(s) with {}",
1411        candidates.len(),
1412        agent.name()
1413    );
1414    let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1415
1416    let mut picked = Vec::new();
1417    for candidate in &candidates {
1418        match verdicts.iter().find(|v| v.item == candidate.number) {
1419            Some(v) if v.split => {
1420                log!("  split #{}: {}", candidate.number, v.reason.trim());
1421                picked.push((candidate.number, candidate.kind));
1422            }
1423            // Not a warning: no is the expected answer, and one line per item
1424            // saying so is the whole screen printed twice.
1425            Some(v) => logdim!("  leave #{} whole: {}", candidate.number, v.reason.trim()),
1426            None => logdim!("  no verdict for #{}, leaving it whole", candidate.number),
1427        }
1428    }
1429    if picked.is_empty() {
1430        log!("nothing worth splitting");
1431    }
1432    Ok(picked)
1433}
1434
1435/// Numbers split by what they actually name.
1436///
1437/// Issues and pull requests share one number sequence per repository, so a
1438/// person should not have to remember which command takes which. Both `run` and
1439/// `resume` sort the numbers themselves and route each one.
1440#[derive(Debug, Default)]
1441struct Sorted {
1442    issues: Vec<i64>,
1443    prs: Vec<i64>,
1444}
1445
1446fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1447    let mut sorted = Sorted::default();
1448    for number in numbers {
1449        match repo.item_kind(*number)? {
1450            ItemKind::Issue => sorted.issues.push(*number),
1451            ItemKind::Pr => sorted.prs.push(*number),
1452        }
1453    }
1454    if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1455        log!(
1456            "{} issue(s) and {} pull request(s) given",
1457            sorted.issues.len(),
1458            sorted.prs.len()
1459        );
1460    }
1461    Ok(sorted)
1462}
1463
1464/// Which named issues a named pull request already carries, and which one.
1465///
1466/// Naming both an issue and the pull request that closes it is the ordinary way
1467/// to say "finish this one". Working both would continue the same pull request
1468/// twice, once from the list of pull requests and once from `run_nth_issue`,
1469/// which finds the open pull request for an issue and continues it rather than
1470/// implementing over the top.
1471///
1472/// A failed lookup drops nothing. Not knowing what a pull request closes is not
1473/// evidence that it closes nothing, and skipping an issue on a guess is the
1474/// worse mistake: continuing the same pull request twice is visible and wastes
1475/// calls, while silently dropping an issue looks like spar ignored it.
1476fn issues_covered_by(repo: &Repo, prs: &[i64]) -> BTreeMap<i64, i64> {
1477    let mut covered = BTreeMap::new();
1478    for number in prs {
1479        match repo.pr_view(*number) {
1480            Ok(view) => {
1481                for issue in &view.closing_issues_references {
1482                    covered.insert(issue.number, *number);
1483                }
1484            }
1485            Err(e) => logwarn!(
1486                "#{number}: could not read which issue(s) it closes, so none are skipped: {}",
1487                e.last_line()
1488            ),
1489        }
1490    }
1491    covered
1492}
1493
1494/// The named issues still worth triaging once the named pull requests have
1495/// taken the ones they close.
1496fn issues_not_on_named_prs(issues: &[i64], covered: &BTreeMap<i64, i64>) -> Vec<i64> {
1497    issues
1498        .iter()
1499        .copied()
1500        .filter(|issue| !covered.contains_key(issue))
1501        .collect()
1502}
1503
1504fn make_plan(
1505    agents: &[Agent],
1506    cfg: &Config,
1507    repo: &Repo,
1508    issues: &[Issue],
1509    plan_out: &Path,
1510) -> Result<Plan> {
1511    let plan = triage::triage(agents, cfg, repo, issues)?;
1512
1513    std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1514        .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1515    log!("plan written to {}", plan_out.display());
1516
1517    for item in &plan.order {
1518        // The dependencies too: they decide the order, and an item held back
1519        // because one of them did not land is easier to read next to them.
1520        let after = if item.depends_on.is_empty() {
1521            String::new()
1522        } else {
1523            format!(
1524                " after {}",
1525                item.depends_on
1526                    .iter()
1527                    .map(|n| format!("#{n}"))
1528                    .collect::<Vec<_>>()
1529                    .join(", ")
1530            )
1531        };
1532        log!(
1533            "  do   #{} [{}/{}]{after} {}",
1534            item.issue,
1535            item.complexity,
1536            item.risk,
1537            item.title
1538        );
1539    }
1540    for item in &plan.skipped {
1541        if item.tracker {
1542            log!(
1543                "  hold #{} (both reviewers: tracks work filed elsewhere)",
1544                item.issue
1545            );
1546        } else {
1547            log!("  skip #{} (both reviewers: not worth doing)", item.issue);
1548        }
1549    }
1550    for item in &plan.contested {
1551        log!("  ??   #{} contested, parked for you to decide", item.issue);
1552    }
1553    Ok(plan)
1554}
1555
1556/// Post the shared reasoning on every issue both agents declined, and close it
1557/// when the config says so. Contested issues are never touched.
1558///
1559/// A tracker is never closed, whatever `close_skipped` says. Declining to open
1560/// a pull request for an umbrella is right, and closing it does not follow from
1561/// that: its parts are still open, and the shared context and the alternatives
1562/// somebody recorded against are the reason the issue exists. spar closed a
1563/// real one as "not planned" while all three of its subtasks were open, which
1564/// is what this exists to stop.
1565fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1566    for item in &plan.skipped {
1567        let body = review::skip_comment(item, &repo.style);
1568        let close = cfg.loop_cfg.close_skipped && !item.tracker;
1569        let outcome = if close {
1570            repo.close_issue(item.issue, &body)
1571        } else {
1572            repo.comment_issue(item.issue, &body)
1573        };
1574        match outcome {
1575            Ok(()) if close => log!("  closed #{}", item.issue),
1576            Ok(()) if item.tracker => {
1577                log!(
1578                    "  left #{} open, it tracks work filed elsewhere",
1579                    item.issue
1580                )
1581            }
1582            Ok(()) => {}
1583            Err(e) => logdim!("could not update #{}: {e}", item.issue),
1584        }
1585    }
1586}
1587
1588// ---------------------------------------------------------------------------
1589// Subcommands
1590// ---------------------------------------------------------------------------
1591
1592fn cmd_scrub_filter() -> Result<i32> {
1593    let mut input = String::new();
1594    std::io::stdin()
1595        .read_to_string(&mut input)
1596        .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1597    let out = style::scrub(&input, &crate::repo::style_from_env());
1598    let mut stdout = std::io::stdout();
1599    stdout
1600        .write_all(out.as_bytes())
1601        .and_then(|_| stdout.write_all(b"\n"))
1602        .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1603    Ok(0)
1604}
1605
1606fn cmd_clean(
1607    repo_path: &Path,
1608    config_path: Option<&Path>,
1609    all: bool,
1610    pr_state: bool,
1611) -> Result<i32> {
1612    let cfg = config::load(config_path)?;
1613    let repo = Repo::open(repo_path, &cfg)?;
1614    let mut removed = repo.prune_worktrees(all);
1615    removed.extend(repo.prune_state());
1616    if pr_state {
1617        removed.extend(repo.prune_pr_state(None));
1618    }
1619    if removed.is_empty() {
1620        if repo.write_summary().failed > 0 {
1621            println!("nothing removed");
1622        } else {
1623            println!("nothing to clean");
1624        }
1625    } else {
1626        for item in removed {
1627            println!("removed {item}");
1628        }
1629    }
1630    Ok(report_writes(&repo))
1631}
1632
1633/// Post a review that was produced earlier and not sent.
1634fn cmd_post(
1635    prs: &[i64],
1636    repo_path: &Path,
1637    config_path: Option<&Path>,
1638    file: Option<&Path>,
1639    dry_run: bool,
1640) -> Result<i32> {
1641    let cfg = config::load(config_path)?;
1642    let repo = Repo::open(repo_path, &cfg)?;
1643
1644    if file.is_some() && prs.len() > 1 {
1645        bail!("--file posts one review, so give it one pull request number");
1646    }
1647
1648    let mut failed = false;
1649    for number in prs {
1650        let text = match file {
1651            Some(path) => std::fs::read_to_string(path)
1652                .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1653            None => match repo.read_pending_comment(*number) {
1654                Some(text) => text,
1655                None => {
1656                    logging::error(format!(
1657                        "no saved review for PR #{number}. `spar review {number} --dry-run` \
1658                         produces one, or pass --file."
1659                    ));
1660                    failed = true;
1661                    continue;
1662                }
1663            },
1664        };
1665        if text.trim().is_empty() {
1666            logging::error(format!("the saved review for PR #{number} is empty"));
1667            failed = true;
1668            continue;
1669        }
1670        if dry_run {
1671            println!("\n{}\n", text.trim());
1672            log!("would post the above to PR #{number}");
1673            continue;
1674        }
1675        // Through the style gate like anything else spar sends, so an edit that
1676        // reintroduces a banned dash is caught rather than published.
1677        match repo.comment_pr(*number, &text) {
1678            Ok(()) => log!("posted to PR #{number}"),
1679            Err(e) => {
1680                logging::error(format!("could not post to PR #{number}: {e}"));
1681                failed = true;
1682            }
1683        }
1684    }
1685    Ok(i32::from(failed).max(report_writes(&repo)))
1686}
1687
1688/// Run a brainstorm, or file the ideas from a saved one.
1689///
1690/// `--from` builds no agents and makes no calls, like `post`: the file is the
1691/// whole input, and a person may have edited it, which is the point of the
1692/// two step path.
1693#[allow(clippy::too_many_arguments)]
1694fn cmd_brainstorm(
1695    topic: Vec<String>,
1696    common: &Common,
1697    max_rounds: Option<u32>,
1698    ideas: usize,
1699    out: Option<PathBuf>,
1700    file_issues: bool,
1701    from: Option<&Path>,
1702) -> Result<i32> {
1703    if ideas == 0 {
1704        bail!("--ideas must be at least 1");
1705    }
1706    if let Some(path) = from {
1707        let cfg = config::load(common.config.as_deref())?;
1708        let repo = Repo::open(&common.repo, &cfg)?;
1709        let text = std::fs::read_to_string(path)
1710            .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1711        let entries = brainstorm::parse_session(&text);
1712        if entries.is_empty() {
1713            bail!(
1714                "no ideas in {}: an idea is a `## ` heading with its sections under it",
1715                path.display()
1716            );
1717        }
1718        log!(
1719            "filing {} idea{} from {}",
1720            entries.len(),
1721            if entries.len() == 1 { "" } else { "s" },
1722            path.display()
1723        );
1724        brainstorm::file_ideas(&repo, &entries);
1725        return Ok(report_writes(&repo));
1726    }
1727
1728    let overrides = Overrides {
1729        max_rounds,
1730        ..Overrides::default()
1731    };
1732    let (cfg, repo, agents) = prepare(common, Some(overrides))?;
1733    let joined = topic.join(" ").trim().to_string();
1734    let session = brainstorm::Session {
1735        topic: (!joined.is_empty()).then_some(joined),
1736        budget: cfg.loop_cfg.max_rounds,
1737        wanted: ideas,
1738    };
1739    let now = brainstorm::now_secs();
1740    let outcome = brainstorm::run(&agents, &cfg, &repo, &session)?;
1741    let path = out.unwrap_or_else(|| brainstorm::default_path(&repo, &session, now));
1742    let text = brainstorm::render_session(&outcome, &brainstorm::date_line(now), &repo.style);
1743    crate::repo::write_text_atomic(&path, &text)?;
1744    brainstorm::print_summary(&outcome, &path);
1745    if file_issues {
1746        println!();
1747        brainstorm::file_ideas(&repo, &brainstorm::issue_entries(&outcome, &repo.style));
1748    }
1749
1750    let spent = spend::taken();
1751    if let Err(e) = repo.record_spend(&spent) {
1752        logdim!("could not record what this run spent: {e}");
1753    }
1754    if let Some(summary) = spend::summary(&spent) {
1755        println!("\n{summary}");
1756    }
1757    Ok(report_writes(&repo))
1758}
1759
1760/// Append the settings a config does not mention, commented out.
1761///
1762/// Append only by design. Rewriting somebody's config to insert options would
1763/// take their comments and their ordering with it, and `--force` already exists
1764/// for anyone who wants the generated file back.
1765fn cmd_init_update(out: &Path) -> Result<i32> {
1766    let text = std::fs::read_to_string(out)
1767        .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1768    // Refuse to append to something that does not parse, rather than making a
1769    // broken config longer.
1770    config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1771
1772    let unset = config::unmentioned_options(&text);
1773    if unset.is_empty() {
1774        println!("{} already mentions every setting.", out.display());
1775        return Ok(0);
1776    }
1777
1778    let mut block = String::new();
1779    if !text.ends_with('\n') {
1780        block.push('\n');
1781    }
1782    block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1783    block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1784    let mut section = "";
1785    for option in &unset {
1786        if option.section != section {
1787            section = option.section;
1788            block.push_str(&format!("\n# [{section}]\n"));
1789        }
1790        // With the same note `spar init` writes. A config that gained a setting
1791        // this way used to gain a bare line and nothing saying what it was for,
1792        // which is the half of the setting that matters when you are reading it
1793        // for the first time.
1794        block.push('\n');
1795        block.push_str(&wrap_comment(note_for(&option.key)));
1796        block.push_str(&format!("# {} = {}\n", option.key, option.default));
1797    }
1798
1799    use std::io::Write;
1800    std::fs::OpenOptions::new()
1801        .append(true)
1802        .open(out)
1803        .and_then(|mut f| f.write_all(block.as_bytes()))
1804        .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1805
1806    println!(
1807        "added {} setting(s) to {} as comments",
1808        unset.len(),
1809        out.display()
1810    );
1811    Ok(0)
1812}
1813
1814fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1815    if out.exists() && !force {
1816        logging::error(format!(
1817            "{} already exists. `--update` appends any settings it does not mention, \
1818             `--force` overwrites it.",
1819            out.display()
1820        ));
1821        return Ok(1);
1822    }
1823
1824    let presets = config::available_presets();
1825    if presets.is_empty() {
1826        bail!("no presets available, which should be impossible in a released build");
1827    }
1828
1829    let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1830    for name in &presets {
1831        let raw = config::load_preset(name)?;
1832        // A preset that will not build is a broken preset, not an uninstalled
1833        // CLI. Skipping it silently reported it as "missing" and sent people
1834        // looking for an install problem that was not there.
1835        let mut spec: config::AgentSpec = match raw
1836            .as_table()
1837            .cloned()
1838            .ok_or_else(|| spar_err!("not a table"))
1839            .and_then(|t| {
1840                toml::Value::Table(t)
1841                    .try_into()
1842                    .map_err(|e| spar_err!("{e}"))
1843            }) {
1844            Ok(spec) => spec,
1845            Err(e) => {
1846                println!("  BROKEN   {name:10} {}", e.first_line());
1847                continue;
1848            }
1849        };
1850        spec.name = name.clone();
1851        match Agent::new(spec.clone()).resolve_bin() {
1852            Ok(path) => {
1853                println!("  found    {name:10} {}", path.display());
1854                found.push((name.clone(), path.to_path_buf(), spec));
1855            }
1856            Err(_) => println!("  missing  {name}"),
1857        }
1858    }
1859
1860    if found.len() < 2 {
1861        logging::error(format!(
1862            "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1863             presets as a reference.",
1864            found.len(),
1865            out.display()
1866        ));
1867        return Ok(1);
1868    }
1869
1870    // Prefer a pair that cannot share blind spots, if one is available.
1871    let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1872    if found.len() > 2 {
1873        log!(
1874            "{} agents available, picking {} and {}. Edit {} to change.",
1875            found.len(),
1876            chosen[0].0,
1877            chosen[1].0,
1878            out.display()
1879        );
1880    }
1881
1882    let mut text = String::from(
1883        "# Generated by `spar init`. Each agent inherits a command template from a\n\
1884         # built in preset; anything set here overrides it.\n\
1885         #\n\
1886         # Commented lines are the other options, each with a working value.\n\
1887         # Uncomment one to change it.\n\n",
1888    );
1889    for (name, _, spec) in &chosen {
1890        text.push_str(&agent_block(name, spec));
1891    }
1892    text.push_str(&settings_block(&chosen[0].0));
1893
1894    std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1895    println!("\nwrote {}", out.display());
1896    println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1897    Ok(0)
1898}
1899
1900/// An agent's stand in, under the agent it stands in for.
1901///
1902/// Never counted against `doctor`'s exit code, deliberately. A fallback that is
1903/// not installed does not stop a run either, and a check that disagrees with
1904/// the runtime teaches people to ignore it.
1905fn report_fallback(agent: &Agent) {
1906    let Some(backup) = agent.fallback() else {
1907        return;
1908    };
1909    match backup.resolve_bin() {
1910        Ok(bin) => println!(
1911            "        fallback    {}  ({})",
1912            bin.display(),
1913            backup.spec.describe()
1914        ),
1915        Err(_) => println!(
1916            "        fallback    {} not found, so it will not stand in. Set {} to its path.",
1917            backup.program(),
1918            backup.env_key()
1919        ),
1920    }
1921}
1922
1923/// One settable option: whether the generated config leaves it commented out,
1924/// its key, and the note beside it.
1925///
1926/// The value is deliberately absent. Every value comes from the defaults
1927/// themselves, because a value typed in here is a second copy of a number that
1928/// lives somewhere else, and the second copy is the one that goes stale. This
1929/// one did: the generated config offered a title budget of 90, a summary of
1930/// 200, a detail of 320, a body of 900 and an issue body of 4000, long after
1931/// those became 140, 2000, 6000, 8000 and 20000. Uncommenting a line to see
1932/// what it did cut every comment spar posts to a fifth of its length.
1933type Setting = (bool, &'static str, &'static str);
1934
1935const LOOP_OPTIONS: &[Setting] = &[
1936    (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."),
1937    (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."),
1938    (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1939    (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1940    (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1941    (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."),
1942    (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."),
1943    (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."),
1944    (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."),
1945    (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."),
1946    (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1947    (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."),
1948    (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1949    (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."),
1950    (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."),
1951    (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."),
1952    (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1953    (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1954    (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."),
1955    (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1956    (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."),
1957    (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."),
1958    (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."),
1959    (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."),
1960    (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."),
1961    (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."),
1962    (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."),
1963];
1964
1965const STYLE_OPTIONS: &[Setting] = &[
1966    (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1967    (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1968    (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1969    (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."),
1970    (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1971    (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1972    (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1973    (true, "max_body_chars", "A pull request body."),
1974    (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."),
1975];
1976
1977/// The `[loop]` and `[style]` blocks of a generated config.
1978///
1979/// Safety valves, not editors: the length budgets here are sized so real
1980/// content is never touched, which is why they read as large numbers.
1981fn settings_block(first_implementor: &str) -> String {
1982    let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1983        .into_iter()
1984        .map(|option| (option.key, option.default))
1985        .collect();
1986    // first_implementor has no default: it is whichever agent was written
1987    // first, and until there is a config there is no answer to give.
1988    let value = |key: &str| match key {
1989        "first_implementor" => format!("\"{first_implementor}\""),
1990        other => defaults.get(other).cloned().unwrap_or_default(),
1991    };
1992
1993    let mut out = String::from("[loop]\n");
1994    out.push_str(&option_lines(LOOP_OPTIONS, &value));
1995    out.push_str(concat!(
1996        "\n[loop.effort_schedule]\n",
1997        "# One key per kind of call, so a value means what its name says. Values\n",
1998        "# are whatever each agent's own CLI accepts, listed above, so these are\n",
1999        "# examples rather than defaults. Left out, a call falls back to\n",
2000        "# round_1 or rest, and then to the effort the agent's own block asked\n",
2001        "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2002        "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2003        "# triage      = \"low\"    # both agents, over the whole queue, per wave\n",
2004        "# implement   = \"high\"   # writing the first draft\n",
2005        "# review_1    = \"high\"   # the deep first review\n",
2006        "# review_rest = \"low\"    # later rounds, which see a small delta\n",
2007        "# respond     = \"high\"   # the author answering a review\n",
2008        "# close       = \"low\"    # the closing merge safety pass\n",
2009        "# screen      = \"low\"    # the follow-up screen, one call for the queue\n",
2010        "# checkin     = \"high\"   # judging comments other people left\n",
2011        "# split       = \"high\"   # proposing and checking a split\n",
2012        "# brainstorm  = \"high\"   # every brainstorm round\n",
2013        "# round_1     = \"high\"   # read by any call above with no key set\n",
2014        "# rest        = \"low\"    # the same, for later rounds and the close\n\n",
2015    ));
2016    out.push_str("[style]\n");
2017    out.push_str(&option_lines(STYLE_OPTIONS, &value));
2018    out
2019}
2020
2021/// Option lines with their notes lined up in a column, a long note wrapping
2022/// onto continuation lines that stay in the column rather than running off the
2023/// edge or restarting at the margin.
2024fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2025    let mut out = String::new();
2026    for (commented, key, note) in options {
2027        if !out.is_empty() {
2028            out.push('\n');
2029        }
2030        out.push_str(&wrap_comment(note));
2031        let lead = if *commented { "# " } else { "" };
2032        out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2033    }
2034    out
2035}
2036
2037/// One prerequisite check: a label and something that either reports a version
2038/// or explains what is missing.
2039type Probe = Box<dyn Fn() -> Result<String>>;
2040
2041/// One agent's block, with the options commented out beside a working value.
2042///
2043/// The values come from the preset rather than from here, so a CLI that adds a
2044/// model is a file edit. They are hints only: nothing validates against them,
2045/// because a stale list that refused a model which actually works would be
2046/// worse than no hint at all.
2047fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2048    let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2049
2050    // Only what the preset has hints for. An option with none used to be
2051    // written as `# effort = "..."`, and a placeholder is not a working value:
2052    // the line fails the moment somebody takes the file at its word and
2053    // uncomments it. Cursor has no effort setting at all, so for that agent the
2054    // line should not exist rather than exist and be wrong.
2055    //
2056    // The first entry of each list is the one written as the suggested value,
2057    // which is why the presets put the sensible default there rather than in
2058    // whatever order a CLI's help happens to print.
2059    // Once, above both. The preset's note is about the pair, and repeating it
2060    // under each put a sentence about models underneath the effort line.
2061    if let Some(extra) = &spec.options_note {
2062        if !spec.models.is_empty() || !spec.efforts.is_empty() {
2063            out.push('\n');
2064            out.push_str(&wrap_comment(extra));
2065        }
2066    }
2067    for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2068        let Some(suggested) = choices.first() else {
2069            continue;
2070        };
2071        let mut note = format!("Omit {key} to use the CLI's own default.");
2072        if choices.len() > 1 {
2073            note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2074        }
2075        out.push('\n');
2076        out.push_str(&wrap_comment(&note));
2077        out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2078    }
2079
2080    // The value from the spec, not a number typed here, for the reason the
2081    // [loop] block learned: a second copy of a default is the one that goes
2082    // stale.
2083    out.push('\n');
2084    out.push_str(&wrap_comment(
2085        "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2086         never retried, so err long.",
2087    ));
2088    out.push_str(&format!("# timeout = {}\n", spec.timeout));
2089
2090    // Anything but this agent's own preset: a CLI that has just refused is not
2091    // a stand in for itself.
2092    let backup = if name == "cursor" { "gemini" } else { "cursor" };
2093    out.push('\n');
2094    out.push_str(&wrap_comment(
2095        "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2096         of this agent, never alongside it.",
2097    ));
2098    out.push_str(&format!(
2099        "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2100    ));
2101
2102    // The rest of what an agent block takes defines a CLI rather than tunes
2103    // one, so it is pointed at rather than offered: a generated file that
2104    // invites somebody to edit `command` or `output` on a working preset is
2105    // offering them a way to break it.
2106    out.push('\n');
2107    out.push_str(&wrap_comment(
2108        "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2109         that has no preset.",
2110    ));
2111    out.push('\n');
2112    out
2113}
2114
2115/// What `spar init` says about an option, for `--update` to say too.
2116///
2117/// Empty for one with nothing written about it, and for the effort schedule,
2118/// whose two keys are examples rather than settings and are described by the
2119/// stanza they sit in rather than one at a time.
2120fn note_for(key: &str) -> &'static str {
2121    LOOP_OPTIONS
2122        .iter()
2123        .chain(STYLE_OPTIONS)
2124        .find(|(_, name, _)| *name == key)
2125        .map(|(_, _, note)| *note)
2126        .unwrap_or("")
2127}
2128
2129/// Wrap a note across comment lines so a long one does not run off the edge.
2130fn wrap_comment(text: &str) -> String {
2131    const WIDTH: usize = 76;
2132    let mut out = String::new();
2133    let mut line = String::from("#");
2134    for word in text.split_whitespace() {
2135        if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2136            out.push_str(&line);
2137            out.push('\n');
2138            line = String::from("#");
2139        }
2140        line.push(' ');
2141        line.push_str(word);
2142    }
2143    if line.len() > 1 {
2144        out.push_str(&line);
2145        out.push('\n');
2146    }
2147    out
2148}
2149
2150fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2151    let mut ok = true;
2152
2153    let probes: Vec<(&str, Probe)> = vec![
2154        (
2155            "git",
2156            Box::new(|| {
2157                proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2158                    .map(|s| first_line(&s))
2159            }),
2160        ),
2161        (
2162            "gh",
2163            Box::new(|| {
2164                proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2165                    .map(|s| first_line(&s))
2166            }),
2167        ),
2168        (
2169            "gh auth",
2170            Box::new(|| {
2171                let out = proc::exec(
2172                    &["gh".into(), "auth".into(), "status".into()],
2173                    &ExecOpts::new().check(false).timeout_secs(60),
2174                )?;
2175                let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2176                if out.ok() {
2177                    Ok(first_line(&text))
2178                } else {
2179                    Err(spar_err!("not authenticated. Run `gh auth login`."))
2180                }
2181            }),
2182        ),
2183    ];
2184
2185    for (label, probe) in probes {
2186        match probe() {
2187            Ok(detail) => println!("  ok    {label:12} {detail}"),
2188            Err(e) => {
2189                println!("  FAIL  {label:12} {}", e.first_line());
2190                ok = false;
2191            }
2192        }
2193    }
2194
2195    let found = config::find_config(config_path)?;
2196    let Some(path) = found else {
2197        println!("\n  no spar.toml found. Run `spar init` to generate one.");
2198        println!(
2199            "  presets available: {}",
2200            config::available_presets().join(", ")
2201        );
2202        return Ok(if ok { 0 } else { 1 });
2203    };
2204
2205    println!("\n  config: {}", path.display());
2206    let cfg = match config::load(Some(&path)) {
2207        Ok(cfg) => cfg,
2208        Err(e) => {
2209            println!("  FAIL  config       {e}");
2210            return Ok(1);
2211        }
2212    };
2213
2214    // Kept apart from `ok`: a missing gh says nothing about whether the two
2215    // agents are the same CLI, and must not silence the warning below.
2216    let mut resolved = Vec::new();
2217    for spec in &cfg.agents {
2218        let agent = Agent::new(spec.clone());
2219        match agent.resolve_bin() {
2220            Ok(bin) => {
2221                println!(
2222                    "  ok    {:12} {}  ({})",
2223                    spec.name,
2224                    bin.display(),
2225                    spec.describe()
2226                );
2227                report_fallback(&agent);
2228                resolved.push(agent);
2229            }
2230            Err(e) => {
2231                println!("  FAIL  {:12} {}", spec.name, e.first_line());
2232                ok = false;
2233            }
2234        }
2235    }
2236
2237    if resolved.len() == cfg.agents.len() {
2238        if let Some(warning) = agent::correlation_warning(&resolved) {
2239            println!("\n  WARNING  {warning}");
2240        }
2241    }
2242
2243    for line in unknown_effort_values(&cfg) {
2244        println!("\n  WARNING  {line}");
2245    }
2246
2247    println!(
2248        "\n  settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2249        cfg.first_implementor_policy(),
2250        cfg.loop_cfg.max_rounds,
2251        cfg.loop_cfg.auto_merge,
2252        cfg.loop_cfg.worktrees,
2253        cfg.loop_cfg.followups,
2254        cfg.style.terse
2255    );
2256    // What somebody upgrading wants to know. `spar init` refuses to touch an
2257    // existing config, so without this there is no way to learn that a release
2258    // added a setting short of reading the source.
2259    if let Ok(text) = std::fs::read_to_string(&path) {
2260        let unset = config::unmentioned_options(&text);
2261        if !unset.is_empty() {
2262            println!(
2263                "\n  {} setting(s) this config does not mention, all at their defaults:",
2264                unset.len()
2265            );
2266            for option in &unset {
2267                println!(
2268                    "      [{}] {} = {}",
2269                    option.section, option.key, option.default
2270                );
2271            }
2272            println!(
2273                "  `spar init --update {}` appends them as comments.",
2274                path.display()
2275            );
2276        }
2277    }
2278
2279    println!(
2280        "{}",
2281        if ok {
2282            "\nready"
2283        } else {
2284            "\nmissing prerequisites"
2285        }
2286    );
2287    Ok(if ok { 0 } else { 1 })
2288}
2289
2290/// Scheduled effort values the agent that would receive them is not known to
2291/// accept.
2292///
2293/// A warning rather than a refusal, for the reason the hints themselves carry:
2294/// a CLI's options drift, and a stale allow list that refused a value which
2295/// actually works would be worse than no hint at all. But `round_1 = "ultra"`
2296/// against a claude agent is a call that fails at the CLI, and nothing said so
2297/// until it ran.
2298fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2299    let mut out = Vec::new();
2300    for spec in &cfg.agents {
2301        if spec.efforts.is_empty() {
2302            continue;
2303        }
2304        let mut said: Vec<String> = Vec::new();
2305        for call in config::Call::every() {
2306            let Some(value) = cfg.effort_for(spec, call) else {
2307                continue;
2308            };
2309            let known = spec
2310                .efforts
2311                .iter()
2312                .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2313            if known || said.contains(&value) {
2314                continue;
2315            }
2316            said.push(value.clone());
2317            out.push(format!(
2318                "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2319                 to accept ({}). Put this agent's values under \
2320                 [agents.{}.effort_schedule].",
2321                spec.name,
2322                call.key(),
2323                spec.efforts.join(" | "),
2324                spec.name
2325            ));
2326        }
2327    }
2328    out
2329}
2330
2331fn first_line(text: &str) -> String {
2332    text.trim().lines().next().unwrap_or("").trim().to_string()
2333}
2334
2335// ---------------------------------------------------------------------------
2336// Reporting
2337// ---------------------------------------------------------------------------
2338
2339fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2340    report_with(results, cfg, repo, &[], &[])
2341}
2342
2343/// The same, plus what needs a person: waves that never ran, and the issues
2344/// triage parked or declined.
2345///
2346/// `stopped` is work that did not happen and makes the run non-zero. `parked`
2347/// is an outcome rather than a failure, and is the record of what is waiting on
2348/// a decision, which used to reach a person only through plan.json.
2349fn report_with(
2350    results: &[IssueRun],
2351    cfg: &Config,
2352    repo: &Repo,
2353    stopped: &[String],
2354    parked: &[String],
2355) -> i32 {
2356    // What the run spent, recorded as it happened. Kept beside the run so a
2357    // resume adds to it rather than starting the count again.
2358    let spent = spend::taken();
2359    if let Err(e) = repo.record_spend(&spent) {
2360        logdim!("could not record what this run spent: {e}");
2361    }
2362    println!("\n{}", "=".repeat(60));
2363    for r in results {
2364        println!(
2365            "#{:<5} {:<10} rounds={} {}",
2366            r.issue,
2367            r.status.to_string(),
2368            r.rounds,
2369            r.pr.as_deref().unwrap_or("")
2370        );
2371        for note in &r.notes {
2372            println!("       {}", first_line(note));
2373        }
2374        for url in &r.filed {
2375            println!("       filed {url}");
2376        }
2377        for dispute in &r.disputes {
2378            println!(
2379                "       disputed: {}",
2380                report_item(&dispute.title, &dispute.file)
2381            );
2382        }
2383        for finding in &r.noted {
2384            println!(
2385                "       noted, not blocking: {}",
2386                report_item(&finding.title, &finding.file)
2387            );
2388        }
2389        if let Some(cost) = spend::for_subject(&spent, r.issue) {
2390            println!("       {cost}");
2391        }
2392    }
2393    println!("{}", "=".repeat(60));
2394
2395    if cfg.alternate_first && results.len() > 1 {
2396        println!(
2397            "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2398             .spar/state/implementors.json.",
2399            cfg.first_implementor
2400        );
2401    }
2402    if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2403        println!("\nApproved PRs are waiting on you to merge.");
2404    }
2405    let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2406    if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2407        println!(
2408            "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2409             Set followups = \"issues\" to file them."
2410        );
2411    }
2412    if let Some(summary) = spend::summary(&spent) {
2413        println!("\n{summary}");
2414    }
2415    for lines in [parked, stopped] {
2416        if lines.is_empty() {
2417            continue;
2418        }
2419        println!();
2420        for line in lines {
2421            println!("{line}");
2422        }
2423    }
2424    let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2425    issue_exit.max(report_writes(repo))
2426}
2427
2428/// A failed write makes the final status non-zero, including a partial failure.
2429/// Independent writes still finish first, so one failure does not discard work
2430/// that another item can land. The non-zero status lets automation retry what
2431/// was missed.
2432fn report_writes(repo: &Repo) -> i32 {
2433    let writes = repo.write_summary();
2434    if let Some(line) = write_summary_line(writes) {
2435        println!("\n{line}");
2436    }
2437    write_exit_code(writes)
2438}
2439
2440fn write_summary_line(writes: WriteSummary) -> Option<String> {
2441    (writes.attempted > 0).then(|| {
2442        format!(
2443            "writes: {} attempted, {} succeeded, {} failed",
2444            writes.attempted,
2445            writes.succeeded(),
2446            writes.failed
2447        )
2448    })
2449}
2450
2451fn write_exit_code(writes: WriteSummary) -> i32 {
2452    i32::from(writes.failed > 0)
2453}
2454
2455fn report_item(title: &str, file: &str) -> String {
2456    match file.trim() {
2457        "" => title.to_string(),
2458        location => format!("{title} ({location})"),
2459    }
2460}
2461
2462#[cfg(test)]
2463mod tests {
2464    use super::*;
2465    use clap::CommandFactory;
2466
2467    fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2468        PlanItem {
2469            issue,
2470            title: format!("issue {issue}"),
2471            complexity: crate::model::Complexity::S,
2472            risk: crate::model::Risk::Low,
2473            depends_on,
2474            reason: "worth doing".into(),
2475        }
2476    }
2477
2478    /// Ordering by dependency did half a job: it sorted, and then every
2479    /// worktree was still built from origin/<base>.
2480    ///
2481    /// With auto_merge off, the default and the recommended setting, a
2482    /// dependency that ends approved is not on the base when the dependent is
2483    /// implemented. The implementor then fails, re-implements the dependency,
2484    /// or declares the issue not worth doing, having spent a full issue's
2485    /// budget to find that out.
2486    #[test]
2487    fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2488        let plan = Plan {
2489            order: vec![planned(9, vec![]), planned(10, vec![9])],
2490            ..Plan::default()
2491        };
2492        let mut landed = BTreeSet::new();
2493
2494        let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2495        assert!(why.contains("#9"), "{why}");
2496        assert!(why.contains("has not merged"), "{why}");
2497        assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2498
2499        // Merged is the only thing that puts the work on the base.
2500        landed.insert(9);
2501        assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2502    }
2503
2504    /// The case the ordering stayed quietest about: a dependency nobody is
2505    /// going to do.
2506    #[test]
2507    fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2508        let declined = Plan {
2509            order: vec![planned(10, vec![9])],
2510            skipped: vec![crate::model::SkippedItem {
2511                issue: 9,
2512                title: "issue 9".into(),
2513                reasons: Default::default(),
2514                tracker: false,
2515            }],
2516            ..Plan::default()
2517        };
2518        let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2519        assert!(why.contains("declined by both reviewers"), "{why}");
2520
2521        let contested = Plan {
2522            order: vec![planned(10, vec![9])],
2523            contested: vec![crate::model::ContestedItem {
2524                issue: 9,
2525                title: "issue 9".into(),
2526                positions: Default::default(),
2527                reasons: Default::default(),
2528                note: None,
2529            }],
2530            ..Plan::default()
2531        };
2532        let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2533        assert!(why.contains("disagree about #9"), "{why}");
2534    }
2535
2536    /// A dependency that is not in this run at all is somebody else's business:
2537    /// it may well be merged already, and holding on it would stop work that is
2538    /// perfectly buildable. It is logged by `report_dependencies` instead.
2539    #[test]
2540    fn a_dependency_outside_the_run_does_not_hold_anything() {
2541        let plan = Plan {
2542            order: vec![planned(10, vec![900])],
2543            ..Plan::default()
2544        };
2545        assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2546    }
2547
2548    /// A disagreement is the one triage outcome that needs a person, and it
2549    /// reached them least reliably: a log line `--quiet` suppresses, an entry
2550    /// in plan.json, and nothing in the report at all.
2551    /// `round_1 = "ultra"` against a claude agent is a call that fails at the
2552    /// CLI, and nothing said so until it ran.
2553    #[test]
2554    fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2555        let text = "\
2556[agents.claude]
2557preset = \"claude\"
2558
2559[agents.codex]
2560preset = \"codex\"
2561
2562[loop.effort_schedule]
2563review_1 = \"ultra\"
2564";
2565        let cfg = config::parse(text).expect("parses");
2566        let said = unknown_effort_values(&cfg);
2567        assert_eq!(1, said.len(), "{said:?}");
2568        assert!(said[0].starts_with("claude"), "{}", said[0]);
2569        assert!(said[0].contains("ultra"), "{}", said[0]);
2570        assert!(said[0].contains("review_1"), "{}", said[0]);
2571        assert!(
2572            said[0].contains("[agents.claude.effort_schedule]"),
2573            "it has to say where to put the right value: {}",
2574            said[0]
2575        );
2576
2577        // With the value in each agent's own words, nothing is said.
2578        let quiet = text.replace(
2579            "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2580            "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2581             [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2582        );
2583        assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2584    }
2585
2586    #[test]
2587    fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2588        let mut positions_map = std::collections::BTreeMap::new();
2589        positions_map.insert("claude".to_string(), "do".to_string());
2590        positions_map.insert("codex".to_string(), "skip".to_string());
2591        let mut reasons = std::collections::BTreeMap::new();
2592        reasons.insert(
2593            "claude".to_string(),
2594            "the retry path is still wrong".to_string(),
2595        );
2596        reasons.insert(
2597            "codex".to_string(),
2598            "fixed in the rewrite last month".to_string(),
2599        );
2600
2601        let line = positions(&positions_map, &reasons);
2602        assert!(line.contains("claude says do"), "{line}");
2603        assert!(line.contains("codex says skip"), "{line}");
2604        assert!(line.contains("the retry path is still wrong"), "{line}");
2605        assert!(line.contains("fixed in the rewrite"), "{line}");
2606
2607        // A verdict with no reason still names who said what.
2608        let bare = positions(&positions_map, &Default::default());
2609        assert!(bare.contains("claude says do"), "{bare}");
2610    }
2611
2612    #[test]
2613    fn report_items_include_their_location() {
2614        assert_eq!(
2615            "Same title (src/a.rs:10)",
2616            report_item("Same title", "src/a.rs:10")
2617        );
2618        assert_eq!("General point", report_item("General point", ""));
2619    }
2620
2621    #[test]
2622    fn every_attempted_write_failing_is_a_failed_run() {
2623        let writes = WriteSummary {
2624            attempted: 3,
2625            failed: 3,
2626        };
2627
2628        assert_eq!(1, write_exit_code(writes));
2629        assert_eq!(
2630            Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2631            write_summary_line(writes)
2632        );
2633    }
2634
2635    #[test]
2636    fn a_partial_write_failure_is_reported_after_the_run() {
2637        let writes = WriteSummary {
2638            attempted: 3,
2639            failed: 1,
2640        };
2641
2642        assert_eq!(1, write_exit_code(writes));
2643        assert_eq!(2, writes.succeeded());
2644    }
2645
2646    #[test]
2647    fn the_parser_is_internally_consistent() {
2648        Cli::command().debug_assert();
2649    }
2650
2651    #[test]
2652    fn quiet_is_accepted_before_or_after_the_subcommand() {
2653        for argv in [
2654            vec!["spar", "--quiet", "run", "42"],
2655            vec!["spar", "run", "42", "--quiet"],
2656            vec!["spar", "resume", "--quiet"],
2657            vec!["spar", "init", "-q"],
2658        ] {
2659            assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2660        }
2661        assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2662    }
2663
2664    #[test]
2665    fn several_issue_numbers_are_accepted() {
2666        let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2667        match cli.command {
2668            Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2669            other => panic!("{other:?}"),
2670        }
2671    }
2672
2673    #[test]
2674    fn issue_numbers_and_flags_can_be_interleaved() {
2675        let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2676        match cli.command {
2677            Command::Run {
2678                issues, loop_flags, ..
2679            } => {
2680                assert_eq!(vec![42, 51], issues);
2681                assert!(loop_flags.auto_merge);
2682            }
2683            other => panic!("{other:?}"),
2684        }
2685    }
2686
2687    #[test]
2688    fn every_command_that_reads_a_config_accepts_one() {
2689        for argv in [
2690            vec!["spar", "run", "42"],
2691            vec!["spar", "triage"],
2692            vec!["spar", "resume"],
2693            vec!["spar", "followup"],
2694            vec!["spar", "checkin"],
2695            vec!["spar", "split"],
2696            vec!["spar", "brainstorm"],
2697            vec!["spar", "clean"],
2698            vec!["spar", "doctor"],
2699        ] {
2700            let mut full = argv.clone();
2701            full.extend(["--config", "other.toml"]);
2702            let cli = Cli::parse_from(&full);
2703            let config = match cli.command {
2704                Command::Run { common, .. }
2705                | Command::Triage { common, .. }
2706                | Command::Resume { common, .. }
2707                | Command::Followup { common, .. }
2708                | Command::Split { common, .. }
2709                | Command::Brainstorm { common, .. }
2710                | Command::Checkin { common, .. } => common.config,
2711                Command::Clean { config, .. } | Command::Doctor { config } => config,
2712                other => panic!("{other:?}"),
2713            };
2714            assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2715        }
2716    }
2717
2718    #[test]
2719    fn auto_merge_is_off_unless_asked_for() {
2720        let cli = Cli::parse_from(["spar", "run"]);
2721        match cli.command {
2722            Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2723            other => panic!("{other:?}"),
2724        }
2725    }
2726
2727    /// The flag reaches resume as well as run, which is what makes "stop, edit
2728    /// the config, carry on with something extra to say" a thing you can do.
2729    #[test]
2730    fn every_command_that_reads_a_config_takes_instructions() {
2731        // `followup` is given no number, because it takes none: its entries
2732        // have no identity a person could type.
2733        for argv in [
2734            vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2735            vec![
2736                "spar",
2737                "triage",
2738                "7",
2739                "--instructions",
2740                "Do not wait for CI.",
2741            ],
2742            vec![
2743                "spar",
2744                "resume",
2745                "7",
2746                "--instructions",
2747                "Do not wait for CI.",
2748            ],
2749            vec![
2750                "spar",
2751                "review",
2752                "7",
2753                "--instructions",
2754                "Do not wait for CI.",
2755            ],
2756            vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2757            vec![
2758                "spar",
2759                "checkin",
2760                "7",
2761                "--instructions",
2762                "Do not wait for CI.",
2763            ],
2764            vec![
2765                "spar",
2766                "split",
2767                "7",
2768                "--instructions",
2769                "Do not wait for CI.",
2770            ],
2771            vec![
2772                "spar",
2773                "brainstorm",
2774                "a",
2775                "subject",
2776                "--instructions",
2777                "Do not wait for CI.",
2778            ],
2779        ] {
2780            let parsed = Cli::parse_from(&argv);
2781            let common = match parsed.command {
2782                Command::Run { common, .. }
2783                | Command::Triage { common, .. }
2784                | Command::Resume { common, .. }
2785                | Command::Review { common, .. }
2786                | Command::Followup { common, .. }
2787                | Command::Split { common, .. }
2788                | Command::Brainstorm { common, .. }
2789                | Command::Checkin { common, .. } => common,
2790                other => panic!("{other:?}"),
2791            };
2792            assert_eq!(
2793                Some("Do not wait for CI."),
2794                common.instructions.as_deref(),
2795                "{argv:?}"
2796            );
2797        }
2798    }
2799
2800    #[test]
2801    fn the_two_close_skipped_flags_are_mutually_exclusive() {
2802        assert!(
2803            Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2804        );
2805    }
2806
2807    /// Only `run` triages, so only `run` can decline an issue. Accepting the
2808    /// flag on `resume` would silently do nothing.
2809    #[test]
2810    fn close_skipped_is_offered_only_where_it_means_something() {
2811        assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2812        assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2813        // `followup` triages what it files, so it can decline it too.
2814        assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2815        assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2816        assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2817        assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2818    }
2819
2820    /// The subject is prose, so it is taken as words rather than one quoted
2821    /// string, and none at all means the repository itself.
2822    #[test]
2823    fn brainstorm_takes_words_or_nothing() {
2824        match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
2825            Command::Brainstorm { topic, ideas, .. } => {
2826                assert_eq!(vec!["novel", "uses", "of", "script"], topic);
2827                assert_eq!(5, ideas);
2828            }
2829            other => panic!("{other:?}"),
2830        }
2831        match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
2832            Command::Brainstorm {
2833                topic,
2834                ideas,
2835                max_rounds,
2836                ..
2837            } => {
2838                assert!(topic.is_empty());
2839                assert_eq!(3, ideas);
2840                assert_eq!(Some(2), max_rounds);
2841            }
2842            other => panic!("{other:?}"),
2843        }
2844    }
2845
2846    /// `--from` files what a saved session says and runs nothing, so it means
2847    /// nothing without `--file-issues`, and there is no session to write with
2848    /// `--out`.
2849    #[test]
2850    fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
2851        assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
2852        assert!(
2853            Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
2854        );
2855        assert!(Cli::try_parse_from([
2856            "spar",
2857            "brainstorm",
2858            "--from",
2859            "s.md",
2860            "--file-issues",
2861            "--out",
2862            "t.md"
2863        ])
2864        .is_err());
2865        assert!(
2866            Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
2867                .is_ok()
2868        );
2869    }
2870
2871    /// An entry in the follow-up queue has no number and its title is prose, so
2872    /// a number on the command line could only be silently ignored.
2873    #[test]
2874    fn followup_takes_no_numbers() {
2875        assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2876        assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2877    }
2878
2879    /// `--screen-only` stops before `--file-only` does, so asking for both says
2880    /// nothing about where to stop.
2881    #[test]
2882    fn the_two_stopping_points_are_mutually_exclusive() {
2883        assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2884        assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2885        assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2886    }
2887
2888    #[test]
2889    fn the_close_skipped_pair_resolves_to_a_tristate() {
2890        let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2891            Command::Run { triage_flags, .. } => {
2892                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2893                    (true, _) => Some(true),
2894                    (_, true) => Some(false),
2895                    _ => None,
2896                }
2897            }
2898            other => panic!("{other:?}"),
2899        };
2900        assert_eq!(None, read(&["spar", "run"]));
2901        assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2902        assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2903    }
2904
2905    #[test]
2906    fn the_default_limit_is_twenty() {
2907        let cli = Cli::parse_from(["spar", "run"]);
2908        match cli.command {
2909            Command::Run { common, .. } => assert_eq!(20, common.limit),
2910            other => panic!("{other:?}"),
2911        }
2912    }
2913
2914    #[test]
2915    fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2916        assert!(matches!(
2917            Cli::parse_from(["spar", "scrub-filter"]).command,
2918            Command::ScrubFilter
2919        ));
2920        let help = Cli::command().render_long_help().to_string();
2921        assert!(
2922            !help.contains("scrub-filter"),
2923            "it is plumbing, not a command"
2924        );
2925    }
2926
2927    #[test]
2928    fn review_takes_pr_numbers_and_a_dry_run() {
2929        let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2930        match cli.command {
2931            Command::Review { items, dry_run, .. } => {
2932                assert_eq!(vec![101, 102], items);
2933                assert!(dry_run);
2934            }
2935            other => panic!("{other:?}"),
2936        }
2937    }
2938
2939    #[test]
2940    fn review_posts_unless_told_not_to() {
2941        match Cli::parse_from(["spar", "review", "101"]).command {
2942            Command::Review { dry_run, .. } => assert!(!dry_run),
2943            other => panic!("{other:?}"),
2944        }
2945    }
2946
2947    #[test]
2948    fn review_with_no_numbers_is_allowed() {
2949        match Cli::parse_from(["spar", "review"]).command {
2950            Command::Review { items, .. } => assert!(items.is_empty()),
2951            other => panic!("{other:?}"),
2952        }
2953    }
2954
2955    #[test]
2956    fn review_takes_its_own_round_budget() {
2957        match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2958            Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2959            other => panic!("{other:?}"),
2960        }
2961    }
2962
2963    #[test]
2964    fn checkin_takes_pr_numbers_and_a_dry_run() {
2965        match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2966            Command::Checkin { items, dry_run, .. } => {
2967                assert_eq!(vec![108, 112], items);
2968                assert!(dry_run);
2969            }
2970            other => panic!("{other:?}"),
2971        }
2972        match Cli::parse_from(["spar", "checkin"]).command {
2973            Command::Checkin { items, dry_run, .. } => {
2974                assert!(items.is_empty());
2975                assert!(!dry_run);
2976            }
2977            other => panic!("{other:?}"),
2978        }
2979    }
2980
2981    /// `--auto-merge` must not exist on the one command whose input is written
2982    /// by somebody else, and `--max-rounds` would be worse than useless: the
2983    /// judgement is two passes by construction, so `--max-rounds 1` could only
2984    /// mean "let one agent decide alone", which removes the one thing standing
2985    /// between a stranger's comment and a push.
2986    #[test]
2987    fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2988        assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2989        assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2990        assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2991        assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
2992        // What it does offer.
2993        assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
2994        assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
2995        assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
2996        assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
2997    }
2998
2999    #[test]
3000    fn resume_takes_a_next_override() {
3001        let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3002        match cli.command {
3003            Command::Resume {
3004                prs, next_actor, ..
3005            } => {
3006                assert_eq!(vec![108], prs);
3007                assert_eq!(Some("codex".to_string()), next_actor);
3008            }
3009            other => panic!("{other:?}"),
3010        }
3011    }
3012}
3013
3014#[cfg(test)]
3015mod absorb_tests {
3016    use super::*;
3017
3018    #[test]
3019    fn absorb_is_off_unless_asked_for() {
3020        match Cli::parse_from(["spar", "run"]).command {
3021            Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3022            other => panic!("{other:?}"),
3023        }
3024    }
3025
3026    #[test]
3027    fn absorb_takes_a_wave_count() {
3028        match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3029            Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3030            other => panic!("{other:?}"),
3031        }
3032    }
3033
3034    #[test]
3035    fn absorb_is_only_offered_where_issues_are_worked() {
3036        assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3037        assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3038        assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3039        // `split` decomposes and stops, so there is nothing for a wave of newly
3040        // filed issues to be absorbed into.
3041        assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3042    }
3043}
3044
3045#[cfg(test)]
3046mod split_tests {
3047    use super::*;
3048
3049    #[test]
3050    fn split_takes_numbers_of_either_kind() {
3051        match Cli::parse_from(["spar", "split", "10", "12"]).command {
3052            Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3053            other => panic!("{other:?}"),
3054        }
3055    }
3056
3057    /// The bare form means both kinds, which no other command does, so it has
3058    /// to be allowed to take nothing.
3059    #[test]
3060    fn split_with_no_numbers_is_allowed() {
3061        match Cli::parse_from(["spar", "split"]).command {
3062            Command::Split {
3063                items,
3064                dry_run,
3065                again,
3066                ..
3067            } => {
3068                assert!(items.is_empty());
3069                assert!(!dry_run);
3070                assert!(!again);
3071            }
3072            other => panic!("{other:?}"),
3073        }
3074    }
3075
3076    /// A wrong split is N issues to close, a checklist to strip out of
3077    /// somebody's body, and branches and pull requests to delete, so the read
3078    /// only half is not optional.
3079    #[test]
3080    fn split_can_be_told_to_write_nothing() {
3081        match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3082            Command::Split { dry_run, .. } => assert!(dry_run),
3083            other => panic!("{other:?}"),
3084        }
3085    }
3086
3087    /// The flag `checkin` established for exactly this: do it again on
3088    /// something spar already dealt with.
3089    #[test]
3090    fn split_can_be_told_to_split_something_it_already_split() {
3091        match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3092            Command::Split { again, .. } => assert!(again),
3093            other => panic!("{other:?}"),
3094        }
3095    }
3096
3097    /// It picks for itself when given nothing, so it takes both of the flags
3098    /// that govern picking, and its config like everything else.
3099    #[test]
3100    fn split_takes_the_flags_that_govern_picking() {
3101        match Cli::parse_from([
3102            "spar",
3103            "split",
3104            "--limit",
3105            "5",
3106            "--min-number",
3107            "480",
3108            "--config",
3109            "other.toml",
3110        ])
3111        .command
3112        {
3113            Command::Split { common, .. } => {
3114                assert_eq!(5, common.limit);
3115                assert_eq!(Some(480), common.min_number);
3116                assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3117            }
3118            other => panic!("{other:?}"),
3119        }
3120    }
3121
3122    /// `split` decomposes and stops. A flag that implied it works, reviews, or
3123    /// merges anything would be a flag that does nothing.
3124    #[test]
3125    fn split_offers_no_flag_that_would_make_it_a_second_run() {
3126        for flag in [
3127            vec!["--auto-merge"],
3128            vec!["--max-rounds", "2"],
3129            vec!["--close-skipped"],
3130            vec!["--no-close-skipped"],
3131            vec!["--keep-worktrees"],
3132        ] {
3133            let mut argv = vec!["spar", "split", "10"];
3134            argv.extend(flag.iter().copied());
3135            assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3136        }
3137    }
3138}
3139
3140#[cfg(test)]
3141mod min_number_tests {
3142    use super::*;
3143
3144    fn read(argv: &[&str]) -> Option<i64> {
3145        match Cli::parse_from(argv).command {
3146            Command::Run { common, .. }
3147            | Command::Triage { common, .. }
3148            | Command::Resume { common, .. }
3149            | Command::Review { common, .. }
3150            | Command::Followup { common, .. }
3151            | Command::Split { common, .. }
3152            | Command::Checkin { common, .. } => common.min_number,
3153            other => panic!("{other:?}"),
3154        }
3155    }
3156
3157    #[test]
3158    fn there_is_no_floor_unless_one_is_asked_for() {
3159        assert_eq!(None, read(&["spar", "run"]));
3160    }
3161
3162    #[test]
3163    fn every_command_that_picks_for_itself_accepts_a_floor() {
3164        for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
3165            assert_eq!(
3166                Some(480),
3167                read(&["spar", cmd, "--min-number", "480"]),
3168                "{cmd}"
3169            );
3170        }
3171    }
3172}
3173
3174#[cfg(test)]
3175mod run_targets_tests {
3176    use super::*;
3177
3178    fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3179        pairs.iter().copied().collect()
3180    }
3181
3182    /// The run this comes from: `spar run 786 783 784 771`, where #786 is the
3183    /// pull request an earlier run opened for #772. Naming it is how a person
3184    /// says "finish this one", and it used to be worked last, behind three
3185    /// issues' full implement and review loops.
3186    #[test]
3187    fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3188        let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3189
3190        assert_eq!(vec![783, 784], kept);
3191    }
3192
3193    #[test]
3194    fn an_issue_no_named_pull_request_closes_is_still_worked() {
3195        let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3196
3197        assert_eq!(vec![783, 784, 771], kept);
3198    }
3199
3200    /// A pull request whose closing issues could not be read covers nothing,
3201    /// so nothing is dropped on a guess.
3202    #[test]
3203    fn an_unreadable_pull_request_drops_no_issue() {
3204        let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3205
3206        assert_eq!(vec![772, 783], kept);
3207    }
3208
3209    /// One pull request can close several issues, and all of them belong to it.
3210    #[test]
3211    fn every_issue_one_pull_request_closes_is_taken_by_it() {
3212        let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3213
3214        assert_eq!(vec![783], kept);
3215    }
3216}
3217
3218#[cfg(test)]
3219mod settings_block_tests {
3220    use super::*;
3221
3222    /// The value a config line offers, with its trailing note removed. Quote
3223    /// aware, since a note is free to contain a `#` and several do.
3224    fn written(line: &str) -> String {
3225        let after = line.split_once('=').expect("an assignment").1;
3226        let mut quoted = false;
3227        for (i, c) in after.char_indices() {
3228            match c {
3229                '"' => quoted = !quoted,
3230                '#' if !quoted => return after[..i].trim().to_string(),
3231                _ => {}
3232            }
3233        }
3234        after.trim().to_string()
3235    }
3236
3237    fn line_for(text: &str, key: &str) -> String {
3238        text.lines()
3239            .find(|l| {
3240                let bare = l.trim_start().trim_start_matches('#').trim_start();
3241                bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3242            })
3243            .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3244            .to_string()
3245    }
3246
3247    /// The guard that was missing. Every number in the generated config used to
3248    /// be typed in beside its comment, which is a second copy of a default that
3249    /// lives in the code, and the copies stopped agreeing: it offered a title
3250    /// budget of 90 against a real 140, a body of 900 against 8000, and three
3251    /// more like it. Uncommenting one to see what it did cut every comment spar
3252    /// posts to a fifth of its length.
3253    #[test]
3254    fn every_value_it_offers_is_the_default_it_actually_has() {
3255        let text = settings_block("claude");
3256        for option in config::known_options() {
3257            // The effort words are per CLI, so the schedule's are examples of
3258            // what one accepts rather than defaults. There is no default
3259            // effort: an agent that names none uses its own CLI's.
3260            if option.section == "loop.effort_schedule" {
3261                continue;
3262            }
3263            let line = line_for(&text, &option.key);
3264            assert_eq!(
3265                option.default,
3266                written(&line),
3267                "the generated config offers `{}`, but the default is {}",
3268                line.trim(),
3269                option.default
3270            );
3271        }
3272    }
3273
3274    /// `doctor` reports what a config does not mention, so a generated one
3275    /// should send nobody to that list on the day it was written. pr_comments
3276    /// was missing from it for exactly that long.
3277    #[test]
3278    fn it_offers_every_option_the_parser_knows_about() {
3279        let text = settings_block("claude");
3280        let missing: Vec<String> = config::unmentioned_options(&text)
3281            .into_iter()
3282            .map(|o| format!("[{}] {}", o.section, o.key))
3283            .collect();
3284        assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3285    }
3286
3287    /// The strongest of these: every line the file suggests has to be a line
3288    /// that works. A commented option is an invitation to uncomment it, and one
3289    /// that then fails to load is worse than never having offered it.
3290    #[test]
3291    fn every_option_it_offers_can_be_uncommented_and_still_load() {
3292        let mut text = String::from(
3293            "[agents.claude]\ncommand = [\"claude\"]\n\n\
3294             [agents.codex]\ncommand = [\"codex\"]\n\n",
3295        );
3296        for line in settings_block("claude").lines() {
3297            text.push_str(uncomment(line).unwrap_or(line));
3298            text.push('\n');
3299        }
3300        let cfg = config::parse(&text).expect("a config of its own suggestions");
3301        assert_eq!("claude", cfg.first_implementor);
3302    }
3303
3304    /// A commented assignment with its `#` removed, or None for a line of
3305    /// prose, which stays a comment.
3306    fn uncomment(line: &str) -> Option<&str> {
3307        let bare = line.trim_start().strip_prefix('#')?.trim_start();
3308        // An assignment, not a wrapped note that happens to contain an `=`:
3309        // the key has to be one bare word.
3310        let key = bare.split_once('=')?.0.trim();
3311        let named = !key.is_empty()
3312            && key
3313                .chars()
3314                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3315        named.then_some(bare)
3316    }
3317
3318    #[test]
3319    fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3320        assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3321    }
3322
3323    /// Every line starts at the margin, and an option's note sits above the
3324    /// option rather than beside it. Beside meant three different columns in
3325    /// one file, and a note that wrapped left the reader tracking indentation
3326    /// to work out which setting it belonged to.
3327    #[test]
3328    fn a_note_sits_above_the_option_it_describes() {
3329        let text = settings_block("claude");
3330        assert!(
3331            text.lines().all(|l| l.chars().count() <= 80),
3332            "a line runs off the edge:\n{text}"
3333        );
3334        assert!(
3335            text.lines().all(|l| !l.starts_with(' ')),
3336            "a line is indented, so the columns are back:\n{text}"
3337        );
3338
3339        // The line before an option carries its note, not another option.
3340        let lines: Vec<&str> = text.lines().collect();
3341        let at = lines
3342            .iter()
3343            .position(|l| l.starts_with("max_rounds"))
3344            .expect("max_rounds");
3345        assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3346        assert!(
3347            lines[at - 1].contains("lifetime cap"),
3348            "the note above it is the end of its own note: {:?}",
3349            lines[at - 1]
3350        );
3351    }
3352
3353    /// A note reads as a sentence now that it leads rather than trails.
3354    #[test]
3355    fn every_note_starts_as_a_sentence() {
3356        for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
3357            let first = note.chars().next().expect("a note");
3358            assert!(
3359                first.is_uppercase(),
3360                "{key} reads as a margin scribble rather than a sentence: {note}"
3361            );
3362        }
3363    }
3364}
3365
3366#[cfg(test)]
3367mod agent_block_tests {
3368    use super::*;
3369
3370    fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3371        let mut spec: config::AgentSpec =
3372            toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3373                .try_into()
3374                .expect("builds");
3375        spec.models = models.iter().map(|s| s.to_string()).collect();
3376        spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3377        spec
3378    }
3379
3380    /// A placeholder is not a working value. `# effort = "..."` was written for
3381    /// every preset that lists no efforts, and taking the file at its word by
3382    /// uncommenting it passes `...` to the CLI as a real setting.
3383    #[test]
3384    fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3385        let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3386        assert!(!block.contains("..."), "{block}");
3387        assert!(!block.contains("effort"), "{block}");
3388        assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3389    }
3390
3391    /// Each option is introduced by its own line, so a block with no effort
3392    /// setting never mentions one.
3393    #[test]
3394    fn only_the_options_that_follow_are_introduced() {
3395        let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3396        assert!(model_only.contains("Omit model to use"), "{model_only}");
3397        assert!(!model_only.contains("Omit effort"), "{model_only}");
3398
3399        let both = agent_block("claude", &spec(&["fable"], &["high"]));
3400        assert!(both.contains("Omit model to use"), "{both}");
3401        assert!(both.contains("Omit effort to use"), "{both}");
3402    }
3403
3404    /// A preset with no hints at all still has to produce a loadable block,
3405    /// which is every preset that has never listed any: gemini and aider.
3406    #[test]
3407    fn a_preset_with_no_hints_still_writes_a_usable_block() {
3408        let block = agent_block("gemini", &spec(&[], &[]));
3409        assert!(!block.contains("..."), "{block}");
3410        assert!(!block.contains("Omit"), "{block}");
3411        assert!(
3412            block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3413            "{block}"
3414        );
3415        // What the block does still carry is unaffected.
3416        assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3417        assert!(block.contains("# timeout = "), "{block}");
3418    }
3419
3420    /// The timeout comes from the spec rather than a number typed into the
3421    /// generator, for the reason the [loop] block learned the hard way.
3422    #[test]
3423    fn the_timeout_offered_is_the_one_the_agent_would_use() {
3424        let mut spec = spec(&["a"], &[]);
3425        spec.timeout = 7200;
3426        assert!(
3427            agent_block("custom", &spec).contains("# timeout = 7200"),
3428            "the generator kept its own copy"
3429        );
3430    }
3431
3432    /// Alternatives are named, and a single choice is not dressed up as one.
3433    #[test]
3434    fn alternatives_are_listed_only_when_there_are_any() {
3435        assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3436        let single = agent_block("b", &spec(&["only"], &[]));
3437        assert!(!single.contains("One of:"), "{single}");
3438    }
3439
3440    /// The preset's own note is about the pair, so it appears once above them
3441    /// rather than under each, which put a sentence about models beneath the
3442    /// effort line.
3443    #[test]
3444    fn the_presets_note_is_said_once() {
3445        let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3446        spec.options_note = Some("Check the current sets with: mytool --help".into());
3447        let block = agent_block("mytool", &spec);
3448        assert_eq!(
3449            1,
3450            block.matches("Check the current sets").count(),
3451            "{block}"
3452        );
3453    }
3454}