use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use anyhow::{Context, Result, bail};
use super::definition::{
RitualDefinition, PhaseDefinition, PhaseKind, ApprovalRequirement,
FailureStrategy, ArtifactRef, ArtifactSpec, PhaseHooks, RitualConfig,
SkipCondition,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateSummary {
pub name: String,
pub description: Option<String>,
pub source: PathBuf,
pub phase_count: usize,
}
pub struct TemplateRegistry {
search_paths: Vec<PathBuf>,
}
impl TemplateRegistry {
pub fn new() -> Self {
let mut search_paths = vec![
PathBuf::from(".gid/rituals/"),
];
if let Some(home) = dirs::home_dir() {
search_paths.push(home.join(".gid/rituals/"));
}
Self { search_paths }
}
pub fn for_project(project_root: &Path) -> Self {
let mut search_paths = vec![
project_root.join(".gid/rituals/"),
];
if let Some(home) = dirs::home_dir() {
search_paths.push(home.join(".gid/rituals/"));
}
Self { search_paths }
}
pub fn add_path(&mut self, path: PathBuf) {
self.search_paths.push(path);
}
pub fn list(&self) -> Result<Vec<TemplateSummary>> {
let mut templates = Vec::new();
let mut seen_names = std::collections::HashSet::new();
for builtin in Self::builtin_templates() {
if !seen_names.contains(&builtin.name) {
seen_names.insert(builtin.name.clone());
templates.push(TemplateSummary {
name: builtin.name.clone(),
description: builtin.description.clone(),
source: PathBuf::from("<builtin>"),
phase_count: builtin.phases.len(),
});
}
}
for search_path in &self.search_paths {
if !search_path.exists() {
continue;
}
let entries = std::fs::read_dir(search_path)
.with_context(|| format!("Failed to read template directory: {}", search_path.display()))?;
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str());
if !matches!(ext, Some("yml") | Some("yaml")) {
continue;
}
let name = path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
let name = match name {
Some(n) => n,
None => continue,
};
if seen_names.contains(&name) {
continue;
}
match Self::load_from_file(&path) {
Ok(def) => {
seen_names.insert(name.clone());
templates.push(TemplateSummary {
name,
description: def.description,
source: path,
phase_count: def.phases.len(),
});
}
Err(e) => {
tracing::warn!("Failed to load template {}: {}", path.display(), e);
}
}
}
}
Ok(templates)
}
pub fn load(&self, name: &str) -> Result<RitualDefinition> {
for builtin in Self::builtin_templates() {
if builtin.name == name {
return Ok(builtin);
}
}
for search_path in &self.search_paths {
let path = search_path.join(format!("{}.yml", name));
if path.exists() {
return Self::load_from_file(&path);
}
let path = search_path.join(format!("{}.yaml", name));
if path.exists() {
return Self::load_from_file(&path);
}
}
bail!("Template not found: {}", name)
}
fn load_from_file(path: &Path) -> Result<RitualDefinition> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read template: {}", path.display()))?;
let def: RitualDefinition = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse template: {}", path.display()))?;
Ok(def)
}
fn builtin_templates() -> Vec<RitualDefinition> {
vec![
Self::full_dev_cycle_template(),
Self::quick_impl_template(),
Self::bugfix_template(),
]
}
fn full_dev_cycle_template() -> RitualDefinition {
RitualDefinition {
name: "full-dev-cycle".to_string(),
description: Some("Complete development cycle: idea → research → requirements → design → implement → verify".to_string()),
extends: None,
phases: vec![
PhaseDefinition {
id: "discover-existing".to_string(),
kind: PhaseKind::Shell {
command: concat!(
"echo '=== Codebase Discovery ===' && ",
"find . -type f \\( -name '*.rs' -o -name '*.ts' -o -name '*.py' \\) ",
"-not -path '*/target/*' -not -path '*/node_modules/*' -not -path '*/.git/*' ",
"2>/dev/null | head -500 > /tmp/gid-discovery-files.txt && ",
"echo \"Files indexed: $(wc -l < /tmp/gid-discovery-files.txt)\" && ",
"echo '(Discovery complete — review matches in research phase)'"
).to_string(),
},
model: None,
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: Some(1),
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Skip, harness_config: None,
},
PhaseDefinition {
id: "capture-idea".to_string(),
kind: PhaseKind::Skill { name: "idea-intake".to_string() },
model: Some("sonnet".to_string()),
approval: ApprovalRequirement::Optional,
skip_if: None,
timeout_minutes: Some(30),
input: vec![],
output: vec![
ArtifactSpec { path: ".gid/features/{feature}/idea.md".to_string(), required: false },
],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "research".to_string(),
kind: PhaseKind::Skill { name: "research".to_string() },
model: Some("opus".to_string()), approval: ApprovalRequirement::Required, skip_if: None, timeout_minutes: Some(30),
input: vec![
ArtifactRef { from_phase: Some("capture-idea".to_string()), path: ".gid/features/{feature}/idea.md".to_string() },
],
output: vec![
ArtifactSpec { path: "docs/RESEARCH-*.md".to_string(), required: true },
],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "draft-requirements".to_string(),
kind: PhaseKind::Skill { name: "requirements".to_string() },
model: Some("sonnet".to_string()),
approval: ApprovalRequirement::Required,
skip_if: None,
timeout_minutes: Some(60),
input: vec![
ArtifactRef { from_phase: Some("capture-idea".to_string()), path: ".gid/features/{feature}/idea.md".to_string() },
],
output: vec![
ArtifactSpec { path: ".gid/features/{feature}/requirements.md".to_string(), required: true },
],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "draft-design".to_string(),
kind: PhaseKind::Skill { name: "design-doc".to_string() },
model: Some("sonnet".to_string()),
approval: ApprovalRequirement::Required,
skip_if: None,
timeout_minutes: Some(90),
input: vec![
ArtifactRef { from_phase: Some("draft-requirements".to_string()), path: ".gid/features/{feature}/requirements.md".to_string() },
],
output: vec![
ArtifactSpec { path: ".gid/features/{feature}/design.md".to_string(), required: true },
],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "generate-graph".to_string(),
kind: PhaseKind::Skill { name: "design-to-graph".to_string() },
model: Some("sonnet".to_string()),
approval: ApprovalRequirement::Required,
skip_if: None,
timeout_minutes: Some(30),
input: vec![
ArtifactRef { from_phase: Some("draft-requirements".to_string()), path: ".gid/features/{feature}/requirements.md".to_string() },
ArtifactRef { from_phase: Some("draft-design".to_string()), path: ".gid/features/{feature}/design.md".to_string() },
],
output: vec![
ArtifactSpec { path: ".gid/graph.yml".to_string(), required: true },
],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "plan-tasks".to_string(),
kind: PhaseKind::GidCommand {
command: "plan".to_string(),
args: vec![],
},
model: None,
approval: ApprovalRequirement::Optional,
skip_if: None,
timeout_minutes: Some(10),
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "execute-tasks".to_string(),
kind: PhaseKind::Harness { config_overrides: None },
model: Some("opus".to_string()),
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: None,
input: vec![],
output: vec![],
hooks: PhaseHooks {
pre: vec![],
post: vec!["gid extract".to_string()],
},
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "extract-code".to_string(),
kind: PhaseKind::GidCommand {
command: "extract".to_string(),
args: vec![],
},
model: None,
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: Some(15),
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Skip,
harness_config: None,
},
PhaseDefinition {
id: "verify-quality".to_string(),
kind: PhaseKind::GidCommand {
command: "advise".to_string(),
args: vec!["--strict".to_string()],
},
model: None,
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: Some(15),
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
],
config: RitualConfig {
default_model: "sonnet".to_string(),
default_approval: ApprovalRequirement::Optional,
state_file: ".gid/ritual-state.json".to_string(),
log_file: ".gid/execution-log.jsonl".to_string(),
notify: None,
},
task_context: None,
}
}
fn quick_impl_template() -> RitualDefinition {
RitualDefinition {
name: "quick-impl".to_string(),
description: Some("Quick implementation: design → graph → implement → verify".to_string()),
extends: None,
phases: vec![
PhaseDefinition {
id: "draft-design".to_string(),
kind: PhaseKind::Skill { name: "draft-design".to_string() },
model: Some("sonnet".to_string()),
approval: ApprovalRequirement::Auto,
skip_if: Some(SkipCondition::FileExists { file_exists: "DESIGN.md".to_string() }),
timeout_minutes: Some(30),
input: vec![],
output: vec![
ArtifactSpec { path: "DESIGN.md".to_string(), required: false },
],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Skip,
harness_config: None,
},
PhaseDefinition {
id: "generate-graph".to_string(),
kind: PhaseKind::Skill { name: "design-to-graph".to_string() },
model: Some("sonnet".to_string()),
approval: ApprovalRequirement::Optional,
skip_if: None,
timeout_minutes: Some(30),
input: vec![],
output: vec![
ArtifactSpec { path: ".gid/graph.yml".to_string(), required: true },
],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "execute-tasks".to_string(),
kind: PhaseKind::Harness { config_overrides: None },
model: Some("opus".to_string()),
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: None,
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "verify".to_string(),
kind: PhaseKind::GidCommand {
command: "advise".to_string(),
args: vec![],
},
model: None,
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: Some(15),
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
],
config: RitualConfig {
default_model: "sonnet".to_string(),
default_approval: ApprovalRequirement::Auto,
state_file: ".gid/ritual-state.json".to_string(),
log_file: ".gid/execution-log.jsonl".to_string(),
notify: None,
},
task_context: None,
}
}
fn bugfix_template() -> RitualDefinition {
RitualDefinition {
name: "bugfix".to_string(),
description: Some("Bug fix workflow: analyze → fix → verify".to_string()),
extends: None,
phases: vec![
PhaseDefinition {
id: "analyze".to_string(),
kind: PhaseKind::GidCommand {
command: "advise".to_string(),
args: vec![],
},
model: None,
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: Some(10),
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Skip,
harness_config: None,
},
PhaseDefinition {
id: "fix".to_string(),
kind: PhaseKind::Harness { config_overrides: None },
model: Some("opus".to_string()),
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: None,
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "verify".to_string(),
kind: PhaseKind::Shell {
command: "cargo test || npm test || pytest".to_string(),
},
model: None,
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: Some(30),
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
],
config: RitualConfig {
default_model: "sonnet".to_string(),
default_approval: ApprovalRequirement::Auto,
state_file: ".gid/ritual-state.json".to_string(),
log_file: ".gid/execution-log.jsonl".to_string(),
notify: None,
},
task_context: None,
}
}
}
impl Default for TemplateRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
#[test]
fn test_builtin_templates() {
let templates = TemplateRegistry::builtin_templates();
assert!(templates.len() >= 3);
let full = templates.iter().find(|t| t.name == "full-dev-cycle").unwrap();
assert_eq!(full.phases.len(), 10);
let quick = templates.iter().find(|t| t.name == "quick-impl").unwrap();
assert_eq!(quick.phases.len(), 4);
let bugfix = templates.iter().find(|t| t.name == "bugfix").unwrap();
assert_eq!(bugfix.phases.len(), 3);
}
#[test]
fn test_list_builtins() {
let registry = TemplateRegistry::new();
let templates = registry.list().unwrap();
assert!(templates.iter().any(|t| t.name == "full-dev-cycle"));
assert!(templates.iter().any(|t| t.name == "quick-impl"));
assert!(templates.iter().any(|t| t.name == "bugfix"));
}
#[test]
fn test_load_builtin() {
let registry = TemplateRegistry::new();
let full = registry.load("full-dev-cycle").unwrap();
assert_eq!(full.name, "full-dev-cycle");
assert_eq!(full.phases.len(), 10);
let quick = registry.load("quick-impl").unwrap();
assert_eq!(quick.name, "quick-impl");
}
#[test]
fn test_load_from_file() {
let temp_dir = TempDir::new().unwrap();
let template_dir = temp_dir.path().join(".gid/rituals");
fs::create_dir_all(&template_dir).unwrap();
let template_yaml = r#"
name: custom-template
description: A custom template
phases:
- id: step1
kind: shell
command: echo hello
"#;
fs::write(template_dir.join("custom-template.yml"), template_yaml).unwrap();
let registry = TemplateRegistry::for_project(temp_dir.path());
let templates = registry.list().unwrap();
assert!(templates.iter().any(|t| t.name == "custom-template"));
let custom = registry.load("custom-template").unwrap();
assert_eq!(custom.name, "custom-template");
assert_eq!(custom.phases.len(), 1);
}
#[test]
fn test_load_not_found() {
let registry = TemplateRegistry::new();
let result = registry.load("nonexistent-template");
assert!(result.is_err());
}
#[test]
fn test_project_shadows_global() {
let temp_dir = TempDir::new().unwrap();
let template_dir = temp_dir.path().join(".gid/rituals");
fs::create_dir_all(&template_dir).unwrap();
let template_yaml = r#"
name: full-dev-cycle
description: Custom full-dev-cycle
phases:
- id: custom-step
kind: shell
command: echo custom
"#;
fs::write(template_dir.join("full-dev-cycle.yml"), template_yaml).unwrap();
let registry = TemplateRegistry::for_project(temp_dir.path());
let custom = registry.load("full-dev-cycle").unwrap();
assert_eq!(custom.phases.len(), 10); }
#[test]
fn test_full_dev_cycle_structure() {
let template = TemplateRegistry::full_dev_cycle_template();
assert_eq!(template.phases[0].id, "discover-existing");
assert_eq!(template.phases[1].id, "capture-idea");
assert_eq!(template.phases[2].id, "research");
assert_eq!(template.phases[3].id, "draft-requirements");
assert_eq!(template.phases[4].id, "draft-design");
assert_eq!(template.phases[5].id, "generate-graph");
assert_eq!(template.phases[6].id, "plan-tasks");
assert_eq!(template.phases[7].id, "execute-tasks");
assert_eq!(template.phases[8].id, "extract-code");
assert_eq!(template.phases[9].id, "verify-quality");
assert!(matches!(template.phases[0].approval, ApprovalRequirement::Auto));
assert!(template.phases[0].model.is_none());
assert!(matches!(template.phases[2].approval, ApprovalRequirement::Required)); assert!(matches!(template.phases[3].approval, ApprovalRequirement::Required)); assert!(matches!(template.phases[4].approval, ApprovalRequirement::Required)); assert!(matches!(template.phases[7].approval, ApprovalRequirement::Auto)); }
}