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