Add studio sdlc command for SDLC maintenance pipeline

Implements a pipeline that orchestrates 7 g3 agents in sequence:
1. euler - dependency graph and hotspots analysis
2. breaker - whitebox exploration and edge-case discovery
3. hopper - deep testing and regression integrity
4. fowler - refactoring to deduplicate and reduce complexity
5. carmack - in-place rewriting for readability and concision
6. lamport - human-readable documentation and validation
7. huffman - semantic compression of memory

Features:
- Commit cursor tracking (--from flag to set starting point)
- Crash recovery (resumes from last incomplete stage)
- Git worktree isolation for all pipeline work
- Visual pipeline display with status icons
- Summary generation saved to .g3/sessions/sdlc/
- Pipeline state persisted to analysis/sdlc/pipeline.json

CLI:
- studio sdlc run [-c N] [--from COMMIT]
- studio sdlc status
- studio sdlc reset

Also adds huffman agent to embedded agents list.
This commit is contained in:
Dhanji R. Prasanna
2026-02-05 10:46:10 +11:00
parent fdb1255f02
commit add8060526
4 changed files with 994 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ use std::process::{Command, Stdio};
use termimad::MadSkin;
mod git;
mod sdlc;
mod session;
use git::GitWorktree;
@@ -82,6 +83,30 @@ enum Commands {
/// Session ID
session_id: String,
},
/// Run the SDLC maintenance pipeline
Sdlc {
#[command(subcommand)]
action: SdlcAction,
},
}
#[derive(Subcommand)]
enum SdlcAction {
/// Run the SDLC pipeline (or resume if interrupted)
Run {
/// Number of commits to process per stage (default: 10)
#[arg(long, short, default_value = "10")]
commits: u32,
/// Set the commit cursor to start from (skips commits before this)
#[arg(long)]
from: Option<String>,
},
/// Show current pipeline status
Status,
/// Reset pipeline state (start fresh)
Reset,
}
fn main() -> Result<()> {
@@ -96,6 +121,11 @@ fn main() -> Result<()> {
Commands::Status { session_id } => cmd_status(&session_id),
Commands::Accept { session_id } => cmd_accept(&session_id),
Commands::Discard { session_id } => cmd_discard(&session_id),
Commands::Sdlc { action } => match action {
SdlcAction::Run { commits, from } => cmd_sdlc_run(commits, from),
SdlcAction::Status => cmd_sdlc_status(),
SdlcAction::Reset => cmd_sdlc_reset(),
},
}
}
@@ -510,6 +540,292 @@ fn cmd_discard(session_id: &str) -> Result<()> {
Ok(())
}
/// Run the SDLC pipeline
fn cmd_sdlc_run(commits_per_run: u32, from_commit: Option<String>) -> Result<()> {
let repo_root = get_repo_root()?;
// Load or create pipeline state
let mut state = match sdlc::PipelineState::load(&repo_root)? {
Some(mut existing) => {
// Resume from where we left off
existing.resume();
println!("\x1b[1;32msdlc:\x1b[0m resuming pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", existing.run_id);
existing
}
None => {
let mut state = sdlc::PipelineState::new(commits_per_run);
// If --from is specified, set the cursor
if let Some(ref from) = from_commit {
// Resolve the commit hash
let resolved = resolve_commit(&repo_root, from)?;
state.commit_cursor = Some(resolved.clone());
println!("\x1b[1;32msdlc:\x1b[0m starting new pipeline run \x1b[38;2;216;177;114m{}\x1b[0m (from {})",
state.run_id, &resolved[..8.min(resolved.len())]);
} else {
println!("\x1b[1;32msdlc:\x1b[0m starting new pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", state.run_id);
}
state
}
};
// Get current HEAD commit
let head_commit = get_head_commit(&repo_root)?;
// Check if there are commits to process
let commits_to_process = if let Some(cursor) = &state.commit_cursor {
count_commits_between(&repo_root, cursor, &head_commit)?
} else {
// First run - use commits_per_run as the count
commits_per_run
};
if commits_to_process == 0 {
println!("\x1b[1;32msdlc:\x1b[0m no new commits since last run");
return Ok(());
}
println!("\x1b[1;32msdlc:\x1b[0m {} commits to process", commits_to_process.min(commits_per_run));
// Display the pipeline
sdlc::display_pipeline(&state);
// Create a dedicated worktree for SDLC
let g3_binary = get_g3_binary_path()?;
let sdlc_session = Session::new("sdlc");
let worktree = GitWorktree::new(&repo_root);
let worktree_path = worktree.create(&sdlc_session)?;
// Save session info for crash recovery
state.session_id = Some(sdlc_session.id.clone());
sdlc_session.save(&repo_root, &worktree_path)?;
state.save(&repo_root)?;
// Run each stage
while !state.is_complete() && state.current_stage < sdlc::PIPELINE_STAGES.len() {
let stage = &sdlc::PIPELINE_STAGES[state.current_stage];
// Display current stage
sdlc::display_current_stage(&state);
println!();
// Mark as running and save
state.mark_running();
state.save(&repo_root)?;
let start_time = std::time::Instant::now();
// Build the task prompt for this agent
let task = format!(
"Focus on changes in the past {} commits (up to {}). {}",
commits_per_run.min(commits_to_process),
&head_commit[..8.min(head_commit.len())],
stage.focus
);
// Run the agent
let result = run_agent_in_worktree(
&g3_binary,
&worktree_path,
stage.name,
&task,
);
let duration = start_time.elapsed().as_secs();
match result {
Ok(true) => {
// Success
state.mark_complete(duration, commits_to_process.min(commits_per_run), &head_commit);
println!();
println!("\x1b[1;32msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m complete in {}",
stage.name, format_duration_short(duration));
}
Ok(false) => {
// Agent completed but with non-zero exit
state.mark_failed("Agent exited with non-zero status");
println!();
println!("\x1b[1;31msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m failed", stage.name);
state.save(&repo_root)?;
break;
}
Err(e) => {
// Error running agent
state.mark_failed(&e.to_string());
println!();
println!("\x1b[1;31msdlc:\x1b[0m stage \x1b[1m{}\x1b[0m error: {}", stage.name, e);
state.save(&repo_root)?;
break;
}
}
state.save(&repo_root)?;
// Display updated pipeline
sdlc::display_pipeline(&state);
}
// Cleanup worktree
worktree.remove(&sdlc_session)?;
sdlc_session.delete(&repo_root)?;
// Generate and display summary
if state.is_complete() {
let summary = sdlc::generate_summary(&state);
println!("{}", summary);
// Save summary to .g3/sessions/sdlc/
let summary_dir = repo_root.join(".g3").join("sessions").join("sdlc");
fs::create_dir_all(&summary_dir).ok();
let summary_path = summary_dir.join(format!("run-{}.md", state.run_id));
fs::write(&summary_path, &summary).ok();
println!("\x1b[1;32msdlc:\x1b[0m pipeline complete!");
} else if state.has_failures() {
println!();
println!("\x1b[1;33msdlc:\x1b[0m pipeline paused due to failures");
println!(" Run 'studio sdlc run' to retry failed stages");
}
Ok(())
}
/// Show SDLC pipeline status
fn cmd_sdlc_status() -> Result<()> {
let repo_root = get_repo_root()?;
match sdlc::PipelineState::load(&repo_root)? {
Some(state) => {
println!("\x1b[1;32msdlc:\x1b[0m pipeline run \x1b[38;2;216;177;114m{}\x1b[0m", state.run_id);
sdlc::display_pipeline(&state);
if state.is_complete() {
println!("Status: \x1b[1;32mComplete\x1b[0m");
} else if state.has_failures() {
println!("Status: \x1b[1;31mFailed\x1b[0m (run 'studio sdlc run' to retry)");
} else {
println!("Status: \x1b[1;33mIn Progress\x1b[0m (stage {}/{})",
state.current_stage + 1, sdlc::PIPELINE_STAGES.len());
}
if let Some(cursor) = &state.commit_cursor {
println!("Commit cursor: {}", cursor);
}
}
None => {
println!("\x1b[1;32msdlc:\x1b[0m no active pipeline");
println!();
println!("Run 'studio sdlc run' to start a new pipeline");
}
}
Ok(())
}
/// Reset SDLC pipeline state
fn cmd_sdlc_reset() -> Result<()> {
let repo_root = get_repo_root()?;
if sdlc::PipelineState::load(&repo_root)?.is_some() {
sdlc::PipelineState::delete(&repo_root)?;
println!("\x1b[1;32msdlc:\x1b[0m pipeline state reset");
} else {
println!("\x1b[1;32msdlc:\x1b[0m no pipeline state to reset");
}
Ok(())
}
/// Format duration in short form
fn format_duration_short(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
/// Resolve a commit reference (hash, branch, tag, etc.) to a full hash
fn resolve_commit(repo_root: &Path, commit_ref: &str) -> Result<String> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["rev-parse", commit_ref])
.output()
.context("Failed to resolve commit")?;
if !output.status.success() {
bail!("Failed to resolve commit '{}'", commit_ref);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Get the current HEAD commit hash
fn get_head_commit(repo_root: &Path) -> Result<String> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["rev-parse", "HEAD"])
.output()
.context("Failed to get HEAD commit")?;
if !output.status.success() {
bail!("Failed to get HEAD commit");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Count commits between two refs
fn count_commits_between(repo_root: &Path, from: &str, to: &str) -> Result<u32> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["rev-list", "--count", &format!("{}..{}", from, to)])
.output()
.context("Failed to count commits")?;
if !output.status.success() {
// If the from commit doesn't exist (first run), return a large number
return Ok(u32::MAX);
}
let count: u32 = String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.unwrap_or(0);
Ok(count)
}
/// Run a g3 agent in a worktree
fn run_agent_in_worktree(
g3_binary: &Path,
worktree_path: &Path,
agent: &str,
task: &str,
) -> Result<bool> {
let mut cmd = Command::new(g3_binary);
cmd.arg("--workspace").arg(worktree_path);
cmd.arg("--agent").arg(agent);
cmd.arg(task);
cmd.current_dir(worktree_path);
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let status = cmd.status().context("Failed to run g3 agent")?;
// If the agent made commits, commit them
if status.success() {
// Stage and commit any changes made by the agent
let _ = Command::new("git")
.current_dir(worktree_path)
.args(["add", "-A"])
.output();
}
Ok(status.success())
}
/// Check if a process is running by PID
fn is_process_running(pid: u32) -> bool {
// Use kill -0 to check if process exists