use crate::brain::goal::GoalManager;
use crate::services::ServiceContext;
use crate::tui::plan::PlanStatus;
use crate::utils::plan_files::{self, PlanModeState};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApproveOutcome {
SeedTurn { prompt: String },
Refused(String),
}
pub fn validate_for_approve(md_body: &str) -> Result<(), String> {
if md_body.trim().is_empty() {
return Err("the session plan .md is empty".to_string());
}
let warnings = plan_files::template_section_warnings(md_body);
if warnings.is_empty() {
Ok(())
} else {
Err(warnings.join("; "))
}
}
fn seed_prompt(md_path: &std::path::Path) -> String {
format!(
"[SYSTEM: PLAN APPROVED] The user approved the SESSION PLAN at {}. \
Read its ## Implementation steps section. Emit exactly ONE `plan` \
add_tasks call with ALL tasks, 1:1 with the numbered steps. Map \
'Done when:' bullets to acceptance_criteria when present. Omit \
dependencies unless step prose explicitly requires ordering \
(depends on / after / blocked by). Then call `plan` start and \
continue executing the checklist in this same turn. Do NOT edit \
project files until start succeeds. The approval was already \
acknowledged to the user β do NOT restate that the plan is approved or \
announce that you are starting; go straight to the work.",
md_path.display()
)
}
fn start_prompt() -> String {
"[SYSTEM: PLAN APPROVED] The user approved the checklist. Its tasks are \
already defined β do NOT call `plan` add_tasks again. Call `plan` start to \
begin the first task, then execute the checklist in this same turn, \
calling `plan` complete as each task finishes. Do NOT edit project files \
until start succeeds. The approval was already acknowledged to the user β \
do NOT restate that the plan is approved or announce that you are starting; \
go straight to the work and report results as you go."
.to_string()
}
pub async fn try_approve(session_id: Uuid) -> ApproveOutcome {
let md_path = plan_files::plan_md_path(session_id).await;
match plan_files::plan_mode_state(session_id).await {
PlanModeState::NoPlan => ApproveOutcome::Refused(
"No plan to approve: this session has no live plan. Start one with /plan \
(design) or ask for a checklist."
.to_string(),
),
PlanModeState::PreInitEditing => ApproveOutcome::Refused(
"Nothing approvable yet: Plan mode is waiting for `plan init` to create \
the design document. Let the agent draft it first (or /discard to leave \
Plan mode)."
.to_string(),
),
PlanModeState::PostInitEditing => {
let Some(mut plan) = plan_files::load_plan(session_id).await else {
return ApproveOutcome::Refused(
"Plan JSON is unreadable; cannot approve. /discard and start over.".to_string(),
);
};
if !plan.tasks.is_empty() {
plan.approve();
if let Err(e) = plan_files::save_plan(&plan).await {
return ApproveOutcome::Refused(format!(
"Failed to persist the approval: {e}. Try again."
));
}
return ApproveOutcome::SeedTurn {
prompt: start_prompt(),
};
}
let body = std::fs::read_to_string(&md_path).unwrap_or_default();
if let Err(why) = validate_for_approve(&body) {
return ApproveOutcome::Refused(format!(
"Plan not ready to approve: {why}. Fill the template in {} first.",
md_path.display()
));
}
plan.approve();
if let Err(e) = plan_files::save_plan(&plan).await {
return ApproveOutcome::Refused(format!(
"Failed to persist the approval: {e}. The .md is untouched; try again."
));
}
ApproveOutcome::SeedTurn {
prompt: seed_prompt(&md_path),
}
}
PlanModeState::Active => {
let Some(plan) = plan_files::load_plan(session_id).await else {
return ApproveOutcome::Refused(
"Plan JSON is unreadable; cannot retry. /discard and start over.".to_string(),
);
};
if !plan.tasks.is_empty() {
return ApproveOutcome::Refused(
"The checklist is already Active: /execute is not applicable. \
Continue the checklist (or /discard to drop the plan)."
.to_string(),
);
}
if !md_path.exists() {
return ApproveOutcome::Refused(
"This checklist plan has no design document to seed from. \
Add tasks with the plan tool instead."
.to_string(),
);
}
let body = std::fs::read_to_string(&md_path).unwrap_or_default();
if let Err(why) = validate_for_approve(&body) {
return ApproveOutcome::Refused(format!(
"Cannot retry the seed: the frozen plan fails validation ({why}). \
/discard and re-plan."
));
}
ApproveOutcome::SeedTurn {
prompt: seed_prompt(&md_path),
}
}
}
}
pub async fn enter_plan_mode(session_id: Uuid) -> String {
match plan_files::plan_mode_state(session_id).await {
PlanModeState::PostInitEditing => {
"A design plan is already being edited. Refine it, then approve with \
/execute (or /discard it)."
.to_string()
}
PlanModeState::Active => "A checklist is already Active for this session. Continue it, or \
/discard it before planning something new."
.to_string(),
PlanModeState::PreInitEditing | PlanModeState::NoPlan => {
match plan_files::set_pre_init_editing(session_id).await {
Ok(()) => "π Plan mode on. Describe what you want planned: the agent \
will explore, then draft a design document for your approval. \
Project writes stay blocked until you approve. Leave with \
/discard."
.to_string(),
Err(e) => format!("Could not enter Plan mode: {e}"),
}
}
}
}
pub async fn discard(session_id: Uuid, svc: &ServiceContext) -> String {
match plan_files::plan_mode_state(session_id).await {
PlanModeState::NoPlan => "No live plan to discard.".to_string(),
PlanModeState::PreInitEditing => {
plan_files::discard_plan(session_id).await;
"Plan mode off (pre-init flag cleared).".to_string()
}
PlanModeState::PostInitEditing | PlanModeState::Active => {
plan_files::discard_plan(session_id).await;
if let Err(e) = GoalManager::new(svc.clone()).clear_goal(session_id).await {
tracing::warn!("Failed to clear plan goal on discard: {e}");
}
"ποΈ Plan discarded: design document and checklist removed. The session \
has no live plan."
.to_string()
}
}
}
const PLAN_PROSE_CAP: usize = 3000;
pub async fn show_plan(session_id: Uuid) -> String {
match plan_files::plan_mode_state(session_id).await {
PlanModeState::NoPlan => "No active plan for this session.".to_string(),
PlanModeState::PreInitEditing => {
"Plan mode is on (pre-init): no design document yet. The agent still \
needs to run `plan init`."
.to_string()
}
PlanModeState::PostInitEditing => {
let md = plan_files::plan_md_path(session_id).await;
let body = std::fs::read_to_string(&md).unwrap_or_default();
let title = plan_files::load_plan(session_id)
.await
.map(|p| p.title)
.unwrap_or_default();
let ready = match validate_for_approve(&body) {
Ok(()) => "Ready to approve: /execute.".to_string(),
Err(why) => format!("Not approvable yet: {why}."),
};
let trimmed = body.trim();
let prose = if trimmed.is_empty() {
"(design document is still empty)".to_string()
} else {
crate::utils::truncate_str(trimmed, PLAN_PROSE_CAP).to_string()
};
format!(
"π Editing design plan{}\n{ready}\n\n{prose}\n\nDocument: {}",
if title.is_empty() {
String::new()
} else {
format!(": {title}")
},
md.display()
)
}
PlanModeState::Active => {
let Some(plan) = plan_files::load_plan(session_id).await else {
return "Plan JSON is unreadable.".to_string();
};
format_active_checklist(&plan)
}
}
}
pub fn format_active_checklist(plan: &crate::tui::plan::PlanDocument) -> String {
if plan.tasks.is_empty() {
return format!(
"π {} is Active but the checklist is still empty (seed did not \
finish). Retry with /execute, or /discard.",
plan.title
);
}
let done = plan
.tasks
.iter()
.filter(|t| {
matches!(
t.status,
crate::tui::plan::TaskStatus::Completed | crate::tui::plan::TaskStatus::Skipped
)
})
.count();
let lines = plan
.tasks
.iter()
.map(|t| format!("{} {}. {}", t.status.icon(), t.order, t.title))
.collect::<Vec<_>>()
.join("\n");
format!(
"π {} (Active, {done}/{} done)\n{lines}",
plan.title,
plan.tasks.len()
)
}
pub async fn in_seed_window(session_id: Uuid) -> bool {
if plan_files::plan_mode_state(session_id).await != PlanModeState::Active {
return false;
}
if !plan_files::plan_md_path(session_id).await.exists() {
return false;
}
plan_files::load_plan(session_id).await.is_some_and(|p| {
p.status == PlanStatus::Active
&& p.tasks
.iter()
.all(|t| matches!(t.status, crate::tui::plan::TaskStatus::Pending))
})
}