Files
g3/crates/g3-planner/src/planner.rs
Jochen 7b47495881 Document retry config location and verify planning mode logic
Add documentation for retry configuration in planning mode:
- Document retry settings in .g3.toml under [agent] section
- Note RetryConfig implementation in g3-core/src/retry.rs
- Clarify hardcoded vs config-based retry values

Verify existing retry loop and coach feedback parsing:
- Confirm execute_with_retry() handles recoverable errors
- Document feedback extraction source priority order
- Provide manual verification steps for testing
2025-12-11 14:56:27 +11:00

1073 lines
39 KiB
Rust

//! Main planning mode orchestration
//!
//! This module contains the main logic for running planning mode,
//! including the state machine transitions and user interactions.
use anyhow::{Context, Result};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use crate::git;
use crate::history;
use crate::llm;
use crate::state::{
ApprovalChoice, BranchConfirmChoice, CompletionChoice, DirtyFilesChoice,
PlannerState, RecoveryChoice, RecoveryInfo,
};
/// Configuration for planning mode
#[derive(Debug, Clone)]
pub struct PlannerConfig {
/// The codepath to work in
pub codepath: PathBuf,
/// Whether git operations are disabled
pub no_git: bool,
/// Maximum turns for coach/player loop
pub max_turns: usize,
/// Whether to run in quiet mode
pub quiet: bool,
/// Path to config file
pub config_path: Option<String>,
}
impl PlannerConfig {
/// Get the g3-plan directory path
pub fn plan_dir(&self) -> PathBuf {
self.codepath.join("g3-plan")
}
/// Get the path to new_requirements.md
pub fn new_requirements_path(&self) -> PathBuf {
self.plan_dir().join("new_requirements.md")
}
/// Get the path to current_requirements.md
pub fn current_requirements_path(&self) -> PathBuf {
self.plan_dir().join("current_requirements.md")
}
/// Get the path to todo.g3.md
pub fn todo_path(&self) -> PathBuf {
self.plan_dir().join("todo.g3.md")
}
/// Get the path to planner_history.txt
pub fn history_path(&self) -> PathBuf {
self.plan_dir().join("planner_history.txt")
}
}
/// Result of running planning mode
#[derive(Debug)]
pub enum PlannerResult {
/// User quit normally
Quit,
/// Completed a planning cycle
Completed,
/// Error occurred
Error(String),
}
/// Expand tilde in path to home directory
pub fn expand_codepath(path: &str) -> Result<PathBuf> {
let expanded = shellexpand::tilde(path);
let path = PathBuf::from(expanded.as_ref());
// Resolve to absolute path
let resolved = if path.is_absolute() {
path
} else {
std::env::current_dir()?.join(path)
};
// Canonicalize if path exists, otherwise just return resolved
if resolved.exists() {
Ok(resolved.canonicalize()?)
} else {
Ok(resolved)
}
}
/// Prompt user for codepath if not provided
pub fn prompt_for_codepath() -> Result<PathBuf> {
print!("Enter codepath (path to your project): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.is_empty() || input == "quit" || input == "q" {
anyhow::bail!("User quit during codepath prompt");
}
expand_codepath(input)
}
/// Read a line of user input
fn read_line() -> Result<String> {
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
/// Print a message to stdout
fn print_msg(msg: &str) {
println!("{}", msg);
}
/// Print a message and flush stdout (for prompts)
fn print_prompt(msg: &str) {
print!("{}", msg);
io::stdout().flush().ok();
}
/// Initialize the planning directory structure
pub fn initialize_plan_dir(config: &PlannerConfig) -> Result<()> {
let plan_dir = config.plan_dir();
// Create plan directory if it doesn't exist
if !plan_dir.exists() {
fs::create_dir_all(&plan_dir)
.context("Failed to create g3-plan directory")?;
print_msg(&format!("📁 Created {}", plan_dir.display()));
}
// Ensure history file exists
history::ensure_history_file(&plan_dir)?;
Ok(())
}
/// Check git repository status (if git is enabled)
pub fn check_git_status(config: &PlannerConfig) -> Result<()> {
if config.no_git {
print_msg("⚠️ Git operations disabled (--no-git flag)");
return Ok(());
}
// Check if we're in a git repo
if !git::check_git_repo(&config.codepath)? {
print_msg("No git repository found for the codepath. Please initialize a git repo and try again.");
anyhow::bail!("No git repository found");
}
// Get and display current branch
let branch = git::get_current_branch(&config.codepath)?;
let prompt = "Current git branch: {branch}\nIs this the correct branch to work on? [Y/n]".replace("{branch}", &branch);
print_prompt(&format!("{} ", prompt));
let input = read_line()?;
match BranchConfirmChoice::from_input(&input) {
Some(BranchConfirmChoice::Confirm) => {},
Some(BranchConfirmChoice::Quit) | None => {
print_msg("Exiting - please switch to the correct branch and restart.");
anyhow::bail!("User declined branch confirmation");
}
}
// Check for dirty/untracked files (ignore new_requirements.md)
let ignore_pattern = "g3-plan/new_requirements.md";
let dirty_files = git::check_dirty_files(&config.codepath, Some(ignore_pattern))?;
if !dirty_files.is_empty() {
let warning = r#"Warning: There are uncommitted changes in the git repository:
{files}
This may be expected if resuming from a previous session.
Do you want to proceed anyway? [Y/n]"#
.replace("{files}", &dirty_files.to_display_string());
print_msg(&warning);
print_prompt("[Y/n] ");
let input = read_line()?;
match DirtyFilesChoice::from_input(&input) {
Some(DirtyFilesChoice::Proceed) => {},
Some(DirtyFilesChoice::Quit) | None => {
print_msg("Exiting - please commit or stash your changes and restart.");
anyhow::bail!("User declined to proceed with dirty files");
}
}
}
Ok(())
}
/// Check startup state and determine if recovery is needed
pub fn check_startup_state(config: &PlannerConfig) -> PlannerState {
let plan_dir = config.plan_dir();
// Check for recovery situation
if let Some(recovery_info) = RecoveryInfo::detect(&plan_dir) {
return PlannerState::Recovery(recovery_info);
}
PlannerState::PromptForRequirements
}
/// Handle recovery situation
pub fn handle_recovery(config: &PlannerConfig, info: &RecoveryInfo) -> Result<PlannerState> {
// Build the recovery prompt
let datetime = info.requirements_modified.as_deref().unwrap_or("unknown time");
let todo_info = if let Some(ref contents) = info.todo_contents {
"- todo.g3.md contents:\n{contents}".replace("{contents}", contents)
} else {
String::new()
};
let prompt = r#"The last run didn't complete successfully. Found:
- current_requirements.md from {datetime}
{todo_info}
Would you like to resume the previous implementation?
[Y] Yes - Attempt to resume
[N] No - Mark as complete and proceed to review new_requirements.md
[Q] Quit - Exit and investigate manually"#
.replace("{datetime}", datetime)
.replace("{todo_info}", &todo_info);
print_msg(&prompt);
print_prompt("Choice: ");
loop {
let input = read_line()?;
match RecoveryChoice::from_input(&input) {
Some(RecoveryChoice::Resume) => {
// Log recovery attempt
history::write_attempting_recovery(&config.plan_dir())?;
return Ok(PlannerState::ImplementRequirements);
}
Some(RecoveryChoice::MarkComplete) => {
// Log skipped recovery
history::write_skipped_recovery(&config.plan_dir())?;
return Ok(PlannerState::ImplementationComplete);
}
Some(RecoveryChoice::Quit) => {
return Ok(PlannerState::Quit);
}
None => {
print_prompt("Invalid choice. Please enter Y, N, or Q: ");
}
}
}
}
/// Prompt for new requirements
pub fn prompt_for_new_requirements(config: &PlannerConfig) -> Result<PlannerState> {
// Delete existing todo file since we're starting fresh
let todo_path = config.todo_path();
if todo_path.exists() {
fs::remove_file(&todo_path)
.context("Failed to delete old todo.g3.md")?;
}
// Display prompt
let prompt = r#"I will help you refine the current requirements of your project.
Please write or edit your requirements in `{codepath}/g3-plan/new_requirements.md`.
Hit enter for me to start a review of that file."#
.replace("{codepath}", &config.codepath.display().to_string());
print_msg(&prompt);
print_prompt("Press Enter when ready: ");
let input = read_line()?;
if input.to_lowercase() == "quit" || input.to_lowercase() == "q" {
return Ok(PlannerState::Quit);
}
// Check if new_requirements.md exists
let new_req_path = config.new_requirements_path();
if !new_req_path.exists() {
let error_msg = "File not found: {path}/g3-plan/new_requirements.md"
.replace("{path}", &config.codepath.display().to_string());
print_msg(&format!("{}", error_msg));
print_msg("Please create the file and try again.");
return Ok(PlannerState::PromptForRequirements);
}
// Ensure the file has the ORIGINAL_REQUIREMENTS tag
ensure_original_requirements_tag(&new_req_path)?;
// Log that we're refining requirements
history::write_refining_requirements(&config.plan_dir())?;
Ok(PlannerState::RefineRequirements)
}
/// Ensure the new_requirements.md file has the ORIGINAL_REQUIREMENTS tag
fn ensure_original_requirements_tag(path: &Path) -> Result<()> {
let content = fs::read_to_string(path)
.context("Failed to read new_requirements.md")?;
// Check if either tag is already present
if content.contains("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}")
|| content.contains("{{CURRENT REQUIREMENTS}}") {
return Ok(());
}
// Prepend the ORIGINAL_REQUIREMENTS tag
let new_content = format!("{}\n\n{}", "{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}", content);
fs::write(path, new_content)
.context("Failed to update new_requirements.md with ORIGINAL_REQUIREMENTS tag")?;
Ok(())
}
/// Check if requirements have CURRENT REQUIREMENTS tag after LLM refinement
pub fn check_current_requirements_tag(config: &PlannerConfig) -> Result<bool> {
let new_req_path = config.new_requirements_path();
let content = fs::read_to_string(&new_req_path)
.context("Failed to read new_requirements.md")?;
Ok(content.contains("{{CURRENT REQUIREMENTS}}"))
}
/// Prompt user to approve refined requirements
pub fn prompt_for_approval(config: &PlannerConfig) -> Result<ApprovalChoice> {
let prompt = r#"The LLM has updated `{codepath}/g3-plan/new_requirements.md`.
Please review the file. If it's acceptable, type 'yes' to proceed with implementation.
Type 'no' to continue refining, or 'quit' to exit."#
.replace("{codepath}", &config.codepath.display().to_string());
print_msg(&prompt);
print_prompt("Choice: ");
loop {
let input = read_line()?;
match ApprovalChoice::from_input(&input) {
Some(choice) => return Ok(choice),
None => {
print_prompt("Invalid choice. Please enter 'yes', 'no', or 'quit': ");
}
}
}
}
/// Move new_requirements.md to current_requirements.md
pub fn promote_requirements(config: &PlannerConfig) -> Result<()> {
let new_req_path = config.new_requirements_path();
let current_req_path = config.current_requirements_path();
fs::rename(&new_req_path, &current_req_path)
.context("Failed to rename new_requirements.md to current_requirements.md")?;
print_msg(&format!(
"📄 Renamed new_requirements.md to current_requirements.md"
));
Ok(())
}
/// Read current requirements content
pub fn read_current_requirements(config: &PlannerConfig) -> Result<String> {
let path = config.current_requirements_path();
fs::read_to_string(&path)
.context("Failed to read current_requirements.md")
}
/// Read todo file content
pub fn read_todo(config: &PlannerConfig) -> Result<Option<String>> {
let path = config.todo_path();
if path.exists() {
Ok(Some(fs::read_to_string(&path)
.context("Failed to read todo.g3.md")?))
} else {
Ok(None)
}
}
/// Check if all todos are complete
pub fn check_todos_complete(todo_contents: &str) -> bool {
// Check if there are any incomplete items (- [ ])
!todo_contents.contains("- [ ]")
}
/// Prompt user to confirm implementation completion
pub fn prompt_for_completion(config: &PlannerConfig) -> Result<CompletionChoice> {
let todo_contents = read_todo(config)?.unwrap_or_else(|| "(no todo file)".to_string());
let prompt = r#"The coach/player loop has completed.
Todo file contents:
{todo_contents}
Do you consider the todos and requirements completed? [Y/n]
If not, we'll return to the coach/player loop."#
.replace("{todo_contents}", &todo_contents);
print_msg(&prompt);
print_prompt("Choice: ");
loop {
let input = read_line()?;
match CompletionChoice::from_input(&input) {
Some(choice) => return Ok(choice),
None => {
print_prompt("Invalid choice. Please enter Y, N, or Q: ");
}
}
}
}
/// Complete the implementation - rename files and prepare for commit
pub fn complete_implementation(config: &PlannerConfig) -> Result<(String, String)> {
let plan_dir = config.plan_dir();
// Generate timestamped filenames
let req_filename = history::completed_requirements_filename();
let todo_filename = history::completed_todo_filename();
// Rename current_requirements.md
let current_req = config.current_requirements_path();
let completed_req = plan_dir.join(&req_filename);
if current_req.exists() {
fs::rename(&current_req, &completed_req)
.context("Failed to rename current_requirements.md")?;
print_msg(&format!("📄 Renamed to {}", req_filename));
}
// Rename todo.g3.md
let todo_path = config.todo_path();
let completed_todo = plan_dir.join(&todo_filename);
if todo_path.exists() {
fs::rename(&todo_path, &completed_todo)
.context("Failed to rename todo.g3.md")?;
print_msg(&format!("📄 Renamed to {}", todo_filename));
}
// Log completion
history::write_completed_requirements(&plan_dir, &req_filename, &todo_filename)?;
Ok((req_filename, todo_filename))
}
/// Stage files and make git commit
pub fn stage_and_commit(
config: &PlannerConfig,
summary: &str,
description: &str,
) -> Result<()> {
if config.no_git {
print_msg("⚠️ Skipping git commit (--no-git flag)");
return Ok(());
}
// Stage files
print_msg("📦 Staging files...");
let staging_result = git::stage_files(&config.codepath, &config.plan_dir())?;
if !staging_result.staged.is_empty() {
print_msg(&format!(" Staged {} files", staging_result.staged.len()));
}
if !staging_result.excluded.is_empty() {
print_msg(&format!(" Excluded {} files (temporary/artifacts)", staging_result.excluded.len()));
}
// Show pre-commit message
let pre_commit = r#"Ready to make a git commit with the following message:
Summary: {summary}
Description:
{description}
Please review the currently staged files (use `git status` in another terminal).
Press Enter to continue with the commit, or type 'quit' to exit without committing."#
.replace("{summary}", summary)
.replace("{description}", description);
print_msg(&pre_commit);
let input = read_line()?;
if input.to_lowercase() == "quit" || input.to_lowercase() == "q" {
print_msg("Skipping commit. Files remain staged.");
return Ok(());
}
// If you're modifying this function, ENSURE that:
// - history::write_git_commit() is called BEFORE git::commit()
// - No conditional logic can skip the history write if the commit proceeds
// - Tests in commit_history_ordering_test.rs continue to pass
history::write_git_commit(&config.plan_dir(), summary)?;
// Re-stage g3-plan directory to include the GIT COMMIT entry we just wrote
// This ensures planner_history.txt changes are included in the commit
git::stage_plan_dir(&config.codepath, &config.plan_dir())?;
// Make commit
print_msg("📝 Making git commit...");
let _commit_sha = git::commit(&config.codepath, summary, description)?;
print_msg("✅ Commit successful");
Ok(())
}
/// Parse commit message from LLM response
pub fn parse_commit_message(response: &str) -> (String, String) {
let mut summary = String::new();
let mut description = String::new();
if let Some(summary_start) = response.find("{{COMMIT_SUMMARY}}") {
let after_tag = &response[summary_start + "{{COMMIT_SUMMARY}}".len()..];
if let Some(end) = after_tag.find("{{COMMIT_DESCRIPTION}}") {
summary = after_tag[..end].trim().to_string();
} else {
summary = after_tag.lines().next().unwrap_or("").trim().to_string();
}
}
if let Some(desc_start) = response.find("{{COMMIT_DESCRIPTION}}") {
let after_tag = &response[desc_start + "{{COMMIT_DESCRIPTION}}".len()..];
description = after_tag.trim().to_string();
}
// Ensure summary is max 72 chars
if summary.len() > 72 {
summary = format!("{}...", &summary[..69]);
}
// Ensure description lines are max 72 chars
let wrapped_desc: Vec<String> = description
.lines()
.take(10) // Max 10 lines
.map(|line| {
if line.len() > 72 {
format!("{}...", &line[..69])
} else {
line.to_string()
}
})
.collect();
description = wrapped_desc.join("\n");
// Fallback if parsing failed
if summary.is_empty() {
summary = "Implement requirements".to_string();
}
(summary, description)
}
/// Tools available to the planner agent
pub fn get_planner_tools() -> Vec<&'static str> {
vec![
"read_file",
"write_file",
"shell",
"code_search",
"str_replace",
"final_output",
]
}
/// Tools NOT available to the planner agent
pub fn get_excluded_planner_tools() -> Vec<&'static str> {
vec![
"todo_write", // Planner should not write todos during refinement
]
}
/// Run the coach/player implementation loop
///
/// This function runs the actual implementation phase using g3-core's Agent
/// in a coach/player feedback loop similar to autonomous mode.
pub async fn run_coach_player_loop(
planner_config: &PlannerConfig,
g3_config: &g3_config::Config,
requirements_content: &str,
) -> Result<()> {
use g3_core::project::Project;
use g3_core::retry::{execute_with_retry, RetryConfig, RetryResult};
use g3_core::feedback_extraction::{extract_coach_feedback, FeedbackExtractionConfig};
use g3_core::Agent;
let max_turns = planner_config.max_turns;
// Create project with custom requirements path
let project = Project::new_autonomous_with_requirements(
planner_config.codepath.clone(),
requirements_content.to_string(),
)?;
// Enter the workspace
project.ensure_workspace_exists()?;
project.enter_workspace()?;
print_msg(&format!("📁 Working in: {}", planner_config.codepath.display()));
print_msg(&format!("🔄 Max turns: {}", max_turns));
// Set environment variable for custom todo path
std::env::set_var("G3_TODO_PATH", planner_config.todo_path().display().to_string());
let mut turn = 1;
let mut coach_feedback = String::new();
while turn <= max_turns {
print_msg(&format!("\n=== Turn {}/{} ===", turn, max_turns));
// Player phase - implement requirements
print_msg("🎯 Player: Implementing requirements...");
let player_config = g3_config.for_player()?;
let ui_writer = llm::PlannerUiWriter::new();
let mut player_agent = Agent::new_autonomous_with_readme_and_quiet(
player_config,
ui_writer,
None,
planner_config.quiet,
).await?;
let player_prompt = if coach_feedback.is_empty() || turn == 1 {
format!(
"You are G3 in implementation mode. Read and implement the following requirements:\n\n{}\n\nImplement this step by step. Write the todo list to: {}\n\nCreate all necessary files and code.",
requirements_content,
planner_config.todo_path().display()
)
} else {
format!(
"You are G3 in implementation mode. Address the following coach feedback:\n\n{}\n\nOriginal requirements:\n{}\n\nFix the issues mentioned above.",
coach_feedback,
requirements_content
)
};
// Execute player task with retry logic
let player_retry_config = RetryConfig::planning("player");
let player_result = execute_with_retry(
&mut player_agent,
&player_prompt,
&player_retry_config,
false, // show_prompt
false, // show_code
None, // discovery
|msg| print_msg(msg),
).await;
match player_result {
RetryResult::Success(result) => {
print_msg(&format!("✅ Player completed: {} chars response", result.response.len()));
}
RetryResult::MaxRetriesReached(err) => {
print_msg(&format!("⚠️ Player failed after max retries: {}", err));
// Continue to coach phase anyway to get feedback
}
RetryResult::ContextLengthExceeded(err) => {
print_msg(&format!("⚠️ Player context length exceeded: {}", err));
// Continue to next turn
turn += 1;
continue;
}
RetryResult::Panic(e) => {
print_msg(&format!("💥 Player panic: {}", e));
return Err(e);
}
}
// Coach phase - review implementation
print_msg("🎓 Coach: Reviewing implementation...");
let coach_config = g3_config.for_coach()?;
let coach_ui_writer = llm::PlannerUiWriter::new();
let mut coach_agent = Agent::new_autonomous_with_readme_and_quiet(
coach_config,
coach_ui_writer,
None,
planner_config.quiet,
).await?;
let coach_prompt = format!(
"You are G3 in coach mode. Review the implementation against these requirements:\n\n{}\n\nCheck:\n1. Are requirements implemented correctly?\n2. Does the code compile?\n3. What's missing?\n\nUse the final_output tool to provide your feedback.\nIf implementation is COMPLETE, include 'IMPLEMENTATION_APPROVED' in your feedback.\nOtherwise, provide specific feedback for the player to fix.",
requirements_content
);
// Execute coach task with retry logic
let coach_retry_config = RetryConfig::planning("coach");
let coach_result = execute_with_retry(
&mut coach_agent,
&coach_prompt,
&coach_retry_config,
false, // show_prompt
false, // show_code
None, // discovery
|msg| print_msg(msg),
).await;
match coach_result {
RetryResult::Success(result) => {
// Extract feedback using the robust extraction module
let feedback_config = FeedbackExtractionConfig::default();
let extracted = extract_coach_feedback(&result, &coach_agent, &feedback_config);
print_msg(&format!("📝 Coach feedback extracted from {:?}: {} chars",
extracted.source, extracted.content.len()));
// Check for approval
if extracted.is_approved() || result.response.contains("IMPLEMENTATION_APPROVED") {
print_msg("✅ Coach approved implementation!");
return Ok(());
}
coach_feedback = extracted.content;
// Display first 25 lines of coach feedback
let lines: Vec<&str> = coach_feedback.lines().collect();
for line in lines.iter().take(25) {
print_msg(&format!(" {}", line));
}
if lines.len() > 25 {
print_msg(" ...");
}
}
RetryResult::MaxRetriesReached(err) => {
print_msg(&format!("⚠️ Coach failed after max retries: {}", err));
coach_feedback = "Please review and fix any issues.".to_string();
}
RetryResult::ContextLengthExceeded(err) => {
print_msg(&format!("⚠️ Coach context length exceeded: {}", err));
coach_feedback = "Context window full. Please continue with current progress.".to_string();
}
RetryResult::Panic(e) => {
print_msg(&format!("💥 Coach panic: {}", e));
return Err(e);
}
}
turn += 1;
}
print_msg(&format!("⏰ Reached max turns ({})", max_turns));
Ok(())
}
/// Main entry point for planning mode
///
/// This function orchestrates the entire planning workflow:
/// 1. Initialize the planning directory
/// 2. Check git status (if enabled)
/// 3. Detect and handle recovery situations
/// 4. Run the refinement and implementation loop
pub async fn run_planning_mode(
codepath: Option<String>,
workspace: Option<std::path::PathBuf>,
no_git: bool,
config_path: Option<&str>,
) -> anyhow::Result<()> {
print_msg("\n🎯 G3 Planning Mode");
print_msg("==================\n");
// Get codepath first (needed for setting workspace path early)
let codepath = match codepath {
Some(path) => {
let expanded = expand_codepath(&path)?;
print_msg(&format!("📁 Codepath: {}", expanded.display()));
expanded
}
None => {
let path = prompt_for_codepath()?;
print_msg(&format!("📁 Codepath: {}", path.display()));
path
}
};
// Verify codepath exists
if !codepath.exists() {
anyhow::bail!("Codepath does not exist: {}", codepath.display());
}
// Determine workspace directory (use workspace arg if provided, else use codepath)
let workspace_dir = workspace.unwrap_or_else(|| codepath.clone());
print_msg(&format!("📁 Workspace: {}", workspace_dir.display()));
// Set G3_WORKSPACE_PATH environment variable EARLY for all logging
std::env::set_var("G3_WORKSPACE_PATH", workspace_dir.display().to_string());
// Create logs directory and verify it exists
let logs_dir = workspace_dir.join("logs");
if !logs_dir.exists() {
fs::create_dir_all(&logs_dir)
.context("Failed to create logs directory")?;
}
print_msg(&format!("📁 Logs directory: {}", logs_dir.display()));
// Create the LLM provider for planning
print_msg("🔧 Initializing planner provider...");
let provider = match llm::create_planner_provider(config_path).await {
Ok(p) => p,
Err(e) => {
print_msg(&format!("❌ Failed to initialize provider: {}", e));
print_msg("Please check your configuration file.");
anyhow::bail!("Provider initialization failed: {}", e);
}
};
print_msg(&format!("✅ Provider initialized: {}", provider.name()));
// Create configuration
let config = PlannerConfig {
codepath: codepath.clone(),
no_git,
max_turns: 5, // Default, could be made configurable
quiet: false,
config_path: config_path.map(|s| s.to_string()),
};
// Initialize plan directory
initialize_plan_dir(&config)?;
// Check git status
check_git_status(&config)?;
// Main planning loop
let mut state = check_startup_state(&config);
loop {
state = match state {
PlannerState::Startup => {
// Startup state transitions to checking for recovery
check_startup_state(&config)
}
PlannerState::Recovery(info) => {
handle_recovery(&config, &info)?
}
PlannerState::PromptForRequirements => {
prompt_for_new_requirements(&config)?
}
PlannerState::RefineRequirements => {
// Call LLM for refinement with full tool execution
print_msg("\n🔄 Refinement phase - calling LLM...");
let codepath_str = config.codepath.display().to_string();
let workspace_str = workspace_dir.display().to_string();
// Load config and call LLM with full tool execution capability
let g3_config = g3_config::Config::load(config.config_path.as_deref())?;
let response = llm::call_refinement_llm_with_tools(
&g3_config,
&codepath_str,
&workspace_str,
).await;
match response {
Ok(_) => print_msg("✅ LLM refinement complete."),
Err(e) => print_msg(&format!("⚠️ LLM refinement error: {}", e)),
}
if check_current_requirements_tag(&config)? {
match prompt_for_approval(&config)? {
ApprovalChoice::Approve => PlannerState::ImplementRequirements,
ApprovalChoice::Refine => PlannerState::PromptForRequirements,
ApprovalChoice::Quit => PlannerState::Quit,
}
} else {
print_msg(&format!("{}", "The LLM didn't update the requirements file with {{CURRENT REQUIREMENTS}}. Please restart the app."));
PlannerState::Quit
}
}
PlannerState::ImplementRequirements => {
// Promote requirements and run coach/player
if config.new_requirements_path().exists() {
promote_requirements(&config)?;
}
// Write git HEAD to history before implementation
if !config.no_git {
let head_sha = git::get_head_sha(&config.codepath)?;
history::write_git_head(&config.plan_dir(), &head_sha)?;
print_msg(&format!("📝 Recorded git HEAD: {}", &head_sha[..12.min(head_sha.len())]));
}
// Read requirements and generate summary
let requirements_content = read_current_requirements(&config)?;
print_msg("📝 Generating requirements summary...");
let summary = match llm::generate_requirements_summary(
provider.as_ref(),
&requirements_content,
).await {
Ok(s) => s,
Err(e) => {
print_msg(&format!("⚠️ Summary generation failed: {}", e));
"Requirements implementation in progress".to_string()
}
};
// Write start implementing entry with summary
history::write_start_implementing(&config.plan_dir(), &summary)?;
print_msg("📝 Recorded implementation start in history");
// Run the actual coach/player loop
print_msg("\n🚀 Starting coach/player implementation loop...");
let g3_config = g3_config::Config::load(config.config_path.as_deref())?;
let implementation_result = run_coach_player_loop(
&config,
&g3_config,
&requirements_content,
).await;
match implementation_result {
Ok(_) => print_msg("✅ Coach/player loop completed"),
Err(e) => {
print_msg(&format!("⚠️ Implementation error: {}", e));
print_msg("You can try to resume or mark as complete.");
}
}
PlannerState::ImplementationComplete
}
PlannerState::ImplementationComplete => {
// Check completion and commit
match prompt_for_completion(&config)? {
CompletionChoice::Complete => {
let (req_file, todo_file) = complete_implementation(&config)?;
// Read requirements for LLM context
let requirements_content = if config.plan_dir().join(&req_file).exists() {
std::fs::read_to_string(config.plan_dir().join(&req_file))
.unwrap_or_else(|_| "Requirements unavailable".to_string())
} else {
"Requirements unavailable".to_string()
};
// Generate commit message using LLM
print_msg("📝 Generating commit message...");
let (summary, description) = match llm::generate_commit_message(
provider.as_ref(),
&requirements_content,
&req_file,
&todo_file,
).await {
Ok((s, d)) => (s, d),
Err(e) => {
print_msg(&format!("⚠️ Commit message generation failed: {}", e));
("Implement planning requirements".to_string(),
format!("Requirements: {}\nTodo: {}", req_file, todo_file))
}
};
stage_and_commit(&config, &summary, &description)?;
PlannerState::PromptForRequirements
}
CompletionChoice::Continue => PlannerState::ImplementRequirements,
CompletionChoice::Quit => PlannerState::Quit,
}
}
PlannerState::Quit => {
print_msg("\n👋 Exiting planning mode.");
break;
}
};
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_expand_codepath_tilde() {
let result = expand_codepath("~/test/path").unwrap();
assert!(result.to_string_lossy().contains("test/path"));
assert!(!result.to_string_lossy().contains('~'));
}
#[test]
fn test_planner_config_paths() {
let config = PlannerConfig {
codepath: PathBuf::from("/test/project"),
no_git: false,
max_turns: 5,
quiet: false,
config_path: None,
};
assert_eq!(config.plan_dir(), PathBuf::from("/test/project/g3-plan"));
assert_eq!(config.new_requirements_path(), PathBuf::from("/test/project/g3-plan/new_requirements.md"));
assert_eq!(config.current_requirements_path(), PathBuf::from("/test/project/g3-plan/current_requirements.md"));
assert_eq!(config.todo_path(), PathBuf::from("/test/project/g3-plan/todo.g3.md"));
}
#[test]
fn test_check_todos_complete() {
assert!(check_todos_complete("- [x] Task 1\n- [x] Task 2"));
assert!(!check_todos_complete("- [x] Task 1\n- [ ] Task 2"));
assert!(!check_todos_complete("- [ ] Task 1"));
assert!(check_todos_complete("No tasks here"));
}
#[test]
fn test_parse_commit_message() {
let response = r#"Some preamble
{{COMMIT_SUMMARY}}
Add planning mode with state machine
{{COMMIT_DESCRIPTION}}
Implements the planning workflow including:
- Requirements refinement
- Git integration
- History tracking"#;
let (summary, desc) = parse_commit_message(response);
assert_eq!(summary, "Add planning mode with state machine");
assert!(desc.contains("Implements the planning workflow"));
assert!(desc.contains("Requirements refinement"));
}
#[test]
fn test_parse_commit_message_truncation() {
let long_summary = "A".repeat(100);
let response = format!("{{{{COMMIT_SUMMARY}}}}\n{}\n{{{{COMMIT_DESCRIPTION}}}}\nDesc", long_summary);
let (summary, _) = parse_commit_message(&response);
assert!(summary.len() <= 72);
assert!(summary.ends_with("..."));
}
#[test]
fn test_ensure_original_requirements_tag() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("new_requirements.md");
// Write content without tag
fs::write(&path, "Some requirements").unwrap();
ensure_original_requirements_tag(&path).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}"));
assert!(content.contains("Some requirements"));
}
#[test]
fn test_ensure_original_requirements_tag_already_present() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("new_requirements.md");
// Write content with tag already
let content_with_tag = format!("{}\n\nSome requirements", "{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}");
fs::write(&path, &content_with_tag).unwrap();
ensure_original_requirements_tag(&path).unwrap();
let content = fs::read_to_string(&path).unwrap();
// Should not duplicate the tag
assert_eq!(content.matches("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}").count(), 1);
}
#[test]
fn test_initialize_plan_dir() {
let temp_dir = TempDir::new().unwrap();
let config = PlannerConfig {
codepath: temp_dir.path().to_path_buf(),
no_git: true,
max_turns: 5,
quiet: false,
config_path: None,
};
initialize_plan_dir(&config).unwrap();
assert!(config.plan_dir().exists());
assert!(config.history_path().exists());
}
}