Implement planning mode
This commit is contained in:
989
crates/g3-planner/src/planner.rs
Normal file
989
crates/g3-planner/src/planner.rs
Normal file
@@ -0,0 +1,989 @@
|
||||
//! 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::prompts;
|
||||
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(());
|
||||
}
|
||||
|
||||
// Make commit
|
||||
print_msg("📝 Making git commit...");
|
||||
let _commit_sha = git::commit(&config.codepath, summary, description)?;
|
||||
print_msg("✅ Commit successful");
|
||||
|
||||
// Log commit to history
|
||||
history::write_git_commit(&config.plan_dir(), summary)?;
|
||||
|
||||
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::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;
|
||||
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() {
|
||||
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\nContext requirements:\n{}\n\nFix the issues mentioned above.",
|
||||
coach_feedback,
|
||||
requirements_content
|
||||
)
|
||||
};
|
||||
|
||||
let player_result = player_agent
|
||||
.execute_task_with_timing(&player_prompt, None, false, false, false, true, None)
|
||||
.await;
|
||||
|
||||
match player_result {
|
||||
Ok(result) => print_msg(&format!("✅ Player completed: {} chars response", result.response.len())),
|
||||
Err(e) => print_msg(&format!("⚠️ Player error: {}", e)),
|
||||
}
|
||||
|
||||
// Coach phase - review implementation
|
||||
print_msg("🎓 Coach: Reviewing implementation...");
|
||||
|
||||
let coach_config = g3_config.for_coach()?;
|
||||
let coach_ui_writer = llm::PlannerUiWriter;
|
||||
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\nIf COMPLETE, respond with 'IMPLEMENTATION_APPROVED'.\nOtherwise, provide specific feedback for the player to fix.",
|
||||
requirements_content
|
||||
);
|
||||
|
||||
let coach_result = coach_agent
|
||||
.execute_task_with_timing(&coach_prompt, None, false, false, false, true, None)
|
||||
.await;
|
||||
|
||||
match coach_result {
|
||||
Ok(result) => {
|
||||
if result.response.contains("IMPLEMENTATION_APPROVED") || result.is_approved() {
|
||||
print_msg("✅ Coach approved implementation!");
|
||||
return Ok(());
|
||||
}
|
||||
coach_feedback = result.response;
|
||||
print_msg(&format!("📝 Coach feedback: {} chars", coach_feedback.len()));
|
||||
}
|
||||
Err(e) => {
|
||||
print_msg(&format!("⚠️ Coach error: {}", e));
|
||||
coach_feedback = "Please review and fix any issues.".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
no_git: bool,
|
||||
config_path: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
print_msg("\n🎯 G3 Planning Mode");
|
||||
print_msg("==================\n");
|
||||
|
||||
// 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()));
|
||||
|
||||
// Get codepath from argument or prompt user
|
||||
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());
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// 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,
|
||||
).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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user