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 is oversized or unscoped.
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::util::natural_cmp;
16
17/// Arguments for the plan command.
18pub struct PlanArgs {
19    pub id: Option<String>,
20    pub strategy: Option<String>,
21    pub auto: bool,
22    pub force: bool,
23    pub dry_run: bool,
24}
25
26/// Execute the `bn plan` command.
27pub fn cmd_plan(beans_dir: &Path, args: PlanArgs) -> Result<()> {
28    let config = Config::load_with_extends(beans_dir)?;
29    let workspace = beans_dir.parent().unwrap_or_else(|| Path::new("."));
30
31    let index = Index::load_or_rebuild(beans_dir)?;
32
33    match args.id {
34        Some(ref id) => plan_specific(beans_dir, &config, &index, workspace, id, &args),
35        None => plan_auto_pick(beans_dir, &config, &index, workspace, &args),
36    }
37}
38
39/// Plan a specific bean by ID.
40fn plan_specific(
41    beans_dir: &Path,
42    config: &Config,
43    _index: &Index,
44    _workspace: &Path,
45    id: &str,
46    args: &PlanArgs,
47) -> Result<()> {
48    let bean_path = find_bean_file(beans_dir, id)?;
49    let bean = Bean::from_file(&bean_path)?;
50
51    let is_oversized = bean.produces.len() > crate::blocking::MAX_PRODUCES
52        || bean.paths.len() > crate::blocking::MAX_PATHS;
53
54    if !is_oversized && !args.force {
55        eprintln!("Bean {} is small enough to run directly.", id,);
56        eprintln!("  Use bn run {} to dispatch it.", id);
57        eprintln!("  Use bn plan {} --force to plan anyway.", id);
58        return Ok(());
59    }
60
61    spawn_plan(beans_dir, config, id, &bean, args)
62}
63
64/// Auto-pick the highest-priority ready bean that is oversized.
65fn plan_auto_pick(
66    beans_dir: &Path,
67    config: &Config,
68    index: &Index,
69    _workspace: &Path,
70    args: &PlanArgs,
71) -> Result<()> {
72    // Find all open beans that are oversized (too many produces or paths)
73    let mut candidates: Vec<(String, String, u8)> = Vec::new();
74
75    for entry in &index.beans {
76        if entry.status != Status::Open {
77            continue;
78        }
79        // Skip beans that are claimed
80        if entry.claimed_by.is_some() {
81            continue;
82        }
83
84        let bean_path = match find_bean_file(beans_dir, &entry.id) {
85            Ok(p) => p,
86            Err(_) => continue,
87        };
88        let bean = match Bean::from_file(&bean_path) {
89            Ok(b) => b,
90            Err(_) => continue,
91        };
92
93        let is_oversized = bean.produces.len() > crate::blocking::MAX_PRODUCES
94            || bean.paths.len() > crate::blocking::MAX_PATHS;
95
96        if is_oversized {
97            candidates.push((entry.id.clone(), entry.title.clone(), entry.priority));
98        }
99    }
100
101    if candidates.is_empty() {
102        eprintln!("✓ All ready beans are small enough to run directly.");
103        eprintln!("  Use bn run to dispatch them.");
104        return Ok(());
105    }
106
107    // Sort by priority (ascending P0 first), then by ID
108    candidates.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| natural_cmp(&a.0, &b.0)));
109
110    // Show all candidates
111    eprintln!("{} beans need planning:", candidates.len());
112    for (id, title, priority) in &candidates {
113        eprintln!("  P{}  {:6}  {}", priority, id, title);
114    }
115    eprintln!();
116
117    // Pick first (highest priority, lowest ID)
118    let (id, title, _) = &candidates[0];
119    eprintln!("Planning: {} — {}", id, title);
120
121    // Load the full bean for prompt building
122    let bean_path = find_bean_file(beans_dir, id)?;
123    let bean = Bean::from_file(&bean_path)?;
124
125    spawn_plan(beans_dir, config, id, &bean, args)
126}
127
128/// Spawn the plan command for a bean.
129///
130/// If `config.plan` is set, uses that template (backward compatible).
131/// Otherwise, builds a rich decomposition prompt and spawns `pi` directly.
132fn spawn_plan(
133    beans_dir: &Path,
134    config: &Config,
135    id: &str,
136    bean: &Bean,
137    args: &PlanArgs,
138) -> Result<()> {
139    // If a custom plan template is configured, use it (backward compat)
140    if let Some(ref template) = config.plan {
141        return spawn_template(template, id, args);
142    }
143
144    // Built-in decomposition: build prompt and spawn pi
145    spawn_builtin(beans_dir, id, bean, args)
146}
147
148/// Spawn the plan using a user-configured template command.
149fn spawn_template(template: &str, id: &str, args: &PlanArgs) -> Result<()> {
150    let mut cmd = template.replace("{id}", id);
151
152    if let Some(ref strategy) = args.strategy {
153        cmd = format!("{} --strategy {}", cmd, strategy);
154    }
155
156    if args.dry_run {
157        eprintln!("Would spawn: {}", cmd);
158        return Ok(());
159    }
160
161    eprintln!("Spawning: {}", cmd);
162    run_shell_command(&cmd, id, args.auto)
163}
164
165/// Build a decomposition prompt and spawn `pi` with it directly.
166fn spawn_builtin(beans_dir: &Path, id: &str, bean: &Bean, args: &PlanArgs) -> Result<()> {
167    let prompt = build_decomposition_prompt(id, bean, args.strategy.as_deref());
168
169    // Find the bean file to pass as context
170    let bean_path = find_bean_file(beans_dir, id)?;
171    let bean_path_str = bean_path.display().to_string();
172
173    // Build pi command: pass the bean file as context and the prompt
174    let escaped_prompt = shell_escape(&prompt);
175    let cmd = format!("pi @{} {}", bean_path_str, escaped_prompt);
176
177    if args.dry_run {
178        eprintln!("Would spawn: {}", cmd);
179        eprintln!("\n--- Built-in decomposition prompt ---");
180        eprintln!("{}", prompt);
181        return Ok(());
182    }
183
184    eprintln!("Spawning built-in decomposition for bean {}...", id);
185    run_shell_command(&cmd, id, args.auto)
186}
187
188/// Execute a shell command, either interactively or non-interactively.
189fn run_shell_command(cmd: &str, id: &str, auto: bool) -> Result<()> {
190    if auto {
191        let status = std::process::Command::new("sh").args(["-c", cmd]).status();
192        match status {
193            Ok(s) if s.success() => {
194                eprintln!("Planning complete. Use bn tree {} to see children.", id);
195            }
196            Ok(s) => {
197                anyhow::bail!("Plan command exited with code {}", s.code().unwrap_or(-1));
198            }
199            Err(e) => {
200                anyhow::bail!("Failed to run plan command: {}", e);
201            }
202        }
203    } else {
204        let status = std::process::Command::new("sh")
205            .args(["-c", cmd])
206            .stdin(std::process::Stdio::inherit())
207            .stdout(std::process::Stdio::inherit())
208            .stderr(std::process::Stdio::inherit())
209            .status();
210        match status {
211            Ok(s) if s.success() => {
212                eprintln!("Planning complete. Use bn tree {} to see children.", id);
213            }
214            Ok(s) => {
215                let code = s.code().unwrap_or(-1);
216                if code != 0 {
217                    anyhow::bail!("Plan command exited with code {}", code);
218                }
219            }
220            Err(e) => {
221                anyhow::bail!("Failed to run plan command: {}", e);
222            }
223        }
224    }
225    Ok(())
226}
227
228/// Build a rich decomposition prompt that embeds the core planning wisdom.
229fn build_decomposition_prompt(id: &str, bean: &Bean, strategy: Option<&str>) -> String {
230    let strategy_guidance = match strategy {
231        Some("feature") | Some("by-feature") => {
232            "Split by feature — each child is a vertical slice (types + impl + tests for one feature)."
233        }
234        Some("layer") | Some("by-layer") => {
235            "Split by layer — types/interfaces first, then implementation, then tests."
236        }
237        Some("file") | Some("by-file") => {
238            "Split by file — each child handles one file or closely related file group."
239        }
240        Some("phase") => {
241            "Split by phase — scaffold first, then core logic, then edge cases, then polish."
242        }
243        Some(other) => {
244            // Custom strategy, include as-is
245            return build_prompt_text(id, bean, other);
246        }
247        None => "Choose the best strategy: by-feature (vertical slices), by-layer, or by-file.",
248    };
249
250    build_prompt_text(id, bean, strategy_guidance)
251}
252
253/// Assemble the full prompt text with decomposition rules.
254fn build_prompt_text(id: &str, bean: &Bean, strategy_guidance: &str) -> String {
255    let title = &bean.title;
256    let priority = bean.priority;
257    let description = bean.description.as_deref().unwrap_or("(no description)");
258
259    // Build produces/requires context if present
260    let mut dep_context = String::new();
261    if !bean.produces.is_empty() {
262        dep_context.push_str(&format!("\nProduces: {}\n", bean.produces.join(", ")));
263    }
264    if !bean.requires.is_empty() {
265        dep_context.push_str(&format!("Requires: {}\n", bean.requires.join(", ")));
266    }
267
268    format!(
269        r#"Decompose bean {id} into smaller child beans.
270
271## Parent Bean
272- **ID:** {id}
273- **Title:** {title}
274- **Priority:** P{priority}
275{dep_context}
276## Strategy
277{strategy_guidance}
278
279## Sizing Rules
280- A bean is **atomic** if it requires ≤5 functions to write and ≤10 to read
281- Each child should have at most 3 `produces` artifacts and 5 `paths`
282- Count functions concretely by examining the code — don't estimate
283
284## Splitting Rules
285- Create **2-4 children** for medium beans, **3-5** for large ones
286- **Maximize parallelism** — prefer independent beans over sequential chains
287- Each child must have a **verify command** that exits 0 on success
288- Children should be independently testable where possible
289- Use `--produces` and `--requires` to express dependencies between siblings
290
291## Context Embedding Rules
292- **Embed context into descriptions** — don't reference files, include the relevant types/signatures
293- Include: concrete file paths, function signatures, type definitions
294- Include: specific steps, edge cases, error handling requirements
295- Be specific: "Add `fn validate_email(s: &str) -> bool` to `src/util.rs`" not "add validation"
296
297## How to Create Children
298Use `bn create` for each child bean:
299
300```
301bn create "child title" \
302  --parent {id} \
303  --priority {priority} \
304  --verify "test command that exits 0" \
305  --produces "artifact_name" \
306  --requires "artifact_from_sibling" \
307  --description "Full description with:
308- What to implement
309- Which files to modify (with paths)
310- Key types/signatures to use or create
311- Acceptance criteria
312- Edge cases to handle"
313```
314
315## Description Template
316A good child bean description includes:
3171. **What**: One clear sentence of what this child does
3182. **Files**: Specific file paths with what changes in each
3193. **Context**: Embedded type definitions, function signatures, patterns to follow
3204. **Acceptance**: Concrete criteria the verify command checks
3215. **Edge cases**: What could go wrong, what to handle
322
323## Your Task
3241. Read the parent bean's description below
3252. Examine referenced source files to count functions accurately
3263. Decide on a split strategy
3274. Create 2-5 child beans using `bn create` commands
3285. Ensure every child has a verify command
3296. After creating children, run `bn tree {id}` to show the result
330
331## Parent Bean Description
332{description}"#,
333    )
334}
335
336/// Escape a string for safe use as a single shell argument.
337fn shell_escape(s: &str) -> String {
338    // Use single quotes, escaping any internal single quotes
339    let escaped = s.replace('\'', "'\\''");
340    format!("'{}'", escaped)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::fs;
347    use tempfile::TempDir;
348
349    fn setup_beans_dir() -> (TempDir, std::path::PathBuf) {
350        let dir = TempDir::new().unwrap();
351        let beans_dir = dir.path().join(".beans");
352        fs::create_dir(&beans_dir).unwrap();
353        fs::write(
354            beans_dir.join("config.yaml"),
355            "project: test\nnext_id: 10\n",
356        )
357        .unwrap();
358        (dir, beans_dir)
359    }
360
361    #[test]
362    fn plan_help_contains_plan() {
363        // This is verified by the bean's verify command: bn plan --help 2>&1 | grep -q 'plan'
364        // Here we just verify the module exists and compiles
365    }
366
367    #[test]
368    fn plan_no_template_without_auto_errors() {
369        let (dir, beans_dir) = setup_beans_dir();
370
371        // Create an oversized bean (>3 produces) to trigger planning
372        let mut bean = Bean::new("1", "Big bean");
373        bean.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
374        bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
375
376        let _ = Index::build(&beans_dir);
377
378        // Without --auto AND without config.plan, should use builtin
379        // which tries to spawn pi (will fail in test env but that's the intent)
380        let result = cmd_plan(
381            &beans_dir,
382            PlanArgs {
383                id: Some("1".to_string()),
384                strategy: None,
385                auto: false,
386                force: true,
387                dry_run: true, // dry_run so we don't actually spawn
388            },
389        );
390
391        // dry_run should succeed
392        assert!(result.is_ok());
393
394        drop(dir);
395    }
396
397    #[test]
398    fn plan_small_bean_suggests_run() {
399        let (dir, beans_dir) = setup_beans_dir();
400
401        let bean = Bean::new("1", "Small bean");
402        bean.to_file(beans_dir.join("1-small-bean.md")).unwrap();
403
404        let _ = Index::build(&beans_dir);
405
406        // Should succeed (prints advice, doesn't error)
407        let result = cmd_plan(
408            &beans_dir,
409            PlanArgs {
410                id: Some("1".to_string()),
411                strategy: None,
412                auto: false,
413                force: false,
414                dry_run: false,
415            },
416        );
417
418        assert!(result.is_ok());
419
420        drop(dir);
421    }
422
423    #[test]
424    fn plan_force_overrides_size_check() {
425        let (dir, beans_dir) = setup_beans_dir();
426
427        // Config with plan template that just exits 0
428        fs::write(
429            beans_dir.join("config.yaml"),
430            "project: test\nnext_id: 10\nplan: \"true\"\n",
431        )
432        .unwrap();
433
434        let bean = Bean::new("1", "Small bean");
435        bean.to_file(beans_dir.join("1-small-bean.md")).unwrap();
436
437        let _ = Index::build(&beans_dir);
438
439        // With --force, should spawn even for small bean
440        let result = cmd_plan(
441            &beans_dir,
442            PlanArgs {
443                id: Some("1".to_string()),
444                strategy: None,
445                auto: false,
446                force: true,
447                dry_run: false,
448            },
449        );
450
451        assert!(result.is_ok());
452
453        drop(dir);
454    }
455
456    #[test]
457    fn plan_dry_run_does_not_spawn() {
458        let (dir, beans_dir) = setup_beans_dir();
459
460        fs::write(
461            beans_dir.join("config.yaml"),
462            "project: test\nnext_id: 10\nplan: \"echo planning {id}\"\n",
463        )
464        .unwrap();
465
466        let mut bean = Bean::new("1", "Big bean");
467        bean.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
468        bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
469
470        let _ = Index::build(&beans_dir);
471
472        let result = cmd_plan(
473            &beans_dir,
474            PlanArgs {
475                id: Some("1".to_string()),
476                strategy: None,
477                auto: false,
478                force: false,
479                dry_run: true,
480            },
481        );
482
483        assert!(result.is_ok());
484
485        drop(dir);
486    }
487
488    #[test]
489    fn plan_auto_pick_finds_oversized() {
490        let (dir, beans_dir) = setup_beans_dir();
491
492        fs::write(
493            beans_dir.join("config.yaml"),
494            "project: test\nnext_id: 10\nplan: \"true\"\n",
495        )
496        .unwrap();
497
498        // Bean above scope threshold (oversized)
499        let mut big = Bean::new("1", "Big bean");
500        big.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
501        big.to_file(beans_dir.join("1-big-bean.md")).unwrap();
502
503        // Bean below scope threshold
504        let small = Bean::new("2", "Small bean");
505        small.to_file(beans_dir.join("2-small-bean.md")).unwrap();
506
507        let _ = Index::build(&beans_dir);
508
509        let result = cmd_plan(
510            &beans_dir,
511            PlanArgs {
512                id: None,
513                strategy: None,
514                auto: true,
515                force: false,
516                dry_run: false,
517            },
518        );
519
520        assert!(result.is_ok());
521
522        drop(dir);
523    }
524
525    #[test]
526    fn plan_auto_pick_none_needed() {
527        let (dir, beans_dir) = setup_beans_dir();
528
529        // All beans small
530        let bean = Bean::new("1", "Small");
531        bean.to_file(beans_dir.join("1-small.md")).unwrap();
532
533        let _ = Index::build(&beans_dir);
534
535        let result = cmd_plan(
536            &beans_dir,
537            PlanArgs {
538                id: None,
539                strategy: None,
540                auto: false,
541                force: false,
542                dry_run: false,
543            },
544        );
545
546        assert!(result.is_ok());
547
548        drop(dir);
549    }
550
551    #[test]
552    fn build_prompt_includes_decomposition_rules() {
553        let bean = Bean::new("42", "Implement auth system");
554        let prompt = build_decomposition_prompt("42", &bean, None);
555
556        // Core decomposition rules are present
557        assert!(prompt.contains("Decompose bean 42"), "missing header");
558        assert!(prompt.contains("Implement auth system"), "missing title");
559        assert!(prompt.contains("≤5 functions"), "missing sizing rules");
560        assert!(
561            prompt.contains("Maximize parallelism"),
562            "missing parallelism rule"
563        );
564        assert!(
565            prompt.contains("Embed context"),
566            "missing context embedding rule"
567        );
568        assert!(
569            prompt.contains("verify command"),
570            "missing verify requirement"
571        );
572        assert!(prompt.contains("bn create"), "missing create syntax");
573        assert!(prompt.contains("--parent 42"), "missing parent flag");
574        assert!(prompt.contains("--produces"), "missing produces flag");
575        assert!(prompt.contains("--requires"), "missing requires flag");
576    }
577
578    #[test]
579    fn build_prompt_with_strategy() {
580        let bean = Bean::new("1", "Big task");
581        let prompt = build_decomposition_prompt("1", &bean, Some("by-feature"));
582
583        assert!(
584            prompt.contains("vertical slice"),
585            "missing feature strategy guidance"
586        );
587    }
588
589    #[test]
590    fn build_prompt_includes_produces_requires() {
591        let mut bean = Bean::new("5", "Task with deps");
592        bean.produces = vec!["auth_types".to_string(), "auth_middleware".to_string()];
593        bean.requires = vec!["db_connection".to_string()];
594
595        let prompt = build_decomposition_prompt("5", &bean, None);
596
597        assert!(prompt.contains("auth_types"), "missing produces");
598        assert!(prompt.contains("db_connection"), "missing requires");
599    }
600
601    #[test]
602    fn shell_escape_simple() {
603        assert_eq!(shell_escape("hello world"), "'hello world'");
604    }
605
606    #[test]
607    fn shell_escape_with_quotes() {
608        assert_eq!(shell_escape("it's here"), "'it'\\''s here'");
609    }
610
611    #[test]
612    fn plan_builtin_dry_run_shows_prompt() {
613        let (dir, beans_dir) = setup_beans_dir();
614
615        // No plan template configured — will use builtin
616        let mut bean = Bean::new("1", "Big bean");
617        bean.description = Some("x".repeat(2000));
618        bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
619
620        let _ = Index::build(&beans_dir);
621
622        let result = cmd_plan(
623            &beans_dir,
624            PlanArgs {
625                id: Some("1".to_string()),
626                strategy: None,
627                auto: false,
628                force: true,
629                dry_run: true,
630            },
631        );
632
633        assert!(result.is_ok());
634
635        drop(dir);
636    }
637}