use anyhow::Result;
use std::io::IsTerminal;
struct SkillSelection {
skill: &'static str,
rationale: &'static str,
token_budget: usize,
}
pub fn handle(task: String, budget: usize, json: bool) -> Result<()> {
let selections = decide(&task, budget);
if json {
let arr: Vec<serde_json::Value> = selections
.iter()
.map(|s| {
serde_json::json!({
"skill": s.skill,
"rationale": s.rationale,
"token_budget": s.token_budget
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&arr)?);
return Ok(());
}
let is_tty = std::io::stdout().is_terminal();
let sep = " ─────────────────────────────────────────────────────────";
println!();
println!(" bctx plan — {task}");
println!("{sep}");
if selections.is_empty() {
println!(" No skills selected. Describe the task in more detail.");
println!("{sep}");
println!();
return Ok(());
}
println!(
" SpiralCycle DECIDE phase · budget: {} tokens total",
budget
);
println!();
let total_budget: usize = selections.iter().map(|s| s.token_budget).sum();
let max_budget = selections.iter().map(|s| s.token_budget).max().unwrap_or(1);
for (i, s) in selections.iter().enumerate() {
let step = i + 1;
let bar_width = 14usize;
let filled = s
.token_budget
.checked_mul(bar_width)
.and_then(|v| v.checked_div(max_budget))
.unwrap_or(0)
.min(bar_width);
let empty = bar_width - filled;
let bar = if is_tty {
format!(
"\x1b[36m{}\x1b[2m{}\x1b[0m",
"█".repeat(filled),
"░".repeat(empty)
)
} else {
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
};
let skill_label = if is_tty {
format!("\x1b[1m{:<12}\x1b[0m", s.skill)
} else {
format!("{:<12}", s.skill)
};
println!(
" {step}. {skill_label} {bar} {:>5} tok {}",
s.token_budget, s.rationale
);
}
println!();
println!("{sep}");
println!(
" {} skills selected · {} tokens total",
selections.len(),
total_budget
);
println!();
println!(" Run `bctx mcp` to execute via MCP, or use individual `bctx` subcommands");
println!();
Ok(())
}
fn decide(task: &str, budget: usize) -> Vec<SkillSelection> {
let task_lower = task.to_lowercase();
let mut selections: Vec<SkillSelection> = Vec::new();
let per = budget / 6;
if task_lower.contains("find")
|| task_lower.contains("search")
|| task_lower.contains("where")
|| task_lower.contains("which file")
|| task_lower.contains("location")
{
selections.push(SkillSelection {
skill: "compass",
rationale: "BM25 + graph search to locate relevant code",
token_budget: per,
});
}
if task_lower.contains("understand")
|| task_lower.contains("explain")
|| task_lower.contains("symbol")
|| task_lower.contains("function")
|| task_lower.contains("class")
|| task_lower.contains("struct")
|| task_lower.contains("interface")
|| task_lower.contains("api")
{
selections.push(SkillSelection {
skill: "chisel",
rationale: "AST symbol extraction for code structure",
token_budget: per,
});
}
if task_lower.contains("project")
|| task_lower.contains("overview")
|| task_lower.contains("structure")
|| task_lower.contains("directory")
|| task_lower.contains("layout")
|| task_lower.contains("codebase")
{
selections.push(SkillSelection {
skill: "cartograph",
rationale: "directory map + language breakdown",
token_budget: per,
});
selections.push(SkillSelection {
skill: "panorama",
rationale: "high-level project summary with entry points",
token_budget: per / 2,
});
}
if task_lower.contains("depend")
|| task_lower.contains("impact")
|| task_lower.contains("call")
|| task_lower.contains("uses")
|| task_lower.contains("caller")
{
selections.push(SkillSelection {
skill: "surveyor",
rationale: "dependency topology + caller/callee graph",
token_budget: per,
});
}
if task_lower.contains("run")
|| task_lower.contains("execute")
|| task_lower.contains("build")
|| task_lower.contains("test")
|| task_lower.contains("compile")
|| task_lower.contains("output")
{
selections.push(SkillSelection {
skill: "scout",
rationale: "execute command with domain-aware compression",
token_budget: per,
});
selections.push(SkillSelection {
skill: "sieve",
rationale: "filter command output to task-relevant lines",
token_budget: per / 2,
});
}
if task_lower.contains("security")
|| task_lower.contains("vulnerab")
|| task_lower.contains("safe")
|| task_lower.contains("risk")
|| task_lower.contains("audit")
{
selections.push(SkillSelection {
skill: "sentinel",
rationale: "static security risk assessment",
token_budget: per / 2,
});
selections.push(SkillSelection {
skill: "arbiter",
rationale: "structured code review for smells",
token_budget: per,
});
}
if task_lower.contains("remember")
|| task_lower.contains("previous")
|| task_lower.contains("last time")
|| task_lower.contains("history")
|| task_lower.contains("recall")
|| task_lower.contains("fact")
{
selections.push(SkillSelection {
skill: "archivist",
rationale: "retrieve relevant Vault facts",
token_budget: per / 2,
});
}
if task_lower.contains("compress")
|| task_lower.contains("summar")
|| task_lower.contains("token")
|| task_lower.contains("shrink")
|| task_lower.contains("condense")
{
selections.push(SkillSelection {
skill: "condenser",
rationale: "compress content to token budget via Lens pipeline",
token_budget: per,
});
selections.push(SkillSelection {
skill: "thermal",
rationale: "identify hottest token-consuming sections",
token_budget: per / 2,
});
}
if selections.is_empty() {
selections.push(SkillSelection {
skill: "prism",
rationale: "build/update project code index",
token_budget: per,
});
selections.push(SkillSelection {
skill: "compass",
rationale: "BM25 + graph search for relevant context",
token_budget: per,
});
selections.push(SkillSelection {
skill: "condenser",
rationale: "compress retrieved context to token budget",
token_budget: per,
});
}
selections.truncate(6);
selections
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_task_selects_compass() {
let plan = decide("find where the auth token is validated", 6000);
assert!(
plan.iter().any(|s| s.skill == "compass"),
"{:?}",
plan.iter().map(|s| s.skill).collect::<Vec<_>>()
);
}
#[test]
fn security_task_selects_sentinel() {
let plan = decide("security audit of the login flow", 6000);
assert!(plan.iter().any(|s| s.skill == "sentinel"));
}
#[test]
fn build_task_selects_scout() {
let plan = decide("run the test suite and check output", 6000);
assert!(plan.iter().any(|s| s.skill == "scout"));
}
#[test]
fn unknown_task_gets_fallback() {
let plan = decide("frobnicate the widget", 6000);
assert!(!plan.is_empty());
}
#[test]
fn plan_capped_at_six_skills() {
let plan = decide(
"find security issues in test output and summarize the project structure dependencies",
12000,
);
assert!(plan.len() <= 6);
}
#[test]
fn project_overview_task_selects_cartograph() {
let plan = decide("give me an overview of the project structure", 6000);
assert!(
plan.iter().any(|s| s.skill == "cartograph"),
"expected cartograph for project overview task"
);
}
#[test]
fn dependency_task_selects_surveyor() {
let plan = decide(
"show me what depends on this function and its callers",
6000,
);
assert!(
plan.iter().any(|s| s.skill == "surveyor"),
"expected surveyor for dependency task"
);
}
#[test]
fn recall_task_selects_archivist() {
let plan = decide("recall what I know about the previous auth decisions", 6000);
assert!(
plan.iter().any(|s| s.skill == "archivist"),
"expected archivist for recall/memory task"
);
}
#[test]
fn all_skills_have_non_zero_budget() {
let plan = decide("find and explain the database connection setup", 6000);
for s in &plan {
assert!(
s.token_budget > 0,
"skill '{}' has zero token budget",
s.skill
);
}
}
#[test]
fn json_output_is_valid() {
let plan = decide("find where the config is loaded", 6000);
let arr: Vec<serde_json::Value> = plan
.iter()
.map(|s| {
serde_json::json!({
"skill": s.skill,
"rationale": s.rationale,
"token_budget": s.token_budget
})
})
.collect();
let text = serde_json::to_string_pretty(&arr).expect("should serialise");
let parsed: Vec<serde_json::Value> =
serde_json::from_str(&text).expect("should parse back");
assert_eq!(parsed.len(), plan.len());
for item in &parsed {
assert!(item["skill"].is_string());
assert!(item["rationale"].is_string());
assert!(item["token_budget"].is_number());
}
}
}