diff --git a/Cargo.lock b/Cargo.lock index 0267b06..e544d00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1816,7 +1816,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tutti" -version = "0.6.0" +version = "0.7.0" dependencies = [ "chrono", "clap", diff --git a/dashboard/app.js b/dashboard/app.js index a2301f1..6e5c83e 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -737,7 +737,7 @@ function pollFocusUsage() { if (!fa) return; var url = "/v1/agents/" + encodeURIComponent(fa.workspace) + "/" + encodeURIComponent(fa.agent) + "/focus?lines=1&usage=1"; fetch(url).then(function(res) { return res.json(); }).then(function(json) { - if (!appState.focusAgent || appState.focusAgent.agent !== fa.agent) return; + if (!appState.focusAgent || appState.focusAgent.workspace !== fa.workspace || appState.focusAgent.agent !== fa.agent) return; if (!json.data || !json.data.usage) return; // Update just the usage stats section var u = json.data.usage; @@ -746,6 +746,12 @@ function pollFocusUsage() { statsHtml += statRow("output tokens", formatTokens(u.output_tokens || 0)); statsHtml += statRow("cache read", formatTokens(u.cache_read || 0), "green"); statsHtml += statRow("cache write", formatTokens(u.cache_write || 0)); + var ctxPct = json.data.context_pct; + if (ctxPct != null) { + var ctxColor = ctxPct <= 70 ? "green" : (ctxPct <= 90 ? "amber" : "red"); + statsHtml += statRow("context", ctxPct + "%", ctxColor); + statsHtml += '
'; + } $focusStats.innerHTML = statsHtml; }).catch(function() { /* silently ignore usage poll errors */ }); } @@ -788,23 +794,34 @@ function renderFocusView(data) { if (wasAtBottom) termEl.scrollTop = termEl.scrollHeight; } - // Usage stats - var u = data.usage || {}; - var statsHtml = ""; - statsHtml += statRow("input tokens", formatTokens(u.input_tokens || 0)); - statsHtml += statRow("output tokens", formatTokens(u.output_tokens || 0)); - statsHtml += statRow("cache read", formatTokens(u.cache_read || 0), "green"); - statsHtml += statRow("cache write", formatTokens(u.cache_write || 0)); - // Context bar - var ctxPct = data.context_pct; - if (ctxPct != null) { + // Usage stats — only update if this response includes usage data. + // The separate pollFocusUsage() handles the slow usage poll; don't + // overwrite it with zeros from the fast terminal poll. + if (data.usage && (data.usage.input_tokens || data.usage.output_tokens || data.usage.cache_read || data.usage.cache_write)) { + var u = data.usage; + var statsHtml = ""; + statsHtml += statRow("input tokens", formatTokens(u.input_tokens || 0)); + statsHtml += statRow("output tokens", formatTokens(u.output_tokens || 0)); + statsHtml += statRow("cache read", formatTokens(u.cache_read || 0), "green"); + statsHtml += statRow("cache write", formatTokens(u.cache_write || 0)); + // Context bar + var ctxPct = data.context_pct; + if (ctxPct != null) { + var ctxColor = ctxPct <= 70 ? "green" : (ctxPct <= 90 ? "amber" : "red"); + statsHtml += statRow("context", ctxPct + "%", ctxColor); + statsHtml += '
'; + } else { + statsHtml += statRow("context", "\u2014"); + } + $focusStats.innerHTML = statsHtml; + } else if (data.context_pct != null && $focusStats.innerHTML === "") { + // At least show context bar even without full usage data + var ctxPct = data.context_pct; var ctxColor = ctxPct <= 70 ? "green" : (ctxPct <= 90 ? "amber" : "red"); - statsHtml += statRow("context", ctxPct + "%", ctxColor); + var statsHtml = statRow("context", ctxPct + "%", ctxColor); statsHtml += '
'; - } else { - statsHtml += statRow("context", "\u2014"); + $focusStats.innerHTML = statsHtml; } - $focusStats.innerHTML = statsHtml; // Diff var d = data.diff || {}; diff --git a/src/automation/mod.rs b/src/automation/mod.rs index e610279..0a8766b 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -202,7 +202,7 @@ impl<'a> WorkflowResolver<'a> { .agents .iter() .find(|a| a.name == effective_agent) - .and_then(|a| a.resolved_runtime(&self.config.defaults)) + .and_then(|a| a.resolved_runtime(&self.config.defaults, &self.config.roles)) .unwrap_or_else(|| "unknown".to_string()); steps.push(ResolvedStep::Prompt { step_id: id.clone(), @@ -1063,12 +1063,196 @@ impl<'a> WorkflowExecutor<'a> { wait_for_idle, wait_timeout_secs, startup_grace_secs, + artifact_glob: step_artifact_glob, + artifact_name: step_artifact_name, .. } = step { if runtime == "codex" || runtime == "claude-code" { maybe_submit_buffered_prompt(session_name, &rendered)?; } + + // Artifact-polling mode: artifact_glob set but wait_for_idle is false. + // Poll for the artifact file instead of idle detection. + // This supports interactive skills (e.g. /office-hours) where the + // agent goes idle while waiting for human input. + let use_artifact_polling = step_artifact_glob.is_some() + && step_artifact_name.is_some() + && !*wait_for_idle; + + if use_artifact_polling + && let Some((ref expanded_pattern, ref pre_snap)) = + artifact_pre_snapshot + { + let poll_interval = Duration::from_secs(5); + let deadline = Duration::from_secs(*wait_timeout_secs); + let poll_start = std::time::Instant::now(); + let art_name = step_artifact_name.as_deref().unwrap(); + + eprintln!( + " artifact-polling mode: waiting up to {}s for new file matching '{}'", + wait_timeout_secs, expanded_pattern + ); + + let mut artifact_found = false; + while poll_start.elapsed() < deadline { + std::thread::sleep(poll_interval); + + // Check if session is still alive + if !TmuxSession::session_exists(session_name) { + // Session exited — check if artifact was produced + // before breaking + if let Ok(artifact_path) = + capture_artifact(pre_snap, expanded_pattern, art_name) + { + match store_artifact_output( + self.project_root, + &run_id, + art_name, + &artifact_path, + ) { + Ok(result) => { + output_files.insert( + art_name.to_string(), + result.json_path.display().to_string(), + ); + outputs + .insert(art_name.to_string(), result.value); + artifact_found = true; + } + Err(e) => { + failed_steps.push(step_index); + success = false; + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: StepStatus::Failed, + duration_ms: started.elapsed().as_millis() + as u64, + exit_code: None, + timed_out: false, + message: Some(format!( + "artifact store failed for '{}': {e}", + art_name + )), + stdout: None, + stderr: None, + }); + // Abort the prompt step on store failure + break; + } + } + } else { + // Session died without producing the artifact + failed_steps.push(step_index); + success = false; + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: StepStatus::Failed, + duration_ms: started.elapsed().as_millis() + as u64, + exit_code: None, + timed_out: false, + message: Some(format!( + "session exited before artifact '{}' was produced", + art_name + )), + stdout: None, + stderr: None, + }); + } + break; + } + + // Check if new artifact file has appeared + if let Ok(artifact_path) = + capture_artifact(pre_snap, expanded_pattern, art_name) + { + match store_artifact_output( + self.project_root, + &run_id, + art_name, + &artifact_path, + ) { + Ok(result) => { + output_files.insert( + art_name.to_string(), + result.json_path.display().to_string(), + ); + outputs.insert(art_name.to_string(), result.value); + artifact_found = true; + } + Err(e) => { + failed_steps.push(step_index); + success = false; + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: StepStatus::Failed, + duration_ms: started.elapsed().as_millis() + as u64, + exit_code: None, + timed_out: false, + message: Some(format!( + "artifact store failed for '{}': {e}", + art_name + )), + stdout: None, + stderr: None, + }); + } + } + break; + } + } + + if !artifact_found && success { + failed_steps.push(step_index); + success = false; + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: StepStatus::Failed, + duration_ms: started.elapsed().as_millis() as u64, + exit_code: None, + timed_out: true, + message: Some(format!( + "artifact '{}' not found after {}s of polling", + art_name, wait_timeout_secs + )), + stdout: None, + stderr: None, + }); + break; + } + + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: if artifact_found { + StepStatus::Success + } else { + StepStatus::Failed + }, + duration_ms: started.elapsed().as_millis() as u64, + exit_code: if artifact_found { Some(0) } else { None }, + timed_out: false, + message: Some(if artifact_found { + format!( + "artifact '{}' captured via polling after {}s", + art_name, + poll_start.elapsed().as_secs() + ) + } else { + format!("artifact '{}' failed", art_name) + }), + stdout: None, + stderr: None, + }); + continue; + } + if *wait_for_idle && !wait_for_prompt_activity_or_output( runtime, @@ -1076,7 +1260,7 @@ impl<'a> WorkflowExecutor<'a> { &rendered, baseline_pane_hash, output_json.as_deref(), - Duration::from_secs(20), + Duration::from_secs((*startup_grace_secs).max(20)), )? { failed_steps.push(step_index); @@ -1088,10 +1272,10 @@ impl<'a> WorkflowExecutor<'a> { duration_ms: started.elapsed().as_millis() as u64, exit_code: None, timed_out: true, - message: Some( - "prompt did not start activity or produce output within 20s" - .to_string(), - ), + message: Some(format!( + "prompt did not start activity or produce output within {}s", + (*startup_grace_secs).max(20) + )), stdout: None, stderr: None, }); @@ -1382,7 +1566,7 @@ impl<'a> WorkflowExecutor<'a> { std::thread::sleep(Duration::from_secs(2)); if let Ok(artifact_path) = capture_artifact(pre_snap, expanded_pattern, art_name) - && let Ok(saved) = store_artifact_output( + && let Ok(result) = store_artifact_output( self.project_root, &run_id, art_name, @@ -1391,9 +1575,9 @@ impl<'a> WorkflowExecutor<'a> { { output_files.insert( art_name.to_string(), - saved.path.display().to_string(), + result.json_path.display().to_string(), ); - outputs.insert(art_name.to_string(), saved); + outputs.insert(art_name.to_string(), result.value); } } step_results.push(StepResult { @@ -1446,7 +1630,7 @@ impl<'a> WorkflowExecutor<'a> { std::thread::sleep(Duration::from_secs(2)); if let Ok(artifact_path) = capture_artifact(pre_snap, expanded_pattern, art_name) - && let Ok(saved) = store_artifact_output( + && let Ok(result) = store_artifact_output( self.project_root, &run_id, art_name, @@ -1455,9 +1639,9 @@ impl<'a> WorkflowExecutor<'a> { { output_files.insert( art_name.to_string(), - saved.path.display().to_string(), + result.json_path.display().to_string(), ); - outputs.insert(art_name.to_string(), saved); + outputs.insert(art_name.to_string(), result.value); } } step_results.push(StepResult { @@ -1540,12 +1724,12 @@ impl<'a> WorkflowExecutor<'a> { art_name, &artifact_path, ) { - Ok(saved) => { + Ok(result) => { output_files.insert( art_name.to_string(), - saved.path.display().to_string(), + result.json_path.display().to_string(), ); - outputs.insert(art_name.to_string(), saved); + outputs.insert(art_name.to_string(), result.value); } Err(e) => { failed_steps.push(step_index); @@ -2378,6 +2562,9 @@ impl<'a> WorkflowExecutor<'a> { } } + let (template_id, template_version) = + crate::state::parse_template_tag(&self.project_root.join("tutti.toml")) + .unwrap_or((None, None)); append_automation_run( self.project_root, &AutomationRunRecord { @@ -2391,6 +2578,8 @@ 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_version, }, )?; @@ -2591,13 +2780,22 @@ fn capture_artifact( Ok(newest.clone()) } +/// Artifact output with both the raw file path (for inject_files) and the +/// canonical JSON path (for checkpoint/resume). +struct ArtifactStoreResult { + /// The in-memory output value (path points to the raw artifact file). + value: StepOutputValue, + /// The canonical JSON path that can be read back by `load_resume_outputs`. + json_path: PathBuf, +} + /// Store an artifact file as a step output value (copy to workflow-outputs and register). fn store_artifact_output( project_root: &Path, run_id: &str, artifact_name: &str, artifact_path: &Path, -) -> Result { +) -> Result { let body = std::fs::read_to_string(artifact_path).map_err(|e| { TuttiError::ConfigValidation(format!( "failed reading artifact '{}' at {}: {e}", @@ -2623,9 +2821,12 @@ fn store_artifact_output( )) })?; - Ok(StepOutputValue { - path: raw_path, - json: json_value, + Ok(ArtifactStoreResult { + value: StepOutputValue { + path: raw_path, + json: json_value, + }, + json_path: canonical_path, }) } @@ -2643,7 +2844,7 @@ fn prompt_agent_with_files( .agents .iter() .find(|a| a.name == agent) - .and_then(|a| a.resolved_runtime(&config.defaults)) + .and_then(|a| a.resolved_runtime(&config.defaults, &config.roles)) .unwrap_or_else(|| "unknown".to_string()); if !TmuxSession::session_exists(&session_name) { @@ -4672,6 +4873,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }], tool_packs: vec![], workflows: vec![workflow], @@ -4680,6 +4882,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, } } @@ -5889,6 +6092,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }, AgentConfig { name: "reviewer".to_string(), @@ -5902,6 +6106,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }, ], tool_packs: vec![], @@ -5960,6 +6165,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let dir = std::env::temp_dir().join("tutti-test-resolver-control-steps"); @@ -6099,8 +6305,12 @@ mod tests { std::fs::write(&artifact, "# Design\nThis is the design doc.").unwrap(); let result = store_artifact_output(&dir, "run-001", "design_doc", &artifact).unwrap(); - assert!(result.path.exists()); - assert!(matches!(result.json, Value::String(_))); + assert!(result.value.path.exists()); + assert!(result.json_path.exists()); + assert!(matches!(result.value.json, Value::String(_))); + // json_path should be resumable (valid JSON file) + let json_body = std::fs::read_to_string(&result.json_path).unwrap(); + let _: Value = serde_json::from_str(&json_body).unwrap(); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/budget/mod.rs b/src/budget/mod.rs index 4899303..0c81bec 100644 --- a/src/budget/mod.rs +++ b/src/budget/mod.rs @@ -258,6 +258,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }], tool_packs: vec![], workflows: vec![], @@ -271,6 +272,7 @@ mod tests { agent_weekly_tokens: caps, }), webhooks: vec![], + roles: None, } } diff --git a/src/cli/detect.rs b/src/cli/detect.rs index d542fd0..c16fe11 100644 --- a/src/cli/detect.rs +++ b/src/cli/detect.rs @@ -22,8 +22,14 @@ pub fn run(agent_ref: &str, lines: u32, json: bool) -> Result<()> { let resolved = super::agent_ref::resolve(agent_ref)?; let agent = resolved.agent_config()?; let runtime_name = agent - .resolved_runtime(&resolved.config.defaults) - .unwrap_or_else(|| "unknown".to_string()); + .resolved_runtime(&resolved.config.defaults, &resolved.config.roles) + .ok_or_else(|| { + TuttiError::ConfigValidation(format!( + "agent '{}' has no runtime — set 'runtime' on the agent, assign a 'role' \ + with a [roles] mapping, or set 'defaults.runtime' in tutti.toml", + resolved.agent_name + )) + })?; let session = TmuxSession::session_name(&resolved.workspace_name, &resolved.agent_name); ensure_session_running(&session, &resolved.agent_name)?; diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 233212d..a0dfaad 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -151,7 +151,7 @@ fn evaluate_checks( let launch_targets_supported_runtime = config.agents.iter().any(|agent| { agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .as_deref() .is_some_and(|rt| matches!(rt, "claude-code" | "codex" | "openclaw" | "aider")) }); @@ -185,7 +185,7 @@ fn evaluate_checks( && policy_configured && config.agents.iter().any(|agent| { agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .as_deref() .is_some_and(|rt| matches!(rt, "codex" | "openclaw" | "aider")) }) @@ -198,7 +198,7 @@ fn evaluate_checks( } for agent in &config.agents { - let Some(runtime_name) = agent.resolved_runtime(&config.defaults) else { + let Some(runtime_name) = agent.resolved_runtime(&config.defaults, &config.roles) else { checks.push(DoctorCheck { check: format!("runtime/{}", agent.name), status: DoctorStatus::Fail, @@ -592,6 +592,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }], tool_packs: vec![], workflows: vec![], @@ -600,6 +601,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, } } diff --git a/src/cli/down.rs b/src/cli/down.rs index 7ed1db0..141e136 100644 --- a/src/cli/down.rs +++ b/src/cli/down.rs @@ -79,7 +79,7 @@ pub fn run( project_root: project_root.to_path_buf(), agent_name: agent.name.clone(), runtime: agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .unwrap_or_else(|| "—".to_string()), session_name: session.clone(), reason: "manual".to_string(), @@ -177,7 +177,7 @@ fn run_all(clean: bool) -> Result<()> { project_root: project_root.to_path_buf(), agent_name: agent.name.clone(), runtime: agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .unwrap_or_else(|| "—".to_string()), session_name: session.clone(), reason: "manual".to_string(), diff --git a/src/cli/handoff.rs b/src/cli/handoff.rs index 19a110b..144eeac 100644 --- a/src/cli/handoff.rs +++ b/src/cli/handoff.rs @@ -574,6 +574,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }], tool_packs: vec![], workflows: vec![], @@ -586,6 +587,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, } } diff --git a/src/cli/init.rs b/src/cli/init.rs index cba4990..d06c779 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,8 +1,11 @@ -use crate::config::defaults::{DEFAULT_CONFIG, DEFAULT_GLOBAL_CONFIG}; +#[cfg(test)] +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}; -pub fn run() -> Result<()> { +pub fn run(template_name: Option<&str>) -> Result<()> { let cwd = std::env::current_dir()?; let config_path = cwd.join("tutti.toml"); @@ -10,7 +13,72 @@ pub fn run() -> Result<()> { return Err(TuttiError::ConfigAlreadyExists(cwd.clone())); } - std::fs::write(&config_path, DEFAULT_CONFIG)?; + let project_name = cwd + .file_name() + .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 + ); + } + println!(); + + // 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!( + "Using 'minimal' template. Run `tt init --template ` to choose a different template." + ); + 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!(); + println!("Run `tt init --template ` to choose a specific template."); + template::generate_config(&parsed, project_name)? + } + }; + + std::fs::write(&config_path, &config_content)?; println!("Created tutti.toml in {}", cwd.display()); // Ensure global config exists @@ -23,21 +91,39 @@ pub fn run() -> Result<()> { println!("Created global config at {}", global_path.display()); } - // Register this workspace in the global config + // Register using the workspace name from the generated config, falling back to dir basename + 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()?; - // Derive workspace name from directory name - let ws_name = cwd - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed"); - global.register_workspace(ws_name, &cwd); + global.register_workspace(&workspace_name, &cwd); global.save()?; - println!("Registered workspace '{ws_name}'"); + 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); + println!(); + println!(" Roles:"); + for (role, def) in &parsed.metadata.roles { + println!( + " {:<16} → {} ({})", + role, + def.default_runtime, + def.description.as_deref().unwrap_or("") + ); + } + println!(); +} + /// Init into a specific directory (used for testing). #[cfg(test)] pub fn run_in(dir: &std::path::Path) -> Result<()> { @@ -51,6 +137,26 @@ pub fn run_in(dir: &std::path::Path) -> Result<()> { Ok(()) } +/// Init with a template into a specific directory (used for testing). +#[cfg(test)] +pub fn run_template_in(dir: &std::path::Path, template_name: &str) -> Result<()> { + let config_path = dir.join("tutti.toml"); + + if config_path.exists() { + return Err(TuttiError::ConfigAlreadyExists(dir.to_path_buf())); + } + + let project_name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unnamed"); + + let (_name, parsed) = template::load_template(template_name)?; + let config_content = template::generate_config(&parsed, project_name)?; + std::fs::write(&config_path, config_content)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -81,4 +187,129 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn init_template_generates_valid_config() { + let dir = std::env::temp_dir().join(format!("tutti-test-tpl-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + 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(); + } + + #[test] + fn init_template_role_remap_works() { + let dir = std::env::temp_dir().join(format!("tutti-test-remap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + 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), + Some("claude-code".to_string()) + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn init_template_refuses_nonexistent() { + let err = template::load_template("nonexistent-template").unwrap_err(); + assert!(err.to_string().contains("nonexistent-template")); + } + + #[test] + fn init_template_refuses_overwrite() { + let dir = std::env::temp_dir().join(format!("tutti-test-tpldup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + run_template_in(&dir, "minimal").unwrap(); + let err = run_template_in(&dir, "minimal").unwrap_err(); + assert!(err.to_string().contains("already exists")); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn validate_rejects_role_without_roles_table() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "backend" +role = "implementer" +"#; + let config: crate::config::TuttiConfig = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("[roles] table is not defined")); + } + + #[test] + fn validate_rejects_unknown_role() { + let toml_str = r#" +[workspace] +name = "test" + +[roles] +implementer = "claude-code" + +[[agent]] +name = "backend" +role = "planner" +"#; + let config: crate::config::TuttiConfig = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("[roles] does not define it")); + } + + #[test] + fn validate_accepts_explicit_runtime_with_role() { + let toml_str = r#" +[workspace] +name = "test" + +[roles] +implementer = "claude-code" + +[[agent]] +name = "backend" +role = "implementer" +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()) + ); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ae8ce7e..169f017 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -39,7 +39,11 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { /// Initialize a new tutti.toml in the current directory - Init, + Init { + /// Template name or path to a template .toml file + #[arg(short, long)] + template: Option, + }, /// Launch agent sessions Up { diff --git a/src/cli/send.rs b/src/cli/send.rs index f817dea..7d8ada9 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -207,7 +207,7 @@ fn resolve_agent_ref(agent_ref: &str) -> Result { .find(|a| a.name == agent_name) .ok_or_else(|| TuttiError::AgentNotFound(agent_ref.to_string()))?; let runtime_name = agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .unwrap_or_else(|| "unknown".to_string()); let project_root = config_path.parent().ok_or_else(|| { TuttiError::ConfigValidation("could not determine workspace root".to_string()) @@ -227,7 +227,7 @@ fn resolve_agent_ref(agent_ref: &str) -> Result { .find(|a| a.name == agent_ref) .ok_or_else(|| TuttiError::AgentNotFound(agent_ref.to_string()))?; let runtime_name = agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .unwrap_or_else(|| "unknown".to_string()); let project_root = config_path.parent().ok_or_else(|| { TuttiError::ConfigValidation("could not determine workspace root".to_string()) diff --git a/src/cli/snapshot.rs b/src/cli/snapshot.rs index 2558b19..8887c52 100644 --- a/src/cli/snapshot.rs +++ b/src/cli/snapshot.rs @@ -56,7 +56,7 @@ pub fn gather_workspace_snapshots_with_selected_tail( for agent in &config.agents { let runtime_name = agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .unwrap_or_else(|| "—".to_string()); let session = TmuxSession::session_name(&config.workspace.name, &agent.name); diff --git a/src/cli/up.rs b/src/cli/up.rs index 8906a83..790ec05 100644 --- a/src/cli/up.rs +++ b/src/cli/up.rs @@ -137,12 +137,14 @@ pub fn run( budget::enforce_pre_exec(&config, project_root, "up", Some(&agent.name))?; print_budget_warnings(&budget_outcome); - let runtime_name = agent.resolved_runtime(&config.defaults).ok_or_else(|| { - TuttiError::ConfigValidation(format!( - "agent '{}' has no runtime (set runtime on agent or in [defaults])", - agent.name - )) - })?; + let runtime_name = agent + .resolved_runtime(&config.defaults, &config.roles) + .ok_or_else(|| { + TuttiError::ConfigValidation(format!( + "agent '{}' has no runtime (set runtime on agent or in [defaults])", + agent.name + )) + })?; let session = TmuxSession::session_name(&config.workspace.name, &agent.name); @@ -713,7 +715,7 @@ fn resolve_launch_permissions<'a>( let needs_supported_runtime_policy = agents.iter().any(|agent| { agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .as_deref() .is_some_and(runtime_supports_policy_constrained_no_prompt) }); @@ -1358,7 +1360,7 @@ fn agent_uses_profile( let Some(profile) = global.get_profile(profile_name) else { return false; }; - let Some(runtime_name) = agent.resolved_runtime(&config.defaults) else { + let Some(runtime_name) = agent.resolved_runtime(&config.defaults, &config.roles) else { return false; }; runtime::compatible_command_override( @@ -1537,7 +1539,8 @@ fn run_all( } } - let runtime_name = match agent.resolved_runtime(&config.defaults) { + let runtime_name = match agent.resolved_runtime(&config.defaults, &config.roles) + { Some(rt) => rt, None => { eprintln!(" Skipping {} (no runtime)", agent.name); @@ -1771,6 +1774,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, } } @@ -1803,6 +1807,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }, AgentConfig { name: "codex-agent".to_string(), @@ -1816,6 +1821,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }, ], tool_packs: vec![], @@ -1825,6 +1831,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let global = GlobalConfig { user: None, @@ -1943,6 +1950,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let env = build_workspace_env(&config); assert_eq!(env.get("GIT_AUTHOR_NAME").unwrap(), "Test User"); @@ -1978,6 +1986,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let mut env = build_workspace_env(&config); // Simulate agent-level override @@ -2014,6 +2023,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let global = GlobalConfig { user: None, @@ -2065,6 +2075,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let global = GlobalConfig { user: None, @@ -2128,6 +2139,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let global = GlobalConfig { user: None, @@ -2180,6 +2192,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let global = GlobalConfig { user: None, @@ -2241,6 +2254,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let global = GlobalConfig { user: None, @@ -2327,6 +2341,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let resolved = resolve_launch_settings( @@ -2360,6 +2375,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, }; let launch_settings = LaunchSettings { mode: LaunchMode::Auto, @@ -2561,6 +2577,7 @@ mod tests { persistent: false, memory: Some(".tutti/state/memory/backend.md".to_string()), env: HashMap::new(), + role: None, }; let injected = @@ -2604,6 +2621,7 @@ mod tests { persistent: false, memory: Some(".tutti/state/memory/backend.md".to_string()), env: HashMap::new(), + role: None, }; // Inject twice @@ -2649,6 +2667,7 @@ mod tests { persistent: false, memory: Some(".tutti/state/memory/backend.md".to_string()), env: HashMap::new(), + role: None, }; inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); @@ -2679,6 +2698,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }; inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); @@ -2707,6 +2727,7 @@ mod tests { persistent: false, memory: Some(".tutti/state/memory/backend.md".to_string()), env: HashMap::new(), + role: None, }; inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); @@ -2739,6 +2760,7 @@ mod tests { persistent: false, memory: Some(".tutti/state/memory/backend.md".to_string()), env: HashMap::new(), + role: None, }; // working_dir == project_root → should not mutate CLAUDE.md, returns false @@ -2783,6 +2805,7 @@ mod tests { persistent: false, memory: Some(".tutti/state/memory/backend.md".to_string()), env: HashMap::new(), + role: None, }; let result = @@ -2819,6 +2842,7 @@ mod tests { persistent: false, memory: Some(".tutti/state/memory/backend.md".to_string()), env: HashMap::new(), + role: None, }; // file_injected=true → claude-code skips prompt prepending diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 9e57663..bc56598 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -827,6 +827,7 @@ mod tests { persistent: false, memory: None, env: HashMap::new(), + role: None, }) .collect(), tool_packs: vec![], @@ -836,6 +837,7 @@ mod tests { observe: None, budget: None, webhooks: vec![], + roles: None, } } diff --git a/src/config/defaults.rs b/src/config/defaults.rs index f9fc436..c0bf14f 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -1,4 +1,6 @@ /// Default tutti.toml template written by `tt init`. +/// Kept for backwards compatibility and tests; new `tt init` uses templates. +#[allow(dead_code)] pub const DEFAULT_CONFIG: &str = r#"# tutti.toml — your agent team configuration # Docs: https://github.com/nutthouse/tutti diff --git a/src/config/mod.rs b/src/config/mod.rs index c95c49a..0d1bde2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -12,6 +12,9 @@ pub struct TuttiConfig { pub workspace: WorkspaceConfig, #[serde(default)] pub defaults: DefaultsConfig, + /// Role-to-runtime mapping table. Agents with `role` resolve their runtime here. + #[serde(default)] + pub roles: Option>, #[serde(default)] pub launch: Option, #[serde(default, rename = "agent")] @@ -116,6 +119,9 @@ pub struct AgentConfig { /// Agent-level environment variables (override workspace env). #[serde(default)] pub env: HashMap, + /// Role name for runtime resolution via [roles] table. + #[serde(default)] + pub role: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -558,8 +564,20 @@ fn step_is_control(step: &WorkflowStepConfig) -> bool { } impl AgentConfig { - pub fn resolved_runtime(&self, defaults: &DefaultsConfig) -> Option { - self.runtime.clone().or_else(|| defaults.runtime.clone()) + pub(crate) fn resolved_runtime( + &self, + defaults: &DefaultsConfig, + roles: &Option>, + ) -> Option { + // Resolution order: explicit runtime > role lookup > defaults.runtime + self.runtime + .clone() + .or_else(|| { + self.role + .as_ref() + .and_then(|role| roles.as_ref().and_then(|r| r.get(role).cloned())) + }) + .or_else(|| defaults.runtime.clone()) } pub fn resolved_worktree(&self, defaults: &DefaultsConfig) -> bool { @@ -623,10 +641,32 @@ impl TuttiConfig { // Check for dependency cycles topological_sort(&self.agents)?; + // Validate role mappings + for agent in &self.agents { + if let Some(ref role) = agent.role { + match &self.roles { + None => { + return Err(TuttiError::ConfigValidation(format!( + "agent '{}' has role '{}' but [roles] table is not defined", + agent.name, role + ))); + } + Some(roles) => { + if !roles.contains_key(role) { + return Err(TuttiError::ConfigValidation(format!( + "agent '{}' has role '{}' but [roles] does not define it", + agent.name, role + ))); + } + } + } + } + } + // Check runtimes are known let known_runtimes = ["claude-code", "codex", "aider", "openclaw"]; for agent in &self.agents { - if let Some(rt) = agent.resolved_runtime(&self.defaults) + if let Some(rt) = agent.resolved_runtime(&self.defaults, &self.roles) && !known_runtimes.contains(&rt.as_str()) { return Err(TuttiError::ConfigValidation(format!( @@ -778,7 +818,10 @@ impl TuttiConfig { artifact_name, .. } => { + // Allow wait_timeout_secs/startup_grace_secs when artifact_glob + // is set (artifact-polling mode uses wait_timeout_secs as deadline) if !wait_for_idle.unwrap_or(false) + && artifact_glob.is_none() && (wait_timeout_secs.is_some() || startup_grace_secs.is_some()) { return Err(TuttiError::ConfigValidation(format!( @@ -857,13 +900,9 @@ impl TuttiConfig { idx + 1 ))); } - if !wait_for_idle.unwrap_or(false) { - return Err(TuttiError::ConfigValidation(format!( - "workflow '{}', step {} uses artifact_glob but wait_for_idle is not true; artifact capture requires waiting for the step to complete", - workflow.name, - idx + 1 - ))); - } + // artifact_glob works in two modes: + // - wait_for_idle=true: wait for idle, then capture (non-interactive) + // - wait_for_idle=false/omitted: poll for artifact file (interactive skills) // Validate artifact_name matches step-id character rules if !n .chars() @@ -2247,13 +2286,77 @@ workflow_source = "run" persistent: false, memory: None, env: HashMap::new(), + role: None, }; assert_eq!( - agent.resolved_runtime(&defaults), + agent.resolved_runtime(&defaults, &None), Some("claude-code".to_string()) ); } + #[test] + fn resolved_runtime_uses_role_mapping() { + let defaults = DefaultsConfig { + worktree: true, + runtime: Some("claude-code".to_string()), + }; + let roles: Option> = Some( + [("reviewer".to_string(), "codex".to_string())] + .into_iter() + .collect(), + ); + let agent = AgentConfig { + name: "reviewer".to_string(), + runtime: None, + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: None, + env: HashMap::new(), + role: Some("reviewer".to_string()), + }; + assert_eq!( + agent.resolved_runtime(&defaults, &roles), + Some("codex".to_string()) + ); + } + + #[test] + fn resolved_runtime_explicit_overrides_role() { + let defaults = DefaultsConfig { + worktree: true, + runtime: Some("claude-code".to_string()), + }; + let roles: Option> = Some( + [("reviewer".to_string(), "codex".to_string())] + .into_iter() + .collect(), + ); + let agent = AgentConfig { + name: "reviewer".to_string(), + runtime: Some("aider".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: None, + env: HashMap::new(), + role: Some("reviewer".to_string()), + }; + // Explicit runtime wins over role mapping + assert_eq!( + agent.resolved_runtime(&defaults, &roles), + Some("aider".to_string()) + ); + } + #[test] fn resolved_branch_default() { let agent = AgentConfig { @@ -2268,6 +2371,7 @@ workflow_source = "run" persistent: false, memory: None, env: HashMap::new(), + role: None, }; assert_eq!(agent.resolved_branch(), "tutti/backend"); } @@ -2286,6 +2390,7 @@ workflow_source = "run" persistent: false, memory: None, env: HashMap::new(), + role: None, }; assert!(!agent.resolved_fresh_worktree()); agent.fresh_worktree = Some(true); @@ -2473,7 +2578,7 @@ artifact_name = "design_doc" } #[test] - fn artifact_requires_wait_for_idle() { + fn artifact_glob_without_wait_for_idle_is_valid() { let toml_str = r#" [workspace] name = "test" @@ -2490,12 +2595,14 @@ type = "prompt" id = "design" agent = "planner" text = "/office-hours" +wait_timeout_secs = 3600 artifact_glob = "~/.gstack/projects/{slug}/*-design-*.md" artifact_name = "design_doc" "#; let config: TuttiConfig = toml::from_str(toml_str).unwrap(); - let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("wait_for_idle")); + // Should validate without error — artifact_glob without wait_for_idle + // uses artifact-polling mode for interactive skills + config.validate().unwrap(); } #[test] diff --git a/src/error.rs b/src/error.rs index 52b5997..916e0b9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,16 @@ pub enum TuttiError { #[error("unknown runtime: {0}")] RuntimeUnknown(String), + #[error( + "template parse error: {0} — check the template syntax or run `tt init --template ` with a valid template" + )] + TemplateParse(String), + + #[error( + "template not found: '{0}' — run `tt init` to list available templates, or provide a path to a custom .toml file" + )] + TemplateNotFound(String), + #[error("agent '{0}' not found in config")] AgentNotFound(String), diff --git a/src/health/mod.rs b/src/health/mod.rs index f0bf2eb..4052139 100644 --- a/src/health/mod.rs +++ b/src/health/mod.rs @@ -157,7 +157,7 @@ pub fn probe_workspace( for agent in &config.agents { let runtime_name = agent - .resolved_runtime(&config.defaults) + .resolved_runtime(&config.defaults, &config.roles) .unwrap_or_else(|| "unknown".to_string()); let session_name = TmuxSession::session_name(&config.workspace.name, &agent.name); let running = TmuxSession::session_exists(&session_name); diff --git a/src/main.rs b/src/main.rs index c7012d9..a9147ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod runtime; mod scheduler; mod session; mod state; +mod template; mod usage; mod webhook; mod worktree; @@ -24,7 +25,7 @@ fn main() { let cli = Cli::parse(); let result = match cli.command { - Commands::Init => cli::init::run(), + Commands::Init { ref template } => cli::init::run(template.as_deref()), Commands::Up { ref agent, ref workspace, diff --git a/src/permissions/mod.rs b/src/permissions/mod.rs index 1a77be6..743f704 100644 --- a/src/permissions/mod.rs +++ b/src/permissions/mod.rs @@ -3,6 +3,7 @@ use crate::error::Result; use serde_json::json; const CLAUDE_TOOL_NAMES: &[&str] = &[ + "AskUserQuestion", "Bash", "Edit", "ExitPlanMode", @@ -13,6 +14,7 @@ const CLAUDE_TOOL_NAMES: &[&str] = &[ "NotebookEdit", "NotebookRead", "Read", + "Skill", "Task", "TodoRead", "TodoWrite", diff --git a/src/state/mod.rs b/src/state/mod.rs index 1a77125..4afcd26 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -5,6 +5,37 @@ use serde_json::Value; use std::collections::HashMap; use std::path::{Path, PathBuf}; +/// Parse template_id and template_version from the first line of a tutti.toml. +/// Expected format: `# template: ` +/// +/// Returns `Ok((None, None))` when the file has no template tag. +/// Returns `Err` when the file cannot be read. +pub fn parse_template_tag(config_path: &Path) -> Result<(Option, Option)> { + let content = std::fs::read_to_string(config_path)?; + let Some(first_line) = content.lines().next() else { + return Ok((None, None)); + }; + let Some(rest) = first_line.strip_prefix("# template: ") else { + return Ok((None, None)); + }; + let mut parts = rest.splitn(2, ' '); + let id = parts.next().map(|s| s.to_string()); + let version = parts.next().map(|s| s.to_string()); + // Reject empty id, missing version, or empty version + match (&id, &version) { + (Some(id_str), Some(ver_str)) + if !id_str.is_empty() + && !ver_str.is_empty() + && id_str + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') => + { + Ok((id, version)) + } + _ => Ok((None, None)), + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentState { pub name: String, @@ -29,6 +60,12 @@ pub struct AutomationRunRecord { pub agent_scope: Option, pub hook_event: Option, pub hook_agent: Option, + /// Template identifier from the `# template:` comment in tutti.toml. + #[serde(default)] + pub template_id: Option, + /// Template version from the `# template:` comment in tutti.toml. + #[serde(default)] + pub template_version: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -407,6 +444,9 @@ impl From<&TuttiError> for FailureCategory { } TuttiError::IssueClaim(_) => FailureCategory::Policy, TuttiError::AgentNotFound(_) => FailureCategory::Config, + TuttiError::TemplateParse(_) | TuttiError::TemplateNotFound(_) => { + FailureCategory::Config + } TuttiError::Ssh(_) | TuttiError::RemoteConnection(_) => FailureCategory::Runtime, TuttiError::Io(_) => FailureCategory::Unknown, } @@ -454,6 +494,13 @@ pub fn classify_failure(error: &TuttiError) -> FailureAttribution { TuttiError::ConfigParse(_) | TuttiError::ConfigValidation(_) => { "Review tutti.toml for syntax or schema errors".to_string() } + TuttiError::TemplateParse(_) => { + "Check template file format — ensure [template] section and separator are present" + .to_string() + } + TuttiError::TemplateNotFound(name) => { + format!("Template '{name}' not found — run `tt init` to see available templates") + } TuttiError::State(_) | TuttiError::UsageData(_) => { "Check .tutti/state/ directory permissions and disk space".to_string() } @@ -1164,6 +1211,8 @@ mod tests { agent_scope: Some("backend".to_string()), hook_event: None, hook_agent: None, + template_id: None, + template_version: None, }; append_automation_run(&dir, &record).unwrap(); append_automation_run(&dir, &record).unwrap(); @@ -1633,4 +1682,72 @@ mod tests { assert_eq!(HealthState::AuthFailed.color(), "red"); assert_eq!(HealthState::ProviderDown.color(), "magenta"); } + + #[test] + fn parse_template_tag_valid() { + let dir = std::env::temp_dir().join(format!("tutti-test-tpl-tag-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let config_path = dir.join("tutti.toml"); + std::fs::write( + &config_path, + "# template: gstack-startup 0.1.0\n[workspace]\nname = \"test\"\n", + ) + .unwrap(); + let (id, version) = parse_template_tag(&config_path).unwrap(); + assert_eq!(id.as_deref(), Some("gstack-startup")); + assert_eq!(version.as_deref(), Some("0.1.0")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn parse_template_tag_missing() { + let dir = std::env::temp_dir().join(format!("tutti-test-tpl-tag2-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let config_path = dir.join("tutti.toml"); + std::fs::write(&config_path, "[workspace]\nname = \"test\"\n").unwrap(); + let (id, version) = parse_template_tag(&config_path).unwrap(); + assert!(id.is_none()); + assert!(version.is_none()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn parse_template_tag_nonexistent_file() { + let missing = std::env::temp_dir().join(format!( + "tutti-test-nonexistent-{}/tutti.toml", + std::process::id() + )); + let result = parse_template_tag(&missing); + assert!(result.is_err()); + } + + #[test] + fn parse_template_tag_rejects_partial_tags() { + let dir = + std::env::temp_dir().join(format!("tutti-test-tpl-partial-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + // Missing version + let config_path = dir.join("tutti.toml"); + std::fs::write( + &config_path, + "# template: minimal\n[workspace]\nname = \"t\"\n", + ) + .unwrap(); + let (id, version) = parse_template_tag(&config_path).unwrap(); + assert!(id.is_none()); + assert!(version.is_none()); + + // Empty id + std::fs::write( + &config_path, + "# template: 0.1.0\n[workspace]\nname = \"t\"\n", + ) + .unwrap(); + let (id, version) = parse_template_tag(&config_path).unwrap(); + assert!(id.is_none()); + assert!(version.is_none()); + + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/src/template/mod.rs b/src/template/mod.rs new file mode 100644 index 0000000..0ba92ae --- /dev/null +++ b/src/template/mod.rs @@ -0,0 +1,319 @@ +use crate::error::{Result, TuttiError}; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::path::Path; + +const SEPARATOR: &str = "# ── config below ──"; + +/// Metadata from the [template] section of a template file. +#[derive(Debug, Clone, Deserialize)] +pub struct TemplateMetadata { + pub name: String, + pub version: String, + pub description: String, + #[allow(dead_code)] + #[serde(default)] + pub author: Option, + #[serde(default)] + pub detect: Vec, + #[serde(default)] + pub detect_all: Vec, + #[serde(default)] + pub roles: BTreeMap, +} + +/// Role definition within a template. +#[derive(Debug, Clone, Deserialize)] +pub struct TemplateRoleDef { + pub default_runtime: String, + #[serde(default)] + pub description: Option, +} + +/// Intermediate struct for deserializing the full template TOML (Phase 1). +#[derive(Debug, Deserialize)] +struct TemplateFile { + template: TemplateMetadata, +} + +/// A parsed template ready for generation. +#[derive(Debug, Clone)] +pub struct ParsedTemplate { + pub metadata: TemplateMetadata, + /// Raw config body (everything below the separator), ready for variable substitution. + pub config_body: String, +} + +/// Parse a template from its raw string content. +pub fn parse_template(content: &str) -> Result { + // Phase 1: Parse metadata via TOML deserialization + let template_file: TemplateFile = + toml::from_str(content).map_err(|e| TuttiError::TemplateParse(e.to_string()))?; + + // Phase 2: Extract config body below separator + let sep_pos = content.find(SEPARATOR).ok_or_else(|| { + TuttiError::TemplateParse( + "template file missing '# ── config below ──' separator".to_string(), + ) + })?; + + let after_sep = &content[sep_pos + SEPARATOR.len()..]; + // Skip the rest of the separator line (newline) + let config_body = after_sep + .strip_prefix('\n') + .or_else(|| after_sep.strip_prefix("\r\n")) + .unwrap_or(after_sep); + + Ok(ParsedTemplate { + metadata: template_file.template, + config_body: config_body.to_string(), + }) +} + +/// Generate a tutti.toml from a parsed template. +/// +/// Returns an error if the rendered config is not valid TOML. +pub fn generate_config(template: &ParsedTemplate, project_name: &str) -> Result { + let header = format!( + "# template: {} {}\n", + template.metadata.name, template.metadata.version + ); + + let body = template + .config_body + .replace("{{project_name}}", project_name); + + let rendered = format!("{header}{body}"); + + // Validate the rendered TOML to catch template/substitution errors early + toml::from_str::(&rendered).map_err(|e| { + TuttiError::TemplateParse(format!( + "generated config from template '{}' is invalid TOML: {e}", + template.metadata.name + )) + })?; + + Ok(rendered) +} + +/// Built-in templates embedded at compile time. +pub struct BuiltinTemplates; + +impl BuiltinTemplates { + /// Get a built-in template by name. + pub fn get(name: &str) -> Option<&'static str> { + match name { + "gstack-startup" => Some(include_str!("../../templates/gstack-startup.toml")), + "rust-cli" => Some(include_str!("../../templates/rust-cli.toml")), + "minimal" => Some(include_str!("../../templates/minimal.toml")), + _ => None, + } + } + + /// List all built-in template names. + pub fn list() -> &'static [&'static str] { + &["gstack-startup", "rust-cli", "minimal"] + } +} + +/// Detect which templates match a given repo root by checking file existence. +pub fn detect_templates(repo_root: &Path) -> Vec<(String, ParsedTemplate, usize)> { + let mut matches = Vec::new(); + + for &name in BuiltinTemplates::list() { + let Some(content) = BuiltinTemplates::get(name) else { + continue; + }; + let Ok(template) = parse_template(content) else { + continue; + }; + + let mut score = 0; + let mut any_match_ok = template.metadata.detect.is_empty(); + let mut all_match_ok = true; + + // Check any-match detection + for file in &template.metadata.detect { + if repo_root.join(file).exists() { + score += 1; + any_match_ok = true; + } + } + + // 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; + } + } + } + + 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 +} + +/// Load a template from a name or path. +pub fn load_template(name_or_path: &str) -> Result<(String, ParsedTemplate)> { + // If it looks like a path, read from filesystem + if name_or_path.contains('/') || name_or_path.ends_with(".toml") { + let path = Path::new(name_or_path); + let content = std::fs::read_to_string(path) + .map_err(|e| TuttiError::TemplateNotFound(format!("{}: {}", name_or_path, e)))?; + let template = parse_template(&content)?; + let name = template.metadata.name.clone(); + 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)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_TEMPLATE: &str = r#" +[template] +name = "test-template" +version = "0.1.0" +description = "A test template" +detect = ["Cargo.toml"] + +[template.roles.backend] +default_runtime = "claude-code" +description = "Backend developer" + +[template.roles.reviewer] +default_runtime = "codex" +description = "Code reviewer" + +# ── config below ── +[workspace] +name = "{{project_name}}" +description = "Generated from test-template" + +[defaults] +worktree = true + +[roles] +backend = "claude-code" +reviewer = "codex" + +[[agent]] +name = "backend" +role = "backend" +prompt = "You own the backend." + +[[agent]] +name = "reviewer" +role = "reviewer" +prompt = "You review code." +"#; + + #[test] + fn parse_template_extracts_metadata() { + let parsed = parse_template(TEST_TEMPLATE).unwrap(); + assert_eq!(parsed.metadata.name, "test-template"); + assert_eq!(parsed.metadata.version, "0.1.0"); + assert_eq!(parsed.metadata.description, "A test template"); + assert_eq!(parsed.metadata.detect, vec!["Cargo.toml"]); + assert_eq!(parsed.metadata.roles.len(), 2); + assert_eq!( + parsed.metadata.roles["backend"].default_runtime, + "claude-code" + ); + assert_eq!(parsed.metadata.roles["reviewer"].default_runtime, "codex"); + } + + #[test] + fn parse_template_extracts_config_body() { + let parsed = parse_template(TEST_TEMPLATE).unwrap(); + assert!(parsed.config_body.contains("[workspace]")); + assert!(parsed.config_body.contains("{{project_name}}")); + assert!(!parsed.config_body.contains("[template]")); + } + + #[test] + fn generate_config_substitutes_variables() { + let parsed = parse_template(TEST_TEMPLATE).unwrap(); + let config = generate_config(&parsed, "my-app").unwrap(); + assert!(config.starts_with("# template: test-template 0.1.0\n")); + assert!(config.contains("name = \"my-app\"")); + assert!(!config.contains("{{project_name}}")); + } + + #[test] + fn generated_config_parses_as_tutti_config() { + let parsed = parse_template(TEST_TEMPLATE).unwrap(); + let config_str = generate_config(&parsed, "my-app").unwrap(); + let config: crate::config::TuttiConfig = toml::from_str(&config_str).unwrap(); + assert_eq!(config.workspace.name, "my-app"); + assert_eq!(config.agents.len(), 2); + assert_eq!(config.agents[0].role, Some("backend".to_string())); + assert!(config.roles.is_some()); + } + + #[test] + fn parse_template_missing_separator_errors() { + let bad = r#" +[template] +name = "bad" +version = "0.1.0" +description = "Missing separator" + +[workspace] +name = "test" +"#; + let err = parse_template(bad).unwrap_err(); + assert!(err.to_string().contains("separator")); + } + + #[test] + fn parse_template_missing_metadata_errors() { + let bad = r#" +# ── config below ── +[workspace] +name = "test" +"#; + let err = parse_template(bad).unwrap_err(); + assert!(err.to_string().contains("template")); + } + + #[test] + fn detect_templates_on_empty_dir() { + let dir = std::env::temp_dir().join(format!("tutti-detect-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let matches = detect_templates(&dir); + // No files = no matches (minimal has no detect, only matches as fallback) + assert!(matches.is_empty()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn builtin_templates_are_valid() { + for &name in BuiltinTemplates::list() { + let content = BuiltinTemplates::get(name).unwrap(); + let parsed = parse_template(content) + .unwrap_or_else(|e| panic!("template '{}' failed to parse: {}", name, e)); + assert_eq!(parsed.metadata.name, name); + + // Verify generated config is valid TOML + let config_str = generate_config(&parsed, "test-project") + .unwrap_or_else(|e| panic!("template '{}' generates invalid config: {}", name, e)); + let _config: crate::config::TuttiConfig = toml::from_str(&config_str) + .unwrap_or_else(|e| panic!("template '{}' generates invalid config: {}", name, e)); + } + } +} diff --git a/templates/gstack-startup.toml b/templates/gstack-startup.toml new file mode 100644 index 0000000..6db9422 --- /dev/null +++ b/templates/gstack-startup.toml @@ -0,0 +1,147 @@ +[template] +name = "gstack-startup" +version = "0.1.0" +description = "Full SDLC team for startup repos" +detect = ["Cargo.toml"] + +[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" + +[template.roles.reviewer] +default_runtime = "codex" +description = "Reviews diffs for correctness and style" + +[template.roles.docs-release] +default_runtime = "claude-code" +description = "Updates docs and changelog" + +# ── config below ── +[workspace] +name = "{{project_name}}" +description = "Full SDLC team — generated from gstack-startup template" + +[defaults] +worktree = true + +[roles] +planner = "claude-code" +implementer = "claude-code" +tester = "claude-code" +reviewer = "codex" +docs-release = "claude-code" + +[[agent]] +name = "planner" +role = "planner" +scope = "docs/**" +prompt = "You own planning and design." + +[[agent]] +name = "implementer" +role = "implementer" +scope = "src/**" +prompt = "You own implementation. Commit and push when done." + +[[agent]] +name = "tester" +role = "tester" +scope = "src/**" +prompt = "You write and run tests." + +[[agent]] +name = "reviewer" +role = "reviewer" +prompt = "You review PRs for correctness and style." + +[[agent]] +name = "docs-release" +role = "docs-release" +prompt = "You update docs, changelog, and version." + +# ─── 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_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 && cargo 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/minimal.toml b/templates/minimal.toml new file mode 100644 index 0000000..9d9ca75 --- /dev/null +++ b/templates/minimal.toml @@ -0,0 +1,37 @@ +[template] +name = "minimal" +version = "0.1.0" +description = "Minimal two-agent setup" + +[template.roles.backend] +default_runtime = "claude-code" +description = "Backend development" + +[template.roles.frontend] +default_runtime = "claude-code" +description = "Frontend development" + +# ── config below ── +[workspace] +name = "{{project_name}}" +description = "Minimal agent team — generated from minimal template" + +[defaults] +worktree = true +runtime = "claude-code" + +[roles] +backend = "claude-code" +frontend = "claude-code" + +[[agent]] +name = "backend" +role = "backend" +scope = "src/api/**" +prompt = "You own the API layer. Follow existing patterns." + +[[agent]] +name = "frontend" +role = "frontend" +scope = "src/app/**" +prompt = "You own the UI. Follow existing component patterns." diff --git a/templates/rust-cli.toml b/templates/rust-cli.toml new file mode 100644 index 0000000..a08c785 --- /dev/null +++ b/templates/rust-cli.toml @@ -0,0 +1,68 @@ +[template] +name = "rust-cli" +version = "0.1.0" +description = "Focused Rust CLI team" +detect_all = ["Cargo.toml", "src/main.rs"] + +[template.roles.implementer] +default_runtime = "claude-code" +description = "Writes code, commits, pushes" + +[template.roles.tester] +default_runtime = "claude-code" +description = "Writes and runs tests" + +[template.roles.reviewer] +default_runtime = "codex" +description = "Reviews diffs for correctness and style" + +# ── config below ── +[workspace] +name = "{{project_name}}" +description = "Rust CLI team — generated from rust-cli template" + +[defaults] +worktree = true + +[roles] +implementer = "claude-code" +tester = "claude-code" +reviewer = "codex" + +[[agent]] +name = "implementer" +role = "implementer" +scope = "src/**" +prompt = "You own implementation. Follow existing patterns. Commit and push when done." + +[[agent]] +name = "tester" +role = "tester" +scope = "src/**" +depends_on = ["implementer"] +prompt = "You write and run tests. Run cargo test after changes." + +[[agent]] +name = "reviewer" +role = "reviewer" +prompt = "You review PRs for correctness and style." + +[[workflow]] +name = "verify" +description = "Run cargo test and clippy" + +[[workflow.step]] +id = "test" +type = "command" +run = "cargo test --quiet" +cwd = "workspace" +fail_mode = "closed" +timeout_secs = 600 + +[[workflow.step]] +id = "clippy" +type = "command" +run = "cargo clippy -- -D warnings" +cwd = "workspace" +fail_mode = "closed" +timeout_secs = 300 diff --git a/tutti.toml b/tutti.toml index 5a45b37..eec38ae 100644 --- a/tutti.toml +++ b/tutti.toml @@ -314,3 +314,86 @@ inject_files = [".tutti/state/auto/selected_issue.json", ".tutti/state/auto/bran wait_for_idle = true wait_timeout_secs = 900 text = "Summarize final readiness, residual risks, release impact, and merge recommendation." + +# ─── 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. +# Usage: tt run sdlc-gstack + +[[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" +# No wait_for_idle — artifact-polling mode: tutti polls for the design doc +# instead of idle-detecting, so interactive /office-hours can wait for human input + +[[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" +# No wait_for_idle — artifact-polling mode for interactive /plan-eng-review + +[[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 && cargo 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