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
1073 lines
39 KiB
Rust
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, ¤t_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(¤t_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());
|
|
}
|
|
}
|