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