use std::collections::BTreeSet;
use super::{RetryPolicy, TaskContextItem, TaskDag, TaskNode, TaskOperator, TaskRisk, TaskState};
const MAX_DIRECT_CONTEXT_INSPECTIONS: usize = 24;
pub fn draft_task_dag(task: &str, context: &[TaskContextItem]) -> TaskDag {
let id = format!("task.{}", slug(task));
let mut receipts = context
.iter()
.filter_map(|item| (!item.source.trim().is_empty()).then_some(item.source.clone()))
.collect::<Vec<_>>();
receipts.sort();
receipts.dedup();
let has_code = context.iter().any(|item| {
matches!(
item.kind.as_str(),
"function" | "type" | "trait" | "module" | "code"
)
});
let has_docs = context
.iter()
.any(|item| matches!(item.kind.as_str(), "doc" | "section" | "skill" | "agent"));
let mut nodes = vec![TaskNode {
id: "context".to_string(),
title: "Gather receipt-backed context".to_string(),
operator: TaskOperator::ReadContext,
state: TaskState::Pending,
depends_on: Vec::new(),
inputs: vec![task.to_string()],
outputs: vec!["context_pack".to_string()],
required_checks: vec!["receipt_coverage".to_string()],
tools: vec!["focus".to_string()],
retry: RetryPolicy::default(),
risk: TaskRisk::Low,
receipts: receipts.clone(),
}];
let mut inspect_ids = Vec::new();
let mut used_ids = BTreeSet::from(["context".to_string()]);
for item in context.iter().take(MAX_DIRECT_CONTEXT_INSPECTIONS) {
let inspect_id = unique_node_id(&format!("inspect-{}", slug(&item.id)), &mut used_ids);
inspect_ids.push(inspect_id.clone());
nodes.push(TaskNode {
id: inspect_id,
title: format!("Inspect {}", item.title),
operator: TaskOperator::ReadContext,
state: TaskState::Pending,
depends_on: vec!["context".to_string()],
inputs: vec![item.id.clone()],
outputs: vec![format!("inspected:{}", item.id)],
required_checks: vec!["receipt_read".to_string()],
tools: vec!["read".to_string(), "get_node".to_string()],
retry: RetryPolicy::default(),
risk: TaskRisk::Low,
receipts: if item.source.trim().is_empty() {
Vec::new()
} else {
vec![item.source.clone()]
},
});
}
if context.len() > MAX_DIRECT_CONTEXT_INSPECTIONS {
let remaining = &context[MAX_DIRECT_CONTEXT_INSPECTIONS..];
let inspect_id = unique_node_id("inspect-remaining-context", &mut used_ids);
inspect_ids.push(inspect_id.clone());
nodes.push(TaskNode {
id: inspect_id,
title: format!("Inspect {} remaining context items", remaining.len()),
operator: TaskOperator::ReadContext,
state: TaskState::Pending,
depends_on: vec!["context".to_string()],
inputs: remaining.iter().map(|item| item.id.clone()).collect(),
outputs: vec!["inspected:remaining_context".to_string()],
required_checks: vec!["receipt_read".to_string()],
tools: vec!["read".to_string(), "get_node".to_string()],
retry: RetryPolicy::default(),
risk: TaskRisk::Low,
receipts: remaining
.iter()
.filter_map(|item| (!item.source.trim().is_empty()).then_some(item.source.clone()))
.collect(),
});
}
let decompose_deps = if inspect_ids.is_empty() {
vec!["context".to_string()]
} else {
inspect_ids
};
nodes.push(TaskNode {
id: "decompose".to_string(),
title: "Decompose task into bounded work".to_string(),
operator: TaskOperator::Decompose,
state: TaskState::Pending,
depends_on: decompose_deps,
inputs: vec!["context_pack".to_string()],
outputs: vec!["work_items".to_string()],
required_checks: vec!["dag_is_acyclic".to_string()],
tools: vec!["brief_task".to_string()],
retry: RetryPolicy::default(),
risk: TaskRisk::Low,
receipts: Vec::new(),
});
let change_operator = if has_code {
TaskOperator::Edit
} else {
TaskOperator::ProposeDoc
};
nodes.push(TaskNode {
id: "change".to_string(),
title: if has_code {
"Prepare code change proposal".to_string()
} else if has_docs {
"Prepare documentation proposal".to_string()
} else {
"Prepare knowledge proposal".to_string()
},
operator: change_operator,
state: TaskState::Pending,
depends_on: vec!["decompose".to_string()],
inputs: vec!["work_items".to_string()],
outputs: vec!["change_set".to_string()],
required_checks: if has_code {
vec!["compile_or_typecheck".to_string()]
} else {
vec!["sigil_validation".to_string()]
},
tools: if has_code {
vec!["edit".to_string()]
} else {
vec!["propose".to_string(), "sigil-diff".to_string()]
},
retry: RetryPolicy {
max_attempts: 2,
backoff_ms: 0,
},
risk: if has_code {
TaskRisk::High
} else {
TaskRisk::Medium
},
receipts,
});
nodes.push(TaskNode {
id: "verify".to_string(),
title: "Verify proposed work".to_string(),
operator: TaskOperator::Check,
state: TaskState::Pending,
depends_on: vec!["change".to_string()],
inputs: vec!["change_set".to_string()],
outputs: vec!["verification_report".to_string()],
required_checks: if has_code {
vec!["tests_pass".to_string(), "lint_passes".to_string()]
} else {
vec!["proposal_diff_reviewed".to_string()]
},
tools: if has_code {
vec!["test".to_string(), "lint".to_string()]
} else {
vec!["sigil-diff".to_string()]
},
retry: RetryPolicy {
max_attempts: 2,
backoff_ms: 250,
},
risk: TaskRisk::Medium,
receipts: Vec::new(),
});
nodes.push(TaskNode {
id: "review".to_string(),
title: "Review receipts and risks".to_string(),
operator: TaskOperator::Review,
state: TaskState::Pending,
depends_on: vec!["verify".to_string()],
inputs: vec!["verification_report".to_string()],
outputs: vec!["review_notes".to_string()],
required_checks: vec!["receipts_match_claims".to_string()],
tools: vec!["review".to_string()],
retry: RetryPolicy::default(),
risk: TaskRisk::Low,
receipts: Vec::new(),
});
nodes.push(TaskNode {
id: "approval".to_string(),
title: "Human approval gate".to_string(),
operator: TaskOperator::HumanApproval,
state: TaskState::Pending,
depends_on: vec!["review".to_string()],
inputs: vec!["review_notes".to_string()],
outputs: vec!["approved_or_rejected".to_string()],
required_checks: vec!["human_disposes".to_string()],
tools: vec!["sigil-accept".to_string()],
retry: RetryPolicy::default(),
risk: TaskRisk::High,
receipts: Vec::new(),
});
TaskDag {
id,
title: task.trim().to_string(),
nodes,
}
}
fn slug(value: &str) -> String {
let mut out = String::new();
let mut last_dash = false;
for ch in value.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
out.push(ch);
last_dash = false;
} else if !last_dash && !out.is_empty() {
out.push('-');
last_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
"untitled".to_string()
} else {
out
}
}
fn unique_node_id(base: &str, used: &mut BTreeSet<String>) -> String {
let mut candidate = base.to_string();
let mut suffix = 2usize;
while !used.insert(candidate.clone()) {
candidate = format!("{base}-{suffix}");
suffix += 1;
}
candidate
}