use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RitualPhase {
Idle,
Initializing,
Triaging,
WaitingClarification,
WritingRequirements,
Designing,
Reviewing,
WaitingApproval,
Planning,
Graphing,
Implementing,
Verifying,
Done,
Escalated,
Cancelled,
}
impl RitualPhase {
pub fn display_name(&self) -> &'static str {
match self {
Self::Idle => "Idle",
Self::Initializing => "Initializing",
Self::Triaging => "Triage",
Self::WaitingClarification => "Waiting for Clarification",
Self::WritingRequirements => "Requirements",
Self::Designing => "Design",
Self::Reviewing => "Reviewing",
Self::WaitingApproval => "Waiting for Approval",
Self::Planning => "Planning",
Self::Graphing => "Graph",
Self::Implementing => "Implement",
Self::Verifying => "Verify",
Self::Done => "Done",
Self::Escalated => "Escalated",
Self::Cancelled => "Cancelled",
}
}
pub fn next(&self) -> Option<RitualPhase> {
match self {
Self::Initializing => Some(Self::Triaging),
Self::Triaging => Some(Self::Designing),
Self::WaitingClarification => Some(Self::WritingRequirements),
Self::WritingRequirements => Some(Self::Reviewing),
Self::Designing => Some(Self::Reviewing),
Self::Reviewing => Some(Self::WaitingApproval),
Self::WaitingApproval => Some(Self::Planning),
Self::Planning => Some(Self::Graphing),
Self::Graphing => Some(Self::Reviewing),
Self::Implementing => Some(Self::Verifying),
Self::Verifying => Some(Self::Done),
_ => None,
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Done | Self::Escalated | Self::Cancelled)
}
pub fn is_paused(&self) -> bool {
matches!(self, Self::WaitingClarification | Self::WaitingApproval)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RitualState {
#[serde(default = "default_ritual_id")]
pub id: String,
pub phase: RitualPhase,
pub task: String,
#[serde(default)]
pub target_root: Option<String>,
pub project: Option<ProjectState>,
pub strategy: Option<ImplementStrategy>,
pub verify_retries: u32,
pub phase_retries: HashMap<String, u32>,
pub failed_phase: Option<RitualPhase>,
pub error_context: Option<String>,
#[serde(default)]
pub review_target: Option<String>,
#[serde(default)]
pub triage_size: Option<String>,
#[serde(default)]
pub phase_tokens: HashMap<String, u64>,
#[serde(default)]
pub review_round: u32,
pub transitions: Vec<TransitionRecord>,
pub started_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn default_ritual_id() -> String {
generate_ritual_id()
}
pub fn generate_ritual_id() -> String {
use std::time::SystemTime;
let ts = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("r-{:06x}", ts & 0xFFFFFF)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TransitionRecord {
pub from: RitualPhase,
pub to: RitualPhase,
pub event: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProjectState {
pub has_requirements: bool,
pub has_design: bool,
pub has_graph: bool,
pub has_source: bool,
pub has_tests: bool,
pub language: Option<String>,
pub source_file_count: usize,
pub verify_command: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ImplementStrategy {
SingleLlm,
MultiAgent { tasks: Vec<String> },
}
impl RitualState {
pub fn new() -> Self {
let now = Utc::now();
Self {
id: generate_ritual_id(),
phase: RitualPhase::Idle,
task: String::new(),
target_root: None,
project: None,
strategy: None,
verify_retries: 0,
phase_retries: HashMap::new(),
failed_phase: None,
error_context: None,
review_target: None,
review_round: 0,
phase_tokens: HashMap::new(),
transitions: Vec::new(),
started_at: now,
triage_size: None,
updated_at: now,
}
}
pub fn with_phase(mut self, phase: RitualPhase) -> Self {
self.transitions.push(TransitionRecord {
from: self.phase.clone(),
to: phase.clone(),
event: format!("{:?} → {:?}", self.phase, phase),
timestamp: Utc::now(),
});
self.phase = phase;
self.updated_at = Utc::now();
self
}
pub fn with_task(mut self, task: String) -> Self {
self.task = task;
self
}
pub fn with_target_root(mut self, root: String) -> Self {
self.target_root = Some(root);
self
}
pub fn with_project(mut self, ps: ProjectState) -> Self {
self.project = Some(ps);
self
}
pub fn with_strategy(mut self, strategy: ImplementStrategy) -> Self {
self.strategy = Some(strategy);
self
}
pub fn with_review_target(mut self, target: &str) -> Self {
self.review_target = Some(target.to_string());
self
}
pub fn with_review_round(mut self, round: u32) -> Self {
self.review_round = round;
self
}
pub fn inc_review_round(mut self) -> Self {
self.review_round += 1;
self
}
pub fn add_phase_tokens(mut self, phase: &str, tokens: u64) -> Self {
*self.phase_tokens.entry(phase.to_string()).or_insert(0) += tokens;
self
}
pub fn total_tokens(&self) -> u64 {
self.phase_tokens.values().sum()
}
pub fn inc_verify_retries(mut self) -> Self {
self.verify_retries += 1;
self
}
pub fn inc_phase_retry(mut self, phase_key: &str) -> Self {
*self.phase_retries.entry(phase_key.to_string()).or_insert(0) += 1;
self
}
pub fn with_failed_phase(mut self, phase: RitualPhase) -> Self {
self.failed_phase = Some(phase);
self
}
pub fn with_error_context(mut self, error: String) -> Self {
self.error_context = Some(error);
self
}
pub fn retries_for(&self, phase_key: &str) -> u32 {
*self.phase_retries.get(phase_key).unwrap_or(&0)
}
pub fn verify_command(&self) -> &str {
self.project
.as_ref()
.and_then(|p| p.verify_command.as_deref())
.unwrap_or("echo 'No verify command configured'")
}
}
impl Default for RitualState {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TriageResult {
pub clarity: String,
#[serde(default)]
pub clarify_questions: Vec<String>,
pub size: String,
#[serde(default)]
pub skip_design: bool,
#[serde(default)]
pub skip_graph: bool,
}
#[derive(Clone, Debug)]
pub enum RitualEvent {
Start { task: String },
UserCancel,
UserRetry,
UserSkipPhase,
ProjectDetected(ProjectState),
TriageCompleted(TriageResult),
UserClarification { response: String },
UserApproval { approved: String },
PlanDecided(ImplementStrategy),
SkillCompleted { phase: String, artifacts: Vec<String> },
SkillFailed { phase: String, error: String },
ShellCompleted { stdout: String, exit_code: i32 },
ShellFailed { stderr: String, exit_code: i32 },
}
#[derive(Clone, Debug)]
pub enum RitualAction {
DetectProject,
RunTriage { task: String },
RunSkill { name: String, context: String },
RunShell { command: String },
RunHarness { tasks: Vec<String> },
RunPlanning,
UpdateGraph { description: String },
Notify { message: String },
SaveState,
Cleanup,
ApplyReview { approved: String },
}
impl RitualAction {
pub fn is_event_producing(&self) -> bool {
matches!(
self,
RitualAction::DetectProject
| RitualAction::RunTriage { .. }
| RitualAction::RunSkill { .. }
| RitualAction::RunShell { .. }
| RitualAction::RunHarness { .. }
| RitualAction::RunPlanning
)
}
pub fn is_fire_and_forget(&self) -> bool {
!self.is_event_producing()
}
}
pub fn transition(state: &RitualState, event: RitualEvent) -> (RitualState, Vec<RitualAction>) {
use RitualPhase::*;
use RitualEvent::*;
use RitualAction::*;
match (&state.phase, event) {
(Idle, Start { task }) => (
state.clone().with_phase(Initializing).with_task(task.clone()),
vec![
Notify { message: format!("🔧 Ritual started: \"{}\"", task) },
SaveState,
DetectProject,
],
),
(Initializing, ProjectDetected(ps)) => (
state.clone().with_phase(Triaging).with_project(ps),
vec![
Notify { message: "🔍 Triaging task...".into() },
SaveState,
RunTriage { task: state.task.clone() },
],
),
(Triaging, TriageCompleted(result)) if result.clarity == "clear" => {
let skip_design = result.skip_design;
let skip_graph = result.skip_graph;
let mut new_state = state.clone();
new_state.triage_size = Some(result.size.clone());
new_state.error_context = Some(format!(
"triage: size={}, skip_design={}, skip_graph={}",
result.size, skip_design, skip_graph
));
if skip_design && skip_graph {
(
new_state.with_phase(Planning),
vec![
Notify { message: format!("⚡ Small task ({}). Skipping design & graph.", result.size) },
SaveState,
RunPlanning,
],
)
} else if skip_design {
(
new_state.with_phase(Planning),
vec![
Notify { message: format!("📋 Medium task ({}). Skipping design.", result.size) },
SaveState,
RunPlanning,
],
)
} else {
let has_requirements = state.project.as_ref().map_or(false, |p| p.has_requirements);
if has_requirements {
let skill = if state.project.as_ref().map_or(false, |p| p.has_design) {
"update-design"
} else {
"draft-design"
};
(
new_state.with_phase(Designing),
vec![
Notify { message: format!("📝 Phase 2/5: {}...", skill) },
SaveState,
RunSkill { name: skill.into(), context: state.task.clone() },
],
)
} else {
(
new_state.with_phase(WritingRequirements),
vec![
Notify { message: "📋 Phase 1/5: Writing requirements...".into() },
SaveState,
RunSkill { name: "draft-requirements".into(), context: state.task.clone() },
],
)
}
}
}
(Triaging, TriageCompleted(result)) => {
let questions = result.clarify_questions.join("\n• ");
(
state.clone().with_phase(WaitingClarification),
vec![
Notify { message: format!(
"❓ Task needs clarification:\n• {}\n\nPlease reply with details, then /ritual retry.",
questions
)},
SaveState,
],
)
}
(WaitingClarification, UserClarification { response }) => {
let enriched_task = format!("{}\n\nClarification: {}", state.task, response);
(
state.clone().with_phase(Triaging).with_task(enriched_task.clone()),
vec![
Notify { message: "🔍 Re-triaging with clarification...".into() },
SaveState,
RunTriage { task: enriched_task },
],
)
}
(WaitingClarification, UserRetry) => (
state.clone().with_phase(Triaging),
vec![
Notify { message: "🔍 Re-triaging...".into() },
SaveState,
RunTriage { task: state.task.clone() },
],
),
(WritingRequirements, SkillCompleted { .. }) => (
state.clone().with_phase(Reviewing).with_review_target("requirements"),
vec![
Notify { message: "📝 Reviewing requirements...".into() },
SaveState,
RunSkill { name: "review-requirements".into(), context: state.task.clone() },
],
),
(Designing, SkillCompleted { .. }) => {
let design_was_updated = state.project.as_ref().map_or(false, |p| p.has_design);
let is_large = state.triage_size.as_deref() == Some("large");
if design_was_updated && !is_large {
(
state.clone().with_phase(Planning),
vec![
Notify { message: "📝 Design updated (incremental). Skipping review → Planning...".into() },
SaveState,
RunPlanning,
],
)
} else {
(
state.clone().with_phase(Reviewing).with_review_target("design"),
vec![
Notify { message: "📝 Reviewing design document...".into() },
SaveState,
RunSkill { name: "review-design".into(), context: state.task.clone() },
],
)
}
}
(Planning, PlanDecided(strategy)) => {
let skill = if state.project.as_ref().map_or(false, |p| p.has_graph) {
"update-graph"
} else {
"generate-graph"
};
(
state.clone().with_phase(Graphing).with_strategy(strategy),
vec![
Notify { message: format!("📊 Phase 2/4: {}...", skill) },
SaveState,
RunSkill { name: skill.into(), context: state.task.clone() },
],
)
}
(Graphing, SkillCompleted { .. }) => {
let graph_was_updated = state.project.as_ref().map_or(false, |p| p.has_graph);
let is_large = state.triage_size.as_deref() == Some("large");
if graph_was_updated && !is_large {
let action = match &state.strategy {
Some(ImplementStrategy::MultiAgent { tasks }) => RunHarness { tasks: tasks.clone() },
_ => RunSkill { name: "implement".into(), context: state.task.clone() },
};
(
state.clone().with_phase(Implementing),
vec![
Notify { message: "📊 Graph updated (incremental). Skipping review → Implementing...".into() },
SaveState,
action,
],
)
} else {
(
state.clone().with_phase(Reviewing).with_review_target("tasks"),
vec![
Notify { message: "📝 Reviewing task breakdown...".into() },
SaveState,
RunSkill { name: "review-tasks".into(), context: state.task.clone() },
],
)
}
}
(Reviewing, SkillCompleted { .. }) => {
let round = state.review_round + 1; let target = state.review_target.as_deref().unwrap_or("unknown");
(
state.clone().with_phase(WaitingApproval).inc_review_round(),
vec![
Notify { message: format!(
"📋 Review complete ({} round {}/2). Check `.gid/reviews/` for findings.\n\
Auto-applying all findings in 3 minutes if no response.",
target, round
)},
SaveState,
],
)
}
(WaitingApproval, UserApproval { approved }) => {
let review_target = state.review_target.clone().unwrap_or_default();
if state.review_round < 2 {
let review_skill = match review_target.as_str() {
"requirements" => "review-requirements",
"design" => "review-design",
"tasks" => "review-tasks",
_ => "review-design",
};
return (
state.clone().with_phase(Reviewing),
vec![
ApplyReview { approved },
Notify { message: format!(
"🔄 Applied round 1 findings. Starting review round 2/2 for {}...",
review_target
)},
SaveState,
RunSkill { name: review_skill.into(), context: state.task.clone() },
],
);
}
match review_target.as_str() {
"requirements" => {
let skill = if state.project.as_ref().map_or(false, |p| p.has_design) {
"update-design"
} else {
"draft-design"
};
(
state.clone().with_phase(Designing).with_review_round(0),
vec![
ApplyReview { approved },
Notify { message: format!("✅ 2 review rounds complete. Phase 2/5: {}...", skill) },
SaveState,
RunSkill { name: skill.into(), context: state.task.clone() },
],
)
}
"design" => (
state.clone().with_phase(Planning).with_review_round(0),
vec![
ApplyReview { approved },
Notify { message: "✅ 2 review rounds complete. 🧠 Planning implementation strategy...".into() },
SaveState,
RunPlanning,
],
),
"tasks" => {
let action = match &state.strategy {
Some(ImplementStrategy::MultiAgent { tasks }) => RunHarness { tasks: tasks.clone() },
_ => RunSkill { name: "implement".into(), context: state.task.clone() },
};
(
state.clone().with_phase(Implementing).with_review_round(0),
vec![
ApplyReview { approved },
Notify { message: "✅ 2 review rounds complete. 💻 Implementing...".into() },
SaveState,
action,
],
)
}
other => (
state.clone().with_phase(Planning).with_review_round(0),
vec![
ApplyReview { approved },
Notify { message: format!("⚠️ Unknown review_target '{}', defaulting to Planning", other) },
SaveState,
RunPlanning,
],
),
}
}
(WaitingApproval, UserSkipPhase) => {
let review_target = state.review_target.clone().unwrap_or_default();
match review_target.as_str() {
"requirements" => {
let skill = if state.project.as_ref().map_or(false, |p| p.has_design) {
"update-design"
} else {
"draft-design"
};
(
state.clone().with_phase(Designing).with_review_round(0),
vec![
Notify { message: "⏭️ Skipping review, moving to design...".into() },
SaveState,
RunSkill { name: skill.into(), context: state.task.clone() },
],
)
}
"design" => (
state.clone().with_phase(Planning).with_review_round(0),
vec![
Notify { message: "⏭️ Skipping review, moving to planning...".into() },
SaveState,
RunPlanning,
],
),
"tasks" => {
let action = match &state.strategy {
Some(ImplementStrategy::MultiAgent { tasks }) => RunHarness { tasks: tasks.clone() },
_ => RunSkill { name: "implement".into(), context: state.task.clone() },
};
(
state.clone().with_phase(Implementing).with_review_round(0),
vec![
Notify { message: "⏭️ Skipping review, moving to implementation...".into() },
SaveState,
action,
],
)
}
other => (
state.clone().with_phase(Planning).with_review_round(0),
vec![
Notify { message: format!("⚠️ Skipping review (unknown review_target '{}'), defaulting to Planning", other) },
SaveState,
RunPlanning,
],
),
}
}
(Reviewing, SkillFailed { error, .. }) => {
let review_target = state.review_target.clone().unwrap_or_default();
let next = match review_target.as_str() {
"requirements" => {
let skill = if state.project.as_ref().map_or(false, |p| p.has_design) {
"update-design"
} else {
"draft-design"
};
(
state.clone().with_phase(Designing),
vec![
Notify { message: format!("⚠️ Review failed ({}), continuing to design...", error) },
SaveState,
RunSkill { name: skill.into(), context: state.task.clone() },
],
)
}
"design" => (
state.clone().with_phase(Planning),
vec![
Notify { message: format!("⚠️ Review failed ({}), continuing to planning...", error) },
SaveState,
RunPlanning,
],
),
"tasks" => {
let action = match &state.strategy {
Some(ImplementStrategy::MultiAgent { tasks }) => RunHarness { tasks: tasks.clone() },
_ => RunSkill { name: "implement".into(), context: state.task.clone() },
};
(
state.clone().with_phase(Implementing),
vec![
Notify { message: format!("⚠️ Review failed ({}), continuing to implementation...", error) },
SaveState,
action,
],
)
}
other => (
state.clone().with_phase(Planning),
vec![
Notify { message: format!("⚠️ Review failed ({}) for unknown target '{}', defaulting to Planning", error, other) },
SaveState,
RunPlanning,
],
),
};
next
}
(Implementing, SkillCompleted { .. }) => {
let cmd = state.verify_command().to_string();
(
state.clone().with_phase(Verifying),
vec![
Notify { message: "✅ Phase 4/4: Verifying...".into() },
SaveState,
RunShell { command: cmd },
],
)
}
(Verifying, ShellCompleted { exit_code, .. }) if exit_code == 0 => (
state.clone().with_phase(Done),
vec![
Notify { message: "🎉 Ritual complete!".into() },
UpdateGraph { description: state.task.clone() },
SaveState,
Cleanup,
],
),
(Verifying, ShellFailed { stderr, .. }) if state.verify_retries < 3 => (
state.clone()
.with_phase(Implementing)
.inc_verify_retries()
.with_error_context(stderr.clone()),
vec![
Notify { message: format!(
"🔄 Build failed (attempt {}/3), fixing...",
state.verify_retries + 1
)},
SaveState,
RunSkill {
name: "implement".into(),
context: format!(
"FIX BUILD/TEST ERROR:\n{}\n\nOriginal task: {}",
stderr, state.task
),
},
],
),
(Verifying, ShellFailed { stderr, .. }) => (
state.clone()
.with_phase(Escalated)
.with_failed_phase(Verifying)
.with_error_context(stderr.clone()),
vec![
Notify { message: format!(
"❌ Build failed after 3 attempts.\nLast error: {}",
truncate(&stderr, 200)
)},
SaveState,
],
),
(Verifying, ShellCompleted { exit_code, stdout }) if exit_code != 0 && state.verify_retries < 3 => (
state.clone()
.with_phase(Implementing)
.inc_verify_retries()
.with_error_context(stdout.clone()),
vec![
Notify { message: format!(
"🔄 Tests returned exit code {} (attempt {}/3), fixing...",
exit_code, state.verify_retries + 1
)},
SaveState,
RunSkill {
name: "implement".into(),
context: format!(
"FIX: verify exited with code {}\nOutput:\n{}\n\nOriginal task: {}",
exit_code, stdout, state.task
),
},
],
),
(Verifying, ShellCompleted { exit_code, stdout }) if exit_code != 0 => (
state.clone()
.with_phase(Escalated)
.with_failed_phase(Verifying)
.with_error_context(stdout.clone()),
vec![
Notify { message: format!("❌ Verify failed (exit {}) after 3 attempts.", exit_code) },
SaveState,
],
),
(WritingRequirements, SkillFailed { error, .. }) if state.retries_for("requirements") < 2 => (
state.clone().with_phase(WritingRequirements).inc_phase_retry("requirements"),
vec![
Notify { message: format!("🔄 Requirements failed, retrying... ({})", truncate(&error, 100)) },
SaveState,
RunSkill {
name: "draft-requirements".into(),
context: format!("RETRY — previous error: {}\n\nOriginal task: {}", error, state.task),
},
],
),
(Designing, SkillFailed { error, .. }) if state.retries_for("designing") < 2 => (
state.clone().with_phase(Designing).inc_phase_retry("designing"),
vec![
Notify { message: format!("🔄 Design failed, retrying... ({})", truncate(&error, 100)) },
SaveState,
RunSkill {
name: if state.project.as_ref().map_or(false, |p| p.has_design) {
"update-design"
} else {
"draft-design"
}.into(),
context: format!("RETRY — previous error: {}\n\nOriginal task: {}", error, state.task),
},
],
),
(Graphing, SkillFailed { error, .. }) if state.retries_for("graphing") < 2 => (
state.clone().with_phase(Graphing).inc_phase_retry("graphing"),
vec![
Notify { message: format!("🔄 Graph generation failed, retrying... ({})", truncate(&error, 100)) },
SaveState,
RunSkill {
name: if state.project.as_ref().map_or(false, |p| p.has_graph) {
"update-graph"
} else {
"generate-graph"
}.into(),
context: format!("RETRY — previous error: {}\n\nOriginal task: {}", error, state.task),
},
],
),
(Planning, SkillFailed { error, .. }) if state.retries_for("planning") < 2 => (
state.clone().with_phase(Planning).inc_phase_retry("planning"),
vec![
Notify { message: format!("🔄 Planning failed, retrying... ({})", truncate(&error, 100)) },
SaveState,
RunPlanning,
],
),
(Implementing, SkillFailed { error, .. }) if state.retries_for("implementing") < 2 => (
state.clone().with_phase(Implementing).inc_phase_retry("implementing"),
vec![
Notify { message: format!("🔄 Implementation failed, retrying... ({})", truncate(&error, 100)) },
SaveState,
RunSkill {
name: "implement".into(),
context: format!("RETRY — previous error: {}\n\nOriginal task: {}", error, state.task),
},
],
),
(phase, SkillFailed { error, .. }) => (
state.clone()
.with_phase(Escalated)
.with_failed_phase(phase.clone())
.with_error_context(error.clone()),
vec![
Notify { message: format!(
"❌ {} failed: {}",
phase.display_name(),
truncate(&error, 200)
)},
SaveState,
],
),
(_, UserCancel) => (
state.clone().with_phase(Cancelled),
vec![
Notify { message: "🛑 Ritual cancelled.".into() },
SaveState,
],
),
(Escalated, UserRetry) => {
let retry_phase = state.failed_phase.clone().unwrap_or(Implementing);
let context = format!(
"RETRY after escalation.\nPrevious error: {}\n\nOriginal task: {}",
state.error_context.as_deref().unwrap_or("unknown"),
state.task
);
let mut new_state = state.clone()
.with_error_context(String::new());
match &retry_phase {
Verifying => { new_state.verify_retries = 0; }
Designing => { new_state.phase_retries.remove("designing"); }
Graphing => { new_state.phase_retries.remove("graphing"); }
Implementing => { new_state.phase_retries.remove("implementing"); }
Planning => { new_state.phase_retries.remove("planning"); }
Triaging => { new_state.phase_retries.remove("triaging"); }
_ => {}
}
if matches!(retry_phase, Designing | Graphing | Triaging) {
return (
new_state.with_phase(Initializing),
vec![
Notify { message: "🔄 Retrying — re-detecting project state...".into() },
SaveState,
DetectProject,
],
);
}
let action = match &retry_phase {
Planning => RunPlanning,
Implementing => RunSkill {
name: "implement".into(),
context,
},
Verifying => RunShell {
command: state.verify_command().to_string(),
},
_ => RunSkill {
name: "implement".into(),
context,
},
};
(
new_state.with_phase(retry_phase),
vec![
Notify { message: "🔄 Retrying...".into() },
SaveState,
action,
],
)
}
(phase, UserSkipPhase) => {
match phase.next() {
Some(next_phase) => {
let action = match &next_phase {
Triaging => {
if state.project.is_none() {
return (
state.clone().with_phase(Initializing),
vec![
Notify { message: format!("⏭️ Skipped {}. Detecting project...", phase.display_name()) },
SaveState,
DetectProject,
],
);
}
RunTriage { task: state.task.clone() }
}
Designing => {
let skill = if state.project.as_ref().map_or(false, |p| p.has_design) {
"update-design"
} else {
"draft-design"
};
RunSkill { name: skill.into(), context: state.task.clone() }
}
WaitingClarification => {
return (
state.clone()
.with_phase(Escalated)
.with_failed_phase(phase.clone()),
vec![
Notify { message: "❌ Cannot skip to WaitingClarification.".into() },
SaveState,
],
);
}
Reviewing | WaitingApproval => {
return match phase {
WritingRequirements => {
let skill = if state.project.as_ref().map_or(false, |p| p.has_design) {
"update-design"
} else {
"draft-design"
};
(
state.clone().with_phase(Designing),
vec![
Notify { message: "⏭️ Skipping review, moving to design...".into() },
SaveState,
RunSkill { name: skill.into(), context: state.task.clone() },
],
)
}
Designing => (
state.clone().with_phase(Planning),
vec![
Notify { message: "⏭️ Skipping review, moving to planning...".into() },
SaveState,
RunPlanning,
],
),
Graphing => {
let action = match &state.strategy {
Some(ImplementStrategy::MultiAgent { tasks }) => RunHarness { tasks: tasks.clone() },
_ => RunSkill { name: "implement".into(), context: state.task.clone() },
};
(
state.clone().with_phase(Implementing),
vec![
Notify { message: "⏭️ Skipping review, moving to implementation...".into() },
SaveState,
action,
],
)
}
_ => (
state.clone().with_phase(Planning),
vec![
Notify { message: "⏭️ Skipping review...".into() },
SaveState,
RunPlanning,
],
),
};
}
Planning => RunPlanning,
Graphing => {
let skill = if state.project.as_ref().map_or(false, |p| p.has_graph) {
"update-graph"
} else {
"generate-graph"
};
RunSkill { name: skill.into(), context: state.task.clone() }
}
Implementing => {
match &state.strategy {
Some(ImplementStrategy::MultiAgent { tasks }) =>
RunHarness { tasks: tasks.clone() },
_ =>
RunSkill { name: "implement".into(), context: state.task.clone() },
}
}
Verifying => RunShell { command: state.verify_command().to_string() },
Done => {
return (
state.clone().with_phase(Done),
vec![
Notify { message: format!("⏭️ Skipped {}. Ritual complete.", phase.display_name()) },
SaveState,
],
);
}
_ => {
return (
state.clone()
.with_phase(Escalated)
.with_failed_phase(phase.clone()),
vec![
Notify { message: format!("❌ Cannot skip to {:?}.", next_phase) },
SaveState,
],
);
}
};
(
state.clone().with_phase(next_phase.clone()),
vec![
Notify { message: format!("⏭️ Skipped {}. Moving to {}...", phase.display_name(), next_phase.display_name()) },
SaveState,
action,
],
)
}
None => (
state.clone()
.with_phase(Escalated)
.with_failed_phase(phase.clone()),
vec![
Notify { message: format!("❌ Cannot skip {} — no next phase.", phase.display_name()) },
SaveState,
],
),
}
}
(phase, event) => (
state.clone()
.with_phase(Escalated)
.with_failed_phase(phase.clone())
.with_error_context(format!(
"Unexpected event {:?} in phase {}",
std::mem::discriminant(&event),
phase.display_name()
)),
vec![
Notify { message: format!(
"❌ Unexpected event in {}. Ritual paused — use /ritual retry or /ritual cancel.",
phase.display_name()
)},
SaveState,
],
),
}
}
pub fn truncate(s: &str, max_chars: usize) -> String {
s.chars().take(max_chars).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn idle_state() -> RitualState {
RitualState::new()
}
fn project_with_design() -> ProjectState {
ProjectState {
has_requirements: true,
has_design: true,
has_graph: false,
has_source: true,
has_tests: false,
language: Some("rust".into()),
source_file_count: 10,
verify_command: Some("cargo build 2>&1 && cargo test 2>&1".into()),
}
}
fn project_greenfield() -> ProjectState {
ProjectState {
has_requirements: false,
has_design: false,
has_graph: false,
has_source: false,
has_tests: false,
language: None,
source_file_count: 0,
verify_command: None,
}
}
fn assert_invariant(state: &RitualState, actions: &[RitualAction]) {
let ep_count = actions.iter().filter(|a| a.is_event_producing()).count();
if state.phase.is_terminal() || state.phase.is_paused() {
assert_eq!(ep_count, 0,
"Terminal/paused state {:?} must have 0 EP actions, got {}",
state.phase, ep_count);
} else {
assert_eq!(ep_count, 1,
"Non-terminal state {:?} must have exactly 1 EP action, got {}",
state.phase, ep_count);
}
}
#[test]
fn test_happy_path_start() {
let (s, a) = transition(&idle_state(), RitualEvent::Start { task: "add feature".into() });
assert_eq!(s.phase, RitualPhase::Initializing);
assert_eq!(s.task, "add feature");
assert_invariant(&s, &a);
}
#[test]
fn test_happy_path_project_detected_greenfield() {
let state = idle_state().with_phase(RitualPhase::Initializing).with_task("test".into());
let (s, a) = transition(&state, RitualEvent::ProjectDetected(project_greenfield()));
assert_eq!(s.phase, RitualPhase::Triaging);
let has_triage = a.iter().any(|a| matches!(a, RitualAction::RunTriage { .. }));
assert!(has_triage);
assert_invariant(&s, &a);
}
#[test]
fn test_happy_path_project_detected_existing() {
let state = idle_state().with_phase(RitualPhase::Initializing).with_task("test".into());
let (s, a) = transition(&state, RitualEvent::ProjectDetected(project_with_design()));
assert_eq!(s.phase, RitualPhase::Triaging);
let has_triage = a.iter().any(|a| matches!(a, RitualAction::RunTriage { .. }));
assert!(has_triage);
assert_invariant(&s, &a);
}
#[test]
fn test_happy_path_design_complete() {
let state = idle_state().with_phase(RitualPhase::Designing);
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "design".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Reviewing);
assert_eq!(s.review_target, Some("design".to_string()));
assert_invariant(&s, &a);
let (s2, a2) = transition(&s, RitualEvent::SkillCompleted { phase: "review-design".into(), artifacts: vec![] });
assert_eq!(s2.phase, RitualPhase::WaitingApproval);
assert_eq!(s2.review_round, 1);
assert_invariant(&s2, &a2);
let (s3, a3) = transition(&s2, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s3.phase, RitualPhase::Reviewing);
assert_invariant(&s3, &a3);
let (s4, a4) = transition(&s3, RitualEvent::SkillCompleted { phase: "review-design".into(), artifacts: vec![] });
assert_eq!(s4.phase, RitualPhase::WaitingApproval);
assert_eq!(s4.review_round, 2);
assert_invariant(&s4, &a4);
let (s5, a5) = transition(&s4, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s5.phase, RitualPhase::Planning);
assert_eq!(s5.review_round, 0); assert_invariant(&s5, &a5);
}
#[test]
fn test_happy_path_plan_decided() {
let state = idle_state()
.with_phase(RitualPhase::Planning)
.with_project(project_greenfield());
let (s, a) = transition(&state, RitualEvent::PlanDecided(ImplementStrategy::SingleLlm));
assert_eq!(s.phase, RitualPhase::Graphing);
assert_invariant(&s, &a);
}
#[test]
fn test_happy_path_graph_complete() {
let state = idle_state().with_phase(RitualPhase::Graphing);
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "graph".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Reviewing);
assert_eq!(s.review_target, Some("tasks".to_string()));
assert_invariant(&s, &a);
let (s2, _) = transition(&s, RitualEvent::SkillCompleted { phase: "review-tasks".into(), artifacts: vec![] });
assert_eq!(s2.phase, RitualPhase::WaitingApproval);
assert_eq!(s2.review_round, 1);
let (s3, a3) = transition(&s2, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s3.phase, RitualPhase::Reviewing); assert_invariant(&s3, &a3);
let (s4, _) = transition(&s3, RitualEvent::SkillCompleted { phase: "review-tasks".into(), artifacts: vec![] });
assert_eq!(s4.phase, RitualPhase::WaitingApproval);
assert_eq!(s4.review_round, 2);
let (s5, a5) = transition(&s4, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s5.phase, RitualPhase::Implementing);
assert_eq!(s5.review_round, 0);
assert_invariant(&s5, &a5);
}
#[test]
fn test_happy_path_implement_complete() {
let state = idle_state()
.with_phase(RitualPhase::Implementing)
.with_project(project_with_design());
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "impl".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Verifying);
assert_invariant(&s, &a);
}
#[test]
fn test_happy_path_verify_success() {
let state = idle_state().with_phase(RitualPhase::Verifying).with_task("test".into());
let (s, a) = transition(&state, RitualEvent::ShellCompleted { stdout: "ok".into(), exit_code: 0 });
assert_eq!(s.phase, RitualPhase::Done);
assert!(s.phase.is_terminal());
assert_invariant(&s, &a);
}
#[test]
fn test_verify_fail_retry() {
let state = idle_state()
.with_phase(RitualPhase::Verifying)
.with_task("test".into());
let (s, a) = transition(&state, RitualEvent::ShellFailed { stderr: "error".into(), exit_code: 1 });
assert_eq!(s.phase, RitualPhase::Implementing);
assert_eq!(s.verify_retries, 1);
assert_invariant(&s, &a);
}
#[test]
fn test_verify_fail_escalate_after_3() {
let mut state = idle_state()
.with_phase(RitualPhase::Verifying)
.with_task("test".into());
state.verify_retries = 3;
let (s, a) = transition(&state, RitualEvent::ShellFailed { stderr: "error".into(), exit_code: 1 });
assert_eq!(s.phase, RitualPhase::Escalated);
assert_invariant(&s, &a);
}
#[test]
fn test_design_fail_retry_once() {
let state = idle_state()
.with_phase(RitualPhase::Designing)
.with_task("test".into())
.with_project(project_greenfield());
let (s, a) = transition(&state, RitualEvent::SkillFailed { phase: "design".into(), error: "oops".into() });
assert_eq!(s.phase, RitualPhase::Designing);
assert_eq!(s.retries_for("designing"), 1);
assert_invariant(&s, &a);
}
#[test]
fn test_design_fail_escalate_after_retry() {
let mut state = idle_state()
.with_phase(RitualPhase::Designing)
.with_task("test".into());
state.phase_retries.insert("designing".into(), 2);
let (s, a) = transition(&state, RitualEvent::SkillFailed { phase: "design".into(), error: "oops".into() });
assert_eq!(s.phase, RitualPhase::Escalated);
assert_invariant(&s, &a);
}
#[test]
fn test_cancel() {
let state = idle_state().with_phase(RitualPhase::Implementing);
let (s, a) = transition(&state, RitualEvent::UserCancel);
assert_eq!(s.phase, RitualPhase::Cancelled);
assert_invariant(&s, &a);
}
#[test]
fn test_retry_from_escalated() {
let state = idle_state()
.with_phase(RitualPhase::Escalated)
.with_failed_phase(RitualPhase::Implementing)
.with_task("test".into())
.with_project(project_with_design());
let (s, a) = transition(&state, RitualEvent::UserRetry);
assert_eq!(s.phase, RitualPhase::Implementing);
assert_invariant(&s, &a);
}
#[test]
fn test_retry_resets_verify_retries() {
let mut state = idle_state()
.with_phase(RitualPhase::Escalated)
.with_failed_phase(RitualPhase::Verifying)
.with_task("test".into())
.with_project(project_with_design());
state.verify_retries = 3;
let (s, a) = transition(&state, RitualEvent::UserRetry);
assert_eq!(s.phase, RitualPhase::Verifying);
assert_eq!(s.verify_retries, 0, "UserRetry must reset verify_retries");
assert_invariant(&s, &a);
}
#[test]
fn test_retry_resets_phase_retries() {
let mut state = idle_state()
.with_phase(RitualPhase::Escalated)
.with_failed_phase(RitualPhase::Implementing)
.with_task("test".into());
state.phase_retries.insert("implementing".into(), 1);
let (s, a) = transition(&state, RitualEvent::UserRetry);
assert_eq!(s.phase, RitualPhase::Implementing);
assert_eq!(s.retries_for("implementing"), 0, "UserRetry must reset phase_retries");
assert_invariant(&s, &a);
}
#[test]
fn test_retry_design_re_detects_project() {
let state = idle_state()
.with_phase(RitualPhase::Escalated)
.with_failed_phase(RitualPhase::Designing)
.with_task("test".into())
.with_project(project_greenfield());
let (s, a) = transition(&state, RitualEvent::UserRetry);
assert_eq!(s.phase, RitualPhase::Initializing);
let has_detect = a.iter().any(|a| matches!(a, RitualAction::DetectProject));
assert!(has_detect, "Design retry must re-detect project state");
assert_invariant(&s, &a);
}
#[test]
fn test_planning_retry_once() {
let state = idle_state()
.with_phase(RitualPhase::Planning)
.with_task("test".into());
let (s, a) = transition(&state, RitualEvent::SkillFailed { phase: "planning".into(), error: "oops".into() });
assert_eq!(s.phase, RitualPhase::Planning);
assert_eq!(s.retries_for("planning"), 1);
assert_invariant(&s, &a);
}
#[test]
fn test_planning_escalate_after_retry() {
let mut state = idle_state()
.with_phase(RitualPhase::Planning)
.with_task("test".into());
state.phase_retries.insert("planning".into(), 2);
let (s, a) = transition(&state, RitualEvent::SkillFailed { phase: "planning".into(), error: "oops".into() });
assert_eq!(s.phase, RitualPhase::Escalated);
assert_invariant(&s, &a);
}
#[test]
fn test_skip_design_to_planning() {
let state = idle_state().with_phase(RitualPhase::Designing).with_task("test".into());
let (s, a) = transition(&state, RitualEvent::UserSkipPhase);
assert_eq!(s.phase, RitualPhase::Planning);
assert_invariant(&s, &a);
}
#[test]
fn test_skip_review_approval() {
let state = idle_state()
.with_phase(RitualPhase::WaitingApproval)
.with_review_target("design")
.with_task("test".into());
let (s, a) = transition(&state, RitualEvent::UserSkipPhase);
assert_eq!(s.phase, RitualPhase::Planning);
assert_invariant(&s, &a);
let state2 = idle_state()
.with_phase(RitualPhase::WaitingApproval)
.with_review_target("tasks")
.with_task("test".into());
let (s2, a2) = transition(&state2, RitualEvent::UserSkipPhase);
assert_eq!(s2.phase, RitualPhase::Implementing);
assert_invariant(&s2, &a2);
}
#[test]
fn test_skip_initializing_to_designing() {
let state = idle_state().with_phase(RitualPhase::Initializing).with_task("test".into());
let (s, a) = transition(&state, RitualEvent::UserSkipPhase);
assert_eq!(s.phase, RitualPhase::Initializing);
assert_invariant(&s, &a);
}
#[test]
fn test_skip_verifying_to_done() {
let state = idle_state().with_phase(RitualPhase::Verifying).with_task("test".into());
let (s, a) = transition(&state, RitualEvent::UserSkipPhase);
assert_eq!(s.phase, RitualPhase::Done);
assert_invariant(&s, &a);
}
#[test]
fn test_skip_idle_escalates() {
let state = idle_state();
let (s, a) = transition(&state, RitualEvent::UserSkipPhase);
assert_eq!(s.phase, RitualPhase::Escalated);
assert_invariant(&s, &a);
}
#[test]
fn test_unexpected_event_escalates() {
let state = idle_state().with_phase(RitualPhase::Designing);
let (s, a) = transition(&state, RitualEvent::ShellCompleted { stdout: "x".into(), exit_code: 0 });
assert_eq!(s.phase, RitualPhase::Escalated);
assert_invariant(&s, &a);
}
#[test]
fn test_multi_agent_strategy() {
let state = idle_state()
.with_phase(RitualPhase::Graphing)
.with_strategy(ImplementStrategy::MultiAgent { tasks: vec!["task1".into(), "task2".into()] });
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "graph".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Reviewing);
assert_invariant(&s, &a);
let (s2, _) = transition(&s, RitualEvent::SkillCompleted { phase: "review-tasks".into(), artifacts: vec![] });
let (s3, a3) = transition(&s2, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s3.phase, RitualPhase::Reviewing); assert_invariant(&s3, &a3);
let (s4, _) = transition(&s3, RitualEvent::SkillCompleted { phase: "review-tasks".into(), artifacts: vec![] });
let (s5, a5) = transition(&s4, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s5.phase, RitualPhase::Implementing);
let has_harness = a5.iter().any(|a| matches!(a, RitualAction::RunHarness { .. }));
assert!(has_harness);
assert_invariant(&s5, &a5);
}
fn triage_clear_small() -> TriageResult {
TriageResult {
clarity: "clear".into(),
clarify_questions: vec![],
size: "small".into(),
skip_design: true,
skip_graph: true,
}
}
fn triage_clear_medium() -> TriageResult {
TriageResult {
clarity: "clear".into(),
clarify_questions: vec![],
size: "medium".into(),
skip_design: true,
skip_graph: false,
}
}
fn triage_ambiguous() -> TriageResult {
TriageResult {
clarity: "ambiguous".into(),
clarify_questions: vec!["What file?".into(), "Which module?".into()],
size: "medium".into(),
skip_design: false,
skip_graph: false,
}
}
#[test]
fn test_triage_small_skips_design_and_graph() {
let state = idle_state()
.with_phase(RitualPhase::Triaging)
.with_task("fix typo".into())
.with_project(project_with_design());
let (s, a) = transition(&state, RitualEvent::TriageCompleted(triage_clear_small()));
assert_eq!(s.phase, RitualPhase::Planning);
let has_planning = a.iter().any(|a| matches!(a, RitualAction::RunPlanning));
assert!(has_planning);
assert_invariant(&s, &a);
}
#[test]
fn test_triage_medium_skips_design() {
let state = idle_state()
.with_phase(RitualPhase::Triaging)
.with_task("add endpoint".into())
.with_project(project_with_design());
let (s, a) = transition(&state, RitualEvent::TriageCompleted(triage_clear_medium()));
assert_eq!(s.phase, RitualPhase::Planning);
assert_invariant(&s, &a);
}
#[test]
fn test_triage_large_full_flow() {
let state = idle_state()
.with_phase(RitualPhase::Triaging)
.with_task("new subsystem".into())
.with_project(project_greenfield());
let (s, a) = transition(&state, RitualEvent::TriageCompleted(TriageResult {
clarity: "clear".into(),
clarify_questions: vec![],
size: "large".into(),
skip_design: false,
skip_graph: false,
}));
assert_eq!(s.phase, RitualPhase::WritingRequirements);
let has_draft_req = a.iter().any(|a| matches!(a, RitualAction::RunSkill { name, .. } if name == "draft-requirements"));
assert!(has_draft_req);
assert_invariant(&s, &a);
}
#[test]
fn test_triage_ambiguous_waits() {
let state = idle_state()
.with_phase(RitualPhase::Triaging)
.with_task("fix the bug".into())
.with_project(project_with_design());
let (s, a) = transition(&state, RitualEvent::TriageCompleted(triage_ambiguous()));
assert_eq!(s.phase, RitualPhase::WaitingClarification);
let ep_count = a.iter().filter(|a| a.is_event_producing()).count();
assert_eq!(ep_count, 0, "WaitingClarification is a pause state with 0 EP actions");
}
#[test]
fn test_clarification_re_triages() {
let state = idle_state()
.with_phase(RitualPhase::WaitingClarification)
.with_task("fix the bug".into())
.with_project(project_with_design());
let (s, a) = transition(&state, RitualEvent::UserClarification {
response: "the auth retry bug in llm.rs".into(),
});
assert_eq!(s.phase, RitualPhase::Triaging);
assert!(s.task.contains("auth retry bug"));
assert_invariant(&s, &a);
}
#[test]
fn test_skip_triage() {
let state = idle_state()
.with_phase(RitualPhase::Triaging)
.with_task("test".into())
.with_project(project_with_design());
let (s, a) = transition(&state, RitualEvent::UserSkipPhase);
assert_eq!(s.phase, RitualPhase::Designing);
assert_invariant(&s, &a);
}
#[test]
fn test_waiting_clarification_cancel() {
let state = idle_state()
.with_phase(RitualPhase::WaitingClarification)
.with_task("test".into());
let (s, a) = transition(&state, RitualEvent::UserCancel);
assert_eq!(s.phase, RitualPhase::Cancelled);
assert_invariant(&s, &a);
}
#[test]
fn test_waiting_clarification_retry_re_triages() {
let state = idle_state()
.with_phase(RitualPhase::WaitingClarification)
.with_task("fix something".into())
.with_project(project_with_design());
let (s, a) = transition(&state, RitualEvent::UserRetry);
assert_eq!(s.phase, RitualPhase::Triaging);
assert_invariant(&s, &a);
}
#[test]
fn test_truncate_ascii() {
assert_eq!(truncate("hello world", 5), "hello");
assert_eq!(truncate("hi", 10), "hi");
}
#[test]
fn test_truncate_utf8() {
assert_eq!(truncate("你好世界", 2), "你好");
assert_eq!(truncate("hello你好", 6), "hello你");
}
#[test]
fn test_full_happy_path_trace() {
let mut state = idle_state();
let (s, a) = transition(&state, RitualEvent::Start { task: "add X".into() });
assert_eq!(s.phase, RitualPhase::Initializing);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::ProjectDetected(project_greenfield()));
assert_eq!(s.phase, RitualPhase::Triaging);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::TriageCompleted(TriageResult {
clarity: "clear".into(),
clarify_questions: vec![],
size: "large".into(),
skip_design: false,
skip_graph: false,
}));
assert_eq!(s.phase, RitualPhase::WritingRequirements);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "draft-requirements".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Reviewing);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "review-requirements".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::WaitingApproval);
assert_eq!(s.review_round, 1);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s.phase, RitualPhase::Reviewing); assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "review-requirements".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::WaitingApproval);
assert_eq!(s.review_round, 2);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s.phase, RitualPhase::Designing);
assert_eq!(s.review_round, 0); assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "draft-design".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Reviewing);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "review-design".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::WaitingApproval);
assert_eq!(s.review_round, 1);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s.phase, RitualPhase::Reviewing); assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "review-design".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::WaitingApproval);
assert_eq!(s.review_round, 2);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s.phase, RitualPhase::Planning);
assert_eq!(s.review_round, 0);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::PlanDecided(ImplementStrategy::SingleLlm));
assert_eq!(s.phase, RitualPhase::Graphing);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "generate-graph".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Reviewing);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "review-tasks".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::WaitingApproval);
assert_eq!(s.review_round, 1);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s.phase, RitualPhase::Reviewing); assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "review-tasks".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::WaitingApproval);
assert_eq!(s.review_round, 2);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::UserApproval { approved: "all".into() });
assert_eq!(s.phase, RitualPhase::Implementing);
assert_eq!(s.review_round, 0);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "implement".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Verifying);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::ShellCompleted { stdout: "all tests passed".into(), exit_code: 0 });
assert_eq!(s.phase, RitualPhase::Done);
assert_invariant(&s, &a);
assert_eq!(s.transitions.len(), 21);
}
#[test]
fn test_verify_retry_loop_trace() {
let mut state = idle_state()
.with_phase(RitualPhase::Implementing)
.with_task("test".into())
.with_project(project_with_design());
for i in 0..3 {
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "impl".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Verifying);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::ShellFailed { stderr: format!("error {}", i), exit_code: 1 });
assert_eq!(s.phase, RitualPhase::Implementing, "retry {} should go back to Implementing", i);
assert_invariant(&s, &a);
state = s;
}
assert_eq!(state.verify_retries, 3);
let (s, a) = transition(&state, RitualEvent::SkillCompleted { phase: "impl".into(), artifacts: vec![] });
assert_eq!(s.phase, RitualPhase::Verifying);
assert_invariant(&s, &a);
state = s;
let (s, a) = transition(&state, RitualEvent::ShellFailed { stderr: "still broken".into(), exit_code: 1 });
assert_eq!(s.phase, RitualPhase::Escalated);
assert_invariant(&s, &a);
}
}