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