Implement planning mode

This commit is contained in:
Jochen
2025-12-09 09:59:28 +11:00
parent 4aa84e2144
commit ff8b3e7c7b
24 changed files with 3817 additions and 346 deletions

View File

@@ -6,9 +6,15 @@ description = "Fast-discovery planner for G3 AI coding agent"
[dependencies]
g3-providers = { path = "../g3-providers" }
g3-core = { path = "../g3-core" }
g3-config = { path = "../g3-config" }
serde = { workspace = true }
serde_json = { workspace = true }
const_format = "0.2"
anyhow = { workspace = true }
tokio = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
shellexpand = "3.1"
[dev-dependencies]
tempfile = "3.8"

View File

@@ -0,0 +1,396 @@
//! Git operations for planning mode
//!
//! This module provides git functionality for the planner:
//! - Repository detection
//! - Branch information
//! - Dirty file detection
//! - Staging and committing
use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
/// Files and directories to exclude from staging
const EXCLUDE_PATTERNS: &[&str] = &[
"target/",
"node_modules/",
"__pycache__/",
".venv/",
"*.log",
"*.tmp",
"*.bak",
".DS_Store",
"Thumbs.db",
"*.pyc",
"tmp/",
"temp/",
".pytest_cache/",
".mypy_cache/",
".ruff_cache/",
"*.swp",
"*.swo",
"*~",
];
/// Check if the given path is within a git repository
pub fn check_git_repo(codepath: &Path) -> Result<bool> {
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(codepath)
.output()
.context("Failed to execute git command")?;
Ok(output.status.success())
}
/// Get the root directory of the git repository
pub fn get_repo_root(codepath: &Path) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(codepath)
.output()
.context("Failed to get git repo root")?;
if !output.status.success() {
anyhow::bail!("Not in a git repository");
}
let root = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in git output")?
.trim()
.to_string();
Ok(root)
}
/// Get the current git branch name
pub fn get_current_branch(codepath: &Path) -> Result<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(codepath)
.output()
.context("Failed to get current git branch")?;
if !output.status.success() {
// Might be in detached HEAD state
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to get branch name: {}", stderr);
}
let branch = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in git output")?
.trim()
.to_string();
if branch.is_empty() {
// Detached HEAD state - get short SHA instead
let sha_output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(codepath)
.output()
.context("Failed to get HEAD SHA")?;
let sha = String::from_utf8(sha_output.stdout)
.context("Invalid UTF-8 in git output")?
.trim()
.to_string();
Ok(format!("(detached HEAD at {})", sha))
} else {
Ok(branch)
}
}
/// Get the current HEAD SHA
pub fn get_head_sha(codepath: &Path) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(codepath)
.output()
.context("Failed to get HEAD SHA")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to get HEAD SHA: {}", stderr);
}
let sha = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in git output")?
.trim()
.to_string();
Ok(sha)
}
/// Information about dirty/untracked files
#[derive(Debug, Default)]
pub struct DirtyFiles {
pub modified: Vec<String>,
pub untracked: Vec<String>,
pub staged: Vec<String>,
}
impl DirtyFiles {
pub fn is_empty(&self) -> bool {
self.modified.is_empty() && self.untracked.is_empty() && self.staged.is_empty()
}
pub fn to_display_string(&self) -> String {
let mut lines = Vec::new();
if !self.staged.is_empty() {
lines.push("Staged:".to_string());
for f in &self.staged {
lines.push(format!(" {}", f));
}
}
if !self.modified.is_empty() {
lines.push("Modified:".to_string());
for f in &self.modified {
lines.push(format!(" {}", f));
}
}
if !self.untracked.is_empty() {
lines.push("Untracked:".to_string());
for f in &self.untracked {
lines.push(format!(" {}", f));
}
}
lines.join("\n")
}
}
/// Check for untracked, uncommitted, or dirty files
/// Optionally ignores files matching a given path pattern
pub fn check_dirty_files(codepath: &Path, ignore_pattern: Option<&str>) -> Result<DirtyFiles> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(codepath)
.output()
.context("Failed to check git status")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to check git status: {}", stderr);
}
let status_output = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in git output")?;
let mut result = DirtyFiles::default();
for line in status_output.lines() {
if line.len() < 3 {
continue;
}
let status = &line[0..2];
let file = line[3..].trim();
// Check if this file should be ignored
if let Some(pattern) = ignore_pattern {
if file.contains(pattern) {
continue;
}
}
match status {
"??" => result.untracked.push(file.to_string()),
" M" | "MM" | "AM" => result.modified.push(file.to_string()),
"M " | "A " | "D " | "R " => result.staged.push(file.to_string()),
_ => {
// Other statuses (deleted, renamed, etc.)
if status.starts_with(' ') {
result.modified.push(file.to_string());
} else {
result.staged.push(file.to_string());
}
}
}
}
Ok(result)
}
/// Check if a file should be excluded from staging based on patterns
fn should_exclude(path: &str) -> bool {
for pattern in EXCLUDE_PATTERNS {
if pattern.ends_with('/') {
// Directory pattern
let dir_name = pattern.trim_end_matches('/');
if path.contains(&format!("/{}/", dir_name)) || path.starts_with(&format!("{}/", dir_name)) {
return true;
}
} else if pattern.starts_with('*') {
// Wildcard pattern
let suffix = pattern.trim_start_matches('*');
if path.ends_with(suffix) {
return true;
}
} else {
// Exact match
if path == *pattern || path.ends_with(&format!("/{}", pattern)) {
return true;
}
}
}
false
}
/// Stage files for commit, excluding temporary/artifact files
/// Stages all files in the specified directory plus any modified/new code files
pub fn stage_files(codepath: &Path, plan_dir: &Path) -> Result<StagingResult> {
let mut result = StagingResult::default();
// First, stage all files in the g3-plan directory
let plan_dir_str = plan_dir.to_string_lossy();
let add_plan_output = Command::new("git")
.args(["add", &plan_dir_str])
.current_dir(codepath)
.output()
.context("Failed to stage g3-plan directory")?;
if !add_plan_output.status.success() {
let stderr = String::from_utf8_lossy(&add_plan_output.stderr);
// Don't fail if directory doesn't exist yet
if !stderr.contains("did not match any files") {
anyhow::bail!("Failed to stage g3-plan directory: {}", stderr);
}
}
// Get list of all changed files
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(codepath)
.output()
.context("Failed to get git status")?;
let status_str = String::from_utf8(status_output.stdout)
.context("Invalid UTF-8 in git output")?;
// Stage files that aren't excluded
for line in status_str.lines() {
if line.len() < 3 {
continue;
}
let status = &line[0..2];
let file = line[3..].trim();
// Skip already staged files
if !status.starts_with(' ') && status != "??" {
continue;
}
// Check if this file should be excluded
if should_exclude(file) {
result.excluded.push(file.to_string());
continue;
}
// Stage the file
let add_output = Command::new("git")
.args(["add", file])
.current_dir(codepath)
.output()
.context(format!("Failed to stage file: {}", file))?;
if add_output.status.success() {
result.staged.push(file.to_string());
} else {
result.failed.push(file.to_string());
}
}
Ok(result)
}
/// Result of staging operation
#[derive(Debug, Default)]
pub struct StagingResult {
pub staged: Vec<String>,
pub excluded: Vec<String>,
pub failed: Vec<String>,
}
/// Make a git commit with the given summary and description
pub fn commit(codepath: &Path, summary: &str, description: &str) -> Result<String> {
// Combine summary and description into full commit message
let full_message = if description.is_empty() {
summary.to_string()
} else {
format!("{}\n\n{}", summary, description)
};
let output = Command::new("git")
.args(["commit", "-m", &full_message])
.current_dir(codepath)
.output()
.context("Failed to make git commit")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Git commit failed: {}", stderr);
}
// Get the commit SHA
get_head_sha(codepath)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_exclude_target() {
assert!(should_exclude("target/debug/something"));
assert!(should_exclude("some/path/target/release/bin"));
}
#[test]
fn test_should_exclude_node_modules() {
assert!(should_exclude("node_modules/package/index.js"));
assert!(should_exclude("frontend/node_modules/react/index.js"));
}
#[test]
fn test_should_exclude_log_files() {
assert!(should_exclude("app.log"));
assert!(should_exclude("logs/debug.log"));
}
#[test]
fn test_should_exclude_temp_files() {
assert!(should_exclude("file.tmp"));
assert!(should_exclude("file.bak"));
assert!(should_exclude("file.swp"));
}
#[test]
fn test_should_not_exclude_normal_files() {
assert!(!should_exclude("src/main.rs"));
assert!(!should_exclude("Cargo.toml"));
assert!(!should_exclude("README.md"));
assert!(!should_exclude("package.json"));
}
#[test]
fn test_dirty_files_display() {
let dirty = DirtyFiles {
modified: vec!["src/main.rs".to_string()],
untracked: vec!["new_file.txt".to_string()],
staged: vec!["Cargo.toml".to_string()],
};
let display = dirty.to_display_string();
assert!(display.contains("Modified:"));
assert!(display.contains("src/main.rs"));
assert!(display.contains("Untracked:"));
assert!(display.contains("new_file.txt"));
assert!(display.contains("Staged:"));
assert!(display.contains("Cargo.toml"));
}
}

View File

@@ -0,0 +1,234 @@
//! Planner history management
//!
//! This module manages the planner_history.txt file which serves as:
//! - An audit log of planning steps
//! - A comprehensive reference of historic requirements and implementations
//! - A file that requires merging/resolution if updated on separate git branches
use anyhow::{Context, Result};
use chrono::Local;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
use crate::prompts;
/// Format a timestamp for planner_history.txt entries
/// Format: YYYY-MM-DD HH:MM:SS (ISO 8601 for readability)
pub fn format_timestamp() -> String {
Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
}
/// Format a timestamp for filenames
/// Format: YYYY-MM-DD_HH-MM-SS (filesystem-safe)
pub fn format_timestamp_for_filename() -> String {
Local::now().format("%Y-%m-%d_%H-%M-%S").to_string()
}
/// Ensure the planner_history.txt file exists, creating it if necessary
pub fn ensure_history_file(plan_dir: &Path) -> Result<()> {
let history_path = plan_dir.join("planner_history.txt");
if !history_path.exists() {
fs::write(&history_path, "")
.context("Failed to create planner_history.txt")?;
}
Ok(())
}
/// Append an entry to planner_history.txt
fn append_entry(plan_dir: &Path, entry: &str) -> Result<()> {
let history_path = plan_dir.join("planner_history.txt");
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&history_path)
.context("Failed to open planner_history.txt for appending")?;
writeln!(file, "{}", entry)
.context("Failed to write to planner_history.txt")?;
Ok(())
}
/// Write a "REFINING REQUIREMENTS" entry
pub fn write_refining_requirements(plan_dir: &Path) -> Result<()> {
let timestamp = format_timestamp();
let entry = "{timestamp} - REFINING REQUIREMENTS (new_requirements.md)"
.replace("{timestamp}", &timestamp);
append_entry(plan_dir, &entry)
}
/// Write a "GIT HEAD" entry with the current SHA
pub fn write_git_head(plan_dir: &Path, sha: &str) -> Result<()> {
let timestamp = format_timestamp();
let entry = "{timestamp} - GIT HEAD ({sha})"
.replace("{timestamp}", &timestamp)
.replace("{sha}", sha);
append_entry(plan_dir, &entry)
}
/// Write a "START IMPLEMENTING" entry with a summary block
pub fn write_start_implementing(plan_dir: &Path, summary: &str) -> Result<()> {
let timestamp = format_timestamp();
let entry = "{timestamp} - START IMPLEMENTING (current_requirements.md)"
.replace("{timestamp}", &timestamp);
// Format the summary with proper indentation
let indented_summary = summary
.lines()
.map(|line| format!(" {}", line))
.collect::<Vec<_>>()
.join("\n");
let summary_block = "<<\n{summary}\n>>"
.replace("{summary}", &indented_summary);
append_entry(plan_dir, &entry)?;
append_entry(plan_dir, &summary_block)?;
Ok(())
}
/// Write an "ATTEMPTING RECOVERY" entry
pub fn write_attempting_recovery(plan_dir: &Path) -> Result<()> {
let timestamp = format_timestamp();
let entry = "{timestamp} ATTEMPTING RECOVERY"
.replace("{timestamp}", &timestamp);
append_entry(plan_dir, &entry)
}
/// Write a "USER SKIPPED RECOVERY" entry
pub fn write_skipped_recovery(plan_dir: &Path) -> Result<()> {
let timestamp = format_timestamp();
let entry = "{timestamp} USER SKIPPED RECOVERY"
.replace("{timestamp}", &timestamp);
append_entry(plan_dir, &entry)
}
/// Write a "COMPLETED REQUIREMENTS" entry
pub fn write_completed_requirements(
plan_dir: &Path,
requirements_file: &str,
todo_file: &str,
) -> Result<()> {
let timestamp = format_timestamp();
let entry = "{timestamp} - COMPLETED REQUIREMENTS ({requirements_file}, {todo_file})"
.replace("{timestamp}", &timestamp)
.replace("{requirements_file}", requirements_file)
.replace("{todo_file}", todo_file);
append_entry(plan_dir, &entry)
}
/// Write a "GIT COMMIT" entry
pub fn write_git_commit(plan_dir: &Path, message: &str) -> Result<()> {
let timestamp = format_timestamp();
// Truncate message if too long for a single line
let truncated_message = if message.len() > 72 {
format!("{}...", &message[..69])
} else {
message.to_string()
};
let entry = "{timestamp} - GIT COMMIT ({message})"
.replace("{timestamp}", &timestamp)
.replace("{message}", &truncated_message);
append_entry(plan_dir, &entry)
}
/// Generate the completed requirements filename
pub fn completed_requirements_filename() -> String {
format!("completed_requirements_{}.md", format_timestamp_for_filename())
}
/// Generate the completed todo filename
pub fn completed_todo_filename() -> String {
format!("completed_todo_{}.md", format_timestamp_for_filename())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_format_timestamp() {
let ts = format_timestamp();
// Should be in format YYYY-MM-DD HH:MM:SS
assert_eq!(ts.len(), 19);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], " ");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
}
#[test]
fn test_format_timestamp_for_filename() {
let ts = format_timestamp_for_filename();
// Should be in format YYYY-MM-DD_HH-MM-SS
assert_eq!(ts.len(), 19);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "_");
assert_eq!(&ts[13..14], "-");
assert_eq!(&ts[16..17], "-");
// Should not contain colons (filesystem-safe)
assert!(!ts.contains(':'));
}
#[test]
fn test_ensure_history_file() {
let temp_dir = TempDir::new().unwrap();
let plan_dir = temp_dir.path();
let history_path = plan_dir.join("planner_history.txt");
assert!(!history_path.exists());
ensure_history_file(plan_dir).unwrap();
assert!(history_path.exists());
}
#[test]
fn test_write_entries() {
let temp_dir = TempDir::new().unwrap();
let plan_dir = temp_dir.path();
ensure_history_file(plan_dir).unwrap();
write_refining_requirements(plan_dir).unwrap();
write_git_head(plan_dir, "abc123def456").unwrap();
write_start_implementing(plan_dir, "Test summary line 1\nTest summary line 2").unwrap();
write_attempting_recovery(plan_dir).unwrap();
write_completed_requirements(plan_dir, "completed_requirements_2025-01-01_12-00-00.md", "completed_todo_2025-01-01_12-00-00.md").unwrap();
write_git_commit(plan_dir, "Add feature X").unwrap();
let history_path = plan_dir.join("planner_history.txt");
let content = fs::read_to_string(history_path).unwrap();
assert!(content.contains("REFINING REQUIREMENTS"));
assert!(content.contains("GIT HEAD (abc123def456)"));
assert!(content.contains("START IMPLEMENTING"));
assert!(content.contains("Test summary line 1"));
assert!(content.contains("ATTEMPTING RECOVERY"));
assert!(content.contains("COMPLETED REQUIREMENTS"));
assert!(content.contains("GIT COMMIT"));
}
#[test]
fn test_completed_filenames() {
let req_file = completed_requirements_filename();
let todo_file = completed_todo_filename();
assert!(req_file.starts_with("completed_requirements_"));
assert!(req_file.ends_with(".md"));
assert!(todo_file.starts_with("completed_todo_"));
assert!(todo_file.ends_with(".md"));
// Should not contain colons
assert!(!req_file.contains(':'));
assert!(!todo_file.contains(':'));
}
}

View File

@@ -1,12 +1,24 @@
//! g3-planner: Fast-discovery planner for G3 AI coding agent
//! g3-planner: Planning mode and fast-discovery planner for G3 AI coding agent
//!
//! This crate provides functionality to generate initial discovery tool calls
//! that are injected into the conversation before the first LLM turn.
//! This crate provides:
//! - Planning mode state machine and orchestration
//! - Requirements refinement workflow
//! - Git integration for planning commits
//! - Planner history management
//! - Fast-discovery functionality for codebase exploration
mod code_explore;
pub mod git;
pub mod history;
pub mod llm;
pub mod planner;
pub mod prompts;
pub mod state;
pub use code_explore::explore_codebase;
pub use planner::{expand_codepath, PlannerConfig, PlannerResult};
pub use state::{PlannerState, RecoveryInfo};
pub use planner::run_planning_mode;
use anyhow::Result;
use chrono::Local;

View File

@@ -0,0 +1,321 @@
//! LLM integration for planning mode
//!
//! This module provides LLM-based functionality for:
//! - Requirements refinement
//! - Generating requirements summaries
//! - Generating git commit messages
use anyhow::{anyhow, Context, Result};
use g3_config::Config;
use g3_core::project::Project;
use g3_core::Agent;
use g3_providers::{CompletionRequest, LLMProvider, Message, MessageRole};
use crate::prompts;
/// Create an LLM provider for the planner based on config
pub async fn create_planner_provider(
config_path: Option<&str>,
) -> Result<Box<dyn LLMProvider>> {
// Load configuration
let config = Config::load(config_path)
.context("Failed to load configuration")?;
// Get planner provider reference (or default)
let provider_ref = config.get_planner_provider();
// If no explicit planner provider, notify user about fallback
if config.providers.planner.is_none() {
let msg = "Note: No 'planner' provider specified in config. Using default_provider '{provider}' for planning mode."
.replace("{provider}", provider_ref);
println!(" {}", msg);
}
// Parse the provider reference
let (provider_type, config_name) = Config::parse_provider_reference(provider_ref)?;
// Create the appropriate provider
match provider_type.as_str() {
"anthropic" => {
let anthropic_config = config
.get_anthropic_config(&config_name)
.ok_or_else(|| anyhow!("Anthropic config '{}' not found", config_name))?;
let provider = g3_providers::AnthropicProvider::new_with_name(
format!("anthropic.{}", config_name),
anthropic_config.api_key.clone(),
Some(anthropic_config.model.clone()),
anthropic_config.max_tokens,
anthropic_config.temperature,
anthropic_config.cache_config.clone(),
anthropic_config.enable_1m_context,
anthropic_config.thinking_budget_tokens,
)?;
Ok(Box::new(provider))
}
"openai" => {
let openai_config = config
.get_openai_config(&config_name)
.ok_or_else(|| anyhow!("OpenAI config '{}' not found", config_name))?;
let provider = g3_providers::OpenAIProvider::new_with_name(
format!("openai.{}", config_name),
openai_config.api_key.clone(),
Some(openai_config.model.clone()),
openai_config.base_url.clone(),
openai_config.max_tokens,
openai_config.temperature,
)?;
Ok(Box::new(provider))
}
"databricks" => {
let databricks_config = config
.get_databricks_config(&config_name)
.ok_or_else(|| anyhow!("Databricks config '{}' not found", config_name))?;
let provider = if let Some(token) = &databricks_config.token {
g3_providers::DatabricksProvider::from_token_with_name(
format!("databricks.{}", config_name),
databricks_config.host.clone(),
token.clone(),
databricks_config.model.clone(),
databricks_config.max_tokens,
databricks_config.temperature,
)?
} else {
g3_providers::DatabricksProvider::from_oauth_with_name(
format!("databricks.{}", config_name),
databricks_config.host.clone(),
databricks_config.model.clone(),
databricks_config.max_tokens,
databricks_config.temperature,
)
.await?
};
Ok(Box::new(provider))
}
_ => {
Err(anyhow!(
"Unsupported provider type '{}' for planner. Supported: anthropic, openai, databricks",
provider_type
))
}
}
}
/// Generate a summary of requirements for planner_history.txt
///
/// Uses the planner LLM to generate a concise summary of the requirements.
/// The summary is at most 5 lines, each at most 120 characters.
pub async fn generate_requirements_summary(
provider: &dyn LLMProvider,
requirements: &str,
) -> Result<String> {
let prompt = prompts::GENERATE_REQUIREMENTS_SUMMARY_PROMPT
.replace("{requirements}", requirements);
let messages = vec![Message::new(MessageRole::User, prompt)];
let request = CompletionRequest {
messages,
max_tokens: Some(500), // Summary should be short
temperature: Some(0.3), // Low temperature for consistent output
stream: false,
tools: None,
};
let response = provider
.complete(request)
.await
.context("Failed to generate requirements summary")?;
// Clean up the response - ensure max 5 lines, each max 120 chars
let summary = response
.content
.lines()
.take(5)
.map(|line| {
if line.len() > 120 {
format!("{}...", &line[..117])
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
Ok(summary)
}
/// Generate a git commit message based on the requirements
///
/// Uses the planner LLM to generate a commit summary and description.
/// Returns (summary, description) tuple.
pub async fn generate_commit_message(
provider: &dyn LLMProvider,
requirements: &str,
requirements_file: &str,
todo_file: &str,
) -> Result<(String, String)> {
let prompt = prompts::GENERATE_COMMIT_MESSAGE_PROMPT
.replace("{requirements}", requirements)
.replace("{requirements_file}", requirements_file)
.replace("{todo_file}", todo_file);
let messages = vec![Message::new(MessageRole::User, prompt)];
let request = CompletionRequest {
messages,
max_tokens: Some(1000),
temperature: Some(0.3),
stream: false,
tools: None,
};
let response = provider
.complete(request)
.await
.context("Failed to generate commit message")?;
// Parse the response using the existing parse_commit_message function
Ok(crate::planner::parse_commit_message(&response.content))
}
/// A simple UiWriter implementation for planner output
#[derive(Clone)]
pub struct PlannerUiWriter;
impl g3_core::ui_writer::UiWriter for PlannerUiWriter {
fn print(&self, message: &str) {
println!("{}", message);
}
fn println(&self, message: &str) {
println!("{}", message);
}
fn print_inline(&self, message: &str) {
print!("{}", message);
}
fn print_system_prompt(&self, _prompt: &str) {}
fn print_context_status(&self, message: &str) {
println!("📊 {}", message);
}
fn print_context_thinning(&self, message: &str) {
println!("🗜️ {}", message);
}
fn print_tool_header(&self, tool_name: &str) {
println!("🔧 {}", tool_name);
}
fn print_tool_arg(&self, _key: &str, _value: &str) {}
fn print_tool_output_header(&self) {}
fn update_tool_output_line(&self, _line: &str) {}
fn print_tool_output_line(&self, _line: &str) {}
fn print_tool_output_summary(&self, _hidden_count: usize) {}
fn print_tool_timing(&self, _duration_str: &str) {}
fn print_agent_prompt(&self) {}
fn print_agent_response(&self, _content: &str) {}
fn notify_sse_received(&self) {}
fn flush(&self) {
use std::io::Write;
std::io::stdout().flush().ok();
}
fn prompt_user_yes_no(&self, _message: &str) -> bool {
true // Default to yes for automated planner
}
fn prompt_user_choice(&self, _message: &str, _options: &[&str]) -> usize {
0 // Default to first option
}
fn print_final_output(&self, summary: &str) {
println!("\n📝 Final Output:\n{}", summary);
}
}
/// Call LLM to refine requirements using a full Agent with tool execution
pub async fn call_refinement_llm_with_tools(
config: &Config,
codepath: &str,
) -> Result<String> {
// Build system message with codepath context
let system_prompt = prompts::REFINE_REQUIREMENTS_SYSTEM_PROMPT
.replace("<codepath>", codepath);
// Build user message
let user_message = build_refinement_user_message(codepath);
// Create agent with planner config
let planner_config = config.for_planner()?;
let ui_writer = PlannerUiWriter;
// Create project pointing to codepath as workspace
let workspace = std::path::PathBuf::from(codepath);
let project = Project::new(workspace.clone());
project.ensure_workspace_exists()?;
project.enter_workspace()?;
// Create agent - not autonomous mode, just regular agent with tools
let mut agent = Agent::new_with_readme_and_quiet(
planner_config,
ui_writer,
Some(system_prompt),
false, // not quiet
)
.await?;
// Execute the refinement task
// The agent will have access to tools and execute them
let task = user_message;
let result = agent
.execute_task_with_timing(&task, None, false, false, false, true, None)
.await
.context("Failed to call refinement LLM")?;
println!("📝 Refinement complete");
Ok(result.response)
}
/// Build the user message for requirements refinement
///
/// This message instructs the LLM to read the codebase and refine requirements.
pub fn build_refinement_user_message(codepath: &str) -> String {
format!(
r#"Please refine the requirements for the codebase at: {codepath}
Before making suggestions, please:
1. Read the codebase structure using shell commands like `ls`, `find`, or `tree`
2. Read `{codepath}/g3-plan/planner_history.txt` to understand past planning activities
3. Read any `{codepath}/g3-plan/completed_requirements_*.md` files to see what was implemented before
4. Read `{codepath}/g3-plan/new_requirements.md` which contains the requirements to refine
After understanding the context, update the `{codepath}/g3-plan/new_requirements.md` file by prepending
your refined requirements under the heading `{{{{CURRENT REQUIREMENTS}}}}`.
Use final_output when you are done to indicate completion."#,
codepath = codepath
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_refinement_user_message() {
let msg = build_refinement_user_message("/test/project");
assert!(msg.contains("/test/project"));
assert!(msg.contains("planner_history.txt"));
assert!(msg.contains("new_requirements.md"));
assert!(msg.contains("{{CURRENT REQUIREMENTS}}"));
}
}

View 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, &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(());
}
// 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());
}
}

View File

@@ -1,4 +1,11 @@
//! Prompts used for discovery phase
//! Prompts used for planning mode and discovery phase
//!
//! This module contains all LLM prompts used in the planner crate.
//! All prompts are defined as constants to ensure consistency and maintainability.
// =============================================================================
// DISCOVERY PHASE PROMPTS (existing)
// =============================================================================
/// System prompt for discovery mode - instructs the LLM to analyze codebase and generate exploration commands
pub const DISCOVERY_SYSTEM_PROMPT: &str = r#"You are an expert code analyst. Your task is to analyze a codebase structure and generate shell commands to explore it further.
@@ -35,3 +42,101 @@ Your output MUST include:
- Mark the beginning and end of the commands with "```".
DO NOT ADD ANY COMMENTS OR OTHER EXPLANATION IN THE COMMANDS SECTION, JUST INCLUDE THE SHELL COMMANDS."#;
// =============================================================================
// PLANNING MODE PROMPTS
// =============================================================================
/// System prompt for requirements refinement phase
pub const REFINE_REQUIREMENTS_SYSTEM_PROMPT: &str = r#"You're an experienced software engineering architect. Please help me to ideate and refine
REQUIREMENTS for an implementation (or changes to the existing implementation), at the specified codepath.
The requirements will later be used by an LLM.
IMPORTANT: Before suggesting changes, you MUST:
1. Read and understand the existing codebase at the specified codepath using read_file, shell commands, and code_search
2. Read the `<codepath>/g3-plan/` directory to understand past requirements and implementation history
- Pay particular attention to `planner_history.txt` which contains a chronological record of all planning activities
- Review any `completed_requirements_*.md` files to understand what has been implemented before
3. Use this context to ensure your suggestions are consistent with the existing codebase architecture
I wish to have a compact specification, and DO NOT ATTEMPT TO IMPLEMENT OR BUILD ANYTHING.
At this point ONLY suggest improvements to the requirements. Do not implement anything.
DO NOT DO A RE-WRITE, UNLESS THE USER EXPLICITLY ASKS FOR THAT.
If you think the requirements are totally incoherent and unusable, write constructive feedback on
why that is, and suggest (very briefly) that you could rewrite it if explicitly asked to do so.
If the requirements are usable, make some edits/changes/additions as you deem necessary, and
PREPEND them under the heading `{{CURRENT REQUIREMENTS}}` to the `<codepath>/g3-plan/new_requirements.md` file.
The codepath will be provided in the user message."#;
/// System prompt for generating requirements summary for planner_history.txt
pub const GENERATE_REQUIREMENTS_SUMMARY_PROMPT: &str = r#"Generate a short summary of the following requirements.
Take care that the most important elements of the requirements are reflected.
Do not go into deep detail. Make the summary at most 5 lines long.
Each line should be at most 120 characters long.
Output ONLY the summary text, no headers or formatting.
Requirements:
{requirements}"#;
/// System prompt for generating git commit message
pub const GENERATE_COMMIT_MESSAGE_PROMPT: &str = r#"Generate a git commit message for the following implementation.
REQUIREMENTS THAT WERE IMPLEMENTED:
{requirements}
COMPLETED FILES:
- Requirements: {requirements_file}
- Todo: {todo_file}
Generate a commit message with:
1. A summary line (max 72 characters, imperative mood, e.g., "Add planning mode with...")
2. A blank line
3. A description (max 10 lines, each max 72 characters, wrapped properly)
The description should:
- Describe the implementation concisely
- Include only the most important and salient details
- Mention the completed_requirements and completed_todo filenames
Output format:
{{COMMIT_SUMMARY}}
<summary line here>
{{COMMIT_DESCRIPTION}}
<description here>"#;
// =============================================================================
// CONFIG ERROR MESSAGES
// =============================================================================
/// Error message for old config format
pub const OLD_CONFIG_FORMAT_ERROR: &str = r#"Your configuration file uses an old format that is no longer supported.
Please update your configuration to use the new provider format:
```toml
[providers]
default_provider = "anthropic.default" # Format: "<provider_type>.<config_name>"
planner = "anthropic.planner" # Optional: specific provider for planner
coach = "anthropic.default" # Optional: specific provider for coach
player = "openai.player" # Optional: specific provider for player
# Named configs per provider type
[providers.anthropic.default]
api_key = "your-api-key"
model = "claude-sonnet-4-5"
max_tokens = 64000
[providers.anthropic.planner]
api_key = "your-api-key"
model = "claude-opus-4-5"
thinking_budget_tokens = 16000
[providers.openai.player]
api_key = "your-api-key"
model = "gpt-5"
```
Each mode (planner, coach, player) can specify a full path like "<provider_type>.<config_name>".
If not specified, they fall back to `default_provider`."#;

View File

@@ -0,0 +1,289 @@
//! Planner state machine
//!
//! This module defines the state machine for the planning mode:
//!
//! ```text
//! +------------- RECOVERY (Resume) ---------------------+
//! | |
//! | +---------- RECOVERY (Mark Complete) ----+ |
//! | | | |
//! ^ ^ v v
//! STARTUP -> PROMPT FOR NEW REQUIREMENTS -> REFINE REQUIREMENTS -> IMPLEMENT REQUIREMENTS -> IMPLEMENTATION COMPLETE +
//! ^ v
//! | |
//! +---------------------------------------------------------------------------------------------------------+
//! ```
use std::path::Path;
use chrono::{DateTime, Local};
/// The state of the planning mode
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlannerState {
/// Initial startup state
Startup,
/// Recovery needed - found incomplete previous run
Recovery(RecoveryInfo),
/// Prompting user for new requirements
PromptForRequirements,
/// Refining requirements with LLM
RefineRequirements,
/// Implementing requirements (coach/player loop)
ImplementRequirements,
/// Implementation completed successfully
ImplementationComplete,
/// User quit the application
Quit,
}
/// Information about a recovery situation
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecoveryInfo {
/// Whether current_requirements.md exists
pub has_current_requirements: bool,
/// Timestamp of current_requirements.md if it exists
pub requirements_modified: Option<String>,
/// Whether todo.g3.md exists
pub has_todo: bool,
/// Contents of todo.g3.md if it exists
pub todo_contents: Option<String>,
}
impl RecoveryInfo {
/// Create recovery info by checking file existence
pub fn detect(plan_dir: &Path) -> Option<Self> {
let current_req_path = plan_dir.join("current_requirements.md");
let todo_path = plan_dir.join("todo.g3.md");
let has_current_requirements = current_req_path.exists();
let has_todo = todo_path.exists();
// If neither file exists, no recovery needed
if !has_current_requirements && !has_todo {
return None;
}
let requirements_modified = if has_current_requirements {
get_file_modified_time(&current_req_path)
} else {
None
};
let todo_contents = if has_todo {
std::fs::read_to_string(&todo_path).ok()
} else {
None
};
Some(RecoveryInfo {
has_current_requirements,
requirements_modified,
has_todo,
todo_contents,
})
}
}
/// Get the modified time of a file as a formatted string
fn get_file_modified_time(path: &Path) -> Option<String> {
let metadata = std::fs::metadata(path).ok()?;
let modified = metadata.modified().ok()?;
let datetime: DateTime<Local> = modified.into();
Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
}
/// User's choice when presented with recovery options
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryChoice {
/// Resume the previous implementation
Resume,
/// Mark as complete and proceed to new requirements
MarkComplete,
/// Quit and investigate manually
Quit,
}
impl RecoveryChoice {
/// Parse user input into a recovery choice
pub fn from_input(input: &str) -> Option<Self> {
let input = input.trim().to_lowercase();
match input.as_str() {
"y" | "yes" => Some(RecoveryChoice::Resume),
"n" | "no" => Some(RecoveryChoice::MarkComplete),
"q" | "quit" => Some(RecoveryChoice::Quit),
_ => None,
}
}
}
/// User's choice when asked to approve requirements
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalChoice {
/// Approve and proceed to implementation
Approve,
/// Continue refining
Refine,
/// Quit the application
Quit,
}
impl ApprovalChoice {
/// Parse user input into an approval choice
pub fn from_input(input: &str) -> Option<Self> {
let input = input.trim().to_lowercase();
match input.as_str() {
"y" | "yes" => Some(ApprovalChoice::Approve),
"n" | "no" => Some(ApprovalChoice::Refine),
"q" | "quit" => Some(ApprovalChoice::Quit),
_ => None,
}
}
}
/// User's choice when asked if implementation is complete
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompletionChoice {
/// Yes, implementation is complete
Complete,
/// No, continue with coach/player loop
Continue,
/// Quit the application
Quit,
}
impl CompletionChoice {
/// Parse user input into a completion choice
pub fn from_input(input: &str) -> Option<Self> {
let input = input.trim().to_lowercase();
match input.as_str() {
"y" | "yes" | "" => Some(CompletionChoice::Complete),
"n" | "no" => Some(CompletionChoice::Continue),
"q" | "quit" => Some(CompletionChoice::Quit),
_ => None,
}
}
}
/// User's choice when asked to confirm git branch
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchConfirmChoice {
/// Yes, correct branch
Confirm,
/// No, wrong branch - quit
Quit,
}
impl BranchConfirmChoice {
/// Parse user input into a branch confirmation choice
pub fn from_input(input: &str) -> Option<Self> {
let input = input.trim().to_lowercase();
match input.as_str() {
"y" | "yes" | "" => Some(BranchConfirmChoice::Confirm),
"n" | "no" | "q" | "quit" => Some(BranchConfirmChoice::Quit),
_ => None,
}
}
}
/// User's choice when warned about dirty files
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyFilesChoice {
/// Proceed anyway
Proceed,
/// Quit and handle manually
Quit,
}
impl DirtyFilesChoice {
/// Parse user input into a dirty files choice
pub fn from_input(input: &str) -> Option<Self> {
let input = input.trim().to_lowercase();
match input.as_str() {
"y" | "yes" | "" => Some(DirtyFilesChoice::Proceed),
"n" | "no" | "q" | "quit" => Some(DirtyFilesChoice::Quit),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_recovery_info_no_files() {
let temp_dir = TempDir::new().unwrap();
let result = RecoveryInfo::detect(temp_dir.path());
assert!(result.is_none());
}
#[test]
fn test_recovery_info_with_current_requirements() {
let temp_dir = TempDir::new().unwrap();
let req_path = temp_dir.path().join("current_requirements.md");
std::fs::write(&req_path, "test requirements").unwrap();
let result = RecoveryInfo::detect(temp_dir.path());
assert!(result.is_some());
let info = result.unwrap();
assert!(info.has_current_requirements);
assert!(info.requirements_modified.is_some());
assert!(!info.has_todo);
assert!(info.todo_contents.is_none());
}
#[test]
fn test_recovery_info_with_todo() {
let temp_dir = TempDir::new().unwrap();
let todo_path = temp_dir.path().join("todo.g3.md");
std::fs::write(&todo_path, "- [ ] Test task").unwrap();
let result = RecoveryInfo::detect(temp_dir.path());
assert!(result.is_some());
let info = result.unwrap();
assert!(!info.has_current_requirements);
assert!(info.has_todo);
assert_eq!(info.todo_contents, Some("- [ ] Test task".to_string()));
}
#[test]
fn test_recovery_choice_parsing() {
assert_eq!(RecoveryChoice::from_input("y"), Some(RecoveryChoice::Resume));
assert_eq!(RecoveryChoice::from_input("YES"), Some(RecoveryChoice::Resume));
assert_eq!(RecoveryChoice::from_input("n"), Some(RecoveryChoice::MarkComplete));
assert_eq!(RecoveryChoice::from_input("No"), Some(RecoveryChoice::MarkComplete));
assert_eq!(RecoveryChoice::from_input("q"), Some(RecoveryChoice::Quit));
assert_eq!(RecoveryChoice::from_input("quit"), Some(RecoveryChoice::Quit));
assert_eq!(RecoveryChoice::from_input("invalid"), None);
}
#[test]
fn test_approval_choice_parsing() {
assert_eq!(ApprovalChoice::from_input("yes"), Some(ApprovalChoice::Approve));
assert_eq!(ApprovalChoice::from_input("no"), Some(ApprovalChoice::Refine));
assert_eq!(ApprovalChoice::from_input("quit"), Some(ApprovalChoice::Quit));
}
#[test]
fn test_completion_choice_parsing() {
assert_eq!(CompletionChoice::from_input("y"), Some(CompletionChoice::Complete));
assert_eq!(CompletionChoice::from_input(""), Some(CompletionChoice::Complete)); // Default
assert_eq!(CompletionChoice::from_input("n"), Some(CompletionChoice::Continue));
assert_eq!(CompletionChoice::from_input("quit"), Some(CompletionChoice::Quit));
}
#[test]
fn test_branch_confirm_parsing() {
assert_eq!(BranchConfirmChoice::from_input("y"), Some(BranchConfirmChoice::Confirm));
assert_eq!(BranchConfirmChoice::from_input(""), Some(BranchConfirmChoice::Confirm)); // Default
assert_eq!(BranchConfirmChoice::from_input("n"), Some(BranchConfirmChoice::Quit));
}
#[test]
fn test_dirty_files_choice_parsing() {
assert_eq!(DirtyFilesChoice::from_input("y"), Some(DirtyFilesChoice::Proceed));
assert_eq!(DirtyFilesChoice::from_input(""), Some(DirtyFilesChoice::Proceed)); // Default
assert_eq!(DirtyFilesChoice::from_input("n"), Some(DirtyFilesChoice::Quit));
}
}