use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use crate::agent::{self, Agent, AgentEvent, AgentRequest, Role, StopReason};
use crate::config::{self, Config};
use crate::deferred;
use crate::plan::{self, Plan};
use crate::prompts;
use crate::runner;
use crate::state::{self, TokenUsage};
use crate::tui::iteration::{run_iteration_wizard, IterationOutcome, WorkspaceSnapshot};
use crate::util::{paths, write_atomic};
use super::config::{run_config_wizard, ConfigMode, ConfigOutcome};
use super::init::PLAN_TEMPLATE;
use super::sweep::SweepArgs;
const QUESTIONER_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const PLANNER_TIMEOUT: Duration = Duration::from_secs(30 * 60);
pub async fn run(workspace: PathBuf) -> Result<()> {
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"pitboss start requires an interactive terminal.\n\
For non-interactive scaffolding, use `pitboss init` followed by `pitboss plan` and `pitboss play`."
);
}
let pitboss_dir = workspace.join(".pitboss");
if pitboss_dir.is_dir() {
run_existing_user_flow(workspace).await
} else {
run_new_user_flow(workspace).await
}
}
async fn run_new_user_flow(workspace: PathBuf) -> Result<()> {
match run_config_wizard(&workspace, ConfigMode::Create).await? {
ConfigOutcome::Cancelled => {
eprintln!("start cancelled — no files written");
}
ConfigOutcome::Completed => {
println!(" Next steps:");
println!(" 1. Edit .pitboss/play/plan.md to describe the phases.");
println!(" 2. Run `pitboss start` again — the iteration wizard");
println!(" can also generate plan.md from a goal via the");
println!(" planner agent (New plan → enable interview).");
println!();
}
}
Ok(())
}
async fn run_existing_user_flow(workspace: PathBuf) -> Result<()> {
let snapshot = load_snapshot(&workspace)?;
let had_state = !snapshot.no_state;
let cfg = config::load(&workspace)
.with_context(|| format!("start: loading config in {:?}", workspace))?;
let agent: Arc<dyn Agent + Send + Sync> = Arc::from(agent::build_agent(&cfg)?);
let outcome = match run_iteration_wizard(&snapshot, &workspace, &cfg, agent.clone()).await? {
Some(o) => o,
None => {
eprintln!("start cancelled — no changes made");
return Ok(());
}
};
dispatch_outcome(workspace, outcome, had_state, &cfg).await
}
fn load_snapshot(workspace: &Path) -> Result<WorkspaceSnapshot> {
let config = config::load(workspace)
.with_context(|| format!("start: loading config in {:?}", workspace))?;
let state = state::load(workspace)
.with_context(|| format!("start: loading state in {:?}", workspace))?;
let plan_path = paths::plan_path(workspace);
let plan = match fs::read_to_string(&plan_path) {
Ok(text) => plan::parse(&text).ok(),
Err(_) => None,
};
let deferred_path = paths::deferred_path(workspace);
let deferred_count = match fs::read_to_string(&deferred_path) {
Ok(text) => deferred::parse(&text)
.map(|doc| doc.items.iter().filter(|i| !i.done).count())
.unwrap_or(0),
Err(_) => 0,
};
let (tokens_used, usd_used) = state
.as_ref()
.map(|s| runner::budget_totals(&config, &s.token_usage))
.unwrap_or((0, 0.0));
let (total_phases, current_phase) = match &plan {
Some(p) => (p.phases.len(), Some(p.current_phase.clone())),
None => (0, None),
};
Ok(WorkspaceSnapshot {
branch: state.as_ref().map(|s| s.branch.clone()),
current_phase,
completed_count: state.as_ref().map(|s| s.completed.len()).unwrap_or(0),
total_phases,
tokens_used,
tokens_cap: config.budgets.max_total_tokens,
usd_used,
usd_cap: config.budgets.max_total_usd,
deferred_count,
planner_model: config.models.planner.clone(),
implementer_model: config.models.implementer.clone(),
no_state: state.is_none(),
no_plan: plan.is_none(),
})
}
async fn dispatch_outcome(
workspace: PathBuf,
outcome: IterationOutcome,
had_state: bool,
cfg: &Config,
) -> Result<()> {
match outcome {
IterationOutcome::Continue { reset_budget } => {
if reset_budget {
reset_budget_in_state(&workspace)?;
}
if had_state {
super::rebuy::run(workspace, true, false, false, false, false).await
} else {
super::play::run(workspace, true, false, false, false, false).await
}
}
IterationOutcome::Sweep { reset_budget } => {
if reset_budget {
reset_budget_in_state(&workspace)?;
}
super::sweep::run(workspace, default_sweep_args())
.await
.map(|_| ())
}
IterationOutcome::NewPlanLaunchPlay { plan: _ } => {
archive_state(&workspace)?;
super::play::run(workspace, true, false, false, false, false).await
}
IterationOutcome::NewPlanWait { plan } => {
archive_state(&workspace)?;
print_wait_summary(&workspace, &plan, cfg);
Ok(())
}
IterationOutcome::NewPlanTerminated => {
println!();
println!(" No plan instilled — workspace is back to the init seed.");
println!(" Run `pitboss start` again when you're ready to try a new plan.");
println!();
Ok(())
}
}
}
fn print_wait_summary(workspace: &Path, plan: &Plan, cfg: &Config) {
let total = plan.phases.len();
let usd_cap = cfg
.budgets
.max_total_usd
.map(|c| format!("${:.2} cap", c))
.unwrap_or_else(|| "no cap".to_string());
let token_cap = cfg
.budgets
.max_total_tokens
.map(|c| format!("{} token cap", c))
.unwrap_or_else(|| "no token cap".to_string());
println!();
println!(" Plan saved to {}", paths::plan_path(workspace).display());
println!(
" {} phase{} ready • 0 / {} completed",
total,
if total == 1 { "" } else { "s" },
total
);
println!(" Budget: {} • {}", usd_cap, token_cap);
println!();
println!(" When you're ready:");
println!(" pitboss start # opens the wizard again");
println!(" pitboss play --tui # launches the plan directly");
println!();
}
fn reset_budget_in_state(workspace: &Path) -> Result<()> {
let mut state = match state::load(workspace)? {
Some(s) => s,
None => {
debug!("reset_budget requested but state.json is missing — no-op");
return Ok(());
}
};
state.token_usage = TokenUsage::default();
state::save(workspace, Some(&state))
.with_context(|| format!("start: saving reset state in {:?}", workspace))?;
println!(" Budget reset — token usage zeroed.");
Ok(())
}
fn archive_state(workspace: &Path) -> Result<()> {
let live = paths::state_path(workspace);
if !live.is_file() {
return state::save(workspace, None);
}
let stamp = Utc::now().format("%Y%m%dT%H%M%SZ");
let backup = paths::play_dir(workspace).join(format!("state.{}.json.bak", stamp));
fs::rename(&live, &backup)
.with_context(|| format!("start: archiving state.json to {:?}", backup))?;
println!(" Archived prior state to {}", backup.display());
Ok(())
}
pub(crate) struct Prereqs {
pub git_repo: bool,
pub agent_cli_name: &'static str,
pub agent_cli_found: bool,
}
pub(crate) fn check_prereqs(workspace: &Path, cfg: &Config) -> Prereqs {
let git_repo = workspace.join(".git").exists();
let cli_name: &'static str = match cfg.agent.backend.as_deref() {
Some("codex") => "codex",
Some("aider") => "aider",
Some("gemini") => "gemini",
_ => "claude",
};
let agent_cli_found = binary_on_path(cli_name);
Prereqs {
git_repo,
agent_cli_name: cli_name,
agent_cli_found,
}
}
fn binary_on_path(name: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| {
let direct = dir.join(name);
if direct.is_file() {
return true;
}
if cfg!(windows) {
let exe = dir.join(format!("{name}.exe"));
if exe.is_file() {
return true;
}
}
false
})
}
pub(crate) fn print_prereqs(p: &Prereqs) {
println!(" Prerequisites:");
if p.git_repo {
println!(" ✓ Git repository detected");
} else {
println!(" ⚠ Not a git repository");
println!(
" Run `git init` before `pitboss play` — pitboss creates a per-run branch."
);
}
if p.agent_cli_found {
println!(" ✓ Agent CLI `{}` found on PATH", p.agent_cli_name);
} else {
println!(" ⚠ Agent CLI `{}` not found on PATH", p.agent_cli_name);
println!(
" Install it before running pitboss play. See README → Runtime dependencies."
);
}
println!();
}
fn default_sweep_args() -> SweepArgs {
SweepArgs {
max_items: None,
audit: false,
no_audit: false,
dry_run: false,
after: None,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn dispatch_questioner_silent(
workspace: &Path,
cfg: &Config,
agent: &(dyn Agent + Send + Sync),
goal: &str,
repo_summary: &str,
min: u32,
max: u32,
cancel: CancellationToken,
) -> Result<Vec<String>> {
let logs_dir = paths::play_logs_dir(workspace);
std::fs::create_dir_all(&logs_dir).context("start: creating logs dir")?;
let user_prompt = prompts::questioner_ranged(goal, repo_summary, min, max);
let request = AgentRequest {
role: Role::Planner,
model: cfg.models.planner.clone(),
system_prompt: prompts::caveman::system_prompt(&cfg.caveman),
user_prompt,
workdir: workspace.to_path_buf(),
log_path: logs_dir.join("wizard_questioner.log"),
timeout: QUESTIONER_TIMEOUT,
env: std::collections::HashMap::new(),
};
let body = run_agent_collect(agent, request, cancel).await?;
let questions = parse_numbered_questions(&body);
if questions.is_empty() {
bail!("questioner produced no parseable questions");
}
Ok(questions)
}
pub(crate) async fn dispatch_planner_silent(
workspace: &Path,
cfg: &Config,
agent: &(dyn Agent + Send + Sync),
goal: &str,
repo_summary: &str,
cancel: CancellationToken,
) -> Result<Plan> {
let logs_dir = paths::play_logs_dir(workspace);
std::fs::create_dir_all(&logs_dir).context("start: creating logs dir")?;
let user_prompt = prompts::planner(goal, repo_summary);
let request = AgentRequest {
role: Role::Planner,
model: cfg.models.planner.clone(),
system_prompt: prompts::caveman::system_prompt(&cfg.caveman),
user_prompt,
workdir: workspace.to_path_buf(),
log_path: logs_dir.join("wizard_planner.log"),
timeout: PLANNER_TIMEOUT,
env: std::collections::HashMap::new(),
};
let body = run_agent_collect(agent, request, cancel).await?;
let parsed = plan::parse(&body).map_err(|e| anyhow!("planner output failed to parse: {e}"))?;
let plan_path = paths::plan_path(workspace);
write_atomic(&plan_path, body.as_bytes())
.with_context(|| format!("start: writing {}", plan_path.display()))?;
Ok(parsed)
}
async fn run_agent_collect(
agent: &(dyn Agent + Send + Sync),
request: AgentRequest,
cancel: CancellationToken,
) -> Result<String> {
let (tx, mut rx) = mpsc::channel::<AgentEvent>(64);
let collector = tokio::spawn(async move {
let mut buf = String::new();
while let Some(ev) = rx.recv().await {
if let AgentEvent::Stdout(chunk) = ev {
buf.push_str(&chunk);
}
}
buf
});
let outcome = agent.run(request, tx, cancel).await?;
let body = collector.await.unwrap_or_default();
match outcome.stop_reason {
StopReason::Completed if outcome.exit_code == 0 => Ok(body),
StopReason::Completed => {
bail!("agent exited with code {}", outcome.exit_code)
}
StopReason::Timeout => bail!("agent timed out"),
StopReason::Cancelled => bail!("agent was cancelled"),
StopReason::Error(msg) => bail!("agent failed: {msg}"),
}
}
pub(crate) fn parse_numbered_questions(raw: &str) -> Vec<String> {
let mut out = Vec::new();
for line in raw.lines() {
let trimmed = line.trim();
if let Some((prefix, rest)) = trimmed.split_once(". ") {
if prefix.parse::<u32>().is_ok() {
let q = rest.trim().to_string();
if !q.is_empty() {
out.push(q);
}
}
}
}
out
}
pub(crate) fn format_qa_spec(pairs: &[(String, String)]) -> String {
let mut out = String::new();
for (i, (q, a)) in pairs.iter().enumerate() {
out.push_str(&format!("Q{n}: {q}\nA{n}: {a}\n\n", n = i + 1));
}
out.trim_end().to_string()
}
pub(crate) fn goal_with_spec(goal: &str, spec: &str) -> String {
if spec.is_empty() {
goal.to_string()
} else {
format!("{goal}\n\n## Design Specification\n\n{spec}")
}
}
pub(crate) fn archive_plan_and_restore_seed(workspace: &Path) -> Result<()> {
let live = paths::plan_path(workspace);
if live.is_file() {
let stamp = Utc::now().format("%Y%m%dT%H%M%SZ");
let backup = paths::play_dir(workspace).join(format!("plan.{}.md.bak", stamp));
fs::rename(&live, &backup)
.with_context(|| format!("start: archiving plan.md to {:?}", backup))?;
println!(" Archived generated plan to {}", backup.display());
}
write_atomic(&live, PLAN_TEMPLATE.as_bytes())
.with_context(|| format!("start: writing seed plan to {}", live.display()))?;
Ok(())
}