1use std::path::Path;
8
9use anyhow::Result;
10
11use crate::bean::{Bean, Status};
12use crate::config::Config;
13use crate::discovery::find_bean_file;
14use crate::index::Index;
15use crate::tokens::calculate_tokens;
16use crate::util::natural_cmp;
17
18pub struct PlanArgs {
20 pub id: Option<String>,
21 pub strategy: Option<String>,
22 pub auto: bool,
23 pub force: bool,
24 pub dry_run: bool,
25}
26
27pub fn cmd_plan(beans_dir: &Path, args: PlanArgs) -> Result<()> {
29 let config = Config::load_with_extends(beans_dir)?;
30 let workspace = beans_dir.parent().unwrap_or_else(|| Path::new("."));
31
32 let index = Index::load_or_rebuild(beans_dir)?;
33
34 match args.id {
35 Some(ref id) => plan_specific(beans_dir, &config, &index, workspace, id, &args),
36 None => plan_auto_pick(beans_dir, &config, &index, workspace, &args),
37 }
38}
39
40fn plan_specific(
42 beans_dir: &Path,
43 config: &Config,
44 _index: &Index,
45 workspace: &Path,
46 id: &str,
47 args: &PlanArgs,
48) -> Result<()> {
49 let bean_path = find_bean_file(beans_dir, id)?;
50 let bean = Bean::from_file(&bean_path)?;
51 let tokens = calculate_tokens(&bean, workspace);
52
53 if tokens < config.max_tokens as u64 && !args.force {
54 let tokens_k = format_tokens_k(tokens);
55 eprintln!(
56 "Bean {} is {} tokens — small enough to run directly.",
57 id, tokens_k
58 );
59 eprintln!(" Use bn run {} to dispatch it.", id);
60 eprintln!(" Use bn plan {} --force to plan anyway.", id);
61 return Ok(());
62 }
63
64 spawn_plan(beans_dir, config, id, &bean, tokens, args)
65}
66
67fn plan_auto_pick(
69 beans_dir: &Path,
70 config: &Config,
71 index: &Index,
72 workspace: &Path,
73 args: &PlanArgs,
74) -> Result<()> {
75 let mut candidates: Vec<(String, String, u8, u64)> = Vec::new();
77
78 for entry in &index.beans {
79 if entry.status != Status::Open {
80 continue;
81 }
82 if entry.claimed_by.is_some() {
84 continue;
85 }
86
87 let bean_path = match find_bean_file(beans_dir, &entry.id) {
88 Ok(p) => p,
89 Err(_) => continue,
90 };
91 let bean = match Bean::from_file(&bean_path) {
92 Ok(b) => b,
93 Err(_) => continue,
94 };
95
96 let tokens = calculate_tokens(&bean, workspace);
97 if tokens >= config.max_tokens as u64 {
98 candidates.push((
99 entry.id.clone(),
100 entry.title.clone(),
101 entry.priority,
102 tokens,
103 ));
104 }
105 }
106
107 if candidates.is_empty() {
108 eprintln!("✓ All ready beans are small enough to run directly.");
109 eprintln!(" Use bn run to dispatch them.");
110 return Ok(());
111 }
112
113 candidates.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| natural_cmp(&a.0, &b.0)));
115
116 eprintln!("{} beans need planning:", candidates.len());
118 for (id, title, priority, tokens) in &candidates {
119 let tokens_k = format_tokens_k(*tokens);
120 eprintln!(" P{} {:6} {:30} {}", priority, id, title, tokens_k);
121 }
122 eprintln!();
123
124 let (id, title, _, tokens) = &candidates[0];
126 let tokens_k = format_tokens_k(*tokens);
127 eprintln!("Planning: {} — {} ({})", id, title, tokens_k);
128
129 let bean_path = find_bean_file(beans_dir, id)?;
131 let bean = Bean::from_file(&bean_path)?;
132
133 spawn_plan(beans_dir, config, id, &bean, *tokens, args)
134}
135
136fn spawn_plan(
141 beans_dir: &Path,
142 config: &Config,
143 id: &str,
144 bean: &Bean,
145 tokens: u64,
146 args: &PlanArgs,
147) -> Result<()> {
148 if let Some(ref template) = config.plan {
150 return spawn_template(template, id, args);
151 }
152
153 spawn_builtin(beans_dir, config, id, bean, tokens, args)
155}
156
157fn spawn_template(template: &str, id: &str, args: &PlanArgs) -> Result<()> {
159 let mut cmd = template.replace("{id}", id);
160
161 if let Some(ref strategy) = args.strategy {
162 cmd = format!("{} --strategy {}", cmd, strategy);
163 }
164
165 if args.dry_run {
166 eprintln!("Would spawn: {}", cmd);
167 return Ok(());
168 }
169
170 eprintln!("Spawning: {}", cmd);
171 run_shell_command(&cmd, id, args.auto)
172}
173
174fn spawn_builtin(
176 beans_dir: &Path,
177 config: &Config,
178 id: &str,
179 bean: &Bean,
180 tokens: u64,
181 args: &PlanArgs,
182) -> Result<()> {
183 let prompt = build_decomposition_prompt(config, id, bean, tokens, args.strategy.as_deref());
184
185 let bean_path = find_bean_file(beans_dir, id)?;
187 let bean_path_str = bean_path.display().to_string();
188
189 let escaped_prompt = shell_escape(&prompt);
191 let cmd = format!("pi @{} {}", bean_path_str, escaped_prompt);
192
193 if args.dry_run {
194 eprintln!("Would spawn: {}", cmd);
195 eprintln!("\n--- Built-in decomposition prompt ---");
196 eprintln!("{}", prompt);
197 return Ok(());
198 }
199
200 eprintln!("Spawning built-in decomposition for bean {}...", id);
201 run_shell_command(&cmd, id, args.auto)
202}
203
204fn run_shell_command(cmd: &str, id: &str, auto: bool) -> Result<()> {
206 if auto {
207 let status = std::process::Command::new("sh").args(["-c", cmd]).status();
208 match status {
209 Ok(s) if s.success() => {
210 eprintln!("Planning complete. Use bn tree {} to see children.", id);
211 }
212 Ok(s) => {
213 anyhow::bail!("Plan command exited with code {}", s.code().unwrap_or(-1));
214 }
215 Err(e) => {
216 anyhow::bail!("Failed to run plan command: {}", e);
217 }
218 }
219 } else {
220 let status = std::process::Command::new("sh")
221 .args(["-c", cmd])
222 .stdin(std::process::Stdio::inherit())
223 .stdout(std::process::Stdio::inherit())
224 .stderr(std::process::Stdio::inherit())
225 .status();
226 match status {
227 Ok(s) if s.success() => {
228 eprintln!("Planning complete. Use bn tree {} to see children.", id);
229 }
230 Ok(s) => {
231 let code = s.code().unwrap_or(-1);
232 if code != 0 {
233 anyhow::bail!("Plan command exited with code {}", code);
234 }
235 }
236 Err(e) => {
237 anyhow::bail!("Failed to run plan command: {}", e);
238 }
239 }
240 }
241 Ok(())
242}
243
244fn build_decomposition_prompt(
246 config: &Config,
247 id: &str,
248 bean: &Bean,
249 tokens: u64,
250 strategy: Option<&str>,
251) -> String {
252 let max_tokens = config.max_tokens;
253 let tokens_k = format_tokens_k(tokens);
254 let max_k = format_tokens_k(max_tokens as u64);
255
256 let strategy_guidance = match strategy {
257 Some("feature") | Some("by-feature") => {
258 "Split by feature — each child is a vertical slice (types + impl + tests for one feature)."
259 }
260 Some("layer") | Some("by-layer") => {
261 "Split by layer — types/interfaces first, then implementation, then tests."
262 }
263 Some("file") | Some("by-file") => {
264 "Split by file — each child handles one file or closely related file group."
265 }
266 Some("phase") => {
267 "Split by phase — scaffold first, then core logic, then edge cases, then polish."
268 }
269 Some(other) => {
270 return build_prompt_text(id, bean, &tokens_k, &max_k, max_tokens, other);
272 }
273 None => "Choose the best strategy: by-feature (vertical slices), by-layer, or by-file.",
274 };
275
276 build_prompt_text(id, bean, &tokens_k, &max_k, max_tokens, strategy_guidance)
277}
278
279fn build_prompt_text(
281 id: &str,
282 bean: &Bean,
283 tokens_k: &str,
284 max_k: &str,
285 max_tokens: u32,
286 strategy_guidance: &str,
287) -> String {
288 let title = &bean.title;
289 let priority = bean.priority;
290 let description = bean.description.as_deref().unwrap_or("(no description)");
291
292 let mut dep_context = String::new();
294 if !bean.produces.is_empty() {
295 dep_context.push_str(&format!("\nProduces: {}\n", bean.produces.join(", ")));
296 }
297 if !bean.requires.is_empty() {
298 dep_context.push_str(&format!("Requires: {}\n", bean.requires.join(", ")));
299 }
300
301 format!(
302 r#"Decompose bean {id} into smaller child beans.
303
304## Parent Bean
305- **ID:** {id}
306- **Title:** {title}
307- **Priority:** P{priority}
308- **Size:** {tokens_k} (max per agent: {max_k})
309{dep_context}
310## Strategy
311{strategy_guidance}
312
313## Sizing Rules
314- A bean is **atomic** if it requires ≤5 functions to write and ≤10 to read
315- An atomic bean fits in ~{max_tokens} tokens of context
316- This bean is {tokens_k} — it needs to be split into children that are each ≤{max_k}
317- Count functions concretely by examining the code — don't estimate
318
319## Splitting Rules
320- Create **2-4 children** for medium beans, **3-5** for large ones
321- **Maximize parallelism** — prefer independent beans over sequential chains
322- Each child must have a **verify command** that exits 0 on success
323- Children should be independently testable where possible
324- Use `--produces` and `--requires` to express dependencies between siblings
325
326## Context Embedding Rules
327- **Embed context into descriptions** — don't reference files, include the relevant types/signatures
328- Include: concrete file paths, function signatures, type definitions
329- Include: specific steps, edge cases, error handling requirements
330- Be specific: "Add `fn validate_email(s: &str) -> bool` to `src/util.rs`" not "add validation"
331
332## How to Create Children
333Use `bn create` for each child bean:
334
335```
336bn create "child title" \
337 --parent {id} \
338 --priority {priority} \
339 --verify "test command that exits 0" \
340 --produces "artifact_name" \
341 --requires "artifact_from_sibling" \
342 --description "Full description with:
343- What to implement
344- Which files to modify (with paths)
345- Key types/signatures to use or create
346- Acceptance criteria
347- Edge cases to handle"
348```
349
350## Description Template
351A good child bean description includes:
3521. **What**: One clear sentence of what this child does
3532. **Files**: Specific file paths with what changes in each
3543. **Context**: Embedded type definitions, function signatures, patterns to follow
3554. **Acceptance**: Concrete criteria the verify command checks
3565. **Edge cases**: What could go wrong, what to handle
357
358## Your Task
3591. Read the parent bean's description below
3602. Examine referenced source files to count functions accurately
3613. Decide on a split strategy
3624. Create 2-5 child beans using `bn create` commands
3635. Ensure every child has a verify command
3646. After creating children, run `bn tree {id}` to show the result
365
366## Parent Bean Description
367{description}"#,
368 )
369}
370
371fn shell_escape(s: &str) -> String {
373 let escaped = s.replace('\'', "'\\''");
375 format!("'{}'", escaped)
376}
377
378fn format_tokens_k(tokens: u64) -> String {
380 if tokens >= 1000 {
381 format!("{}k tokens", tokens / 1000)
382 } else {
383 format!("{} tokens", tokens)
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use std::fs;
391 use tempfile::TempDir;
392
393 fn setup_beans_dir() -> (TempDir, std::path::PathBuf) {
394 let dir = TempDir::new().unwrap();
395 let beans_dir = dir.path().join(".beans");
396 fs::create_dir(&beans_dir).unwrap();
397 fs::write(
398 beans_dir.join("config.yaml"),
399 "project: test\nnext_id: 10\nmax_tokens: 100\n",
400 )
401 .unwrap();
402 (dir, beans_dir)
403 }
404
405 #[test]
406 fn plan_help_contains_plan() {
407 }
410
411 #[test]
412 fn plan_no_template_without_auto_errors() {
413 let (dir, beans_dir) = setup_beans_dir();
414
415 let mut bean = Bean::new("1", "Big bean");
417 bean.description = Some("x".repeat(2000)); bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
419
420 let _ = Index::build(&beans_dir);
421
422 let result = cmd_plan(
425 &beans_dir,
426 PlanArgs {
427 id: Some("1".to_string()),
428 strategy: None,
429 auto: false,
430 force: true,
431 dry_run: true, },
433 );
434
435 assert!(result.is_ok());
437
438 drop(dir);
439 }
440
441 #[test]
442 fn plan_small_bean_suggests_run() {
443 let (dir, beans_dir) = setup_beans_dir();
444
445 let bean = Bean::new("1", "Small bean");
446 bean.to_file(beans_dir.join("1-small-bean.md")).unwrap();
447
448 let _ = Index::build(&beans_dir);
449
450 let result = cmd_plan(
452 &beans_dir,
453 PlanArgs {
454 id: Some("1".to_string()),
455 strategy: None,
456 auto: false,
457 force: false,
458 dry_run: false,
459 },
460 );
461
462 assert!(result.is_ok());
463
464 drop(dir);
465 }
466
467 #[test]
468 fn plan_force_overrides_size_check() {
469 let (dir, beans_dir) = setup_beans_dir();
470
471 fs::write(
473 beans_dir.join("config.yaml"),
474 "project: test\nnext_id: 10\nmax_tokens: 100000\nplan: \"true\"\n",
475 )
476 .unwrap();
477
478 let bean = Bean::new("1", "Small bean");
479 bean.to_file(beans_dir.join("1-small-bean.md")).unwrap();
480
481 let _ = Index::build(&beans_dir);
482
483 let result = cmd_plan(
485 &beans_dir,
486 PlanArgs {
487 id: Some("1".to_string()),
488 strategy: None,
489 auto: false,
490 force: true,
491 dry_run: false,
492 },
493 );
494
495 assert!(result.is_ok());
496
497 drop(dir);
498 }
499
500 #[test]
501 fn plan_dry_run_does_not_spawn() {
502 let (dir, beans_dir) = setup_beans_dir();
503
504 fs::write(
505 beans_dir.join("config.yaml"),
506 "project: test\nnext_id: 10\nmax_tokens: 100\nplan: \"echo planning {id}\"\n",
507 )
508 .unwrap();
509
510 let mut bean = Bean::new("1", "Big bean");
511 bean.description = Some("x".repeat(2000));
512 bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
513
514 let _ = Index::build(&beans_dir);
515
516 let result = cmd_plan(
517 &beans_dir,
518 PlanArgs {
519 id: Some("1".to_string()),
520 strategy: None,
521 auto: false,
522 force: false,
523 dry_run: true,
524 },
525 );
526
527 assert!(result.is_ok());
528
529 drop(dir);
530 }
531
532 #[test]
533 fn plan_auto_pick_finds_largest() {
534 let (dir, beans_dir) = setup_beans_dir();
535
536 fs::write(
537 beans_dir.join("config.yaml"),
538 "project: test\nnext_id: 10\nmax_tokens: 100\nplan: \"true\"\n",
539 )
540 .unwrap();
541
542 let mut big = Bean::new("1", "Big bean");
544 big.description = Some("x".repeat(2000));
545 big.to_file(beans_dir.join("1-big-bean.md")).unwrap();
546
547 let small = Bean::new("2", "Small bean");
549 small.to_file(beans_dir.join("2-small-bean.md")).unwrap();
550
551 let _ = Index::build(&beans_dir);
552
553 let result = cmd_plan(
554 &beans_dir,
555 PlanArgs {
556 id: None,
557 strategy: None,
558 auto: true,
559 force: false,
560 dry_run: false,
561 },
562 );
563
564 assert!(result.is_ok());
565
566 drop(dir);
567 }
568
569 #[test]
570 fn plan_auto_pick_none_needed() {
571 let (dir, beans_dir) = setup_beans_dir();
572
573 let bean = Bean::new("1", "Small");
575 bean.to_file(beans_dir.join("1-small.md")).unwrap();
576
577 let _ = Index::build(&beans_dir);
578
579 let result = cmd_plan(
580 &beans_dir,
581 PlanArgs {
582 id: None,
583 strategy: None,
584 auto: false,
585 force: false,
586 dry_run: false,
587 },
588 );
589
590 assert!(result.is_ok());
591
592 drop(dir);
593 }
594
595 #[test]
596 fn format_tokens_k_small() {
597 assert_eq!(format_tokens_k(500), "500 tokens");
598 }
599
600 #[test]
601 fn format_tokens_k_large() {
602 assert_eq!(format_tokens_k(52000), "52k tokens");
603 }
604
605 #[test]
606 fn format_tokens_k_exact_boundary() {
607 assert_eq!(format_tokens_k(1000), "1k tokens");
608 }
609
610 #[test]
611 fn build_prompt_includes_decomposition_rules() {
612 let bean = Bean::new("42", "Implement auth system");
613 let prompt = build_decomposition_prompt(
614 &Config {
615 project: "test".to_string(),
616 next_id: 100,
617 auto_close_parent: true,
618 max_tokens: 30000,
619 run: None,
620 plan: None,
621 max_loops: 10,
622 max_concurrent: 4,
623 poll_interval: 30,
624 extends: vec![],
625 rules_file: None,
626 file_locking: false,
627 on_close: None,
628 on_fail: None,
629 post_plan: None,
630 verify_timeout: None,
631 review: None,
632 },
633 "42",
634 &bean,
635 65000,
636 None,
637 );
638
639 assert!(prompt.contains("Decompose bean 42"), "missing header");
641 assert!(prompt.contains("Implement auth system"), "missing title");
642 assert!(prompt.contains("≤5 functions"), "missing sizing rules");
643 assert!(
644 prompt.contains("Maximize parallelism"),
645 "missing parallelism rule"
646 );
647 assert!(
648 prompt.contains("Embed context"),
649 "missing context embedding rule"
650 );
651 assert!(
652 prompt.contains("verify command"),
653 "missing verify requirement"
654 );
655 assert!(prompt.contains("bn create"), "missing create syntax");
656 assert!(prompt.contains("--parent 42"), "missing parent flag");
657 assert!(prompt.contains("--produces"), "missing produces flag");
658 assert!(prompt.contains("--requires"), "missing requires flag");
659 assert!(prompt.contains("65k tokens"), "missing token count");
660 }
661
662 #[test]
663 fn build_prompt_with_strategy() {
664 let bean = Bean::new("1", "Big task");
665 let prompt = build_decomposition_prompt(
666 &Config {
667 project: "test".to_string(),
668 next_id: 10,
669 auto_close_parent: true,
670 max_tokens: 30000,
671 run: None,
672 plan: None,
673 max_loops: 10,
674 max_concurrent: 4,
675 poll_interval: 30,
676 extends: vec![],
677 rules_file: None,
678 file_locking: false,
679 on_close: None,
680 on_fail: None,
681 post_plan: None,
682 verify_timeout: None,
683 review: None,
684 },
685 "1",
686 &bean,
687 50000,
688 Some("by-feature"),
689 );
690
691 assert!(
692 prompt.contains("vertical slice"),
693 "missing feature strategy guidance"
694 );
695 }
696
697 #[test]
698 fn build_prompt_includes_produces_requires() {
699 let mut bean = Bean::new("5", "Task with deps");
700 bean.produces = vec!["auth_types".to_string(), "auth_middleware".to_string()];
701 bean.requires = vec!["db_connection".to_string()];
702
703 let prompt = build_decomposition_prompt(
704 &Config {
705 project: "test".to_string(),
706 next_id: 10,
707 auto_close_parent: true,
708 max_tokens: 30000,
709 run: None,
710 plan: None,
711 max_loops: 10,
712 max_concurrent: 4,
713 poll_interval: 30,
714 extends: vec![],
715 rules_file: None,
716 file_locking: false,
717 on_close: None,
718 on_fail: None,
719 post_plan: None,
720 verify_timeout: None,
721 review: None,
722 },
723 "5",
724 &bean,
725 40000,
726 None,
727 );
728
729 assert!(prompt.contains("auth_types"), "missing produces");
730 assert!(prompt.contains("db_connection"), "missing requires");
731 }
732
733 #[test]
734 fn shell_escape_simple() {
735 assert_eq!(shell_escape("hello world"), "'hello world'");
736 }
737
738 #[test]
739 fn shell_escape_with_quotes() {
740 assert_eq!(shell_escape("it's here"), "'it'\\''s here'");
741 }
742
743 #[test]
744 fn plan_builtin_dry_run_shows_prompt() {
745 let (dir, beans_dir) = setup_beans_dir();
746
747 let mut bean = Bean::new("1", "Big bean");
749 bean.description = Some("x".repeat(2000));
750 bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
751
752 let _ = Index::build(&beans_dir);
753
754 let result = cmd_plan(
755 &beans_dir,
756 PlanArgs {
757 id: Some("1".to_string()),
758 strategy: None,
759 auto: false,
760 force: true,
761 dry_run: true,
762 },
763 );
764
765 assert!(result.is_ok());
766
767 drop(dir);
768 }
769}