use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use tracing::{info, warn, error};
use super::composer::ProjectState as ComposerProjectState;
use super::llm::{LlmClient, ToolDefinition};
use super::scope::default_scope_for_phase;
use super::state_machine::{
RitualAction, RitualEvent, RitualState, ImplementStrategy,
ProjectState as V2ProjectState,
};
use crate::graph::{Graph, NodeStatus};
use crate::harness::assemble_task_context;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewDepth {
Light,
Full,
}
#[derive(Debug, Clone)]
pub struct ReviewConfig {
pub model: String,
pub max_iterations: usize,
pub depth: ReviewDepth,
}
pub type NotifyFn = Arc<dyn Fn(String) + Send + Sync>;
pub fn build_triage_prompt(task: &str, project_ctx: &str) -> String {
format!(
r#"You are a triage agent. Assess this development task quickly.
{project_ctx}
Task: "{task}"
Respond with ONLY a JSON object (no markdown, no explanation):
{{
"clarity": "clear" or "ambiguous",
"clarify_questions": ["question1", ...] (only if ambiguous, otherwise empty array),
"size": "small", "medium", or "large",
"skip_design": true/false,
"skip_graph": true/false
}}
Guidelines:
- "small": bug fix, add a simple command, change a config value, rename something
- "medium": add a feature that touches 2-3 files, refactor a module
- "large": new subsystem, architectural change, multi-file feature
- skip_design=true if has_design=true (design already exists — don't redo it) OR if the task is small enough that a design adds no value
- skip_graph=true ONLY if the task modifies existing code without adding new modules, files, or components
- skip_graph=false if the task adds ANY new files, modules, subsystems, or architectural components — even if a graph already exists, it needs to be UPDATED with new nodes
- "ambiguous" if the task description is vague, could mean multiple things, or lacks critical info
- Short ≠ simple. "fix the bug" is ambiguous. "fix the auth retry loop in llm.rs" is clear and small."#
)
}
pub struct V2ExecutorConfig {
pub project_root: PathBuf,
pub llm_client: Option<Arc<dyn LlmClient>>,
pub notify: Option<NotifyFn>,
pub skill_model: String,
pub planning_model: String,
}
impl Default for V2ExecutorConfig {
fn default() -> Self {
Self {
project_root: PathBuf::from("."),
llm_client: None,
notify: None,
skill_model: "opus".to_string(),
planning_model: "sonnet".to_string(),
}
}
}
pub struct V2Executor {
config: V2ExecutorConfig,
}
impl V2Executor {
pub fn new(config: V2ExecutorConfig) -> Self {
Self { config }
}
pub async fn execute(&self, action: &RitualAction, state: &RitualState) -> Option<RitualEvent> {
match action {
RitualAction::DetectProject => Some(self.detect_project().await),
RitualAction::RunTriage { task } => Some(self.run_triage(task, state).await),
RitualAction::RunSkill { name, context } => {
Some(self.run_skill(name, context, state).await)
}
RitualAction::RunShell { command } => Some(self.run_shell(command).await),
RitualAction::RunPlanning => Some(self.run_planning(state).await),
RitualAction::RunHarness { tasks } => Some(self.run_harness(tasks, state).await),
RitualAction::Notify { message } => {
self.notify(message);
None
}
RitualAction::SaveState => {
self.save_state(state);
None
}
RitualAction::UpdateGraph { description } => {
self.update_graph(description);
None
}
RitualAction::ApplyReview { approved } => {
tracing::info!("ApplyReview (approved: {})", approved);
None
}
RitualAction::Cleanup => {
self.cleanup();
None
}
}
}
pub async fn execute_actions(
&self,
actions: &[RitualAction],
state: &RitualState,
) -> Option<RitualEvent> {
let mut event = None;
for action in actions {
if action.is_fire_and_forget() {
let _ = self.execute(action, state).await;
} else {
event = self.execute(action, state).await;
}
}
event
}
async fn detect_project(&self) -> RitualEvent {
info!(project_root = %self.config.project_root.display(), "Detecting project state");
let cs = ComposerProjectState::detect(&self.config.project_root);
let verify_command = self.read_verify_command();
let ps = V2ProjectState {
has_requirements: cs.has_requirements,
has_design: cs.has_design,
has_graph: cs.has_graph,
has_source: cs.has_source_code,
has_tests: cs.has_tests,
language: cs.language.map(|l| format!("{:?}", l)),
source_file_count: cs.source_file_count,
verify_command,
};
info!(
has_design = ps.has_design,
has_graph = ps.has_graph,
has_source = ps.has_source,
source_files = ps.source_file_count,
"Project state detected"
);
RitualEvent::ProjectDetected(ps)
}
async fn run_triage(&self, task: &str, state: &RitualState) -> RitualEvent {
info!(task = task, "Running triage (haiku)");
let llm = match &self.config.llm_client {
Some(c) => c.clone(),
None => {
warn!("No LLM client — defaulting to full flow");
return RitualEvent::TriageCompleted(super::state_machine::TriageResult {
clarity: "clear".into(),
clarify_questions: vec![],
size: "large".into(),
skip_design: false,
skip_graph: false,
});
}
};
let project_ctx = if let Some(ps) = &state.project {
format!(
"Project: lang={}, has_design={}, has_graph={}, source_files={}, has_tests={}",
ps.language.as_deref().unwrap_or("unknown"),
ps.has_design, ps.has_graph,
ps.source_file_count, ps.has_tests
)
} else {
"Project: unknown state".into()
};
let prompt = build_triage_prompt(task, &project_ctx);
match llm.chat(&prompt, "haiku").await {
Ok(response) => {
let json_str = extract_json(&response);
match serde_json::from_str::<super::state_machine::TriageResult>(json_str) {
Ok(mut result) => {
if let Some(ps) = &state.project {
if ps.has_design && !result.skip_design {
info!("Override: skip_design=true (design already exists)");
result.skip_design = true;
}
}
info!(
clarity = result.clarity,
size = result.size,
skip_design = result.skip_design,
skip_graph = result.skip_graph,
"Triage complete"
);
RitualEvent::TriageCompleted(result)
}
Err(e) => {
warn!("Failed to parse triage JSON: {}. Defaulting to full flow.", e);
RitualEvent::TriageCompleted(super::state_machine::TriageResult {
clarity: "clear".into(),
clarify_questions: vec![],
size: "large".into(),
skip_design: false,
skip_graph: false,
})
}
}
}
Err(e) => {
warn!("Triage LLM call failed: {}. Defaulting to full flow.", e);
RitualEvent::TriageCompleted(super::state_machine::TriageResult {
clarity: "clear".into(),
clarify_questions: vec![],
size: "large".into(),
skip_design: false,
skip_graph: false,
})
}
}
}
async fn run_skill(&self, name: &str, context: &str, state: &RitualState) -> RitualEvent {
info!(skill = name, "Running skill phase");
let llm = match &self.config.llm_client {
Some(c) => c.clone(),
None => {
error!("No LLM client configured for skill execution");
return RitualEvent::SkillFailed {
phase: name.to_string(),
error: "No LLM client configured".to_string(),
};
}
};
let base_prompt = match self.load_skill_prompt(name) {
Ok(p) => p,
Err(e) => {
return RitualEvent::SkillFailed {
phase: name.to_string(),
error: format!("Failed to load skill prompt: {}", e),
};
}
};
let effective_context = if name == "implement" {
self.enrich_implement_context(context, state)
} else {
context.to_string()
};
let full_prompt = if effective_context.is_empty() {
base_prompt
} else {
format!("## USER TASK\n{}\n\n## INSTRUCTIONS\n{}", effective_context, base_prompt)
};
let review_config = if name.starts_with("review") {
Some(self.review_config_for_triage_size(state))
} else {
None
};
let model = review_config.as_ref().map(|c| c.model.clone()).unwrap_or_else(|| self.config.skill_model.clone());
let max_iterations = review_config.as_ref().map(|c| c.max_iterations).unwrap_or(100);
let full_prompt = if let Some(ref config) = review_config {
let depth_label = match config.depth {
ReviewDepth::Light => "quick",
ReviewDepth::Full => "full",
};
if config.depth == ReviewDepth::Light {
format!(
"[REVIEW_DEPTH: {}]\n\n## REVIEW SCOPE: LIGHT\nRun ONLY checks #1, #2, #5, #6, #7, #8, #11, #13, #21, #27.\nSkip all other checks. Write findings to file.\n\n{}",
depth_label, full_prompt
)
} else {
format!("[REVIEW_DEPTH: {}]\n\n{}", depth_label, full_prompt)
}
} else {
full_prompt
};
let scope = default_scope_for_phase(name);
let tools = self.scope_to_tool_definitions(&scope);
match llm
.run_skill(
&full_prompt,
tools,
&model,
&self.config.project_root,
max_iterations,
)
.await
{
Ok(result) => {
info!(skill = name, "Skill completed successfully");
RitualEvent::SkillCompleted {
phase: name.to_string(),
artifacts: result.artifacts_created.iter().map(|p| p.to_string_lossy().to_string()).collect(),
}
}
Err(e) => {
warn!(skill = name, error = %e, "Skill failed");
RitualEvent::SkillFailed {
phase: name.to_string(),
error: e.to_string(),
}
}
}
}
async fn run_shell(&self, command: &str) -> RitualEvent {
info!(command = command, "Running shell command");
match tokio::process::Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&self.config.project_root)
.output()
.await
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
if output.status.success() {
info!(exit_code, "Shell command completed successfully");
RitualEvent::ShellCompleted {
stdout: format!("{}{}", stdout, stderr),
exit_code,
}
} else {
warn!(exit_code, "Shell command failed");
RitualEvent::ShellFailed {
stderr: format!("{}{}", stderr, stdout),
exit_code,
}
}
}
Err(e) => {
error!(error = %e, "Failed to execute shell command");
RitualEvent::ShellFailed {
stderr: e.to_string(),
exit_code: -1,
}
}
}
}
async fn run_planning(&self, state: &RitualState) -> RitualEvent {
info!("Running planning phase");
let llm = match &self.config.llm_client {
Some(c) => c.clone(),
None => {
warn!("No LLM client for planning, defaulting to SingleLlm");
return RitualEvent::PlanDecided(ImplementStrategy::SingleLlm);
}
};
let design_path = self.config.project_root.join("DESIGN.md");
let design_content = match std::fs::read_to_string(&design_path) {
Ok(c) => c,
Err(_) => {
info!("No DESIGN.md found, defaulting to SingleLlm");
return RitualEvent::PlanDecided(ImplementStrategy::SingleLlm);
}
};
let design_truncated = if design_content.len() > 15000 {
format!("{}...\n[TRUNCATED — {} bytes total]", Self::safe_truncate(&design_content, 15000), design_content.len())
} else {
design_content
};
let prompt = format!(
r#"You are a project planning assistant. Based on the DESIGN.md below and the task description, decide the implementation strategy.
## TASK
{}
## DESIGN.md
{}
## Instructions
Analyze the scope:
1. How many files need to change?
2. Are the changes independent enough for parallel work?
3. Is this a small fix or a large feature?
Output ONLY a JSON object (no markdown, no explanation):
- Small/focused change: {{"strategy": "single_llm"}}
- Large multi-file change with independent parts: {{"strategy": "multi_agent", "tasks": ["task description 1", "task description 2"]}}
Default to "single_llm" unless you're confident the work is large AND parallelizable."#,
state.task,
design_truncated
);
match llm
.run_skill(
&prompt,
vec![], &self.config.planning_model,
&self.config.project_root,
20,
)
.await
{
Ok(result) => self.parse_planning_result(&result.output),
Err(e) => {
warn!(error = %e, "Planning LLM call failed, defaulting to SingleLlm");
RitualEvent::PlanDecided(ImplementStrategy::SingleLlm)
}
}
}
async fn run_harness(&self, tasks: &[String], state: &RitualState) -> RitualEvent {
info!(task_count = tasks.len(), "Running harness (simplified)");
let context = tasks
.iter()
.enumerate()
.map(|(i, t)| format!("{}. {}", i + 1, t))
.collect::<Vec<_>>()
.join("\n");
self.run_skill("implement", &context, state).await
}
fn notify(&self, message: &str) {
if let Some(ref notify_fn) = self.config.notify {
notify_fn(message.to_string());
} else {
info!(message = message, "Ritual notification (no handler)");
}
}
fn save_state(&self, state: &RitualState) {
let state_path = self.config.project_root.join(".gid").join("ritual-state.json");
if let Some(parent) = state_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match serde_json::to_string_pretty(state) {
Ok(json) => {
if let Err(e) = std::fs::write(&state_path, &json) {
warn!(error = %e, "Failed to save ritual state");
}
}
Err(e) => {
warn!(error = %e, "Failed to serialize ritual state");
}
}
}
fn update_graph(&self, description: &str) {
use crate::graph::{Graph, NodeStatus};
let graph_path = self.config.project_root.join(".gid").join("graph.yml");
if !graph_path.exists() {
info!("No graph.yml found, skipping graph update");
return;
}
let content = match std::fs::read_to_string(&graph_path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read graph.yml: {}", e);
return;
}
};
let mut graph: Graph = match serde_yaml::from_str(&content) {
Ok(g) => g,
Err(e) => {
warn!("Failed to parse graph.yml: {}", e);
return;
}
};
let desc_lower = description.to_lowercase();
let matched_id = graph.nodes.iter()
.filter(|n| {
matches!(n.status, NodeStatus::Todo | NodeStatus::InProgress)
})
.find(|n| {
let title_lower = n.title.to_lowercase();
let node_desc_lower = n.description.as_deref().unwrap_or("").to_lowercase();
desc_lower.contains(&title_lower)
|| title_lower.contains(&desc_lower)
|| (!node_desc_lower.is_empty() && (
desc_lower.contains(&node_desc_lower)
|| node_desc_lower.contains(&desc_lower)
))
})
.map(|n| n.id.clone());
if let Some(id) = matched_id {
if graph.mark_task_done(&id) {
match serde_yaml::to_string(&graph) {
Ok(yaml) => {
if let Err(e) = std::fs::write(&graph_path, &yaml) {
warn!("Failed to write graph.yml: {}", e);
} else {
info!(node_id = %id, "Marked graph node as done");
}
}
Err(e) => warn!("Failed to serialize graph: {}", e),
}
}
} else {
info!(description = description, "No matching graph node found for task");
}
}
fn cleanup(&self) {
info!("Ritual cleanup");
}
fn review_config_for_triage_size(&self, state: &RitualState) -> ReviewConfig {
let size = state.triage_size.as_deref().unwrap_or("medium");
match size {
"small" => ReviewConfig {
model: "sonnet".to_string(),
max_iterations: 30,
depth: ReviewDepth::Light,
},
"medium" => ReviewConfig {
model: self.config.skill_model.clone(),
max_iterations: 50,
depth: ReviewDepth::Light,
},
"large" => ReviewConfig {
model: self.config.skill_model.clone(),
max_iterations: 100,
depth: ReviewDepth::Full,
},
_ => ReviewConfig {
model: self.config.skill_model.clone(),
max_iterations: 50,
depth: ReviewDepth::Light,
},
}
}
fn load_skill_prompt(&self, skill_name: &str) -> Result<String> {
let gid_skill = self
.config
.project_root
.join(".gid")
.join("skills")
.join(format!("{}.md", skill_name));
if gid_skill.exists() {
return Ok(std::fs::read_to_string(&gid_skill)?);
}
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
let rustclaw_skill = home
.join("rustclaw")
.join("skills")
.join(skill_name)
.join("SKILL.md");
if rustclaw_skill.exists() {
return Ok(std::fs::read_to_string(&rustclaw_skill)?);
}
}
match skill_name {
"draft-design" => Ok(include_str!("prompts/draft_design.txt").to_string()),
"update-design" => Ok(
"Read the existing DESIGN.md and the user's task. Update the design document \
to incorporate the new requirements. Write the updated DESIGN.md."
.to_string(),
),
"generate-graph" | "design-to-graph" => Ok(
"Read DESIGN.md from the project root. Generate a GID graph in YAML format \
and write it to .gid/graph.yml.\n\n\
The graph has multiple node types:\n\
```yaml\n\
nodes:\n\
# Feature/module nodes (semantic — the architecture)\n\
- id: feat-dashboard\n\
title: \"Dashboard Module\"\n\
type: feature\n\
status: todo\n\
tags: [module]\n\
description: \"HTTP dashboard server\"\n\
# File nodes (what gets changed)\n\
- id: file-dashboard-rs\n\
title: \"src/dashboard.rs\"\n\
type: file\n\
status: todo\n\
# Task nodes (concrete work items)\n\
- id: task-add-health-endpoint\n\
title: \"Add health check endpoint\"\n\
type: task\n\
status: todo\n\
tags: [implementation]\n\
description: \"Add health check endpoint returning uptime and stats\"\n\
edges:\n\
- from: task-add-health-endpoint\n\
to: feat-dashboard\n\
relation: implements\n\
- from: feat-dashboard\n\
to: file-dashboard-rs\n\
relation: contains\n\
- from: task-a\n\
to: task-b\n\
relation: depends_on\n\
```\n\n\
Node types: feature, component, file, task, layer, doc\n\
Edge relations: depends_on, implements, modifies, contains, tests, related_to\n\n\
Rules:\n\
- Create feature/component nodes for modules and architectural units\n\
- Create file nodes for files being created/modified\n\
- Create task nodes for concrete work items (status: todo)\n\
- Link tasks to features they implement (relation: implements)\n\
- Link features to files they contain (relation: contains)\n\
- Link tasks to tasks they depend on (relation: depends_on)\n\
- Include metadata (design_ref, goals, file_path) on task nodes\n\
Use the Read tool to read DESIGN.md, then Write tool to create .gid/graph.yml."
.to_string(),
),
"update-graph" => Ok(
"Read the existing .gid/graph.yml and DESIGN.md. Update the graph to reflect \
any new tasks or changes from the design.\n\n\
CRITICAL RULES:\n\
- Read the existing graph FIRST\n\
- PRESERVE all existing nodes and edges — do NOT delete or modify them\n\
- Only ADD new nodes (task, feature, component, file) and edges for the new work\n\
- New task nodes should have status: todo\n\
- Link new tasks to features they implement (relation: implements)\n\
- Link tasks to tasks they depend on (relation: depends_on)\n\n\
Node types: feature, component, file, task, layer, doc\n\
Edge relations: depends_on, implements, modifies, contains, tests, related_to\n\n\
Use Read to load existing graph and DESIGN.md, then Write to update .gid/graph.yml."
.to_string(),
),
"implement" => Ok(
"Implement the described changes following the graph-driven layer approach:\n\n\
PROCESS:\n\
1. Read .gid/graph.yml to find all task nodes and their layer assignments\n\
2. Process layers in order (Layer 0 first, then Layer 1, etc.)\n\
3. Within each layer, implement each task node sequentially:\n\
a. Read the design doc section relevant to this task\n\
b. Read any dependency modules (from prior layers) to understand their public API\n\
c. Write the code for this task\n\
d. Update the task node's status to 'done' in graph.yml\n\
4. After completing ALL tasks in a layer, run the project's build/check command\n\
to verify compilation before proceeding to the next layer\n\
5. If build fails, fix the issues within the current layer before moving on\n\n\
RULES:\n\
- Follow existing patterns and conventions in the codebase\n\
- Only implement tasks that are status: todo — skip tasks already marked done\n\
- Layer order is mandatory: never implement a task before its dependencies are done\n\
- Update graph.yml status incrementally, not all at once at the end\n\
- FINAL STEP: After ALL layers are done, run `cargo check` (Rust), `npm run build` (TS),\n\
or `python -m py_compile` (Python) as a final compilation gate. If it fails, fix before exiting.\n\
Do NOT mark the implementation complete until the final check passes."
.to_string(),
),
_ => anyhow::bail!("Unknown skill: {}", skill_name),
}
}
fn read_verify_command(&self) -> Option<String> {
let config_path = self.config.project_root.join(".gid").join("config.yml");
if !config_path.exists() {
let composer_state = ComposerProjectState::detect(&self.config.project_root);
return match composer_state.language {
Some(super::composer::ProjectLanguage::Rust) => {
Some("cargo build 2>&1 && cargo test 2>&1".to_string())
}
Some(super::composer::ProjectLanguage::TypeScript) => {
Some("npm run build 2>&1 && npm test 2>&1".to_string())
}
Some(super::composer::ProjectLanguage::Python) => {
Some("python -m pytest 2>&1".to_string())
}
_ => None,
};
}
match std::fs::read_to_string(&config_path) {
Ok(content) => {
for line in content.lines() {
let trimmed = line.trim();
if let Some(cmd) = trimmed.strip_prefix("verify_command:") {
let cmd = cmd.trim().trim_matches('"').trim_matches('\'');
if !cmd.is_empty() {
return Some(cmd.to_string());
}
}
}
None
}
Err(_) => None,
}
}
fn parse_planning_result(&self, output: &str) -> RitualEvent {
let json_str = extract_json(output);
match serde_json::from_str::<serde_json::Value>(json_str) {
Ok(v) => {
let strategy = v["strategy"].as_str().unwrap_or("single_llm");
match strategy {
"multi_agent" => {
let tasks: Vec<String> = v["tasks"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
if tasks.is_empty() {
RitualEvent::PlanDecided(ImplementStrategy::SingleLlm)
} else {
RitualEvent::PlanDecided(ImplementStrategy::MultiAgent { tasks })
}
}
_ => RitualEvent::PlanDecided(ImplementStrategy::SingleLlm),
}
}
Err(e) => {
warn!(error = %e, "Failed to parse planning JSON, defaulting to SingleLlm");
RitualEvent::PlanDecided(ImplementStrategy::SingleLlm)
}
}
}
fn safe_truncate(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
fn resolve_gid_root(&self, state: &RitualState) -> PathBuf {
if let Some(ref target_root) = state.target_root {
PathBuf::from(target_root).join(".gid")
} else {
self.config.project_root.join(".gid")
}
}
fn build_graph_context(&self, state: &RitualState) -> Option<String> {
let gid_root = self.resolve_gid_root(state);
let graph_path = gid_root.join("graph.yml");
let content = std::fs::read_to_string(&graph_path)
.map_err(|e| tracing::debug!("No graph.yml found: {}", e))
.ok()?;
let graph: Graph = serde_yaml::from_str(&content)
.map_err(|e| tracing::warn!("Failed to parse graph: {}", e))
.ok()?;
let task_ids: Vec<&str> = graph
.nodes
.iter()
.filter(|n| n.node_type.as_deref() == Some("task"))
.filter(|n| !matches!(n.status, NodeStatus::Done))
.map(|n| n.id.as_str())
.collect();
if task_ids.is_empty() {
return None;
}
let contexts: Vec<String> = task_ids
.iter()
.filter_map(|id| {
assemble_task_context(&graph, id, &gid_root)
.map_err(|e| tracing::warn!("Failed to assemble context for {}: {}", id, e))
.ok()
})
.map(|ctx| ctx.render_prompt())
.collect();
if contexts.is_empty() {
None
} else {
Some(contexts.join("\n\n---\n\n"))
}
}
fn enrich_implement_context(&self, raw_context: &str, state: &RitualState) -> String {
let graph_context = self.build_graph_context(state);
match graph_context {
Some(ctx) => format!("{}\n\n## Original Task\n{}", ctx, raw_context),
None => raw_context.to_string(),
}
}
fn scope_to_tool_definitions(&self, scope: &super::scope::ToolScope) -> Vec<ToolDefinition> {
scope
.allowed_tools
.iter()
.map(|name| ToolDefinition {
name: name.clone(),
description: format!("{} tool", name),
input_schema: serde_json::json!({"type": "object"}),
})
.collect()
}
}
fn extract_json(output: &str) -> &str {
if let Some(start) = output.find("```json") {
let json_start = start + 7;
if let Some(end) = output[json_start..].find("```") {
return output[json_start..json_start + end].trim();
}
}
if let Some(start) = output.find("```") {
let json_start = start + 3;
if let Some(end) = output[json_start..].find("```") {
return output[json_start..json_start + end].trim();
}
}
if let Some(start) = output.find('{') {
if let Some(end) = output.rfind('}') {
return &output[start..=end];
}
}
output.trim()
}
pub async fn run_ritual(
task: &str,
executor: &V2Executor,
) -> Result<RitualState> {
use super::state_machine::transition;
let mut state = RitualState::new();
let (new_state, actions) = transition(&state, RitualEvent::Start { task: task.to_string() });
state = new_state;
let mut event = executor.execute_actions(&actions, &state).await;
let max_iterations = 50; let mut iteration = 0;
while let Some(ev) = event {
iteration += 1;
if iteration > max_iterations {
error!("Ritual exceeded max iterations ({}), escalating", max_iterations);
let (final_state, final_actions) = transition(
&state,
RitualEvent::SkillFailed {
phase: "engine".to_string(),
error: format!("Max iterations ({}) exceeded", max_iterations),
},
);
state = final_state;
executor.execute_actions(&final_actions, &state).await;
break;
}
let (new_state, actions) = transition(&state, ev);
state = new_state;
if state.phase.is_terminal() || state.phase.is_paused() {
executor.execute_actions(&actions, &state).await;
break;
}
event = executor.execute_actions(&actions, &state).await;
}
info!(
phase = ?state.phase,
iterations = iteration,
"Ritual completed"
);
Ok(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_json_bare() {
let input = r#"{"strategy": "single_llm"}"#;
assert_eq!(extract_json(input), r#"{"strategy": "single_llm"}"#);
}
#[test]
fn test_extract_json_fenced() {
let input = "Here's the plan:\n```json\n{\"strategy\": \"single_llm\"}\n```\n";
assert_eq!(extract_json(input), r#"{"strategy": "single_llm"}"#);
}
#[test]
fn test_extract_json_code_block() {
let input = "```\n{\"strategy\": \"multi_agent\", \"tasks\": [\"a\"]}\n```";
assert_eq!(
extract_json(input),
r#"{"strategy": "multi_agent", "tasks": ["a"]}"#
);
}
#[test]
fn test_extract_json_with_text() {
let input = "I think single LLM is best.\n{\"strategy\": \"single_llm\"}\nDone.";
assert_eq!(extract_json(input), r#"{"strategy": "single_llm"}"#);
}
#[test]
fn test_parse_planning_single() {
let executor = V2Executor::new(V2ExecutorConfig::default());
let event = executor.parse_planning_result(r#"{"strategy": "single_llm"}"#);
assert!(matches!(event, RitualEvent::PlanDecided(ImplementStrategy::SingleLlm)));
}
#[test]
fn test_parse_planning_multi() {
let executor = V2Executor::new(V2ExecutorConfig::default());
let event = executor.parse_planning_result(
r#"{"strategy": "multi_agent", "tasks": ["impl auth", "impl dashboard"]}"#,
);
match event {
RitualEvent::PlanDecided(ImplementStrategy::MultiAgent { tasks }) => {
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0], "impl auth");
}
_ => panic!("Expected MultiAgent"),
}
}
#[test]
fn test_parse_planning_invalid_json() {
let executor = V2Executor::new(V2ExecutorConfig::default());
let event = executor.parse_planning_result("this is not json at all");
assert!(matches!(event, RitualEvent::PlanDecided(ImplementStrategy::SingleLlm)));
}
#[test]
fn test_parse_planning_multi_empty_tasks() {
let executor = V2Executor::new(V2ExecutorConfig::default());
let event = executor.parse_planning_result(r#"{"strategy": "multi_agent", "tasks": []}"#);
assert!(matches!(event, RitualEvent::PlanDecided(ImplementStrategy::SingleLlm)));
}
#[test]
fn test_safe_truncate_ascii() {
assert_eq!(V2Executor::safe_truncate("hello world", 5), "hello");
assert_eq!(V2Executor::safe_truncate("hello", 100), "hello");
}
#[test]
fn test_safe_truncate_utf8() {
let s = "你好世界"; assert_eq!(V2Executor::safe_truncate(s, 6), "你好"); assert_eq!(V2Executor::safe_truncate(s, 7), "你好"); assert_eq!(V2Executor::safe_truncate(s, 12), s); assert_eq!(V2Executor::safe_truncate(s, 100), s); }
#[test]
fn test_render_prompt_full() {
use crate::harness::types::{TaskContext, TaskInfo};
let ctx = TaskContext {
task_info: TaskInfo {
id: "task-1".into(),
title: "Add auth".into(),
description: "Implement JWT auth middleware".into(),
goals: vec!["GOAL-1".into()],
verify: None,
estimated_turns: 15,
depends_on: vec![],
design_ref: None,
satisfies: vec!["GOAL-1".into()],
},
goals_text: vec!["GOAL-1: All endpoints require valid JWT".into()],
design_excerpt: Some("§3.1 Auth uses RS256 tokens...".into()),
dependency_interfaces: vec![],
guards: vec!["GUARD-1: No plaintext passwords".into()],
};
let rendered = ctx.render_prompt();
assert!(rendered.contains("## Task: Add auth"));
assert!(rendered.contains("JWT auth middleware"));
assert!(rendered.contains("## Design Reference"));
assert!(rendered.contains("RS256"));
assert!(rendered.contains("## Requirements"));
assert!(rendered.contains("GOAL-1"));
assert!(rendered.contains("## Guards"));
assert!(rendered.contains("GUARD-1"));
}
#[test]
fn test_review_design_triggers_review_config() {
let executor = V2Executor::new(V2ExecutorConfig::default());
let mut state = RitualState::new();
state.triage_size = Some("large".into());
let config = executor.review_config_for_triage_size(&state);
let name = "review-design";
assert!(name.starts_with("review"), "review-design should match review prefix");
assert_eq!(config.model, "opus");
assert_eq!(config.max_iterations, 100);
assert_eq!(config.depth, ReviewDepth::Full);
}
#[test]
fn test_review_requirements_triggers_review_config() {
let name = "review-requirements";
assert!(name.starts_with("review"), "review-requirements should match review prefix");
let executor = V2Executor::new(V2ExecutorConfig::default());
let mut state = RitualState::new();
state.triage_size = Some("small".into());
let config = executor.review_config_for_triage_size(&state);
assert_eq!(config.model, "sonnet");
assert_eq!(config.max_iterations, 30);
assert_eq!(config.depth, ReviewDepth::Light);
}
#[test]
fn test_review_tasks_triggers_review_config() {
let name = "review-tasks";
assert!(name.starts_with("review"), "review-tasks should match review prefix");
let executor = V2Executor::new(V2ExecutorConfig::default());
let mut state = RitualState::new();
state.triage_size = Some("medium".into());
let config = executor.review_config_for_triage_size(&state);
assert_eq!(config.model, "opus");
assert_eq!(config.max_iterations, 50);
assert_eq!(config.depth, ReviewDepth::Light);
}
#[test]
fn test_implement_does_not_trigger_review_config() {
let name = "implement";
assert!(!name.starts_with("review"), "implement should NOT match review prefix");
}
#[test]
fn test_review_depth_hint_injected_for_review_phases() {
for name in &["review-design", "review-requirements", "review-tasks"] {
assert!(name.starts_with("review"),
"'{}' should trigger review depth injection", name);
}
let state_small = {
let mut s = RitualState::new();
s.triage_size = Some("small".into());
s
};
let depth = match state_small.triage_size.as_deref().unwrap_or("medium") {
"small" => "quick",
"medium" => "standard",
"large" => "full",
_ => "standard",
};
assert_eq!(depth, "quick");
let state_large = {
let mut s = RitualState::new();
s.triage_size = Some("large".into());
s
};
let depth = match state_large.triage_size.as_deref().unwrap_or("medium") {
"small" => "quick",
"medium" => "standard",
"large" => "full",
_ => "standard",
};
assert_eq!(depth, "full");
}
#[test]
fn test_review_depth_hint_not_injected_for_non_review_phases() {
for name in &["implement", "draft-design", "generate-graph", "draft-requirements"] {
assert!(!name.starts_with("review"),
"'{}' should NOT trigger review depth injection", name);
}
}
#[test]
fn test_render_prompt_partial() {
use crate::harness::types::{TaskContext, TaskInfo};
let ctx = TaskContext {
task_info: TaskInfo {
id: "task-2".into(),
title: "Fix bug".into(),
description: String::new(),
goals: vec![],
verify: None,
estimated_turns: 5,
depends_on: vec![],
design_ref: None,
satisfies: vec![],
},
goals_text: vec![],
design_excerpt: None,
dependency_interfaces: vec![],
guards: vec![],
};
let rendered = ctx.render_prompt();
assert!(rendered.contains("## Task: Fix bug"));
assert!(!rendered.contains("## Design Reference"));
assert!(!rendered.contains("## Requirements"));
assert!(!rendered.contains("## Guards"));
}
#[test]
fn test_enrich_with_graph_context() {
let tmp = tempfile::tempdir().unwrap();
let gid_dir = tmp.path().join(".gid");
std::fs::create_dir_all(&gid_dir).unwrap();
let mut graph = Graph::new();
let mut task_node = crate::graph::Node::new("task-auth", "Implement auth middleware");
task_node.node_type = Some("task".into());
task_node.description = Some("Add JWT-based auth middleware to API gateway".into());
graph.add_node(task_node);
let yaml = serde_yaml::to_string(&graph).unwrap();
std::fs::write(gid_dir.join("graph.yml"), &yaml).unwrap();
let executor = V2Executor::new(V2ExecutorConfig {
project_root: tmp.path().to_path_buf(),
..V2ExecutorConfig::default()
});
let mut state = RitualState::new();
state.task = "implement auth".into();
let enriched = executor.enrich_implement_context("implement auth", &state);
assert!(enriched.contains("Implement auth middleware"),
"enriched context should include task title from graph. Got: {}", enriched);
assert!(enriched.contains("implement auth"),
"enriched context should include original task text");
}
#[test]
fn test_enrich_no_graph() {
let tmp = tempfile::tempdir().unwrap();
let executor = V2Executor::new(V2ExecutorConfig {
project_root: tmp.path().to_path_buf(),
..V2ExecutorConfig::default()
});
let mut state = RitualState::new();
state.task = "fix the bug".into();
let enriched = executor.enrich_implement_context("fix the bug", &state);
assert_eq!(enriched, "fix the bug",
"with no graph, should return raw context unchanged");
}
#[test]
fn test_enrich_no_task_nodes() {
let tmp = tempfile::tempdir().unwrap();
let gid_dir = tmp.path().join(".gid");
std::fs::create_dir_all(&gid_dir).unwrap();
let mut graph = Graph::new();
let mut code_node = crate::graph::Node::new("file-main", "src/main.rs");
code_node.node_type = Some("code".into());
graph.add_node(code_node);
let yaml = serde_yaml::to_string(&graph).unwrap();
std::fs::write(gid_dir.join("graph.yml"), &yaml).unwrap();
let executor = V2Executor::new(V2ExecutorConfig {
project_root: tmp.path().to_path_buf(),
..V2ExecutorConfig::default()
});
let mut state = RitualState::new();
state.task = "refactor main".into();
let enriched = executor.enrich_implement_context("refactor main", &state);
assert_eq!(enriched, "refactor main",
"with no task nodes, should fall back to raw context");
}
#[test]
fn test_enrich_with_error_context() {
let tmp = tempfile::tempdir().unwrap();
let gid_dir = tmp.path().join(".gid");
std::fs::create_dir_all(&gid_dir).unwrap();
let mut graph = Graph::new();
let mut task_node = crate::graph::Node::new("task-api", "Implement API endpoint");
task_node.node_type = Some("task".into());
graph.add_node(task_node);
let yaml = serde_yaml::to_string(&graph).unwrap();
std::fs::write(gid_dir.join("graph.yml"), &yaml).unwrap();
let executor = V2Executor::new(V2ExecutorConfig {
project_root: tmp.path().to_path_buf(),
..V2ExecutorConfig::default()
});
let mut state = RitualState::new();
state.task = "implement API endpoint".into();
let error_context = "FIX BUILD ERROR:\nerror[E0433]: failed to resolve: use of undeclared crate\n\nOriginal task: implement API endpoint";
let enriched = executor.enrich_implement_context(error_context, &state);
assert!(enriched.contains("Implement API endpoint"),
"should include task from graph");
assert!(enriched.contains("FIX BUILD ERROR"),
"should include error message from raw context");
assert!(enriched.contains("E0433"),
"should preserve full error detail");
}
#[test]
fn test_review_config_medium() {
let executor = V2Executor::new(V2ExecutorConfig::default());
let mut state = RitualState::new();
state.triage_size = Some("medium".into());
let config = executor.review_config_for_triage_size(&state);
assert_eq!(config.depth, ReviewDepth::Light,
"medium tasks should get Light review depth");
assert_eq!(config.max_iterations, 50);
assert_eq!(config.model, "opus");
}
#[test]
fn test_review_config_large() {
let executor = V2Executor::new(V2ExecutorConfig::default());
let mut state = RitualState::new();
state.triage_size = Some("large".into());
let config = executor.review_config_for_triage_size(&state);
assert_eq!(config.depth, ReviewDepth::Full,
"large tasks should get Full review depth");
assert_eq!(config.max_iterations, 100);
assert_eq!(config.model, "opus");
}
#[test]
fn test_light_review_prompt_injection() {
let config = ReviewConfig {
model: "sonnet".into(),
max_iterations: 30,
depth: ReviewDepth::Light,
};
let base_prompt = "# Review Design\nRun all checks...";
let depth_label = match config.depth {
ReviewDepth::Light => "quick",
ReviewDepth::Full => "full",
};
let injected = if config.depth == ReviewDepth::Light {
format!(
"[REVIEW_DEPTH: {}]\n\n## REVIEW SCOPE: LIGHT\nRun ONLY checks #1, #2, #5, #6, #7, #8, #11, #13, #21, #27.\nSkip all other checks. Write findings to file.\n\n{}",
depth_label, base_prompt
)
} else {
format!("[REVIEW_DEPTH: {}]\n\n{}", depth_label, base_prompt)
};
assert!(injected.contains("[REVIEW_DEPTH: quick]"),
"light review should inject quick depth label");
assert!(injected.contains("REVIEW SCOPE: LIGHT"),
"light review should inject scope heading");
assert!(injected.contains("#1, #2, #5, #6, #7, #8, #11, #13, #21, #27"),
"light review should list the 10 core checks");
assert!(injected.contains("Skip all other checks"),
"light review should instruct to skip non-core checks");
let full_config = ReviewConfig {
model: "opus".into(),
max_iterations: 55,
depth: ReviewDepth::Full,
};
let full_label = match full_config.depth {
ReviewDepth::Light => "quick",
ReviewDepth::Full => "full",
};
let full_injected = if full_config.depth == ReviewDepth::Light {
format!("[REVIEW_DEPTH: {}]\n\n## REVIEW SCOPE: LIGHT\n...\n\n{}", full_label, base_prompt)
} else {
format!("[REVIEW_DEPTH: {}]\n\n{}", full_label, base_prompt)
};
assert!(full_injected.contains("[REVIEW_DEPTH: full]"),
"full review should inject full depth label");
assert!(!full_injected.contains("REVIEW SCOPE: LIGHT"),
"full review should NOT inject scope restriction");
}
}