Files
g3/crates/g3-planner/src/planner.rs
Jochen 1a13fc5345 Add explicit flush to append_entry and strengthen commit ordering docs
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
2025-12-11 10:05:39 +11:00

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, &current_req_path)
.context("Failed to rename new_requirements.md to current_requirements.md")?;
print_msg(&format!(
"📄 Renamed new_requirements.md to current_requirements.md"
));
Ok(())
}
/// Read current requirements content
pub fn read_current_requirements(config: &PlannerConfig) -> Result<String> {
let path = config.current_requirements_path();
fs::read_to_string(&path)
.context("Failed to read current_requirements.md")
}
/// Read todo file content
pub fn read_todo(config: &PlannerConfig) -> Result<Option<String>> {
let path = config.todo_path();
if path.exists() {
Ok(Some(fs::read_to_string(&path)
.context("Failed to read todo.g3.md")?))
} else {
Ok(None)
}
}
/// Check if all todos are complete
pub fn check_todos_complete(todo_contents: &str) -> bool {
// Check if there are any incomplete items (- [ ])
!todo_contents.contains("- [ ]")
}
/// Prompt user to confirm implementation completion
pub fn prompt_for_completion(config: &PlannerConfig) -> Result<CompletionChoice> {
let todo_contents = read_todo(config)?.unwrap_or_else(|| "(no todo file)".to_string());
let prompt = r#"The coach/player loop has completed.
Todo file contents:
{todo_contents}
Do you consider the todos and requirements completed? [Y/n]
If not, we'll return to the coach/player loop."#
.replace("{todo_contents}", &todo_contents);
print_msg(&prompt);
print_prompt("Choice: ");
loop {
let input = read_line()?;
match CompletionChoice::from_input(&input) {
Some(choice) => return Ok(choice),
None => {
print_prompt("Invalid choice. Please enter Y, N, or Q: ");
}
}
}
}
/// Complete the implementation - rename files and prepare for commit
pub fn complete_implementation(config: &PlannerConfig) -> Result<(String, String)> {
let plan_dir = config.plan_dir();
// Generate timestamped filenames
let req_filename = history::completed_requirements_filename();
let todo_filename = history::completed_todo_filename();
// Rename current_requirements.md
let current_req = config.current_requirements_path();
let completed_req = plan_dir.join(&req_filename);
if current_req.exists() {
fs::rename(&current_req, &completed_req)
.context("Failed to rename current_requirements.md")?;
print_msg(&format!("📄 Renamed to {}", req_filename));
}
// Rename todo.g3.md
let todo_path = config.todo_path();
let completed_todo = plan_dir.join(&todo_filename);
if todo_path.exists() {
fs::rename(&todo_path, &completed_todo)
.context("Failed to rename todo.g3.md")?;
print_msg(&format!("📄 Renamed to {}", todo_filename));
}
// Log completion
history::write_completed_requirements(&plan_dir, &req_filename, &todo_filename)?;
Ok((req_filename, todo_filename))
}
/// Stage files and make git commit
pub fn stage_and_commit(
config: &PlannerConfig,
summary: &str,
description: &str,
) -> Result<()> {
if config.no_git {
print_msg("⚠️ Skipping git commit (--no-git flag)");
return Ok(());
}
// Stage files
print_msg("📦 Staging files...");
let staging_result = git::stage_files(&config.codepath, &config.plan_dir())?;
if !staging_result.staged.is_empty() {
print_msg(&format!(" Staged {} files", staging_result.staged.len()));
}
if !staging_result.excluded.is_empty() {
print_msg(&format!(" Excluded {} files (temporary/artifacts)", staging_result.excluded.len()));
}
// Show pre-commit message
let pre_commit = r#"Ready to make a git commit with the following message:
Summary: {summary}
Description:
{description}
Please review the currently staged files (use `git status` in another terminal).
Press Enter to continue with the commit, or type 'quit' to exit without committing."#
.replace("{summary}", summary)
.replace("{description}", description);
print_msg(&pre_commit);
let input = read_line()?;
if input.to_lowercase() == "quit" || input.to_lowercase() == "q" {
print_msg("Skipping commit. Files remain staged.");
return Ok(());
}
// If you're modifying this function, ENSURE that:
// - history::write_git_commit() is called BEFORE git::commit()
// - No conditional logic can skip the history write if the commit proceeds
// - Tests in commit_history_ordering_test.rs continue to pass
history::write_git_commit(&config.plan_dir(), summary)?;
// Re-stage g3-plan directory to include the GIT COMMIT entry we just wrote
// This ensures planner_history.txt changes are included in the commit
git::stage_plan_dir(&config.codepath, &config.plan_dir())?;
// Make commit
print_msg("📝 Making git commit...");
let _commit_sha = git::commit(&config.codepath, summary, description)?;
print_msg("✅ Commit successful");
Ok(())
}
/// Parse commit message from LLM response
pub fn parse_commit_message(response: &str) -> (String, String) {
let mut summary = String::new();
let mut description = String::new();
if let Some(summary_start) = response.find("{{COMMIT_SUMMARY}}") {
let after_tag = &response[summary_start + "{{COMMIT_SUMMARY}}".len()..];
if let Some(end) = after_tag.find("{{COMMIT_DESCRIPTION}}") {
summary = after_tag[..end].trim().to_string();
} else {
summary = after_tag.lines().next().unwrap_or("").trim().to_string();
}
}
if let Some(desc_start) = response.find("{{COMMIT_DESCRIPTION}}") {
let after_tag = &response[desc_start + "{{COMMIT_DESCRIPTION}}".len()..];
description = after_tag.trim().to_string();
}
// Ensure summary is max 72 chars
if summary.len() > 72 {
summary = format!("{}...", &summary[..69]);
}
// Ensure description lines are max 72 chars
let wrapped_desc: Vec<String> = description
.lines()
.take(10) // Max 10 lines
.map(|line| {
if line.len() > 72 {
format!("{}...", &line[..69])
} else {
line.to_string()
}
})
.collect();
description = wrapped_desc.join("\n");
// Fallback if parsing failed
if summary.is_empty() {
summary = "Implement requirements".to_string();
}
(summary, description)
}
/// Tools available to the planner agent
pub fn get_planner_tools() -> Vec<&'static str> {
vec![
"read_file",
"write_file",
"shell",
"code_search",
"str_replace",
"final_output",
]
}
/// Tools NOT available to the planner agent
pub fn get_excluded_planner_tools() -> Vec<&'static str> {
vec![
"todo_write", // Planner should not write todos during refinement
]
}
/// Run the coach/player implementation loop
///
/// This function runs the actual implementation phase using g3-core's Agent
/// in a coach/player feedback loop similar to autonomous mode.
pub async fn run_coach_player_loop(
planner_config: &PlannerConfig,
g3_config: &g3_config::Config,
requirements_content: &str,
) -> Result<()> {
use g3_core::project::Project;
use g3_core::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());
}
}