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