1use std::collections::BTreeSet;
2
3use super::{RetryPolicy, TaskContextItem, TaskDag, TaskNode, TaskOperator, TaskRisk, TaskState};
4
5const MAX_DIRECT_CONTEXT_INSPECTIONS: usize = 24;
6
7pub fn draft_task_dag(task: &str, context: &[TaskContextItem]) -> TaskDag {
13 let id = format!("task.{}", slug(task));
14 let mut receipts = context
15 .iter()
16 .filter_map(|item| (!item.source.trim().is_empty()).then_some(item.source.clone()))
17 .collect::<Vec<_>>();
18 receipts.sort();
19 receipts.dedup();
20
21 let has_code = context.iter().any(|item| {
22 matches!(
23 item.kind.as_str(),
24 "function" | "type" | "trait" | "module" | "code"
25 )
26 });
27 let has_docs = context
28 .iter()
29 .any(|item| matches!(item.kind.as_str(), "doc" | "section" | "skill" | "agent"));
30
31 let mut nodes = vec![TaskNode {
32 id: "context".to_string(),
33 title: "Gather receipt-backed context".to_string(),
34 operator: TaskOperator::ReadContext,
35 state: TaskState::Pending,
36 depends_on: Vec::new(),
37 inputs: vec![task.to_string()],
38 outputs: vec!["context_pack".to_string()],
39 required_checks: vec!["receipt_coverage".to_string()],
40 tools: vec!["focus".to_string()],
41 retry: RetryPolicy::default(),
42 risk: TaskRisk::Low,
43 receipts: receipts.clone(),
44 }];
45
46 let mut inspect_ids = Vec::new();
47 let mut used_ids = BTreeSet::from(["context".to_string()]);
48 for item in context.iter().take(MAX_DIRECT_CONTEXT_INSPECTIONS) {
49 let inspect_id = unique_node_id(&format!("inspect-{}", slug(&item.id)), &mut used_ids);
50 inspect_ids.push(inspect_id.clone());
51 nodes.push(TaskNode {
52 id: inspect_id,
53 title: format!("Inspect {}", item.title),
54 operator: TaskOperator::ReadContext,
55 state: TaskState::Pending,
56 depends_on: vec!["context".to_string()],
57 inputs: vec![item.id.clone()],
58 outputs: vec![format!("inspected:{}", item.id)],
59 required_checks: vec!["receipt_read".to_string()],
60 tools: vec!["read".to_string(), "get_node".to_string()],
61 retry: RetryPolicy::default(),
62 risk: TaskRisk::Low,
63 receipts: if item.source.trim().is_empty() {
64 Vec::new()
65 } else {
66 vec![item.source.clone()]
67 },
68 });
69 }
70 if context.len() > MAX_DIRECT_CONTEXT_INSPECTIONS {
71 let remaining = &context[MAX_DIRECT_CONTEXT_INSPECTIONS..];
72 let inspect_id = unique_node_id("inspect-remaining-context", &mut used_ids);
73 inspect_ids.push(inspect_id.clone());
74 nodes.push(TaskNode {
75 id: inspect_id,
76 title: format!("Inspect {} remaining context items", remaining.len()),
77 operator: TaskOperator::ReadContext,
78 state: TaskState::Pending,
79 depends_on: vec!["context".to_string()],
80 inputs: remaining.iter().map(|item| item.id.clone()).collect(),
81 outputs: vec!["inspected:remaining_context".to_string()],
82 required_checks: vec!["receipt_read".to_string()],
83 tools: vec!["read".to_string(), "get_node".to_string()],
84 retry: RetryPolicy::default(),
85 risk: TaskRisk::Low,
86 receipts: remaining
87 .iter()
88 .filter_map(|item| (!item.source.trim().is_empty()).then_some(item.source.clone()))
89 .collect(),
90 });
91 }
92
93 let decompose_deps = if inspect_ids.is_empty() {
94 vec!["context".to_string()]
95 } else {
96 inspect_ids
97 };
98 nodes.push(TaskNode {
99 id: "decompose".to_string(),
100 title: "Decompose task into bounded work".to_string(),
101 operator: TaskOperator::Decompose,
102 state: TaskState::Pending,
103 depends_on: decompose_deps,
104 inputs: vec!["context_pack".to_string()],
105 outputs: vec!["work_items".to_string()],
106 required_checks: vec!["dag_is_acyclic".to_string()],
107 tools: vec!["brief_task".to_string()],
108 retry: RetryPolicy::default(),
109 risk: TaskRisk::Low,
110 receipts: Vec::new(),
111 });
112
113 let change_operator = if has_code {
114 TaskOperator::Edit
115 } else {
116 TaskOperator::ProposeDoc
117 };
118 nodes.push(TaskNode {
119 id: "change".to_string(),
120 title: if has_code {
121 "Prepare code change proposal".to_string()
122 } else if has_docs {
123 "Prepare documentation proposal".to_string()
124 } else {
125 "Prepare knowledge proposal".to_string()
126 },
127 operator: change_operator,
128 state: TaskState::Pending,
129 depends_on: vec!["decompose".to_string()],
130 inputs: vec!["work_items".to_string()],
131 outputs: vec!["change_set".to_string()],
132 required_checks: if has_code {
133 vec!["compile_or_typecheck".to_string()]
134 } else {
135 vec!["sigil_validation".to_string()]
136 },
137 tools: if has_code {
138 vec!["edit".to_string()]
139 } else {
140 vec!["propose".to_string(), "sigil-diff".to_string()]
141 },
142 retry: RetryPolicy {
143 max_attempts: 2,
144 backoff_ms: 0,
145 },
146 risk: if has_code {
147 TaskRisk::High
148 } else {
149 TaskRisk::Medium
150 },
151 receipts,
152 });
153
154 nodes.push(TaskNode {
155 id: "verify".to_string(),
156 title: "Verify proposed work".to_string(),
157 operator: TaskOperator::Check,
158 state: TaskState::Pending,
159 depends_on: vec!["change".to_string()],
160 inputs: vec!["change_set".to_string()],
161 outputs: vec!["verification_report".to_string()],
162 required_checks: if has_code {
163 vec!["tests_pass".to_string(), "lint_passes".to_string()]
164 } else {
165 vec!["proposal_diff_reviewed".to_string()]
166 },
167 tools: if has_code {
168 vec!["test".to_string(), "lint".to_string()]
169 } else {
170 vec!["sigil-diff".to_string()]
171 },
172 retry: RetryPolicy {
173 max_attempts: 2,
174 backoff_ms: 250,
175 },
176 risk: TaskRisk::Medium,
177 receipts: Vec::new(),
178 });
179
180 nodes.push(TaskNode {
181 id: "review".to_string(),
182 title: "Review receipts and risks".to_string(),
183 operator: TaskOperator::Review,
184 state: TaskState::Pending,
185 depends_on: vec!["verify".to_string()],
186 inputs: vec!["verification_report".to_string()],
187 outputs: vec!["review_notes".to_string()],
188 required_checks: vec!["receipts_match_claims".to_string()],
189 tools: vec!["review".to_string()],
190 retry: RetryPolicy::default(),
191 risk: TaskRisk::Low,
192 receipts: Vec::new(),
193 });
194
195 nodes.push(TaskNode {
196 id: "approval".to_string(),
197 title: "Human approval gate".to_string(),
198 operator: TaskOperator::HumanApproval,
199 state: TaskState::Pending,
200 depends_on: vec!["review".to_string()],
201 inputs: vec!["review_notes".to_string()],
202 outputs: vec!["approved_or_rejected".to_string()],
203 required_checks: vec!["human_disposes".to_string()],
204 tools: vec!["sigil-accept".to_string()],
205 retry: RetryPolicy::default(),
206 risk: TaskRisk::High,
207 receipts: Vec::new(),
208 });
209
210 TaskDag {
211 id,
212 title: task.trim().to_string(),
213 nodes,
214 }
215}
216
217fn slug(value: &str) -> String {
218 let mut out = String::new();
219 let mut last_dash = false;
220 for ch in value.chars().flat_map(char::to_lowercase) {
221 if ch.is_ascii_alphanumeric() {
222 out.push(ch);
223 last_dash = false;
224 } else if !last_dash && !out.is_empty() {
225 out.push('-');
226 last_dash = true;
227 }
228 }
229 while out.ends_with('-') {
230 out.pop();
231 }
232 if out.is_empty() {
233 "untitled".to_string()
234 } else {
235 out
236 }
237}
238
239fn unique_node_id(base: &str, used: &mut BTreeSet<String>) -> String {
240 let mut candidate = base.to_string();
241 let mut suffix = 2usize;
242 while !used.insert(candidate.clone()) {
243 candidate = format!("{base}-{suffix}");
244 suffix += 1;
245 }
246 candidate
247}