Skip to main content

spar/
brainstorm.rs

1//! Brainstorming with the pair: existing pieces, paired in ways nobody has.
2//!
3//! The premise is that the parts of an answer usually exist already, in the
4//! problem's own field or an adjacent one, and what is missing is the pairing.
5//! So the agents are not asked to invent. They are asked to take inventory of
6//! what exists and then to combine, and every idea has to name its parts and
7//! the nearest thing that already does something like it.
8//!
9//! Nothing is edited between rounds, so the custody loop in [`crate::review`]
10//! does not apply. The shape is the one `spar review` uses:
11//!
12//! 1. **Diverge.** Both agents propose at the same time, neither seeing the
13//!    other. An idea both reach on their own is the strongest signal there is.
14//! 2. **Cross.** Each reads only the other's ideas and, for each, builds on it
15//!    with a further piece, challenges it by naming where it already exists or
16//!    why it cannot work, or keeps it. These are the middle rounds.
17//! 3. **Converge.** Whoever proposed a challenged idea defends it with
18//!    specifics or withdraws it, and both rank what is left.
19//!
20//! The settle is mechanical. Nothing here lets one agent overrule the other:
21//! an idea leaves the set only when its own proposer withdraws it or never
22//! answers the objection, and the order is the sum of two rankings.
23
24use std::path::{Path, PathBuf};
25
26use crate::agent::Agent;
27use crate::clock::file_stamp;
28use crate::config::{Call, Config};
29use crate::error::{ErrorKind, Result};
30use crate::model::{ConvergeDoc, CrossDoc, CrossKind, Idea, IdeasDoc};
31use crate::repo::{Repo, FOLLOWUP_MARKER};
32use crate::review_only::concurrently;
33use crate::style::{self, Style};
34use crate::{bail, log, logdim, logwarn, schema};
35
36// ---------------------------------------------------------------------------
37// Prompts
38// ---------------------------------------------------------------------------
39
40/// The one sentence every prompt here carries. A brainstorm reads, and an
41/// ambient write in the working tree discards the answer.
42const READ_ONLY: &str = "\
43Do not modify, commit, or push anything in your working directory. If you need
44scratch space, use the system temporary directory.";
45
46const DIVERGE_PROMPT: &str = "\
47{opening}
48
49The premise of this session is that the pieces of an answer usually exist
50already, in this field or an adjacent one, and what is missing is the pairing.
51So do not try to invent from nothing. Work in two steps.
52
53First, take inventory. List, for yourself, the existing techniques, primitives,
54results, tools, and patterns that bear on this, from its own field and from
55fields that solve a similar shaped problem differently. Include things that
56seem unrelated at first: the useful pairings are usually the ones nobody has
57had a reason to try.
58
59Then propose {count} ideas. Each pairs two or more pieces from your inventory
60in a way that has not been done. For every idea:
61- title: one line naming it by what it does, not by its ingredients.
62- combines: name the pieces. An idea that cannot name its parts is a wish.
63- how_it_works: the mechanism, concretely enough that somebody could start.
64- why_new: the nearest thing that already exists, and what this does that it
65  does not. If you cannot name a nearest thing, look harder before claiming
66  there is none.
67- enables: what becomes possible that was not.
68- risks: what would make it fail, honestly.
69- first_experiment: the cheapest thing that would show whether it works.
70
71Prefer an idea that would be surprising and real over one that is safe and
72obvious. The other agent proposes on the same subject without seeing your
73answer, then reads yours and challenges anything that already exists or cannot
74work, so an idea that survives is one you could defend with specifics.
75
76{read_only}";
77
78const TOPIC_OPENING: &str = "\
79Brainstorm: {subject}
80
81Your working directory is a code repository. It is context, not the subject,
82unless the subject is about it.";
83
84const REPO_OPENING: &str = "\
85Brainstorm ideas for the repository in your working directory.
86
87Read it first: the README, the code, and the recent history, until you can say
88what it is for, who uses it, and what it does not do yet. The ideas are for
89this project: things it could do, or do differently, that would matter to the
90people who use it.{open_issues}";
91
92const CROSS_PROMPT: &str = "\
93Brainstorm, continued: {subject}
94
95Both agents proposed ideas independently. The full set is numbered below. The
96ones marked \"rule on this\" were proposed by the other agent and you have not
97seen them before. Rule on each of those, and only those. An idea marked \"both
98of you proposed this\" was reached independently by each of you, which is the
99strongest signal this session produces: build on it if you can, do not
100challenge it. Your own are listed so you can see the whole set.
101
102For each idea you rule on, one verdict:
103- build: you can see a further piece that completes it or makes it stronger,
104  or a way to combine it with another idea in the set. Write the revised idea
105  out in full in build, naming everything it combines including the new piece.
106  It stands beside the original rather than replacing it.
107- challenge: it already exists, and you can name where, or it cannot work, and
108  you can say why. Put the thing or the reason in reason. The proposer answers
109  you with specifics, so an objection you cannot substantiate costs a round for
110  nothing.
111- keep: real and new as far as you can tell, and you have nothing to add.
112
113Do not challenge to seem rigorous or build to seem agreeable. The set is only
114as good as the honesty of this round.
115
116{read_only}
117
118Ideas:
119{ideas}";
120
121const CONVERGE_PROMPT: &str = "\
122Brainstorm, last round: {subject}
123
124The full set is below, with what was said about each idea.
125
126Two things.
127
128First, {defend} Set stands=true only if you can give the specific evidence
129that settles the objection in reply: the prior art that does not actually do
130this, the mechanism the objection missed, the number that makes it work. Set
131stands=false to withdraw the idea, which is the right answer when the
132objection is correct. Withdrawing costs nothing. Defending a point you cannot
133substantiate puts it in front of a person with your name on it.
134
135Second, rank every idea that is not withdrawn, best first, in ranking. Weigh
136novelty and feasibility together: an idea nobody has done that could be tried
137this month beats a moonshot, and a moonshot beats a small improvement on
138something that exists. Include every number, your own and the ones you
139challenged. The other agent ranks the same set and the two rankings are
140combined mechanically, so rank by what you actually think.
141
142{read_only}
143
144Ideas:
145{ideas}";
146
147// ---------------------------------------------------------------------------
148// What a session is
149// ---------------------------------------------------------------------------
150
151/// What one `spar brainstorm` was asked for.
152#[derive(Debug, Clone)]
153pub struct Session {
154    /// The subject, or `None` to brainstorm the repository itself.
155    pub topic: Option<String>,
156    /// Rounds, from `[loop] max_rounds`.
157    pub budget: u32,
158    /// How many ideas survive the settle.
159    pub wanted: usize,
160}
161
162impl Session {
163    /// The subject as the prompts and the file name it.
164    pub fn subject(&self) -> String {
165        match &self.topic {
166            Some(topic) => topic.clone(),
167            None => "this repository".to_string(),
168        }
169    }
170}
171
172/// Where an idea stands after the rounds that have run.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum Standing {
175    /// Proposed by one agent and not yet ruled on by the other.
176    Unverified,
177    /// Both agents reached it independently. Never challenged.
178    Corroborated,
179    /// The other agent objected and the proposer has not answered.
180    Challenged,
181    /// Objected to and defended with specifics. A person decides.
182    Defended,
183    /// Objected to and withdrawn by the agent that proposed it.
184    Withdrawn,
185}
186
187/// One idea, with everything the rounds said about it.
188#[derive(Debug, Clone)]
189pub struct Candidate {
190    /// The number the agents see. Stable for the whole session, so a ruling
191    /// from any round matches back.
192    pub id: u32,
193    pub idea: Idea,
194    /// Who proposed it. Two names when both reached it independently.
195    pub proposers: Vec<String>,
196    /// The idea this was built on, when a cross round produced it.
197    pub parent: Option<u32>,
198    /// Who objected, and what they said.
199    pub objection: Option<(String, String)>,
200    /// What the proposer said back, when it stood by the idea.
201    pub defence: Option<String>,
202    pub standing: Standing,
203    /// The sum of its positions in the final rankings, lower first.
204    pub rank_sum: u32,
205}
206
207impl Candidate {
208    fn new(id: u32, idea: Idea, proposer: &str) -> Self {
209        Self {
210            id,
211            idea,
212            proposers: vec![proposer.to_string()],
213            parent: None,
214            objection: None,
215            defence: None,
216            standing: Standing::Unverified,
217            rank_sum: 0,
218        }
219    }
220
221    fn proposed_by(&self, name: &str) -> bool {
222        self.proposers.iter().any(|p| p == name)
223    }
224}
225
226/// What a session produced.
227#[derive(Debug, Clone)]
228pub struct Outcome {
229    pub subject: String,
230    pub agents: Vec<String>,
231    pub rounds: u32,
232    /// Ideas kept, best first.
233    pub kept: Vec<Candidate>,
234    /// Ideas set aside, each with the reason.
235    pub dropped: Vec<(Candidate, String)>,
236    pub notes: Vec<String>,
237}
238
239// ---------------------------------------------------------------------------
240// The rounds
241// ---------------------------------------------------------------------------
242
243/// Run the rounds and settle the set.
244pub fn run(agents: &[Agent], cfg: &Config, repo: &Repo, session: &Session) -> Result<Outcome> {
245    let budget = session.budget.max(1);
246    let checkout = repo.read_checkout("brainstorm")?;
247    let work_dir = checkout.path();
248    let subject = session.subject();
249    let names: Vec<String> = agents.iter().map(|a| a.name().to_string()).collect();
250    let mut notes = Vec::new();
251
252    // -- round 1: both propose, neither seeing the other ------------------
253    log!("brainstorm: {} diverging on {subject}", names.join(" and "));
254    let prompt = diverge_prompt(session, repo);
255    let answers = concurrently(agents, |agent| {
256        let effort = agent.effort(cfg, Call::Brainstorm(1));
257        agent.ask_json::<IdeasDoc>(&prompt, &schema::brainstorm_ideas(), work_dir, &effort)
258    });
259    let mut by_agent: Vec<(String, Vec<Idea>)> = Vec::new();
260    for (name, result) in answers {
261        match result {
262            Ok(doc) => {
263                let ideas: Vec<Idea> = doc
264                    .ideas
265                    .into_iter()
266                    .filter(|idea| !idea.title.trim().is_empty())
267                    .collect();
268                logdim!(
269                    "{name} proposed {} idea{}",
270                    ideas.len(),
271                    plural(ideas.len())
272                );
273                by_agent.push((name, ideas));
274            }
275            Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
276            Err(e) => {
277                logdim!("{name} could not brainstorm: {e}");
278                notes.push(format!("{name} did not answer: {e}"));
279            }
280        }
281    }
282    if by_agent.is_empty() {
283        bail!("neither agent returned any ideas");
284    }
285    let paired = by_agent.len() == 2;
286    if !paired {
287        // One voice is a materially weaker result, not a footnote: nothing
288        // was cross-checked, so nothing below carries two models' judgement.
289        crate::logging::warn(format!(
290            "only {} answered. Nothing was cross-checked, so these ideas carry one model's \
291             judgement rather than two.",
292            by_agent[0].0
293        ));
294        notes.push("only one agent answered, so nothing was cross-checked".into());
295    }
296    let mut set = merge(&by_agent);
297    if set.is_empty() {
298        bail!("neither agent proposed anything with a title");
299    }
300    let shared = set
301        .iter()
302        .filter(|c| c.standing == Standing::Corroborated)
303        .count();
304    if shared > 0 {
305        log!(
306            "brainstorm: {} idea{} in the set, {shared} reached by both",
307            set.len(),
308            plural(set.len())
309        );
310    } else {
311        log!(
312            "brainstorm: {} idea{} in the set",
313            set.len(),
314            plural(set.len())
315        );
316    }
317
318    // -- the middle rounds: each rules on what only the other proposed ----
319    let mut rounds = 1;
320    if paired {
321        let mut round = 2;
322        while round < budget {
323            cross(agents, cfg, work_dir, &subject, &mut set, round)?;
324            rounds = round;
325            round += 1;
326        }
327        // -- the last round: defend or withdraw, then rank ----------------
328        if budget >= 2 {
329            converge(agents, cfg, work_dir, &subject, &mut set, budget)?;
330            rounds = budget;
331        }
332    }
333
334    let (kept, dropped) = settle(set, session.wanted);
335    Ok(Outcome {
336        subject,
337        agents: names,
338        rounds,
339        kept,
340        dropped,
341        notes,
342    })
343}
344
345fn diverge_prompt(session: &Session, repo: &Repo) -> String {
346    let opening = match &session.topic {
347        Some(topic) => TOPIC_OPENING.replace("{subject}", topic.trim()),
348        None => REPO_OPENING.replace("{open_issues}", &open_issues_block(repo)),
349    };
350    DIVERGE_PROMPT
351        .replace("{opening}", &opening)
352        .replace("{count}", &session.wanted.to_string())
353        .replace("{read_only}", READ_ONLY)
354}
355
356/// What is already filed, so repo mode does not propose it again. Best
357/// effort: with no `gh`, no remote, or no access the list is empty, and the
358/// reader is told so rather than left to wonder why a filed idea came back.
359fn open_issues_block(repo: &Repo) -> String {
360    let rows = repo.open_issue_rows();
361    if rows.is_empty() {
362        logdim!("no open issues to exclude, or none could be listed");
363        return String::new();
364    }
365    let mut out =
366        String::from("\n\nThese are already filed as open issues, so do not propose them again:\n");
367    for row in rows {
368        out.push_str(&format!(
369            "- #{} {}\n",
370            row.number,
371            style::one_line(&row.title)
372        ));
373    }
374    out.trim_end().to_string()
375}
376
377/// Both lists into one numbered set. An idea both agents reached is kept once,
378/// in the longer wording, with both names on it.
379pub fn merge(by_agent: &[(String, Vec<Idea>)]) -> Vec<Candidate> {
380    let mut set: Vec<Candidate> = Vec::new();
381    for (name, ideas) in by_agent {
382        for idea in ideas {
383            let same = set
384                .iter_mut()
385                .find(|c| !c.proposed_by(name) && same_idea(&c.idea, idea));
386            match same {
387                Some(existing) => {
388                    if gist(idea).len() > gist(&existing.idea).len() {
389                        existing.idea = idea.clone();
390                    }
391                    existing.proposers.push(name.clone());
392                    existing.standing = Standing::Corroborated;
393                }
394                None => {
395                    let id = set.len() as u32 + 1;
396                    set.push(Candidate::new(id, idea.clone(), name));
397                }
398            }
399        }
400    }
401    set
402}
403
404/// Whether two ideas are the same idea in different words.
405///
406/// Title plus mechanism, because `same_point` wants three shared significant
407/// words and two short titles rarely have them even when they agree.
408fn same_idea(a: &Idea, b: &Idea) -> bool {
409    crate::textsim::same_point(&gist(a), &gist(b))
410}
411
412fn gist(idea: &Idea) -> String {
413    format!("{} {}", idea.title, idea.how_it_works)
414}
415
416fn cross(
417    agents: &[Agent],
418    cfg: &Config,
419    work_dir: &Path,
420    subject: &str,
421    set: &mut Vec<Candidate>,
422    round: u32,
423) -> Result<()> {
424    let pending = set
425        .iter()
426        .filter(|c| c.standing == Standing::Unverified)
427        .count();
428    if pending == 0 {
429        return Ok(());
430    }
431    log!(
432        "brainstorm round {round}: each agent rules on the {pending} idea{} only the other \
433         proposed",
434        plural(pending)
435    );
436    let answers = concurrently(agents, |agent| {
437        let theirs = set
438            .iter()
439            .filter(|c| c.standing == Standing::Unverified && !c.proposed_by(agent.name()))
440            .count();
441        if theirs == 0 {
442            return Ok(CrossDoc::default());
443        }
444        let prompt = CROSS_PROMPT
445            .replace("{subject}", subject)
446            .replace("{read_only}", READ_ONLY)
447            .replace("{ideas}", &ideas_for_prompt(set, Some(agent.name())));
448        agent.ask_json::<CrossDoc>(
449            &prompt,
450            &schema::brainstorm_cross(),
451            work_dir,
452            &agent.effort(cfg, Call::Brainstorm(round)),
453        )
454    });
455    let mut builds = Vec::new();
456    for (name, result) in answers {
457        let doc = match result {
458            Ok(doc) => doc,
459            Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
460            Err(e) => {
461                logdim!("{name} could not rule on the other's ideas: {e}");
462                continue;
463            }
464        };
465        for warning in apply_cross(set, &name, doc, &mut builds) {
466            logwarn!("{warning}");
467        }
468    }
469    if !builds.is_empty() {
470        log!(
471            "brainstorm round {round}: {} idea{} built on another",
472            builds.len(),
473            plural(builds.len())
474        );
475    }
476    set.extend(builds);
477    Ok(())
478}
479
480/// One agent's rulings onto the set. Builds are collected rather than pushed
481/// so a build cannot be ruled on in the round that produced it.
482///
483/// Returns what was dropped and why, for the caller to log.
484pub fn apply_cross(
485    set: &mut [Candidate],
486    name: &str,
487    doc: CrossDoc,
488    builds: &mut Vec<Candidate>,
489) -> Vec<String> {
490    let mut warnings = Vec::new();
491    for verdict in doc.verdicts {
492        let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == verdict.idea) else {
493            warnings.push(format!(
494                "{name} ruled on idea {} which is not in the set, so that ruling was dropped",
495                verdict.idea
496            ));
497            continue;
498        };
499        if target.standing == Standing::Corroborated {
500            // Both proposed it, so neither may challenge it, and the prompt
501            // said so. Building on it is welcome from either.
502            if verdict.verdict != CrossKind::Build {
503                continue;
504            }
505        } else if target.proposed_by(name) {
506            warnings.push(format!(
507                "{name} ruled on its own idea {} and that ruling was dropped",
508                target.id
509            ));
510            continue;
511        } else if target.standing != Standing::Unverified && verdict.verdict != CrossKind::Build {
512            // One already ruled on stays ruled on.
513            continue;
514        }
515        match verdict.verdict {
516            CrossKind::Keep => {}
517            CrossKind::Challenge => {
518                target.standing = Standing::Challenged;
519                target.objection = Some((name.to_string(), verdict.reason));
520            }
521            CrossKind::Build => {
522                let Some(idea) = verdict.build.filter(|i| !i.title.trim().is_empty()) else {
523                    warnings.push(format!(
524                        "{name} said it built on idea {} but wrote nothing, so that was dropped",
525                        target.id
526                    ));
527                    continue;
528                };
529                let mut built = Candidate::new(0, idea, name);
530                built.parent = Some(target.id);
531                builds.push(built);
532            }
533        }
534    }
535    // Numbered after the set, densely, however many rulings were dropped and
536    // whichever agent's builds came first.
537    let mut id = set.len() as u32;
538    for built in builds.iter_mut() {
539        id += 1;
540        built.id = id;
541    }
542    warnings
543}
544
545fn converge(
546    agents: &[Agent],
547    cfg: &Config,
548    work_dir: &Path,
549    subject: &str,
550    set: &mut [Candidate],
551    round: u32,
552) -> Result<()> {
553    let challenged = set
554        .iter()
555        .filter(|c| c.standing == Standing::Challenged)
556        .count();
557    log!(
558        "brainstorm round {round}: {challenged} objection{} to answer, then both rank",
559        plural(challenged)
560    );
561    let answers = concurrently(agents, |agent| {
562        let mine: Vec<String> = set
563            .iter()
564            .filter(|c| c.standing == Standing::Challenged && c.proposed_by(agent.name()))
565            .map(|c| c.id.to_string())
566            .collect();
567        let defend = if mine.is_empty() {
568            "nothing you proposed was challenged, so defences is empty.".to_string()
569        } else {
570            format!(
571                "answer each objection to an idea you proposed. Yours that were challenged: \
572                 {}. One entry in defences for each.",
573                mine.join(", ")
574            )
575        };
576        let prompt = CONVERGE_PROMPT
577            .replace("{subject}", subject)
578            .replace("{defend}", &defend)
579            .replace("{read_only}", READ_ONLY)
580            .replace("{ideas}", &ideas_for_prompt(set, None));
581        agent.ask_json::<ConvergeDoc>(
582            &prompt,
583            &schema::brainstorm_converge(),
584            work_dir,
585            &agent.effort(cfg, Call::Brainstorm(round)),
586        )
587    });
588    for (name, result) in answers {
589        let doc = match result {
590            Ok(doc) => doc,
591            Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
592            Err(e) => {
593                logdim!("{name} could not rank: {e}");
594                continue;
595            }
596        };
597        for warning in apply_converge(set, &name, doc) {
598            logwarn!("{warning}");
599        }
600    }
601    Ok(())
602}
603
604/// One agent's defences and ranking onto the set.
605///
606/// A defence counts only from an agent that proposed the idea. An idea an
607/// agent left out of its ranking scores one place below its last, so an
608/// omission costs the idea rather than the agent that forgot it.
609pub fn apply_converge(set: &mut [Candidate], name: &str, doc: ConvergeDoc) -> Vec<String> {
610    let mut warnings = Vec::new();
611    for defence in doc.defences {
612        let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == defence.idea) else {
613            warnings.push(format!(
614                "{name} defended idea {} which is not in the set, so that was dropped",
615                defence.idea
616            ));
617            continue;
618        };
619        if !target.proposed_by(name) || target.standing != Standing::Challenged {
620            continue;
621        }
622        if defence.stands {
623            target.standing = Standing::Defended;
624            target.defence = Some(defence.reply);
625        } else {
626            target.standing = Standing::Withdrawn;
627        }
628    }
629    let mut placed: Vec<u32> = Vec::new();
630    for (position, id) in doc.ranking.iter().enumerate() {
631        let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == *id) else {
632            warnings.push(format!(
633                "{name} ranked idea {id} which is not in the set, so that place was dropped"
634            ));
635            continue;
636        };
637        if placed.contains(&target.id) {
638            continue;
639        }
640        placed.push(target.id);
641        target.rank_sum += position as u32 + 1;
642    }
643    let last = placed.len() as u32 + 1;
644    for c in set.iter_mut() {
645        if c.standing != Standing::Withdrawn && !placed.contains(&c.id) {
646            c.rank_sum += last;
647        }
648    }
649    warnings
650}
651
652/// Keep the best `wanted`, and say why the rest were set aside.
653///
654/// Reached by both first, then by rank, then by number. A challenged idea whose
655/// proposer never answered goes with the withdrawn ones: nobody stood by it.
656pub fn settle(set: Vec<Candidate>, wanted: usize) -> (Vec<Candidate>, Vec<(Candidate, String)>) {
657    let mut standing = Vec::new();
658    let mut dropped = Vec::new();
659    for c in set {
660        match c.standing {
661            Standing::Withdrawn => {
662                let why = objection_line(&c, "withdrawn after");
663                dropped.push((c, why));
664            }
665            Standing::Challenged => {
666                let why = objection_line(&c, "not defended against");
667                dropped.push((c, why));
668            }
669            _ => standing.push(c),
670        }
671    }
672    standing.sort_by_key(|c| (c.standing != Standing::Corroborated, c.rank_sum, c.id));
673    let rest = standing.split_off(wanted.min(standing.len()));
674    for c in rest {
675        dropped.push((c, "ranked below the cut".into()));
676    }
677    (standing, dropped)
678}
679
680/// One line for the header list. The objection is clipped to a sentence or
681/// two: the whole argument is in the run's log, and a paragraph per dropped
682/// idea would push the ideas that were kept off the first screen.
683fn objection_line(c: &Candidate, lead: &str) -> String {
684    match &c.objection {
685        Some((who, why)) => format!(
686            "{lead} {who}'s objection: {}",
687            style::clip(&style::one_line(why), 240)
688        ),
689        None => format!("{lead} an objection"),
690    }
691}
692
693// ---------------------------------------------------------------------------
694// Rendering
695// ---------------------------------------------------------------------------
696
697/// The set as the agents read it: numbered, tagged by whose it is, with what
698/// has been said about each. `reader` is the agent being asked, so the tags
699/// can say which ideas are theirs to rule on.
700fn ideas_for_prompt(set: &[Candidate], reader: Option<&str>) -> String {
701    let mut out = String::new();
702    for c in set {
703        let tag = match (reader, c.standing) {
704            (_, Standing::Corroborated) => "both of you proposed this".to_string(),
705            (Some(me), Standing::Unverified) if c.proposed_by(me) => "yours".to_string(),
706            (Some(_), Standing::Unverified) => "rule on this".to_string(),
707            (_, Standing::Withdrawn) => "withdrawn".to_string(),
708            _ => format!("proposed by {}", c.proposers.join(" and ")),
709        };
710        out.push_str(&format!(
711            "{}. {} [{tag}]\n",
712            c.id,
713            style::one_line(&c.idea.title)
714        ));
715        if let Some(parent) = c.parent {
716            out.push_str(&format!("   builds on idea {parent}\n"));
717        }
718        out.push_str(&format!(
719            "   combines: {}\n",
720            c.idea
721                .combines
722                .iter()
723                .map(|s| style::one_line(s))
724                .collect::<Vec<_>>()
725                .join("; ")
726        ));
727        for (label, text) in [
728            ("how it works", &c.idea.how_it_works),
729            ("why new", &c.idea.why_new),
730            ("enables", &c.idea.enables),
731            ("risks", &c.idea.risks),
732            ("first experiment", &c.idea.first_experiment),
733        ] {
734            if !text.trim().is_empty() {
735                out.push_str(&format!("   {label}: {}\n", style::one_line(text)));
736            }
737        }
738        if let Some((who, why)) = &c.objection {
739            out.push_str(&format!(
740                "   objection from {who}: {}\n",
741                style::one_line(why)
742            ));
743        }
744        if let Some(reply) = &c.defence {
745            out.push_str(&format!("   reply: {}\n", style::one_line(reply)));
746        }
747        out.push('\n');
748    }
749    out.trim_end().to_string()
750}
751
752/// The six sections an idea is written in, wherever it is written.
753///
754/// One renderer for the session file and the issue body, so a person who read
755/// one recognises the other, and so the headings stay `### `: the session
756/// parser splits entries on `## ` and would take a `## Risks` for a new idea.
757pub fn idea_sections(idea: &Idea, style: &Style) -> String {
758    let mut out = String::new();
759    out.push_str("### Combines\n");
760    let combines: Vec<String> = idea
761        .combines
762        .iter()
763        .map(|s| style::scrub(&style::one_line(s), style))
764        .filter(|s| !s.is_empty())
765        .collect();
766    if combines.is_empty() {
767        out.push_str("(not named)\n");
768    }
769    for piece in combines {
770        out.push_str(&format!("- {piece}\n"));
771    }
772    for (heading, text) in [
773        ("How it works", &idea.how_it_works),
774        ("Why it is new", &idea.why_new),
775        ("What it enables", &idea.enables),
776        ("Risks", &idea.risks),
777        ("First experiment", &idea.first_experiment),
778    ] {
779        out.push_str(&format!("\n### {heading}\n"));
780        let text = style::scrub(text, style);
781        if text.is_empty() {
782            out.push_str("(not given)\n");
783        } else {
784            out.push_str(&text);
785            out.push('\n');
786        }
787    }
788    out
789}
790
791/// The objection and the reply, for a reader deciding whether to try it.
792/// Unnamed on purpose: who said it is provenance, and provenance stays out of
793/// anything that may be filed.
794fn discussion(c: &Candidate, style: &Style) -> String {
795    let mut out = String::new();
796    if let Some((_, why)) = &c.objection {
797        out.push_str(&format!(
798            "\n### Discussion\nObjection: {}\n",
799            style::scrub(why, style)
800        ));
801        if let Some(reply) = &c.defence {
802            out.push_str(&format!("\nReply: {}\n", style::scrub(reply, style)));
803        }
804    }
805    out
806}
807
808/// The whole body of an idea as it is filed: the sections and the discussion.
809pub fn issue_body(c: &Candidate, style: &Style) -> String {
810    let mut body = idea_sections(&c.idea, style);
811    body.push_str(&discussion(c, style));
812    body.trim_end().to_string()
813}
814
815/// Who proposed it and what happened to it. Written above the sections in the
816/// session file, and stripped again by [`parse_session`], so it never reaches
817/// an issue: agent names are usually vendor names, and the attribution gate
818/// exists to keep those off a tracker.
819fn provenance(c: &Candidate, set: &[Candidate]) -> String {
820    let mut line = if c.standing == Standing::Corroborated {
821        format!("Reached independently by {}.", c.proposers.join(" and "))
822    } else {
823        match c.parent.and_then(|id| set.iter().find(|p| p.id == id)) {
824            Some(parent) => format!(
825                "Proposed by {}, building on \"{}\".",
826                c.proposers.join(" and "),
827                style::one_line(&parent.idea.title)
828            ),
829            None => format!("Proposed by {}.", c.proposers.join(" and ")),
830        }
831    };
832    if let (Some((who, _)), Standing::Defended) = (&c.objection, c.standing) {
833        line.push_str(&format!(
834            " Challenged by {who}, defended by {}.",
835            c.proposers.join(" and ")
836        ));
837    }
838    line
839}
840
841const PROVENANCE_LEADS: [&str; 2] = ["Proposed by ", "Reached independently by "];
842
843/// The session file.
844///
845/// A header a person reads, then one entry per kept idea in the follow-up
846/// queue's shape, so `parse_session` and `spar followup --file` both read it
847/// back. Two rules keep that true and the tests hold them: no `## ` line other
848/// than an idea's title, and nothing after the last idea, which is why the
849/// ideas set aside are listed in the header rather than at the end.
850pub fn render_session(outcome: &Outcome, when: &str, style: &Style) -> String {
851    let mut out = String::new();
852    out.push_str(&format!(
853        "# Brainstorm: {}\n",
854        style::scrub(&style::one_line(&outcome.subject), style)
855    ));
856    out.push_str(&format!("Date: {when}\n"));
857    out.push_str(&format!("Agents: {}\n", outcome.agents.join(", ")));
858    out.push_str(&format!("Rounds: {}\n", outcome.rounds));
859    for note in &outcome.notes {
860        out.push_str(&format!(
861            "Note: {}\n",
862            style::scrub(&style::one_line(note), style)
863        ));
864    }
865    if !outcome.dropped.is_empty() {
866        out.push_str("\nAlso considered, and set aside:\n");
867        for (c, why) in &outcome.dropped {
868            out.push_str(&format!(
869                "- {}: {}\n",
870                style::scrub(&style::one_line(&c.idea.title), style),
871                style::scrub(why, style)
872            ));
873        }
874    }
875    let all: Vec<Candidate> = outcome
876        .kept
877        .iter()
878        .chain(outcome.dropped.iter().map(|(c, _)| c))
879        .cloned()
880        .collect();
881    for c in &outcome.kept {
882        out.push_str(&format!(
883            "\n{FOLLOWUP_MARKER}\n## {}\n\n{}\n\n",
884            style::title(&c.idea.title, style),
885            style::scrub(&provenance(c, &all), style)
886        ));
887        out.push_str(&idea_sections(&c.idea, style));
888        out.push_str(&discussion(c, style));
889    }
890    out.trim_end().to_string() + "\n"
891}
892
893/// The ideas in a saved session, as title and body, with the provenance line
894/// taken out of each body so what is filed carries no agent's name.
895pub fn parse_session(text: &str) -> Vec<(String, String)> {
896    crate::followups::parse(text)
897        .into_iter()
898        .filter(|entry| !entry.title.trim().is_empty())
899        .map(|entry| {
900            let body: Vec<&str> = entry
901                .body
902                .lines()
903                .filter(|line| !PROVENANCE_LEADS.iter().any(|lead| line.starts_with(lead)))
904                .collect();
905            (entry.title, body.join("\n").trim().to_string())
906        })
907        .collect()
908}
909
910/// What the kept ideas become when filed from a live run.
911pub fn issue_entries(outcome: &Outcome, style: &Style) -> Vec<(String, String)> {
912    outcome
913        .kept
914        .iter()
915        .map(|c| (c.idea.title.clone(), issue_body(c, style)))
916        .collect()
917}
918
919/// File each idea, or add it to the issue that already covers it. One failure
920/// does not stop the rest; the write ledger makes the exit status say so.
921pub fn file_ideas(repo: &Repo, entries: &[(String, String)]) {
922    for (title, body) in entries {
923        match crate::review::file_as_issue(repo, title, body) {
924            Ok(filed) => println!("  {}", filed.describe(title)),
925            Err(e) => {
926                crate::logging::error(format!("could not file '{}': {e}", style::clip(title, 60)))
927            }
928        }
929    }
930}
931
932/// What the terminal gets: the kept ideas in order, and where the rest went.
933pub fn print_summary(outcome: &Outcome, path: &Path) {
934    println!();
935    println!(
936        "{} idea{} kept, {} set aside, {} round{}",
937        outcome.kept.len(),
938        plural(outcome.kept.len()),
939        outcome.dropped.len(),
940        outcome.rounds,
941        plural(outcome.rounds as usize)
942    );
943    for (i, c) in outcome.kept.iter().enumerate() {
944        let mark = match c.standing {
945            Standing::Corroborated => " (reached by both)",
946            Standing::Defended => " (challenged and defended)",
947            _ => "",
948        };
949        println!(
950            "  {}. {}{mark}",
951            i + 1,
952            style::clip_bare(&style::one_line(&c.idea.title), 90)
953        );
954    }
955    for note in &outcome.notes {
956        println!("  note: {note}");
957    }
958    println!("\nwritten to {}", path.display());
959}
960
961/// Where a session goes unless `--out` says otherwise.
962pub fn default_path(repo: &Repo, session: &Session, now_secs: u64) -> PathBuf {
963    let name = match &session.topic {
964        Some(topic) => slug(topic),
965        None => "repo".to_string(),
966    };
967    repo.brainstorms_dir()
968        .join(format!("{}-{name}.md", file_stamp(now_secs)))
969}
970
971/// A file name fragment from a topic: lowercase, runs of anything but a letter
972/// or digit collapsed to one hyphen, cut at a word, "idea" when nothing is left.
973pub fn slug(text: &str) -> String {
974    let mut out = String::new();
975    let mut gap = false;
976    for ch in text.chars().flat_map(char::to_lowercase) {
977        if ch.is_ascii_alphanumeric() {
978            if gap && !out.is_empty() {
979                out.push('-');
980            }
981            gap = false;
982            out.push(ch);
983        } else {
984            gap = true;
985        }
986    }
987    if out.len() > 40 {
988        let cut = out[..40].rfind('-').unwrap_or(40);
989        out.truncate(cut);
990    }
991    if out.is_empty() {
992        "idea".to_string()
993    } else {
994        out
995    }
996}
997
998fn plural(n: usize) -> &'static str {
999    if n == 1 {
1000        ""
1001    } else {
1002        "s"
1003    }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009    use crate::model::{CrossVerdict, Defence};
1010
1011    fn idea(title: &str, how: &str) -> Idea {
1012        Idea {
1013            title: title.into(),
1014            combines: vec!["hash time locks".into(), "batch auctions".into()],
1015            how_it_works: how.into(),
1016            why_new: "The nearest thing is a plain escrow, which cannot batch.".into(),
1017            enables: "Settling many trades in one transaction.".into(),
1018            risks: "Fee spikes make the batch uneconomic.".into(),
1019            first_experiment: "Simulate ten trades on regtest.".into(),
1020        }
1021    }
1022
1023    fn pair(a: Vec<Idea>, b: Vec<Idea>) -> Vec<Candidate> {
1024        merge(&[("a".to_string(), a), ("b".to_string(), b)])
1025    }
1026
1027    #[test]
1028    fn an_idea_both_agents_reach_is_kept_once_with_both_names() {
1029        let set = pair(
1030            vec![idea(
1031                "Batch settlement with timelocks",
1032                "Each trade posts a hash timelock and a batch auction clears them together at the deadline.",
1033            )],
1034            vec![idea(
1035                "Timelocked batch auction settlement",
1036                "Trades post hash timelocks, and one batch auction clears every one of them at the deadline together.",
1037            )],
1038        );
1039        assert_eq!(1, set.len());
1040        assert_eq!(Standing::Corroborated, set[0].standing);
1041        assert_eq!(vec!["a", "b"], set[0].proposers);
1042    }
1043
1044    #[test]
1045    fn different_ideas_get_their_own_numbers_in_order() {
1046        let set = pair(
1047            vec![idea(
1048                "One",
1049                "A covenant restricts the spend path to a template.",
1050            )],
1051            vec![idea(
1052                "Two",
1053                "A watchtower gossips penalty transactions over a mesh radio.",
1054            )],
1055        );
1056        assert_eq!(vec![1, 2], set.iter().map(|c| c.id).collect::<Vec<_>>());
1057        assert!(set.iter().all(|c| c.standing == Standing::Unverified));
1058    }
1059
1060    fn two() -> Vec<Candidate> {
1061        pair(
1062            vec![idea(
1063                "One",
1064                "A covenant restricts the spend path to a template.",
1065            )],
1066            vec![idea(
1067                "Two",
1068                "A watchtower gossips penalty transactions over a mesh radio.",
1069            )],
1070        )
1071    }
1072
1073    fn verdict(id: i64, kind: CrossKind, build: Option<Idea>) -> CrossVerdict {
1074        CrossVerdict {
1075            idea: id,
1076            verdict: kind,
1077            reason: "because".into(),
1078            build,
1079        }
1080    }
1081
1082    #[test]
1083    fn a_challenge_records_who_objected_and_why() {
1084        let mut set = two();
1085        let mut builds = Vec::new();
1086        let doc = CrossDoc {
1087            verdicts: vec![verdict(1, CrossKind::Challenge, None)],
1088        };
1089        let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1090        assert!(warnings.is_empty(), "{warnings:?}");
1091        assert_eq!(Standing::Challenged, set[0].standing);
1092        assert_eq!(
1093            Some(("b".to_string(), "because".to_string())),
1094            set[0].objection
1095        );
1096        assert_eq!(Standing::Unverified, set[1].standing);
1097    }
1098
1099    /// An agent rules on the other's ideas, never its own, and a ruling on a
1100    /// number that is not in the set says so rather than landing somewhere.
1101    #[test]
1102    fn rulings_on_your_own_idea_or_an_unknown_one_are_dropped_with_a_reason() {
1103        let mut set = two();
1104        let mut builds = Vec::new();
1105        let doc = CrossDoc {
1106            verdicts: vec![
1107                verdict(2, CrossKind::Challenge, None),
1108                verdict(9, CrossKind::Challenge, None),
1109            ],
1110        };
1111        let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1112        assert_eq!(2, warnings.len(), "{warnings:?}");
1113        assert!(warnings[0].contains("its own idea 2"));
1114        assert!(warnings[1].contains("idea 9"));
1115        assert!(set.iter().all(|c| c.standing == Standing::Unverified));
1116    }
1117
1118    #[test]
1119    fn a_build_becomes_a_new_idea_numbered_after_the_set_and_linked_to_its_parent() {
1120        let mut set = two();
1121        let mut builds = Vec::new();
1122        let doc = CrossDoc {
1123            verdicts: vec![
1124                verdict(1, CrossKind::Keep, None),
1125                verdict(
1126                    1,
1127                    CrossKind::Build,
1128                    Some(idea(
1129                        "One, plus",
1130                        "The covenant template also commits to a fee.",
1131                    )),
1132                ),
1133                verdict(1, CrossKind::Build, None),
1134            ],
1135        };
1136        let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1137        assert_eq!(1, warnings.len(), "{warnings:?}");
1138        assert!(warnings[0].contains("wrote nothing"));
1139        assert_eq!(1, builds.len());
1140        assert_eq!(3, builds[0].id);
1141        assert_eq!(Some(1), builds[0].parent);
1142        assert_eq!(vec!["b"], builds[0].proposers);
1143        assert_eq!(Standing::Unverified, builds[0].standing);
1144    }
1145
1146    #[test]
1147    fn an_idea_both_reached_cannot_be_challenged_but_can_be_built_on() {
1148        let mut set = pair(
1149            vec![idea(
1150                "Batch settlement with timelocks",
1151                "Each trade posts a hash timelock and a batch auction clears them together at the deadline.",
1152            )],
1153            vec![idea(
1154                "Timelocked batch auction settlement",
1155                "Trades post hash timelocks, and one batch auction clears every one of them at the deadline together.",
1156            )],
1157        );
1158        // A challenge is ignored, as the prompt said it would be. A build is
1159        // not, from either of them.
1160        let mut builds = Vec::new();
1161        let warnings = apply_cross(
1162            &mut set,
1163            "b",
1164            CrossDoc {
1165                verdicts: vec![
1166                    verdict(1, CrossKind::Challenge, None),
1167                    verdict(
1168                        1,
1169                        CrossKind::Build,
1170                        Some(idea("Batched, plus fees", "The auction also clears fees.")),
1171                    ),
1172                ],
1173            },
1174            &mut builds,
1175        );
1176        assert!(warnings.is_empty(), "{warnings:?}");
1177        assert_eq!(Standing::Corroborated, set[0].standing);
1178        assert!(set[0].objection.is_none());
1179        assert_eq!(1, builds.len());
1180        assert_eq!(Some(1), builds[0].parent);
1181        assert_eq!(2, builds[0].id);
1182    }
1183
1184    #[test]
1185    fn a_defence_stands_or_withdraws_and_only_the_proposer_may_give_one() {
1186        let mut set = two();
1187        set[0].standing = Standing::Challenged;
1188        set[0].objection = Some(("b".into(), "exists".into()));
1189        set[1].standing = Standing::Challenged;
1190        set[1].objection = Some(("a".into(), "cannot work".into()));
1191        let doc = ConvergeDoc {
1192            defences: vec![
1193                Defence {
1194                    idea: 1,
1195                    stands: true,
1196                    reply: "the prior art cannot batch".into(),
1197                },
1198                Defence {
1199                    idea: 2,
1200                    stands: false,
1201                    reply: String::new(),
1202                },
1203            ],
1204            ranking: vec![1, 2],
1205        };
1206        // "a" proposed 1, so its word on 2 does not count.
1207        let warnings = apply_converge(&mut set, "a", doc);
1208        assert!(warnings.is_empty(), "{warnings:?}");
1209        assert_eq!(Standing::Defended, set[0].standing);
1210        assert_eq!(
1211            Some("the prior art cannot batch".to_string()),
1212            set[0].defence
1213        );
1214        assert_eq!(Standing::Challenged, set[1].standing);
1215    }
1216
1217    /// Leaving an idea out of a ranking is not a way to bury it silently: it
1218    /// scores one place below the last one named.
1219    #[test]
1220    fn an_omitted_idea_ranks_last_and_two_rankings_add_up() {
1221        let mut set = two();
1222        set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1223        apply_converge(
1224            &mut set,
1225            "a",
1226            ConvergeDoc {
1227                defences: vec![],
1228                ranking: vec![3, 1],
1229            },
1230        );
1231        apply_converge(
1232            &mut set,
1233            "b",
1234            ConvergeDoc {
1235                defences: vec![],
1236                ranking: vec![1, 2, 3, 1],
1237            },
1238        );
1239        // a: 3 first, 1 second, 2 omitted so third. b: 1, 2, 3 in order.
1240        assert_eq!(2 + 1, set[0].rank_sum);
1241        assert_eq!(3 + 2, set[1].rank_sum);
1242        assert_eq!(1 + 3, set[2].rank_sum);
1243    }
1244
1245    #[test]
1246    fn settle_keeps_the_best_and_says_why_each_of_the_rest_went() {
1247        let mut set = two();
1248        set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1249        set.push(Candidate::new(4, idea("Four", "A fourth mechanism."), "b"));
1250        set[0].rank_sum = 5;
1251        set[1].rank_sum = 2;
1252        set[1].standing = Standing::Corroborated;
1253        set[2].standing = Standing::Withdrawn;
1254        set[2].objection = Some(("b".into(), "already in bip 119".into()));
1255        set[3].standing = Standing::Challenged;
1256        set[3].objection = Some(("a".into(), "breaks under reorg".into()));
1257        let (kept, dropped) = settle(set, 1);
1258        assert_eq!(vec![2], kept.iter().map(|c| c.id).collect::<Vec<_>>());
1259        let reasons: Vec<(u32, String)> =
1260            dropped.iter().map(|(c, why)| (c.id, why.clone())).collect();
1261        assert_eq!(3, reasons.len());
1262        assert_eq!(
1263            (
1264                3,
1265                "withdrawn after b's objection: already in bip 119".to_string()
1266            ),
1267            reasons[0]
1268        );
1269        assert_eq!(
1270            (
1271                4,
1272                "not defended against a's objection: breaks under reorg".to_string()
1273            ),
1274            reasons[1]
1275        );
1276        assert_eq!((1, "ranked below the cut".to_string()), reasons[2]);
1277    }
1278
1279    #[test]
1280    fn reached_by_both_comes_first_whatever_the_ranking_said() {
1281        let mut set = two();
1282        set[0].rank_sum = 2;
1283        set[1].rank_sum = 4;
1284        set[1].standing = Standing::Corroborated;
1285        let (kept, _) = settle(set, 5);
1286        assert_eq!(vec![2, 1], kept.iter().map(|c| c.id).collect::<Vec<_>>());
1287    }
1288
1289    fn outcome() -> Outcome {
1290        let mut set = two();
1291        set[0].standing = Standing::Defended;
1292        set[0].objection = Some(("b".into(), "CTV does this already".into()));
1293        set[0].defence = Some("CTV cannot commit to the fee".into());
1294        let mut built = Candidate::new(3, idea("Three", "A third mechanism."), "b");
1295        built.parent = Some(1);
1296        set.push(built);
1297        set[1].standing = Standing::Withdrawn;
1298        set[1].objection = Some(("a".into(), "radios are not the bottleneck".into()));
1299        let (kept, dropped) = settle(set, 5);
1300        Outcome {
1301            subject: "novel uses of covenants".into(),
1302            agents: vec!["a".into(), "b".into()],
1303            rounds: 3,
1304            kept,
1305            dropped,
1306            notes: vec![],
1307        }
1308    }
1309
1310    /// The whole reason the file is shaped as it is: what spar wrote, spar can
1311    /// read back, and so can `spar followup --file`.
1312    #[test]
1313    fn the_session_file_round_trips_through_the_follow_up_parser() {
1314        let style = Style::default();
1315        let text = render_session(&outcome(), "2026-09-12 14:03 UTC", &style);
1316        let entries = parse_session(&text);
1317        assert_eq!(2, entries.len(), "{text}");
1318        assert_eq!("One", entries[0].0);
1319        assert_eq!("Three", entries[1].0);
1320        for (_, body) in &entries {
1321            assert!(body.starts_with("### Combines"), "{body}");
1322            assert!(!body.contains("Proposed by"), "{body}");
1323            assert!(!body.contains("Challenged by"), "{body}");
1324            assert!(body.contains("### First experiment"), "{body}");
1325        }
1326        assert!(entries[0].1.contains("Objection: CTV does this already"));
1327        assert!(entries[0].1.contains("Reply: CTV cannot commit to the fee"));
1328        // Nothing after the last idea, and no heading that would open one.
1329        let headings: Vec<&str> = text.lines().filter(|l| l.starts_with("## ")).collect();
1330        assert_eq!(vec!["## One", "## Three"], headings);
1331        assert!(
1332            text.contains("Also considered, and set aside:\n- Two: withdrawn after a's objection")
1333        );
1334        assert!(text.contains("Proposed by b, building on \"One\"."));
1335        assert!(text.contains("Challenged by b, defended by a."));
1336    }
1337
1338    #[test]
1339    fn what_is_written_passes_the_style_gate() {
1340        let style = Style::default();
1341        let mut out = outcome();
1342        out.kept[0].idea.how_it_works =
1343            "A covenant \u{2014} the template kind \u{2014} restricts it.".into();
1344        out.kept[0]
1345            .idea
1346            .combines
1347            .push("Generated with Claude Code".into());
1348        let text = render_session(&out, "2026-09-12 14:03 UTC", &style);
1349        assert!(style::violations(&text, &style).is_empty(), "{text}");
1350        assert!(text.contains("A covenant, the template kind, restricts it."));
1351        let body = issue_body(&out.kept[0], &style);
1352        assert!(style::violations(&body, &style).is_empty(), "{body}");
1353        assert!(!body.contains("Proposed by"));
1354    }
1355
1356    #[test]
1357    fn the_prompt_listing_tags_whose_ideas_are_whose() {
1358        let mut set = two();
1359        set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1360        set[2].standing = Standing::Corroborated;
1361        set[2].proposers.push("b".into());
1362        let text = ideas_for_prompt(&set, Some("a"));
1363        assert!(text.contains("1. One [yours]"), "{text}");
1364        assert!(text.contains("2. Two [rule on this]"), "{text}");
1365        assert!(
1366            text.contains("3. Three [both of you proposed this]"),
1367            "{text}"
1368        );
1369        let text = ideas_for_prompt(&set, None);
1370        assert!(text.contains("1. One [proposed by a]"), "{text}");
1371    }
1372
1373    #[test]
1374    fn slugs_are_file_names() {
1375        assert_eq!(
1376            "novel-uses-of-bitcoin-script",
1377            slug("Novel uses of Bitcoin Script!")
1378        );
1379        assert_eq!("idea", slug("   ???  "));
1380        let long = slug("one two three four five six seven eight nine ten eleven twelve");
1381        assert!(long.len() <= 40, "{long}");
1382        assert!(!long.ends_with('-'));
1383        assert_eq!("one-two-three-four-five-six-seven-eight", long);
1384    }
1385
1386    /// The prompts and the schemas say the same things about the same fields.
1387    #[test]
1388    fn the_prompts_name_every_field_the_schema_asks_for() {
1389        let schema = schema::brainstorm_ideas();
1390        let fields = schema["properties"]["ideas"]["items"]["properties"]
1391            .as_object()
1392            .unwrap();
1393        for field in fields.keys() {
1394            assert!(
1395                DIVERGE_PROMPT.contains(field.as_str()),
1396                "{field} is in the schema but not the diverge prompt"
1397            );
1398        }
1399        for verdict in ["build", "challenge", "keep"] {
1400            assert!(CROSS_PROMPT.contains(&format!("- {verdict}:")));
1401        }
1402        assert!(CONVERGE_PROMPT.contains("stands=true"));
1403        assert!(CONVERGE_PROMPT.contains("ranking"));
1404    }
1405}