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