diff --git a/CHANGELOG.md b/CHANGELOG.md index e655f3b..636bce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [0.9.0] - 2026-03-23 + +### Added +- **Interactive `tt init`**: Progressive disclosure bootstrap — auto-detects repo + type, shows a team preview table ("character creation screen"), then offers + Launch / Customize / Quit. One command to working agents. +- **Customize mode**: Swap runtimes for roles, remove agents, change project name + — all with a live preview that re-renders after each change. +- **Auto-launch**: Press L during `tt init` to write config and immediately launch + all agents via `tt up`. +- **Re-init with backup**: Running `tt init` when tutti.toml exists offers to back + up the existing config instead of refusing. +- **Node.js template** (`node-fullstack`): 4-agent team with frontend/backend split, + detects `package.json`, includes sdlc-gstack workflow. +- **Python template** (`python-api`): 3-agent team, detects `pyproject.toml` and + `requirements.txt`, includes simplified sdlc-gstack workflow with pytest. +- **`tt template list`**: New subcommand showing all built-in and custom templates + with detection rules and descriptions. +- **Custom template discovery**: Place `.toml` template files in + `~/.config/tutti/templates/` — they're auto-discovered and participate in repo + detection scoring. +- **Run telemetry**: After each `tt run` completes, step-level timing and pass/fail + data is emitted to `.tutti/state/run-telemetry.jsonl` for future evidence-backed + template comparison. +- **`InputSource` trait**: Interactive prompts are testable via mock input — enables + full coverage of the init flow without stdin. + +### Fixed +- EOF stdin no longer causes infinite loop in interactive prompts — returns default + on EOF or read error. +- Template detection scoring unified: `detect_templates` and custom template + discovery now use the same `score_template_detection` function. +- Negative telemetry durations clamped to zero. + ## [0.8.1] - 2026-03-23 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 5f2c6af..41c0b1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1816,7 +1816,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tutti" -version = "0.8.0" +version = "0.9.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index c617b1c..655937a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tutti" -version = "0.8.1" +version = "0.9.0" edition = "2024" # intentional: codebase uses Rust 2024 let-chain syntax description = "Multi-agent orchestration CLI — your agents, all together" license = "MIT" diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 0a8766b..859f1b8 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -9,10 +9,11 @@ use crate::permissions::evaluate_command_policy; use crate::runtime::{self, AgentStatus}; use crate::session::TmuxSession; use crate::state::{ - AutomationRunRecord, ControlEvent, VerifyLastSummary, WorkflowStepIntentRecord, - WorkflowStepOutcomeRecord, append_automation_run, append_control_event, append_policy_decision, - load_workflow_checkpoint, load_workflow_intent, save_verify_last_summary, - save_workflow_checkpoint, save_workflow_intent, save_workflow_output, + AutomationRunRecord, ControlEvent, RunTelemetryEntry, StepTimingEntry, VerifyLastSummary, + WorkflowStepIntentRecord, WorkflowStepOutcomeRecord, append_automation_run, + append_control_event, append_policy_decision, append_run_telemetry, load_workflow_checkpoint, + load_workflow_intent, save_verify_last_summary, save_workflow_checkpoint, save_workflow_intent, + save_workflow_output, }; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -2578,10 +2579,53 @@ impl<'a> WorkflowExecutor<'a> { agent_scope: agent_scope.map(|s| s.to_string()), hook_event: options.hook_event.clone(), hook_agent: options.hook_agent.clone(), + template_id: template_id.clone(), + template_version: template_version.clone(), + }, + )?; + + // Emit run telemetry (best-effort — failures log a warning but don't fail the run). + let duration_secs = + ((result.finished_at - result.started_at).num_milliseconds() as f64 / 1000.0).max(0.0); + let step_timings: Vec = result + .step_results + .iter() + .map(|s| StepTimingEntry { + id: s.step_type.clone(), + duration_secs: s.duration_ms as f64 / 1000.0, + status: match s.status { + StepStatus::Success => "success".to_string(), + StepStatus::Warning => "warning".to_string(), + StepStatus::Failed => "failed".to_string(), + }, + }) + .collect(); + let passed = result + .step_results + .iter() + .filter(|s| matches!(s.status, StepStatus::Success | StepStatus::Warning)) + .count(); + let failed = result + .step_results + .iter() + .filter(|s| s.status == StepStatus::Failed) + .count(); + append_run_telemetry( + self.project_root, + &RunTelemetryEntry { + run_id: result.run_id.clone(), + workflow: result.workflow_name.clone(), template_id, template_version, + started_at: result.started_at, + completed_at: result.finished_at, + duration_secs, + total_steps: result.step_results.len(), + passed_steps: passed, + failed_steps: failed, + step_timings, }, - )?; + ); save_execution_checkpoint(self.project_root, options, agent_scope, &result)?; diff --git a/src/cli/init.rs b/src/cli/init.rs index d06c779..d390af1 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -3,14 +3,96 @@ use crate::config::defaults::DEFAULT_CONFIG; use crate::config::defaults::DEFAULT_GLOBAL_CONFIG; use crate::config::{GlobalConfig, global_config_path}; use crate::error::{Result, TuttiError}; -use crate::template::{self, BuiltinTemplates}; +use crate::template::{self, BuiltinTemplates, ParsedTemplate}; +use comfy_table::{Table, presets::UTF8_FULL}; +use std::io::{self, BufRead, Write}; +use std::path::Path; + +/// Trait for user input — enables testing interactive flows. +pub trait InputSource { + fn prompt_choice(&mut self, prompt: &str, options: &[&str]) -> String; + fn prompt_text(&mut self, prompt: &str, default: &str) -> String; +} + +/// Standard stdin input for production. +pub struct StdinInput; + +impl InputSource for StdinInput { + fn prompt_choice(&mut self, prompt: &str, options: &[&str]) -> String { + let stdin = io::stdin(); + loop { + print!("{prompt} "); + io::stdout().flush().ok(); + let mut line = String::new(); + match stdin.lock().read_line(&mut line) { + Ok(0) => { + // EOF — abort gracefully + println!(); + return "Q".to_string(); + } + Err(_) => { + // Read error — abort gracefully + return "Q".to_string(); + } + Ok(_) => {} + } + if line.trim().is_empty() { + continue; + } + let choice = line.trim().to_uppercase(); + if options.iter().any(|o| o.to_uppercase() == choice) { + return choice; + } + println!("Invalid choice. Options: {}", options.join(", ")); + } + } + + fn prompt_text(&mut self, prompt: &str, default: &str) -> String { + print!("{prompt} [{default}]: "); + io::stdout().flush().ok(); + let mut line = String::new(); + if io::stdin().lock().read_line(&mut line).unwrap_or(0) == 0 { + return default.to_string(); + } + let trimmed = line.trim(); + if trimmed.is_empty() { + default.to_string() + } else { + trimmed.to_string() + } + } +} pub fn run(template_name: Option<&str>) -> Result<()> { - let cwd = std::env::current_dir()?; + run_with_input(template_name, &mut StdinInput) +} + +pub fn run_with_input(template_name: Option<&str>, input: &mut dyn InputSource) -> Result<()> { + run_with_input_in(template_name, input, &std::env::current_dir()?) +} + +pub fn run_with_input_in( + template_name: Option<&str>, + input: &mut dyn InputSource, + cwd: &Path, +) -> Result<()> { + let cwd = cwd.to_path_buf(); let config_path = cwd.join("tutti.toml"); + // Re-init guard: offer backup instead of hard error if config_path.exists() { - return Err(TuttiError::ConfigAlreadyExists(cwd.clone())); + let choice = input.prompt_choice( + "tutti.toml already exists. [B] Backup and regenerate [Q] Quit", + &["B", "Q"], + ); + if choice == "Q" { + println!("Aborted."); + return Ok(()); + } + // Backup existing file + let backup_path = cwd.join("tutti.toml.bak"); + std::fs::copy(&config_path, &backup_path)?; + println!("Backed up existing config to tutti.toml.bak"); } let project_name = cwd @@ -18,68 +100,387 @@ pub fn run(template_name: Option<&str>) -> Result<()> { .and_then(|n| n.to_str()) .unwrap_or("unnamed"); - let config_content = if let Some(name) = template_name { - // Explicit template specified - let (_tpl_name, parsed) = template::load_template(name)?; - print_template_info(&parsed); - template::generate_config(&parsed, project_name)? - } else { - // Auto-detect: scan repo for matching templates - let matches = template::detect_templates(&cwd); - - if matches.len() == 1 { - // High confidence: one template matches - let (name, parsed, _score) = &matches[0]; - println!("Detected repo type — using '{}' template.", name); - print_template_info(parsed); - template::generate_config(parsed, project_name)? - } else if matches.len() > 1 { - // Low confidence: multiple matches — use first but list alternatives - println!("Multiple templates match this repo:"); - for (name, parsed, score) in &matches { - println!( - " {} (score: {}) — {}", - name, score, parsed.metadata.description - ); + // Resolve template: explicit flag, auto-detect, or user pick + let mut parsed = resolve_template(template_name, &cwd, input)?; + let mut current_project_name = project_name.to_string(); + + // Show team preview + println!(); + render_team_preview(&parsed, ¤t_project_name); + + // Interactive L/S/C/Q loop + loop { + let choice = input.prompt_choice( + "\n [L] Launch now [S] Save config only [C] Customize [Q] Quit", + &["L", "S", "C", "Q"], + ); + match choice.as_str() { + "L" => { + let config_content = template::generate_config(&parsed, ¤t_project_name)?; + write_config_and_register( + &config_path, + &config_content, + ¤t_project_name, + &cwd, + )?; + println!("Created tutti.toml"); + + // Auto-launch + match launch_agents(&cwd) { + Ok(()) => { + println!("\nYour agent team is running!"); + println!("Run `tt serve --port 4040` for the web dashboard"); + } + Err(e) => { + eprintln!("\nFailed to launch agents: {e}"); + println!( + "Config saved to tutti.toml. Fix the issue above, then run: tt up" + ); + } + } + return Ok(()); } - println!(); + "S" => { + let config_content = template::generate_config(&parsed, ¤t_project_name)?; + write_config_and_register( + &config_path, + &config_content, + ¤t_project_name, + &cwd, + )?; + println!("Created tutti.toml"); + println!("\nEdit tutti.toml to configure your agent team, then run: tt up"); + return Ok(()); + } + "C" => { + customize_template(&mut parsed, &mut current_project_name, input); + println!(); + render_team_preview(&parsed, ¤t_project_name); + } + "Q" => { + println!("Aborted — no files were written."); + return Ok(()); + } + _ => {} + } + } +} + +/// Resolve which template to use: explicit name, auto-detect, or user pick. +fn resolve_template( + template_name: Option<&str>, + cwd: &Path, + input: &mut dyn InputSource, +) -> Result { + if let Some(name) = template_name { + // Explicit --template flag + let (_name, parsed) = template::load_template(name)?; + println!( + "Using template: {} v{}", + parsed.metadata.name, parsed.metadata.version + ); + return Ok(parsed); + } + + // Auto-detect from repo files + let mut matches = template::detect_templates(cwd); + // Also check custom templates + let custom = template::discover_custom_templates(); + for (name, tpl) in &custom { + let score = template::score_template_detection(&tpl.metadata, cwd); + if score > 0 { + matches.push((name.clone(), tpl.clone(), score)); + } + } + matches.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); + + if matches.len() == 1 { + let (name, parsed, _score) = matches.into_iter().next().unwrap(); + // Show what we detected + let detected_files: Vec<&str> = parsed + .metadata + .detect + .iter() + .filter(|f| cwd.join(f).exists()) + .map(|s| s.as_str()) + .collect(); + println!( + " Detected: {} \u{2192} {} project", + detected_files.join(", "), + name + ); + println!(" Template: {} ({})", name, parsed.metadata.description); + return Ok(parsed); + } - // Fall back to minimal - let content = BuiltinTemplates::get("minimal").ok_or_else(|| { - TuttiError::TemplateParse( - "built-in 'minimal' template missing — reinstall or run `tt doctor`".into(), - ) - })?; - let parsed = template::parse_template(content)?; + if matches.len() > 1 { + println!("Multiple templates match this repo:\n"); + for (i, (name, parsed, score)) in matches.iter().enumerate() { println!( - "Using 'minimal' template. Run `tt init --template ` to choose a different template." + " {}) {} (score: {}) \u{2014} {}", + i + 1, + name, + score, + parsed.metadata.description ); - template::generate_config(&parsed, project_name)? - } else { - // No matches — fall back to minimal - let content = BuiltinTemplates::get("minimal").ok_or_else(|| { - TuttiError::TemplateParse( - "built-in 'minimal' template missing — reinstall or run `tt doctor`".into(), - ) - })?; - let parsed = template::parse_template(content)?; - println!("No template matched this repo — using 'minimal' template."); - println!("Available templates:"); - for &name in BuiltinTemplates::list() { - if let Some(c) = BuiltinTemplates::get(name) - && let Ok(p) = template::parse_template(c) - { - println!(" {:<20} {}", name, p.metadata.description); + } + println!(); + + // Build options: "1", "2", "3", ... + let opts: Vec = (1..=matches.len()).map(|i| i.to_string()).collect(); + let opt_refs: Vec<&str> = opts.iter().map(|s| s.as_str()).collect(); + let choice = input.prompt_choice("Pick a template:", &opt_refs); + let idx: usize = choice.parse::().unwrap_or(1) - 1; + let (name, parsed, _) = matches.into_iter().nth(idx).unwrap(); + println!("Using template: {}", name); + return Ok(parsed); + } + + // No matches — show all templates and let user pick + println!("No template matched this repo. Available templates:\n"); + let all = list_all_templates(); + for (i, (name, parsed)) in all.iter().enumerate() { + println!(" {}) {:<20} {}", i + 1, name, parsed.metadata.description); + } + println!(); + + let opts: Vec = (1..=all.len()).map(|i| i.to_string()).collect(); + let opt_refs: Vec<&str> = opts.iter().map(|s| s.as_str()).collect(); + let choice = input.prompt_choice("Pick a template:", &opt_refs); + let idx: usize = choice.parse::().unwrap_or(all.len()) - 1; + let (_name, parsed) = all.into_iter().nth(idx).unwrap(); + Ok(parsed) +} + +/// Render the team preview table using comfy-table. +fn render_team_preview(parsed: &ParsedTemplate, project_name: &str) { + println!(" Project: {project_name}"); + println!( + " Template: {} v{}\n", + parsed.metadata.name, parsed.metadata.version + ); + + // Agent table + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(vec!["Agent", "Runtime", "Role"]); + + // Parse the config body to extract agent info + let config_str = parsed.config_body.replace("{{project_name}}", project_name); + if let Ok(config) = toml::from_str::(&config_str) { + for agent in &config.agents { + let runtime = agent + .resolved_runtime(&config.defaults, &config.roles) + .unwrap_or_else(|| "unknown".to_string()); + let role_desc = agent + .role + .as_ref() + .and_then(|r| parsed.metadata.roles.get(r)) + .and_then(|rd| rd.description.as_deref()) + .unwrap_or(""); + table.add_row(vec![&agent.name, &runtime, role_desc]); + } + + println!("{table}"); + + // Workflow summary + if !config.workflows.is_empty() { + println!(); + for wf in &config.workflows { + println!(" Workflow: {} ({} steps)", wf.name, wf.steps.len()); + } + } + } else { + // Fallback: show roles from template metadata + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(vec!["Role", "Runtime", "Description"]); + for (role, def) in &parsed.metadata.roles { + table.add_row(vec![ + role.as_str(), + &def.default_runtime, + def.description.as_deref().unwrap_or(""), + ]); + } + println!("{table}"); + } +} + +/// Customize mode: swap runtimes, remove agents, change project name. +fn customize_template( + parsed: &mut ParsedTemplate, + project_name: &mut String, + input: &mut dyn InputSource, +) { + loop { + println!("\n Customize:"); + println!(" 1) Change runtime for a role"); + println!(" 2) Remove an agent"); + println!(" 3) Change project name"); + println!(" D) Done"); + + let choice = input.prompt_choice(" Pick:", &["1", "2", "3", "D"]); + match choice.as_str() { + "1" => { + let roles: Vec = parsed.metadata.roles.keys().cloned().collect(); + if roles.is_empty() { + println!(" No roles defined."); + continue; + } + println!(" Roles:"); + for (i, role) in roles.iter().enumerate() { + let rt = &parsed.metadata.roles[role].default_runtime; + println!(" {}) {} \u{2192} {}", i + 1, role, rt); + } + let opts: Vec = (1..=roles.len()).map(|i| i.to_string()).collect(); + let opt_refs: Vec<&str> = opts.iter().map(|s| s.as_str()).collect(); + let pick = input.prompt_choice(" Which role?", &opt_refs); + let idx: usize = pick.parse::().unwrap_or(1) - 1; + if idx < roles.len() { + let new_rt = input + .prompt_text(" New runtime (claude-code, codex, aider)", "claude-code"); + // Update both template metadata and config body + let role_name = &roles[idx]; + let old_rt = parsed.metadata.roles[role_name].default_runtime.clone(); + if let Some(role_def) = parsed.metadata.roles.get_mut(role_name) { + role_def.default_runtime = new_rt.clone(); + } + // Update the config body: replace the role's runtime in [roles] table + parsed.config_body = parsed.config_body.replace( + &format!("{role_name} = \"{old_rt}\""), + &format!("{role_name} = \"{new_rt}\""), + ); + println!( + " Changed {} runtime: {} \u{2192} {}", + role_name, old_rt, new_rt + ); } } - println!(); - println!("Run `tt init --template ` to choose a specific template."); - template::generate_config(&parsed, project_name)? + "2" => { + // Parse config to get agent list + let config_str = parsed.config_body.replace("{{project_name}}", project_name); + if let Ok(config) = toml::from_str::(&config_str) { + let agents: Vec = + config.agents.iter().map(|a| a.name.clone()).collect(); + if agents.len() <= 1 { + println!(" Can't remove \u{2014} only one agent left."); + continue; + } + println!(" Agents:"); + for (i, name) in agents.iter().enumerate() { + println!(" {}) {}", i + 1, name); + } + let opts: Vec = (1..=agents.len()).map(|i| i.to_string()).collect(); + let opt_refs: Vec<&str> = opts.iter().map(|s| s.as_str()).collect(); + let pick = input.prompt_choice(" Remove which agent?", &opt_refs); + let idx: usize = pick.parse::().unwrap_or(0); + if idx >= 1 && idx <= agents.len() { + let agent_name = &agents[idx - 1]; + + // Check if any workflow steps reference this agent + let has_wf_refs = config.workflows.iter().any(|wf| { + wf.steps + .iter() + .any(|s| s.agent_name() == Some(agent_name.as_str())) + }); + if has_wf_refs { + // Remove workflow steps that reference this agent + remove_workflow_steps_for_agent(&mut parsed.config_body, agent_name); + println!(" Removed workflow steps referencing agent: {}", agent_name); + } + + // Remove the [[agent]] block from config body + remove_agent_from_config_body(&mut parsed.config_body, agent_name); + // Remove the role from metadata if no other agent uses it + if let Some(role) = config.agents[idx - 1].role.as_ref() { + let other_uses = config + .agents + .iter() + .filter(|a| a.name != *agent_name) + .any(|a| a.role.as_deref() == Some(role)); + if !other_uses { + parsed.metadata.roles.remove(role); + } + } + println!(" Removed agent: {}", agent_name); + } + } + } + "3" => { + let new_name = input.prompt_text(" Project name", project_name); + *project_name = new_name.clone(); + println!(" Project name set to: {}", new_name); + } + "D" => break, + _ => {} } - }; + } +} + +/// Remove all [[workflow.step]] blocks that reference a given agent. +fn remove_workflow_steps_for_agent(config_body: &mut String, agent_name: &str) { + // Repeatedly remove [[workflow.step]] blocks containing agent = "agent_name" + let agent_pattern = format!("agent = \"{}\"", agent_name); + loop { + let Some(agent_pos) = config_body.find(&agent_pattern) else { + break; + }; + // Walk backwards to find the [[workflow.step]] header + let before = &config_body[..agent_pos]; + let Some(block_start) = before.rfind("[[workflow.step]]") else { + break; + }; + // Walk forward from block_start to find the next [[ or end + let after_header = &config_body[block_start + "[[workflow.step]]".len()..]; + let block_end = after_header + .find("\n[[") + .map(|p| block_start + "[[workflow.step]]".len() + p) + .unwrap_or(config_body.len()); + // Include leading newline + let trim_start = if block_start > 0 && config_body.as_bytes()[block_start - 1] == b'\n' { + block_start - 1 + } else { + block_start + }; + config_body.replace_range(trim_start..block_end, ""); + } +} + +/// Remove an [[agent]] block from the config body by agent name. +fn remove_agent_from_config_body(config_body: &mut String, agent_name: &str) { + // Find the [[agent]] block that contains name = "agent_name" + let search = format!("name = \"{}\"", agent_name); + if let Some(name_pos) = config_body.find(&search) { + // Walk backwards to find the [[agent]] header + let before = &config_body[..name_pos]; + if let Some(block_start) = before.rfind("[[agent]]") { + // Walk forward to find the next [[agent]] or [[workflow]] or end + let after_block = &config_body[block_start + "[[agent]]".len()..]; + // Stop at any next table header (both [[array]] and [table]) + let block_end = after_block + .find("\n[") + .map(|p| block_start + "[[agent]]".len() + p) + .unwrap_or(config_body.len()); + // Include any leading newline before the block + let trim_start = if block_start > 0 && config_body.as_bytes()[block_start - 1] == b'\n' + { + block_start - 1 + } else { + block_start + }; + config_body.replace_range(trim_start..block_end, ""); + } + } +} - std::fs::write(&config_path, &config_content)?; - println!("Created tutti.toml in {}", cwd.display()); +/// Write config, ensure global config, register workspace. +fn write_config_and_register( + config_path: &Path, + config_content: &str, + project_name: &str, + cwd: &Path, +) -> Result<()> { + std::fs::write(config_path, config_content)?; // Ensure global config exists let global_path = global_config_path(); @@ -88,40 +489,119 @@ pub fn run(template_name: Option<&str>) -> Result<()> { std::fs::create_dir_all(parent)?; } std::fs::write(&global_path, DEFAULT_GLOBAL_CONFIG)?; - println!("Created global config at {}", global_path.display()); } - // Register using the workspace name from the generated config, falling back to dir basename - let workspace_name = toml::from_str::(&config_content) + // Register workspace + let workspace_name = toml::from_str::(config_content) .map(|c| c.workspace.name) .unwrap_or_else(|_| project_name.to_string()); let mut global = GlobalConfig::load()?; - global.register_workspace(&workspace_name, &cwd); + global.register_workspace(&workspace_name, cwd); global.save()?; println!("Registered workspace '{workspace_name}'"); - - println!("\nEdit tutti.toml to configure your agent team, then run: tt up"); Ok(()) } -fn print_template_info(parsed: &template::ParsedTemplate) { - println!( - "Template: {} v{}", - parsed.metadata.name, parsed.metadata.version - ); - println!(" \"{}\"", parsed.metadata.description); +/// Launch agents by invoking `tt up` as a subprocess. +fn launch_agents(project_root: &Path) -> Result<()> { + let tt_bin = std::env::current_exe().unwrap_or_else(|_| "tt".into()); + let output = std::process::Command::new(&tt_bin) + .arg("up") + .current_dir(project_root) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status(); + + match output { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(TuttiError::ConfigValidation(format!( + "tt up exited with status {}", + status + ))), + Err(e) => Err(TuttiError::ConfigValidation(format!( + "failed to run tt up: {e}" + ))), + } +} + +/// List all available templates (built-in + custom). +fn list_all_templates() -> Vec<(String, ParsedTemplate)> { + let mut templates = Vec::new(); + + for &name in BuiltinTemplates::list() { + if let Some(content) = BuiltinTemplates::get(name) + && let Ok(parsed) = template::parse_template(content) + { + templates.push((name.to_string(), parsed)); + } + } + + // Custom templates + for (name, parsed) in template::discover_custom_templates() { + templates.push((name, parsed)); + } + + templates +} + +/// `tt template list` command handler. +pub fn template_list() -> Result<()> { println!(); - println!(" Roles:"); - for (role, def) in &parsed.metadata.roles { - println!( - " {:<16} → {} ({})", - role, - def.default_runtime, - def.description.as_deref().unwrap_or("") - ); + + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(vec!["Name", "Version", "Description", "Detects"]); + + for &name in BuiltinTemplates::list() { + if let Some(content) = BuiltinTemplates::get(name) + && let Ok(parsed) = template::parse_template(content) + { + let detects = if !parsed.metadata.detect.is_empty() { + parsed.metadata.detect.join(", ") + } else { + "\u{2014}".to_string() + }; + table.add_row(vec![ + name, + &parsed.metadata.version, + &parsed.metadata.description, + &detects, + ]); + } + } + + println!(" Built-in templates:"); + println!("{table}"); + + // Custom templates + let custom = template::discover_custom_templates(); + if custom.is_empty() { + println!("\n Custom templates: none found"); + println!(" Place .toml files in ~/.config/tutti/templates/"); + } else { + println!("\n Custom templates:"); + let mut ctable = Table::new(); + ctable.load_preset(UTF8_FULL); + ctable.set_header(vec!["Name", "Version", "Description", "Detects"]); + for (name, parsed) in &custom { + let detects = if !parsed.metadata.detect.is_empty() { + parsed.metadata.detect.join(", ") + } else { + "\u{2014}".to_string() + }; + ctable.add_row(vec![ + name.as_str(), + &parsed.metadata.version, + &parsed.metadata.description, + &detects, + ]); + } + println!("{ctable}"); } + println!(); + Ok(()) } /// Init into a specific directory (used for testing). @@ -161,6 +641,39 @@ pub fn run_template_in(dir: &std::path::Path, template_name: &str) -> Result<()> mod tests { use super::*; + /// Mock input source for testing interactive flows. + struct MockInput { + responses: Vec, + idx: usize, + } + + impl MockInput { + fn new(responses: Vec<&str>) -> Self { + Self { + responses: responses.into_iter().map(|s| s.to_string()).collect(), + idx: 0, + } + } + } + + impl InputSource for MockInput { + fn prompt_choice(&mut self, _prompt: &str, _options: &[&str]) -> String { + let resp = self.responses.get(self.idx).cloned().unwrap_or_default(); + self.idx += 1; + resp.to_uppercase() + } + + fn prompt_text(&mut self, _prompt: &str, default: &str) -> String { + let resp = self.responses.get(self.idx).cloned().unwrap_or_default(); + self.idx += 1; + if resp.is_empty() { + default.to_string() + } else { + resp + } + } + } + #[test] fn init_creates_parseable_config() { let dir = std::env::temp_dir().join(format!("tutti-test-init-{}", std::process::id())); @@ -196,20 +709,15 @@ mod tests { run_template_in(&dir, "gstack-startup").unwrap(); let contents = std::fs::read_to_string(dir.join("tutti.toml")).unwrap(); - - // Verify template comment on line 1 assert!(contents.starts_with("# template: gstack-startup 0.1.0\n")); - // Verify it parses let config: crate::config::TuttiConfig = toml::from_str(&contents).unwrap(); assert_eq!(config.agents.len(), 5); assert!(config.roles.is_some()); - // Verify role mapping works let roles = config.roles.as_ref().unwrap(); assert_eq!(roles.get("reviewer").unwrap(), "codex"); - // Verify validation passes config.validate().unwrap(); std::fs::remove_dir_all(&dir).unwrap(); @@ -223,12 +731,10 @@ mod tests { run_template_in(&dir, "gstack-startup").unwrap(); let contents = std::fs::read_to_string(dir.join("tutti.toml")).unwrap(); - // Simulate role remap: change reviewer from codex to claude-code let remapped = contents.replace("reviewer = \"codex\"", "reviewer = \"claude-code\""); let config: crate::config::TuttiConfig = toml::from_str(&remapped).unwrap(); config.validate().unwrap(); - // Verify the reviewer agent now resolves to claude-code let reviewer = config.agents.iter().find(|a| a.name == "reviewer").unwrap(); assert_eq!( reviewer.resolved_runtime(&config.defaults, &config.roles), @@ -305,11 +811,98 @@ runtime = "aider" "#; let config: crate::config::TuttiConfig = toml::from_str(toml_str).unwrap(); config.validate().unwrap(); - // Explicit runtime should win let agent = &config.agents[0]; assert_eq!( agent.resolved_runtime(&config.defaults, &config.roles), Some("aider".to_string()) ); } + + #[test] + fn interactive_init_quit_does_not_write_config() { + let dir = std::env::temp_dir().join(format!("tutti-test-iq-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + + let mut input = MockInput::new(vec!["Q"]); + let result = run_with_input_in(None, &mut input, &dir); + + assert!(result.is_ok()); + // Quit should NOT write any files + assert!(!dir.join("tutti.toml").exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn interactive_init_customize_project_name() { + let dir = std::env::temp_dir().join(format!("tutti-test-icn-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + + let mut input = MockInput::new(vec!["C", "3", "my-custom-name", "D", "S"]); + let result = run_with_input_in(None, &mut input, &dir); + + assert!(result.is_ok()); + let contents = std::fs::read_to_string(dir.join("tutti.toml")).unwrap(); + let config: crate::config::TuttiConfig = toml::from_str(&contents).unwrap(); + assert_eq!(config.workspace.name, "my-custom-name"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn interactive_init_backup_existing() { + let dir = std::env::temp_dir().join(format!("tutti-test-bak-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + std::fs::write(dir.join("tutti.toml"), "# old config").unwrap(); + std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + + let mut input = MockInput::new(vec!["B", "S"]); + let result = run_with_input_in(None, &mut input, &dir); + + assert!(result.is_ok()); + assert!(dir.join("tutti.toml.bak").exists()); + let backup = std::fs::read_to_string(dir.join("tutti.toml.bak")).unwrap(); + assert_eq!(backup, "# old config"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn team_preview_renders_without_panic() { + let content = BuiltinTemplates::get("gstack-startup").unwrap(); + let parsed = template::parse_template(content).unwrap(); + // Should not panic + render_team_preview(&parsed, "test-project"); + } + + #[test] + fn template_list_runs_without_error() { + template_list().unwrap(); + } + + #[test] + fn remove_agent_from_config_body_works() { + let mut body = r#" +[[agent]] +name = "planner" +role = "planner" + +[[agent]] +name = "tester" +role = "tester" + +[[workflow]] +name = "test" +"# + .to_string(); + remove_agent_from_config_body(&mut body, "planner"); + assert!(!body.contains("planner")); + assert!(body.contains("tester")); + assert!(body.contains("[[workflow]]")); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 169f017..00a8296 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -393,6 +393,18 @@ pub enum Commands { #[command(subcommand)] command: RemoteSubcommand, }, + + /// Manage templates for tt init + Template { + #[command(subcommand)] + command: TemplateSubcommand, + }, +} + +#[derive(Subcommand)] +pub enum TemplateSubcommand { + /// List available templates (built-in and custom) + List, } #[derive(Subcommand)] diff --git a/src/config/mod.rs b/src/config/mod.rs index 0d1bde2..d06a226 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -230,6 +230,20 @@ pub enum WorkflowStepConfig { }, } +impl WorkflowStepConfig { + /// Return the agent name referenced by this step, if any. + pub fn agent_name(&self) -> Option<&str> { + match self { + Self::Prompt { agent, .. } => Some(agent), + Self::EnsureRunning { agent, .. } => Some(agent), + Self::Land { agent, .. } => Some(agent), + Self::Review { agent, .. } => Some(agent), + Self::Command { agent, .. } => agent.as_deref(), + Self::Workflow { agent, .. } => agent.as_deref(), + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum WorkflowCommandCwd { diff --git a/src/error.rs b/src/error.rs index 916e0b9..6817b9f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; #[derive(Debug, thiserror::Error)] pub enum TuttiError { #[error("tutti.toml already exists in {0}")] + #[allow(dead_code)] ConfigAlreadyExists(PathBuf), #[error("tutti.toml not found (searched from {0} to filesystem root)")] diff --git a/src/main.rs b/src/main.rs index a9147ce..cf4a4b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,8 @@ mod worktree; use clap::Parser; use cli::{ - Cli, Commands, IssueClaimSubcommand, RemoteSubcommand, RunsSubcommand, WorkspacesSubcommand, + Cli, Commands, IssueClaimSubcommand, RemoteSubcommand, RunsSubcommand, TemplateSubcommand, + WorkspacesSubcommand, }; use std::process; @@ -184,6 +185,9 @@ fn main() { } => cli::issue_claim::release(state, reason.as_deref()), IssueClaimSubcommand::Sweep => cli::issue_claim::sweep(), }, + Commands::Template { command } => match command { + TemplateSubcommand::List => cli::init::template_list(), + }, }; if let Err(e) = result { diff --git a/src/state/mod.rs b/src/state/mod.rs index 4afcd26..7a13eed 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -623,6 +623,66 @@ pub fn append_automation_run(project_root: &Path, record: &AutomationRunRecord) Ok(()) } +/// A single step timing entry within a run telemetry record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepTimingEntry { + pub id: String, + pub duration_secs: f64, + pub status: String, +} + +/// Telemetry summary emitted after each `tt run` workflow completes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunTelemetryEntry { + pub run_id: String, + pub workflow: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub template_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub template_version: Option, + pub started_at: DateTime, + pub completed_at: DateTime, + pub duration_secs: f64, + pub total_steps: usize, + pub passed_steps: usize, + pub failed_steps: usize, + pub step_timings: Vec, +} + +/// Append a run telemetry entry to .tutti/state/run-telemetry.jsonl. +/// +/// If the write fails, a warning is printed to stderr but no error is returned. +pub(crate) fn append_run_telemetry(project_root: &Path, entry: &RunTelemetryEntry) { + let state_dir = project_root.join(".tutti").join("state"); + if let Err(e) = std::fs::create_dir_all(&state_dir) { + eprintln!("warn: failed to create telemetry directory: {e}"); + return; + } + let path = state_dir.join("run-telemetry.jsonl"); + let line = match serde_json::to_string(entry) { + Ok(l) => l, + Err(e) => { + eprintln!("warn: failed to serialize run telemetry: {e}"); + return; + } + }; + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path); + match file { + Ok(mut f) => { + use std::io::Write; + if let Err(e) = writeln!(f, "{line}") { + eprintln!("warn: failed to write run telemetry: {e}"); + } + } + Err(e) => { + eprintln!("warn: failed to open run telemetry file: {e}"); + } + } +} + /// Save latest verification summary to .tutti/state/verify-last.json. pub fn save_verify_last_summary(project_root: &Path, summary: &VerifyLastSummary) -> Result<()> { let state_dir = project_root.join(".tutti").join("state"); @@ -1750,4 +1810,119 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + + fn make_telemetry_entry(run_id: &str, workflow: &str) -> RunTelemetryEntry { + let now = Utc::now(); + RunTelemetryEntry { + run_id: run_id.to_string(), + workflow: workflow.to_string(), + template_id: Some("gstack-startup".to_string()), + template_version: Some("0.1.0".to_string()), + started_at: now - chrono::Duration::seconds(1800), + completed_at: now, + duration_secs: 1800.0, + total_steps: 3, + passed_steps: 2, + failed_steps: 1, + step_timings: vec![ + StepTimingEntry { + id: "design".to_string(), + duration_secs: 600.0, + status: "success".to_string(), + }, + StepTimingEntry { + id: "implement".to_string(), + duration_secs: 900.0, + status: "success".to_string(), + }, + StepTimingEntry { + id: "review".to_string(), + duration_secs: 300.0, + status: "failed".to_string(), + }, + ], + } + } + + #[test] + fn run_telemetry_creates_file_with_valid_json() { + let dir = std::env::temp_dir().join(format!( + "tutti-test-telemetry-create-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let entry = make_telemetry_entry("run-001", "sdlc-gstack"); + append_run_telemetry(&dir, &entry); + + let path = dir.join(".tutti/state/run-telemetry.jsonl"); + assert!(path.exists(), "telemetry file should be created"); + + let contents = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines.len(), 1); + + let parsed: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(parsed["run_id"], "run-001"); + assert_eq!(parsed["workflow"], "sdlc-gstack"); + assert_eq!(parsed["template_id"], "gstack-startup"); + assert_eq!(parsed["template_version"], "0.1.0"); + assert_eq!(parsed["total_steps"], 3); + assert_eq!(parsed["passed_steps"], 2); + assert_eq!(parsed["failed_steps"], 1); + assert_eq!(parsed["step_timings"].as_array().unwrap().len(), 3); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn run_telemetry_appends_multiple_runs() { + let dir = std::env::temp_dir().join(format!( + "tutti-test-telemetry-append-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let entry1 = make_telemetry_entry("run-001", "sdlc-gstack"); + let entry2 = make_telemetry_entry("run-002", "sdlc-auto"); + append_run_telemetry(&dir, &entry1); + append_run_telemetry(&dir, &entry2); + + let path = dir.join(".tutti/state/run-telemetry.jsonl"); + let contents = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines.len(), 2, "should have two lines, not overwrite"); + + let parsed1: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + let parsed2: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(parsed1["run_id"], "run-001"); + assert_eq!(parsed2["run_id"], "run-002"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn run_telemetry_creates_missing_directory() { + let dir = + std::env::temp_dir().join(format!("tutti-test-telemetry-mkdir-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // Intentionally do NOT create the directory — append_run_telemetry should create it. + + let entry = make_telemetry_entry("run-003", "sdlc-gstack"); + append_run_telemetry(&dir, &entry); + + let path = dir.join(".tutti/state/run-telemetry.jsonl"); + assert!( + path.exists(), + "telemetry file should be created even if .tutti/state/ was missing" + ); + + let contents = std::fs::read_to_string(&path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(contents.trim()).unwrap(); + assert_eq!(parsed["run_id"], "run-003"); + + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/src/template/mod.rs b/src/template/mod.rs index 0ba92ae..6719e9f 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -105,6 +105,8 @@ impl BuiltinTemplates { match name { "gstack-startup" => Some(include_str!("../../templates/gstack-startup.toml")), "rust-cli" => Some(include_str!("../../templates/rust-cli.toml")), + "node-fullstack" => Some(include_str!("../../templates/node-fullstack.toml")), + "python-api" => Some(include_str!("../../templates/python-api.toml")), "minimal" => Some(include_str!("../../templates/minimal.toml")), _ => None, } @@ -112,7 +114,13 @@ impl BuiltinTemplates { /// List all built-in template names. pub fn list() -> &'static [&'static str] { - &["gstack-startup", "rust-cli", "minimal"] + &[ + "gstack-startup", + "rust-cli", + "node-fullstack", + "python-api", + "minimal", + ] } } @@ -128,37 +136,101 @@ pub fn detect_templates(repo_root: &Path) -> Vec<(String, ParsedTemplate, usize) continue; }; - let mut score = 0; - let mut any_match_ok = template.metadata.detect.is_empty(); - let mut all_match_ok = true; + let score = score_template_detection(&template.metadata, repo_root); + if score > 0 { + matches.push((name.to_string(), template, score)); + } + } + + // Sort by score descending, then alphabetically on tie + matches.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); + matches +} - // Check any-match detection - for file in &template.metadata.detect { +/// Score a template's detection rules against a repo root. +/// Returns 0 if the template doesn't match. +pub(crate) fn score_template_detection(metadata: &TemplateMetadata, repo_root: &Path) -> usize { + let mut score = 0; + let mut any_match_ok = metadata.detect.is_empty(); + let mut all_match_ok = true; + + for file in &metadata.detect { + if repo_root.join(file).exists() { + score += 1; + any_match_ok = true; + } + } + + if !metadata.detect_all.is_empty() { + for file in &metadata.detect_all { if repo_root.join(file).exists() { - score += 1; - any_match_ok = true; + score += 2; + } else { + all_match_ok = false; } } + } + + if any_match_ok && all_match_ok && score > 0 { + score + } else { + 0 + } +} + +/// Discover custom templates from ~/.config/tutti/templates/. +pub(crate) fn discover_custom_templates() -> Vec<(String, ParsedTemplate)> { + let mut templates = Vec::new(); + + let home = match std::env::var("HOME") { + Ok(h) => h, + Err(_) => return templates, + }; - // Check all-match detection - if !template.metadata.detect_all.is_empty() { - for file in &template.metadata.detect_all { - if repo_root.join(file).exists() { - score += 1; - } else { - all_match_ok = false; + let templates_dir = std::path::PathBuf::from(&home) + .join(".config") + .join("tutti") + .join("templates"); + + let entries = match std::fs::read_dir(&templates_dir) { + Ok(e) => e, + Err(_) => return templates, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("toml") { + continue; + } + match std::fs::read_to_string(&path) { + Ok(content) => match parse_template(&content) { + Ok(parsed) => { + let name = parsed.metadata.name.clone(); + if templates.iter().any(|(n, _)| n == &name) { + eprintln!( + "warning: skipped {}: duplicate template name '{}'", + path.display(), + name + ); + } else { + templates.push((name, parsed)); + } } + Err(e) => { + eprintln!( + "warning: skipped {}: invalid template format ({})", + path.display(), + e + ); + } + }, + Err(e) => { + eprintln!("warning: could not read {}: {}", path.display(), e); } } - - if any_match_ok && all_match_ok && score > 0 { - matches.push((name.to_string(), template, score)); - } } - // Sort by score descending - matches.sort_by(|a, b| b.2.cmp(&a.2)); - matches + templates } /// Load a template from a name or path. @@ -173,11 +245,23 @@ pub fn load_template(name_or_path: &str) -> Result<(String, ParsedTemplate)> { return Ok((name, template)); } - // Otherwise look up built-in - let content = BuiltinTemplates::get(name_or_path) - .ok_or_else(|| TuttiError::TemplateNotFound(name_or_path.to_string()))?; - let template = parse_template(content)?; - Ok((name_or_path.to_string(), template)) + // Look up built-in first + if let Some(content) = BuiltinTemplates::get(name_or_path) { + let template = parse_template(content)?; + return Ok((name_or_path.to_string(), template)); + } + + // Check custom templates directory + for (name, parsed) in discover_custom_templates() { + if name == name_or_path { + return Ok((name, parsed)); + } + } + + Err(TuttiError::TemplateNotFound(format!( + "Template '{}' not found. Run `tt template list` to see available templates.", + name_or_path + ))) } #[cfg(test)] diff --git a/templates/node-fullstack.toml b/templates/node-fullstack.toml new file mode 100644 index 0000000..3d35305 --- /dev/null +++ b/templates/node-fullstack.toml @@ -0,0 +1,153 @@ +[template] +name = "node-fullstack" +version = "0.1.0" +description = "Node.js full-stack project with frontend and backend agents" +detect = ["package.json"] + +[template.roles.planner] +default_runtime = "claude-code" +description = "Breaks work into steps, produces structured plans" + +[template.roles.frontend] +default_runtime = "claude-code" +description = "Implements UI components and client-side logic" + +[template.roles.backend] +default_runtime = "claude-code" +description = "Implements API routes, services, and data layer" + +[template.roles.tester] +default_runtime = "claude-code" +description = "Writes and runs tests" + +# ── config below ── +[workspace] +name = "{{project_name}}" +description = "Node.js full-stack team — generated from node-fullstack template" + +[defaults] +worktree = true + +[roles] +planner = "claude-code" +frontend = "claude-code" +backend = "claude-code" +tester = "claude-code" + +[[agent]] +name = "planner" +role = "planner" +scope = "docs/**" +prompt = "You own planning and design." + +[[agent]] +name = "frontend" +role = "frontend" +scope = "src/app/**,src/components/**,src/pages/**,public/**" +prompt = "You own frontend implementation. Build UI components and pages. Commit and push when done." + +[[agent]] +name = "backend" +role = "backend" +scope = "src/api/**,src/services/**,src/models/**" +prompt = "You own backend implementation. Build API routes and services. Commit and push when done." + +[[agent]] +name = "tester" +role = "tester" +scope = "src/**,tests/**" +prompt = "You write and run tests." + +# ─── Interactive SDLC with gstack artifact flow ─── +# Human-in-the-loop workflow: each gstack skill is interactive. +# Tutti automates the artifact handoff between steps, not the skill execution. + +[[workflow]] +name = "sdlc-gstack" +description = "Interactive SDLC with gstack skill artifact flow: design → review → implement → test → ship" + +[[workflow.step]] +id = "ensure_planner" +type = "ensure_running" +agent = "planner" + +[[workflow.step]] +id = "ensure_frontend" +type = "ensure_running" +agent = "frontend" + +[[workflow.step]] +id = "ensure_backend" +type = "ensure_running" +agent = "backend" + +[[workflow.step]] +id = "ensure_tester" +type = "ensure_running" +agent = "tester" + +[[workflow.step]] +id = "design" +type = "prompt" +agent = "planner" +text = "/office-hours" +wait_timeout_secs = 3600 +startup_grace_secs = 120 +artifact_glob = "~/.gstack/projects/{slug}/*-design-*.md" +artifact_name = "design_doc" + +[[workflow.step]] +id = "eng_review" +type = "prompt" +agent = "planner" +text = "/plan-eng-review" +wait_timeout_secs = 3600 +startup_grace_secs = 60 +inject_files = ["{{output.design_doc.path}}"] +artifact_glob = "~/.gstack/projects/{slug}/*-test-plan-*.md" +artifact_name = "test_plan" + +[[workflow.step]] +id = "implement_backend" +type = "prompt" +agent = "backend" +text = "Read the design doc and test plan in your .tutti/artifacts/ directory. Implement the approved backend design. Commit and push when done." +wait_for_idle = true +wait_timeout_secs = 7200 +startup_grace_secs = 120 +inject_files = ["{{output.design_doc.path}}", "{{output.test_plan.path}}"] + +[[workflow.step]] +id = "implement_frontend" +type = "prompt" +agent = "frontend" +text = "Read the design doc in your .tutti/artifacts/ directory. Implement the approved frontend design. Commit and push when done." +wait_for_idle = true +wait_timeout_secs = 7200 +startup_grace_secs = 120 +inject_files = ["{{output.design_doc.path}}"] + +[[workflow.step]] +id = "validate" +type = "command" +run = "cd .tutti/worktrees/backend && npm test --quiet" +fail_mode = "closed" + +[[workflow.step]] +id = "qa" +type = "prompt" +agent = "tester" +text = "/review" +wait_for_idle = true +wait_timeout_secs = 3600 +startup_grace_secs = 60 +inject_files = ["{{output.test_plan.path}}"] + +[[workflow.step]] +id = "ship" +type = "prompt" +agent = "planner" +text = "/ship" +wait_for_idle = true +wait_timeout_secs = 1800 +startup_grace_secs = 60 diff --git a/templates/python-api.toml b/templates/python-api.toml new file mode 100644 index 0000000..9394fc7 --- /dev/null +++ b/templates/python-api.toml @@ -0,0 +1,126 @@ +[template] +name = "python-api" +version = "0.1.0" +description = "Python API project" +detect = ["pyproject.toml", "requirements.txt"] + +[template.roles.planner] +default_runtime = "claude-code" +description = "Breaks work into steps, produces structured plans" + +[template.roles.implementer] +default_runtime = "claude-code" +description = "Writes code, commits, pushes" + +[template.roles.tester] +default_runtime = "claude-code" +description = "Writes and runs tests" + +# ── config below ── +[workspace] +name = "{{project_name}}" +description = "Python API team — generated from python-api template" + +[defaults] +worktree = true + +[roles] +planner = "claude-code" +implementer = "claude-code" +tester = "claude-code" + +[[agent]] +name = "planner" +role = "planner" +scope = "docs/**" +prompt = "You own planning and design." + +[[agent]] +name = "implementer" +role = "implementer" +scope = "src/**,app/**" +prompt = "You own implementation. Follow existing patterns. Commit and push when done." + +[[agent]] +name = "tester" +role = "tester" +scope = "tests/**,src/**" +prompt = "You write and run tests." + +# ─── Interactive SDLC with gstack artifact flow ─── +# Simplified workflow using 3 agents: planner handles design, review, and shipping. + +[[workflow]] +name = "sdlc-gstack" +description = "Interactive SDLC with gstack skill artifact flow: design → review → implement → test → ship" + +[[workflow.step]] +id = "ensure_planner" +type = "ensure_running" +agent = "planner" + +[[workflow.step]] +id = "ensure_implementer" +type = "ensure_running" +agent = "implementer" + +[[workflow.step]] +id = "ensure_tester" +type = "ensure_running" +agent = "tester" + +[[workflow.step]] +id = "design" +type = "prompt" +agent = "planner" +text = "/office-hours" +wait_timeout_secs = 3600 +startup_grace_secs = 120 +artifact_glob = "~/.gstack/projects/{slug}/*-design-*.md" +artifact_name = "design_doc" + +[[workflow.step]] +id = "eng_review" +type = "prompt" +agent = "planner" +text = "/plan-eng-review" +wait_timeout_secs = 3600 +startup_grace_secs = 60 +inject_files = ["{{output.design_doc.path}}"] +artifact_glob = "~/.gstack/projects/{slug}/*-test-plan-*.md" +artifact_name = "test_plan" + +[[workflow.step]] +id = "implement_code" +type = "prompt" +agent = "implementer" +text = "Read the design doc and test plan in your .tutti/artifacts/ directory. Implement the approved design. Commit and push when done." +wait_for_idle = true +wait_timeout_secs = 7200 +startup_grace_secs = 120 +inject_files = ["{{output.design_doc.path}}", "{{output.test_plan.path}}"] + +[[workflow.step]] +id = "validate" +type = "command" +run = "cd .tutti/worktrees/implementer && python -m pytest --quiet" +fail_mode = "closed" + +[[workflow.step]] +id = "qa" +type = "prompt" +agent = "tester" +text = "/review" +wait_for_idle = true +wait_timeout_secs = 3600 +startup_grace_secs = 60 +inject_files = ["{{output.test_plan.path}}"] + +[[workflow.step]] +id = "ship" +type = "prompt" +agent = "planner" +text = "/ship" +wait_for_idle = true +wait_timeout_secs = 1800 +startup_grace_secs = 60