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