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