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 work_dir = repo.root();
247 let subject = session.subject();
248 let names: Vec<String> = agents.iter().map(|a| a.name().to_string()).collect();
249 let mut notes = Vec::new();
250
251 log!("brainstorm: {} diverging on {subject}", names.join(" and "));
253 let prompt = diverge_prompt(session, repo);
254 let answers = concurrently(agents, |agent| {
255 let effort = agent.effort(cfg, Call::Brainstorm(1));
256 agent.ask_json::<IdeasDoc>(&prompt, &schema::brainstorm_ideas(), work_dir, &effort)
257 });
258 let mut by_agent: Vec<(String, Vec<Idea>)> = Vec::new();
259 for (name, result) in answers {
260 match result {
261 Ok(doc) => {
262 let ideas: Vec<Idea> = doc
263 .ideas
264 .into_iter()
265 .filter(|idea| !idea.title.trim().is_empty())
266 .collect();
267 logdim!(
268 "{name} proposed {} idea{}",
269 ideas.len(),
270 plural(ideas.len())
271 );
272 by_agent.push((name, ideas));
273 }
274 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
275 Err(e) => {
276 logdim!("{name} could not brainstorm: {e}");
277 notes.push(format!("{name} did not answer: {e}"));
278 }
279 }
280 }
281 if by_agent.is_empty() {
282 bail!("neither agent returned any ideas");
283 }
284 let paired = by_agent.len() == 2;
285 if !paired {
286 crate::logging::warn(format!(
289 "only {} answered. Nothing was cross-checked, so these ideas carry one model's \
290 judgement rather than two.",
291 by_agent[0].0
292 ));
293 notes.push("only one agent answered, so nothing was cross-checked".into());
294 }
295 let mut set = merge(&by_agent);
296 if set.is_empty() {
297 bail!("neither agent proposed anything with a title");
298 }
299 let shared = set
300 .iter()
301 .filter(|c| c.standing == Standing::Corroborated)
302 .count();
303 if shared > 0 {
304 log!(
305 "brainstorm: {} idea{} in the set, {shared} reached by both",
306 set.len(),
307 plural(set.len())
308 );
309 } else {
310 log!(
311 "brainstorm: {} idea{} in the set",
312 set.len(),
313 plural(set.len())
314 );
315 }
316
317 let mut rounds = 1;
319 if paired {
320 let mut round = 2;
321 while round < budget {
322 cross(agents, cfg, work_dir, &subject, &mut set, round)?;
323 rounds = round;
324 round += 1;
325 }
326 if budget >= 2 {
328 converge(agents, cfg, work_dir, &subject, &mut set, budget)?;
329 rounds = budget;
330 }
331 }
332
333 let (kept, dropped) = settle(set, session.wanted);
334 Ok(Outcome {
335 subject,
336 agents: names,
337 rounds,
338 kept,
339 dropped,
340 notes,
341 })
342}
343
344fn diverge_prompt(session: &Session, repo: &Repo) -> String {
345 let opening = match &session.topic {
346 Some(topic) => TOPIC_OPENING.replace("{subject}", topic.trim()),
347 None => REPO_OPENING.replace("{open_issues}", &open_issues_block(repo)),
348 };
349 DIVERGE_PROMPT
350 .replace("{opening}", &opening)
351 .replace("{count}", &session.wanted.to_string())
352 .replace("{read_only}", READ_ONLY)
353}
354
355fn open_issues_block(repo: &Repo) -> String {
359 let rows = repo.open_issue_rows();
360 if rows.is_empty() {
361 logdim!("no open issues to exclude, or none could be listed");
362 return String::new();
363 }
364 let mut out =
365 String::from("\n\nThese are already filed as open issues, so do not propose them again:\n");
366 for row in rows {
367 out.push_str(&format!(
368 "- #{} {}\n",
369 row.number,
370 style::one_line(&row.title)
371 ));
372 }
373 out.trim_end().to_string()
374}
375
376pub fn merge(by_agent: &[(String, Vec<Idea>)]) -> Vec<Candidate> {
379 let mut set: Vec<Candidate> = Vec::new();
380 for (name, ideas) in by_agent {
381 for idea in ideas {
382 let same = set
383 .iter_mut()
384 .find(|c| !c.proposed_by(name) && same_idea(&c.idea, idea));
385 match same {
386 Some(existing) => {
387 if gist(idea).len() > gist(&existing.idea).len() {
388 existing.idea = idea.clone();
389 }
390 existing.proposers.push(name.clone());
391 existing.standing = Standing::Corroborated;
392 }
393 None => {
394 let id = set.len() as u32 + 1;
395 set.push(Candidate::new(id, idea.clone(), name));
396 }
397 }
398 }
399 }
400 set
401}
402
403fn same_idea(a: &Idea, b: &Idea) -> bool {
408 crate::textsim::same_point(&gist(a), &gist(b))
409}
410
411fn gist(idea: &Idea) -> String {
412 format!("{} {}", idea.title, idea.how_it_works)
413}
414
415fn cross(
416 agents: &[Agent],
417 cfg: &Config,
418 work_dir: &Path,
419 subject: &str,
420 set: &mut Vec<Candidate>,
421 round: u32,
422) -> Result<()> {
423 let pending = set
424 .iter()
425 .filter(|c| c.standing == Standing::Unverified)
426 .count();
427 if pending == 0 {
428 return Ok(());
429 }
430 log!(
431 "brainstorm round {round}: each agent rules on the {pending} idea{} only the other \
432 proposed",
433 plural(pending)
434 );
435 let answers = concurrently(agents, |agent| {
436 let theirs = set
437 .iter()
438 .filter(|c| c.standing == Standing::Unverified && !c.proposed_by(agent.name()))
439 .count();
440 if theirs == 0 {
441 return Ok(CrossDoc::default());
442 }
443 let prompt = CROSS_PROMPT
444 .replace("{subject}", subject)
445 .replace("{read_only}", READ_ONLY)
446 .replace("{ideas}", &ideas_for_prompt(set, Some(agent.name())));
447 agent.ask_json::<CrossDoc>(
448 &prompt,
449 &schema::brainstorm_cross(),
450 work_dir,
451 &agent.effort(cfg, Call::Brainstorm(round)),
452 )
453 });
454 let mut builds = Vec::new();
455 for (name, result) in answers {
456 let doc = match result {
457 Ok(doc) => doc,
458 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
459 Err(e) => {
460 logdim!("{name} could not rule on the other's ideas: {e}");
461 continue;
462 }
463 };
464 for warning in apply_cross(set, &name, doc, &mut builds) {
465 logwarn!("{warning}");
466 }
467 }
468 if !builds.is_empty() {
469 log!(
470 "brainstorm round {round}: {} idea{} built on another",
471 builds.len(),
472 plural(builds.len())
473 );
474 }
475 set.extend(builds);
476 Ok(())
477}
478
479pub fn apply_cross(
484 set: &mut [Candidate],
485 name: &str,
486 doc: CrossDoc,
487 builds: &mut Vec<Candidate>,
488) -> Vec<String> {
489 let mut warnings = Vec::new();
490 for verdict in doc.verdicts {
491 let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == verdict.idea) else {
492 warnings.push(format!(
493 "{name} ruled on idea {} which is not in the set, so that ruling was dropped",
494 verdict.idea
495 ));
496 continue;
497 };
498 if target.standing == Standing::Corroborated {
499 if verdict.verdict != CrossKind::Build {
502 continue;
503 }
504 } else if target.proposed_by(name) {
505 warnings.push(format!(
506 "{name} ruled on its own idea {} and that ruling was dropped",
507 target.id
508 ));
509 continue;
510 } else if target.standing != Standing::Unverified && verdict.verdict != CrossKind::Build {
511 continue;
513 }
514 match verdict.verdict {
515 CrossKind::Keep => {}
516 CrossKind::Challenge => {
517 target.standing = Standing::Challenged;
518 target.objection = Some((name.to_string(), verdict.reason));
519 }
520 CrossKind::Build => {
521 let Some(idea) = verdict.build.filter(|i| !i.title.trim().is_empty()) else {
522 warnings.push(format!(
523 "{name} said it built on idea {} but wrote nothing, so that was dropped",
524 target.id
525 ));
526 continue;
527 };
528 let mut built = Candidate::new(0, idea, name);
529 built.parent = Some(target.id);
530 builds.push(built);
531 }
532 }
533 }
534 let mut id = set.len() as u32;
537 for built in builds.iter_mut() {
538 id += 1;
539 built.id = id;
540 }
541 warnings
542}
543
544fn converge(
545 agents: &[Agent],
546 cfg: &Config,
547 work_dir: &Path,
548 subject: &str,
549 set: &mut [Candidate],
550 round: u32,
551) -> Result<()> {
552 let challenged = set
553 .iter()
554 .filter(|c| c.standing == Standing::Challenged)
555 .count();
556 log!(
557 "brainstorm round {round}: {challenged} objection{} to answer, then both rank",
558 plural(challenged)
559 );
560 let answers = concurrently(agents, |agent| {
561 let mine: Vec<String> = set
562 .iter()
563 .filter(|c| c.standing == Standing::Challenged && c.proposed_by(agent.name()))
564 .map(|c| c.id.to_string())
565 .collect();
566 let defend = if mine.is_empty() {
567 "nothing you proposed was challenged, so defences is empty.".to_string()
568 } else {
569 format!(
570 "answer each objection to an idea you proposed. Yours that were challenged: \
571 {}. One entry in defences for each.",
572 mine.join(", ")
573 )
574 };
575 let prompt = CONVERGE_PROMPT
576 .replace("{subject}", subject)
577 .replace("{defend}", &defend)
578 .replace("{read_only}", READ_ONLY)
579 .replace("{ideas}", &ideas_for_prompt(set, None));
580 agent.ask_json::<ConvergeDoc>(
581 &prompt,
582 &schema::brainstorm_converge(),
583 work_dir,
584 &agent.effort(cfg, Call::Brainstorm(round)),
585 )
586 });
587 for (name, result) in answers {
588 let doc = match result {
589 Ok(doc) => doc,
590 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
591 Err(e) => {
592 logdim!("{name} could not rank: {e}");
593 continue;
594 }
595 };
596 for warning in apply_converge(set, &name, doc) {
597 logwarn!("{warning}");
598 }
599 }
600 Ok(())
601}
602
603pub fn apply_converge(set: &mut [Candidate], name: &str, doc: ConvergeDoc) -> Vec<String> {
609 let mut warnings = Vec::new();
610 for defence in doc.defences {
611 let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == defence.idea) else {
612 warnings.push(format!(
613 "{name} defended idea {} which is not in the set, so that was dropped",
614 defence.idea
615 ));
616 continue;
617 };
618 if !target.proposed_by(name) || target.standing != Standing::Challenged {
619 continue;
620 }
621 if defence.stands {
622 target.standing = Standing::Defended;
623 target.defence = Some(defence.reply);
624 } else {
625 target.standing = Standing::Withdrawn;
626 }
627 }
628 let mut placed: Vec<u32> = Vec::new();
629 for (position, id) in doc.ranking.iter().enumerate() {
630 let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == *id) else {
631 warnings.push(format!(
632 "{name} ranked idea {id} which is not in the set, so that place was dropped"
633 ));
634 continue;
635 };
636 if placed.contains(&target.id) {
637 continue;
638 }
639 placed.push(target.id);
640 target.rank_sum += position as u32 + 1;
641 }
642 let last = placed.len() as u32 + 1;
643 for c in set.iter_mut() {
644 if c.standing != Standing::Withdrawn && !placed.contains(&c.id) {
645 c.rank_sum += last;
646 }
647 }
648 warnings
649}
650
651pub fn settle(set: Vec<Candidate>, wanted: usize) -> (Vec<Candidate>, Vec<(Candidate, String)>) {
656 let mut standing = Vec::new();
657 let mut dropped = Vec::new();
658 for c in set {
659 match c.standing {
660 Standing::Withdrawn => {
661 let why = objection_line(&c, "withdrawn after");
662 dropped.push((c, why));
663 }
664 Standing::Challenged => {
665 let why = objection_line(&c, "not defended against");
666 dropped.push((c, why));
667 }
668 _ => standing.push(c),
669 }
670 }
671 standing.sort_by_key(|c| (c.standing != Standing::Corroborated, c.rank_sum, c.id));
672 let rest = standing.split_off(wanted.min(standing.len()));
673 for c in rest {
674 dropped.push((c, "ranked below the cut".into()));
675 }
676 (standing, dropped)
677}
678
679fn objection_line(c: &Candidate, lead: &str) -> String {
683 match &c.objection {
684 Some((who, why)) => format!(
685 "{lead} {who}'s objection: {}",
686 style::clip(&style::one_line(why), 240)
687 ),
688 None => format!("{lead} an objection"),
689 }
690}
691
692fn ideas_for_prompt(set: &[Candidate], reader: Option<&str>) -> String {
700 let mut out = String::new();
701 for c in set {
702 let tag = match (reader, c.standing) {
703 (_, Standing::Corroborated) => "both of you proposed this".to_string(),
704 (Some(me), Standing::Unverified) if c.proposed_by(me) => "yours".to_string(),
705 (Some(_), Standing::Unverified) => "rule on this".to_string(),
706 (_, Standing::Withdrawn) => "withdrawn".to_string(),
707 _ => format!("proposed by {}", c.proposers.join(" and ")),
708 };
709 out.push_str(&format!(
710 "{}. {} [{tag}]\n",
711 c.id,
712 style::one_line(&c.idea.title)
713 ));
714 if let Some(parent) = c.parent {
715 out.push_str(&format!(" builds on idea {parent}\n"));
716 }
717 out.push_str(&format!(
718 " combines: {}\n",
719 c.idea
720 .combines
721 .iter()
722 .map(|s| style::one_line(s))
723 .collect::<Vec<_>>()
724 .join("; ")
725 ));
726 for (label, text) in [
727 ("how it works", &c.idea.how_it_works),
728 ("why new", &c.idea.why_new),
729 ("enables", &c.idea.enables),
730 ("risks", &c.idea.risks),
731 ("first experiment", &c.idea.first_experiment),
732 ] {
733 if !text.trim().is_empty() {
734 out.push_str(&format!(" {label}: {}\n", style::one_line(text)));
735 }
736 }
737 if let Some((who, why)) = &c.objection {
738 out.push_str(&format!(
739 " objection from {who}: {}\n",
740 style::one_line(why)
741 ));
742 }
743 if let Some(reply) = &c.defence {
744 out.push_str(&format!(" reply: {}\n", style::one_line(reply)));
745 }
746 out.push('\n');
747 }
748 out.trim_end().to_string()
749}
750
751pub fn idea_sections(idea: &Idea, style: &Style) -> String {
757 let mut out = String::new();
758 out.push_str("### Combines\n");
759 let combines: Vec<String> = idea
760 .combines
761 .iter()
762 .map(|s| style::scrub(&style::one_line(s), style))
763 .filter(|s| !s.is_empty())
764 .collect();
765 if combines.is_empty() {
766 out.push_str("(not named)\n");
767 }
768 for piece in combines {
769 out.push_str(&format!("- {piece}\n"));
770 }
771 for (heading, text) in [
772 ("How it works", &idea.how_it_works),
773 ("Why it is new", &idea.why_new),
774 ("What it enables", &idea.enables),
775 ("Risks", &idea.risks),
776 ("First experiment", &idea.first_experiment),
777 ] {
778 out.push_str(&format!("\n### {heading}\n"));
779 let text = style::scrub(text, style);
780 if text.is_empty() {
781 out.push_str("(not given)\n");
782 } else {
783 out.push_str(&text);
784 out.push('\n');
785 }
786 }
787 out
788}
789
790fn discussion(c: &Candidate, style: &Style) -> String {
794 let mut out = String::new();
795 if let Some((_, why)) = &c.objection {
796 out.push_str(&format!(
797 "\n### Discussion\nObjection: {}\n",
798 style::scrub(why, style)
799 ));
800 if let Some(reply) = &c.defence {
801 out.push_str(&format!("\nReply: {}\n", style::scrub(reply, style)));
802 }
803 }
804 out
805}
806
807pub fn issue_body(c: &Candidate, style: &Style) -> String {
809 let mut body = idea_sections(&c.idea, style);
810 body.push_str(&discussion(c, style));
811 body.trim_end().to_string()
812}
813
814fn provenance(c: &Candidate, set: &[Candidate]) -> String {
819 let mut line = if c.standing == Standing::Corroborated {
820 format!("Reached independently by {}.", c.proposers.join(" and "))
821 } else {
822 match c.parent.and_then(|id| set.iter().find(|p| p.id == id)) {
823 Some(parent) => format!(
824 "Proposed by {}, building on \"{}\".",
825 c.proposers.join(" and "),
826 style::one_line(&parent.idea.title)
827 ),
828 None => format!("Proposed by {}.", c.proposers.join(" and ")),
829 }
830 };
831 if let (Some((who, _)), Standing::Defended) = (&c.objection, c.standing) {
832 line.push_str(&format!(
833 " Challenged by {who}, defended by {}.",
834 c.proposers.join(" and ")
835 ));
836 }
837 line
838}
839
840const PROVENANCE_LEADS: [&str; 2] = ["Proposed by ", "Reached independently by "];
841
842pub fn render_session(outcome: &Outcome, when: &str, style: &Style) -> String {
850 let mut out = String::new();
851 out.push_str(&format!(
852 "# Brainstorm: {}\n",
853 style::scrub(&style::one_line(&outcome.subject), style)
854 ));
855 out.push_str(&format!("Date: {when}\n"));
856 out.push_str(&format!("Agents: {}\n", outcome.agents.join(", ")));
857 out.push_str(&format!("Rounds: {}\n", outcome.rounds));
858 for note in &outcome.notes {
859 out.push_str(&format!(
860 "Note: {}\n",
861 style::scrub(&style::one_line(note), style)
862 ));
863 }
864 if !outcome.dropped.is_empty() {
865 out.push_str("\nAlso considered, and set aside:\n");
866 for (c, why) in &outcome.dropped {
867 out.push_str(&format!(
868 "- {}: {}\n",
869 style::scrub(&style::one_line(&c.idea.title), style),
870 style::scrub(why, style)
871 ));
872 }
873 }
874 let all: Vec<Candidate> = outcome
875 .kept
876 .iter()
877 .chain(outcome.dropped.iter().map(|(c, _)| c))
878 .cloned()
879 .collect();
880 for c in &outcome.kept {
881 out.push_str(&format!(
882 "\n{FOLLOWUP_MARKER}\n## {}\n\n{}\n\n",
883 style::title(&c.idea.title, style),
884 style::scrub(&provenance(c, &all), style)
885 ));
886 out.push_str(&idea_sections(&c.idea, style));
887 out.push_str(&discussion(c, style));
888 }
889 out.trim_end().to_string() + "\n"
890}
891
892pub fn parse_session(text: &str) -> Vec<(String, String)> {
895 crate::followups::parse(text)
896 .into_iter()
897 .filter(|entry| !entry.title.trim().is_empty())
898 .map(|entry| {
899 let body: Vec<&str> = entry
900 .body
901 .lines()
902 .filter(|line| !PROVENANCE_LEADS.iter().any(|lead| line.starts_with(lead)))
903 .collect();
904 (entry.title, body.join("\n").trim().to_string())
905 })
906 .collect()
907}
908
909pub fn issue_entries(outcome: &Outcome, style: &Style) -> Vec<(String, String)> {
911 outcome
912 .kept
913 .iter()
914 .map(|c| (c.idea.title.clone(), issue_body(c, style)))
915 .collect()
916}
917
918pub fn file_ideas(repo: &Repo, entries: &[(String, String)]) {
921 for (title, body) in entries {
922 match crate::review::file_as_issue(repo, title, body) {
923 Ok(filed) => println!(" {}", filed.describe(title)),
924 Err(e) => {
925 crate::logging::error(format!("could not file '{}': {e}", style::clip(title, 60)))
926 }
927 }
928 }
929}
930
931pub fn print_summary(outcome: &Outcome, path: &Path) {
933 println!();
934 println!(
935 "{} idea{} kept, {} set aside, {} round{}",
936 outcome.kept.len(),
937 plural(outcome.kept.len()),
938 outcome.dropped.len(),
939 outcome.rounds,
940 plural(outcome.rounds as usize)
941 );
942 for (i, c) in outcome.kept.iter().enumerate() {
943 let mark = match c.standing {
944 Standing::Corroborated => " (reached by both)",
945 Standing::Defended => " (challenged and defended)",
946 _ => "",
947 };
948 println!(
949 " {}. {}{mark}",
950 i + 1,
951 style::clip_bare(&style::one_line(&c.idea.title), 90)
952 );
953 }
954 for note in &outcome.notes {
955 println!(" note: {note}");
956 }
957 println!("\nwritten to {}", path.display());
958}
959
960pub fn default_path(repo: &Repo, session: &Session, now_secs: u64) -> PathBuf {
962 let name = match &session.topic {
963 Some(topic) => slug(topic),
964 None => "repo".to_string(),
965 };
966 repo.brainstorms_dir()
967 .join(format!("{}-{name}.md", file_stamp(now_secs)))
968}
969
970pub fn slug(text: &str) -> String {
973 let mut out = String::new();
974 let mut gap = false;
975 for ch in text.chars().flat_map(char::to_lowercase) {
976 if ch.is_ascii_alphanumeric() {
977 if gap && !out.is_empty() {
978 out.push('-');
979 }
980 gap = false;
981 out.push(ch);
982 } else {
983 gap = true;
984 }
985 }
986 if out.len() > 40 {
987 let cut = out[..40].rfind('-').unwrap_or(40);
988 out.truncate(cut);
989 }
990 if out.is_empty() {
991 "idea".to_string()
992 } else {
993 out
994 }
995}
996
997fn plural(n: usize) -> &'static str {
998 if n == 1 {
999 ""
1000 } else {
1001 "s"
1002 }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008 use crate::model::{CrossVerdict, Defence};
1009
1010 fn idea(title: &str, how: &str) -> Idea {
1011 Idea {
1012 title: title.into(),
1013 combines: vec!["hash time locks".into(), "batch auctions".into()],
1014 how_it_works: how.into(),
1015 why_new: "The nearest thing is a plain escrow, which cannot batch.".into(),
1016 enables: "Settling many trades in one transaction.".into(),
1017 risks: "Fee spikes make the batch uneconomic.".into(),
1018 first_experiment: "Simulate ten trades on regtest.".into(),
1019 }
1020 }
1021
1022 fn pair(a: Vec<Idea>, b: Vec<Idea>) -> Vec<Candidate> {
1023 merge(&[("a".to_string(), a), ("b".to_string(), b)])
1024 }
1025
1026 #[test]
1027 fn an_idea_both_agents_reach_is_kept_once_with_both_names() {
1028 let set = pair(
1029 vec![idea(
1030 "Batch settlement with timelocks",
1031 "Each trade posts a hash timelock and a batch auction clears them together at the deadline.",
1032 )],
1033 vec![idea(
1034 "Timelocked batch auction settlement",
1035 "Trades post hash timelocks, and one batch auction clears every one of them at the deadline together.",
1036 )],
1037 );
1038 assert_eq!(1, set.len());
1039 assert_eq!(Standing::Corroborated, set[0].standing);
1040 assert_eq!(vec!["a", "b"], set[0].proposers);
1041 }
1042
1043 #[test]
1044 fn different_ideas_get_their_own_numbers_in_order() {
1045 let set = pair(
1046 vec![idea(
1047 "One",
1048 "A covenant restricts the spend path to a template.",
1049 )],
1050 vec![idea(
1051 "Two",
1052 "A watchtower gossips penalty transactions over a mesh radio.",
1053 )],
1054 );
1055 assert_eq!(vec![1, 2], set.iter().map(|c| c.id).collect::<Vec<_>>());
1056 assert!(set.iter().all(|c| c.standing == Standing::Unverified));
1057 }
1058
1059 fn two() -> Vec<Candidate> {
1060 pair(
1061 vec![idea(
1062 "One",
1063 "A covenant restricts the spend path to a template.",
1064 )],
1065 vec![idea(
1066 "Two",
1067 "A watchtower gossips penalty transactions over a mesh radio.",
1068 )],
1069 )
1070 }
1071
1072 fn verdict(id: i64, kind: CrossKind, build: Option<Idea>) -> CrossVerdict {
1073 CrossVerdict {
1074 idea: id,
1075 verdict: kind,
1076 reason: "because".into(),
1077 build,
1078 }
1079 }
1080
1081 #[test]
1082 fn a_challenge_records_who_objected_and_why() {
1083 let mut set = two();
1084 let mut builds = Vec::new();
1085 let doc = CrossDoc {
1086 verdicts: vec![verdict(1, CrossKind::Challenge, None)],
1087 };
1088 let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1089 assert!(warnings.is_empty(), "{warnings:?}");
1090 assert_eq!(Standing::Challenged, set[0].standing);
1091 assert_eq!(
1092 Some(("b".to_string(), "because".to_string())),
1093 set[0].objection
1094 );
1095 assert_eq!(Standing::Unverified, set[1].standing);
1096 }
1097
1098 #[test]
1101 fn rulings_on_your_own_idea_or_an_unknown_one_are_dropped_with_a_reason() {
1102 let mut set = two();
1103 let mut builds = Vec::new();
1104 let doc = CrossDoc {
1105 verdicts: vec![
1106 verdict(2, CrossKind::Challenge, None),
1107 verdict(9, CrossKind::Challenge, None),
1108 ],
1109 };
1110 let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1111 assert_eq!(2, warnings.len(), "{warnings:?}");
1112 assert!(warnings[0].contains("its own idea 2"));
1113 assert!(warnings[1].contains("idea 9"));
1114 assert!(set.iter().all(|c| c.standing == Standing::Unverified));
1115 }
1116
1117 #[test]
1118 fn a_build_becomes_a_new_idea_numbered_after_the_set_and_linked_to_its_parent() {
1119 let mut set = two();
1120 let mut builds = Vec::new();
1121 let doc = CrossDoc {
1122 verdicts: vec![
1123 verdict(1, CrossKind::Keep, None),
1124 verdict(
1125 1,
1126 CrossKind::Build,
1127 Some(idea(
1128 "One, plus",
1129 "The covenant template also commits to a fee.",
1130 )),
1131 ),
1132 verdict(1, CrossKind::Build, None),
1133 ],
1134 };
1135 let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1136 assert_eq!(1, warnings.len(), "{warnings:?}");
1137 assert!(warnings[0].contains("wrote nothing"));
1138 assert_eq!(1, builds.len());
1139 assert_eq!(3, builds[0].id);
1140 assert_eq!(Some(1), builds[0].parent);
1141 assert_eq!(vec!["b"], builds[0].proposers);
1142 assert_eq!(Standing::Unverified, builds[0].standing);
1143 }
1144
1145 #[test]
1146 fn an_idea_both_reached_cannot_be_challenged_but_can_be_built_on() {
1147 let mut set = pair(
1148 vec![idea(
1149 "Batch settlement with timelocks",
1150 "Each trade posts a hash timelock and a batch auction clears them together at the deadline.",
1151 )],
1152 vec![idea(
1153 "Timelocked batch auction settlement",
1154 "Trades post hash timelocks, and one batch auction clears every one of them at the deadline together.",
1155 )],
1156 );
1157 let mut builds = Vec::new();
1160 let warnings = apply_cross(
1161 &mut set,
1162 "b",
1163 CrossDoc {
1164 verdicts: vec![
1165 verdict(1, CrossKind::Challenge, None),
1166 verdict(
1167 1,
1168 CrossKind::Build,
1169 Some(idea("Batched, plus fees", "The auction also clears fees.")),
1170 ),
1171 ],
1172 },
1173 &mut builds,
1174 );
1175 assert!(warnings.is_empty(), "{warnings:?}");
1176 assert_eq!(Standing::Corroborated, set[0].standing);
1177 assert!(set[0].objection.is_none());
1178 assert_eq!(1, builds.len());
1179 assert_eq!(Some(1), builds[0].parent);
1180 assert_eq!(2, builds[0].id);
1181 }
1182
1183 #[test]
1184 fn a_defence_stands_or_withdraws_and_only_the_proposer_may_give_one() {
1185 let mut set = two();
1186 set[0].standing = Standing::Challenged;
1187 set[0].objection = Some(("b".into(), "exists".into()));
1188 set[1].standing = Standing::Challenged;
1189 set[1].objection = Some(("a".into(), "cannot work".into()));
1190 let doc = ConvergeDoc {
1191 defences: vec![
1192 Defence {
1193 idea: 1,
1194 stands: true,
1195 reply: "the prior art cannot batch".into(),
1196 },
1197 Defence {
1198 idea: 2,
1199 stands: false,
1200 reply: String::new(),
1201 },
1202 ],
1203 ranking: vec![1, 2],
1204 };
1205 let warnings = apply_converge(&mut set, "a", doc);
1207 assert!(warnings.is_empty(), "{warnings:?}");
1208 assert_eq!(Standing::Defended, set[0].standing);
1209 assert_eq!(
1210 Some("the prior art cannot batch".to_string()),
1211 set[0].defence
1212 );
1213 assert_eq!(Standing::Challenged, set[1].standing);
1214 }
1215
1216 #[test]
1219 fn an_omitted_idea_ranks_last_and_two_rankings_add_up() {
1220 let mut set = two();
1221 set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1222 apply_converge(
1223 &mut set,
1224 "a",
1225 ConvergeDoc {
1226 defences: vec![],
1227 ranking: vec![3, 1],
1228 },
1229 );
1230 apply_converge(
1231 &mut set,
1232 "b",
1233 ConvergeDoc {
1234 defences: vec![],
1235 ranking: vec![1, 2, 3, 1],
1236 },
1237 );
1238 assert_eq!(2 + 1, set[0].rank_sum);
1240 assert_eq!(3 + 2, set[1].rank_sum);
1241 assert_eq!(1 + 3, set[2].rank_sum);
1242 }
1243
1244 #[test]
1245 fn settle_keeps_the_best_and_says_why_each_of_the_rest_went() {
1246 let mut set = two();
1247 set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1248 set.push(Candidate::new(4, idea("Four", "A fourth mechanism."), "b"));
1249 set[0].rank_sum = 5;
1250 set[1].rank_sum = 2;
1251 set[1].standing = Standing::Corroborated;
1252 set[2].standing = Standing::Withdrawn;
1253 set[2].objection = Some(("b".into(), "already in bip 119".into()));
1254 set[3].standing = Standing::Challenged;
1255 set[3].objection = Some(("a".into(), "breaks under reorg".into()));
1256 let (kept, dropped) = settle(set, 1);
1257 assert_eq!(vec![2], kept.iter().map(|c| c.id).collect::<Vec<_>>());
1258 let reasons: Vec<(u32, String)> =
1259 dropped.iter().map(|(c, why)| (c.id, why.clone())).collect();
1260 assert_eq!(3, reasons.len());
1261 assert_eq!(
1262 (
1263 3,
1264 "withdrawn after b's objection: already in bip 119".to_string()
1265 ),
1266 reasons[0]
1267 );
1268 assert_eq!(
1269 (
1270 4,
1271 "not defended against a's objection: breaks under reorg".to_string()
1272 ),
1273 reasons[1]
1274 );
1275 assert_eq!((1, "ranked below the cut".to_string()), reasons[2]);
1276 }
1277
1278 #[test]
1279 fn reached_by_both_comes_first_whatever_the_ranking_said() {
1280 let mut set = two();
1281 set[0].rank_sum = 2;
1282 set[1].rank_sum = 4;
1283 set[1].standing = Standing::Corroborated;
1284 let (kept, _) = settle(set, 5);
1285 assert_eq!(vec![2, 1], kept.iter().map(|c| c.id).collect::<Vec<_>>());
1286 }
1287
1288 fn outcome() -> Outcome {
1289 let mut set = two();
1290 set[0].standing = Standing::Defended;
1291 set[0].objection = Some(("b".into(), "CTV does this already".into()));
1292 set[0].defence = Some("CTV cannot commit to the fee".into());
1293 let mut built = Candidate::new(3, idea("Three", "A third mechanism."), "b");
1294 built.parent = Some(1);
1295 set.push(built);
1296 set[1].standing = Standing::Withdrawn;
1297 set[1].objection = Some(("a".into(), "radios are not the bottleneck".into()));
1298 let (kept, dropped) = settle(set, 5);
1299 Outcome {
1300 subject: "novel uses of covenants".into(),
1301 agents: vec!["a".into(), "b".into()],
1302 rounds: 3,
1303 kept,
1304 dropped,
1305 notes: vec![],
1306 }
1307 }
1308
1309 #[test]
1312 fn the_session_file_round_trips_through_the_follow_up_parser() {
1313 let style = Style::default();
1314 let text = render_session(&outcome(), "2026-09-12 14:03 UTC", &style);
1315 let entries = parse_session(&text);
1316 assert_eq!(2, entries.len(), "{text}");
1317 assert_eq!("One", entries[0].0);
1318 assert_eq!("Three", entries[1].0);
1319 for (_, body) in &entries {
1320 assert!(body.starts_with("### Combines"), "{body}");
1321 assert!(!body.contains("Proposed by"), "{body}");
1322 assert!(!body.contains("Challenged by"), "{body}");
1323 assert!(body.contains("### First experiment"), "{body}");
1324 }
1325 assert!(entries[0].1.contains("Objection: CTV does this already"));
1326 assert!(entries[0].1.contains("Reply: CTV cannot commit to the fee"));
1327 let headings: Vec<&str> = text.lines().filter(|l| l.starts_with("## ")).collect();
1329 assert_eq!(vec!["## One", "## Three"], headings);
1330 assert!(
1331 text.contains("Also considered, and set aside:\n- Two: withdrawn after a's objection")
1332 );
1333 assert!(text.contains("Proposed by b, building on \"One\"."));
1334 assert!(text.contains("Challenged by b, defended by a."));
1335 }
1336
1337 #[test]
1338 fn what_is_written_passes_the_style_gate() {
1339 let style = Style::default();
1340 let mut out = outcome();
1341 out.kept[0].idea.how_it_works =
1342 "A covenant \u{2014} the template kind \u{2014} restricts it.".into();
1343 out.kept[0]
1344 .idea
1345 .combines
1346 .push("Generated with Claude Code".into());
1347 let text = render_session(&out, "2026-09-12 14:03 UTC", &style);
1348 assert!(style::violations(&text, &style).is_empty(), "{text}");
1349 assert!(text.contains("A covenant, the template kind, restricts it."));
1350 let body = issue_body(&out.kept[0], &style);
1351 assert!(style::violations(&body, &style).is_empty(), "{body}");
1352 assert!(!body.contains("Proposed by"));
1353 }
1354
1355 #[test]
1356 fn the_prompt_listing_tags_whose_ideas_are_whose() {
1357 let mut set = two();
1358 set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1359 set[2].standing = Standing::Corroborated;
1360 set[2].proposers.push("b".into());
1361 let text = ideas_for_prompt(&set, Some("a"));
1362 assert!(text.contains("1. One [yours]"), "{text}");
1363 assert!(text.contains("2. Two [rule on this]"), "{text}");
1364 assert!(
1365 text.contains("3. Three [both of you proposed this]"),
1366 "{text}"
1367 );
1368 let text = ideas_for_prompt(&set, None);
1369 assert!(text.contains("1. One [proposed by a]"), "{text}");
1370 }
1371
1372 #[test]
1373 fn slugs_are_file_names() {
1374 assert_eq!(
1375 "novel-uses-of-bitcoin-script",
1376 slug("Novel uses of Bitcoin Script!")
1377 );
1378 assert_eq!("idea", slug(" ??? "));
1379 let long = slug("one two three four five six seven eight nine ten eleven twelve");
1380 assert!(long.len() <= 40, "{long}");
1381 assert!(!long.ends_with('-'));
1382 assert_eq!("one-two-three-four-five-six-seven-eight", long);
1383 }
1384
1385 #[test]
1387 fn the_prompts_name_every_field_the_schema_asks_for() {
1388 let schema = schema::brainstorm_ideas();
1389 let fields = schema["properties"]["ideas"]["items"]["properties"]
1390 .as_object()
1391 .unwrap();
1392 for field in fields.keys() {
1393 assert!(
1394 DIVERGE_PROMPT.contains(field.as_str()),
1395 "{field} is in the schema but not the diverge prompt"
1396 );
1397 }
1398 for verdict in ["build", "challenge", "keep"] {
1399 assert!(CROSS_PROMPT.contains(&format!("- {verdict}:")));
1400 }
1401 assert!(CONVERGE_PROMPT.contains("stands=true"));
1402 assert!(CONVERGE_PROMPT.contains("ranking"));
1403 }
1404}