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