Add file.flush() call in append_entry() to ensure planner history entries are written to disk before git commits execute. While the file handle drop should flush, explicit flush simplifies reasoning about the ordering invariant. Extend code comments in stage_and_commit() to document that the write_git_commit-before-git::commit ordering has regressed multiple times and must be preserved in any refactoring. Requirements: completed_requirements_2025-12-11_10-05-08.md
1027 lines
37 KiB
Rust
1027 lines
37 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::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() {
|
|
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::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\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;
|
|
// Display first 25 lines of coach feedback
|
|
let lines: Vec<&str> = coach_feedback.lines().collect();
|
|
let display_lines = if lines.len() > 25 {
|
|
let mut truncated: Vec<&str> = lines[..25].to_vec();
|
|
truncated.push("...");
|
|
truncated
|
|
} else {
|
|
lines
|
|
};
|
|
print_msg(&format!("📝 Coach feedback ({} chars):", coach_feedback.len()));
|
|
for line in display_lines {
|
|
print_msg(&format!(" {}", line));
|
|
}
|
|
}
|
|
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>,
|
|
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());
|
|
}
|
|
}
|