Skip to main content

bn/commands/
plan.rs

1//! `bn plan` — interactively plan a large bean into children.
2//!
3//! Without an ID, picks the highest-priority ready bean that exceeds max_tokens.
4//! When `config.plan` is set, spawns that template command.
5//! Otherwise, builds a rich decomposition prompt and spawns `pi` directly.
6
7use 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
18/// Arguments for the plan command.
19pub 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
27/// Execute the `bn plan` command.
28pub 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
40/// Plan a specific bean by ID.
41fn 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
67/// Auto-pick the highest-priority ready bean that exceeds max_tokens.
68fn plan_auto_pick(
69    beans_dir: &Path,
70    config: &Config,
71    index: &Index,
72    workspace: &Path,
73    args: &PlanArgs,
74) -> Result<()> {
75    // Find all open beans (GOALs without verify, or any open bean above max_tokens)
76    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        // Skip beans that are claimed
83        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    // Sort by priority (ascending P0 first), then by ID
114    candidates.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| natural_cmp(&a.0, &b.0)));
115
116    // Show all candidates
117    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    // Pick first (highest priority, lowest ID)
125    let (id, title, _, tokens) = &candidates[0];
126    let tokens_k = format_tokens_k(*tokens);
127    eprintln!("Planning: {} — {} ({})", id, title, tokens_k);
128
129    // Load the full bean for prompt building
130    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
136/// Spawn the plan command for a bean.
137///
138/// If `config.plan` is set, uses that template (backward compatible).
139/// Otherwise, builds a rich decomposition prompt and spawns `pi` directly.
140fn 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 a custom plan template is configured, use it (backward compat)
149    if let Some(ref template) = config.plan {
150        return spawn_template(template, id, args);
151    }
152
153    // Built-in decomposition: build prompt and spawn pi
154    spawn_builtin(beans_dir, config, id, bean, tokens, args)
155}
156
157/// Spawn the plan using a user-configured template command.
158fn 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
174/// Build a decomposition prompt and spawn `pi` with it directly.
175fn 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    // Find the bean file to pass as context
186    let bean_path = find_bean_file(beans_dir, id)?;
187    let bean_path_str = bean_path.display().to_string();
188
189    // Build pi command: pass the bean file as context and the prompt
190    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
204/// Execute a shell command, either interactively or non-interactively.
205fn 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
244/// Build a rich decomposition prompt that embeds the core planning wisdom.
245fn 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            // Custom strategy, include as-is
271            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
279/// Assemble the full prompt text with decomposition rules.
280fn 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    // Build produces/requires context if present
293    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
371/// Escape a string for safe use as a single shell argument.
372fn shell_escape(s: &str) -> String {
373    // Use single quotes, escaping any internal single quotes
374    let escaped = s.replace('\'', "'\\''");
375    format!("'{}'", escaped)
376}
377
378/// Format token count as "Nk tokens" string.
379fn 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        // This is verified by the bean's verify command: bn plan --help 2>&1 | grep -q 'plan'
408        // Here we just verify the module exists and compiles
409    }
410
411    #[test]
412    fn plan_no_template_without_auto_errors() {
413        let (dir, beans_dir) = setup_beans_dir();
414
415        // Create a bean big enough to trigger planning
416        let mut bean = Bean::new("1", "Big bean");
417        bean.description = Some("x".repeat(2000)); // > 100 tokens threshold
418        bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
419
420        let _ = Index::build(&beans_dir);
421
422        // Without --auto AND without config.plan, should use builtin
423        // which tries to spawn pi (will fail in test env but that's the intent)
424        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, // dry_run so we don't actually spawn
432            },
433        );
434
435        // dry_run should succeed
436        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        // Should succeed (prints advice, doesn't error)
451        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        // Config with plan template that just exits 0
472        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        // With --force, should spawn even for small bean
484        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        // Bean above threshold
543        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        // Bean below threshold
548        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        // All beans small
574        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        // Core decomposition rules are present
640        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        // No plan template configured — will use builtin
748        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}