1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
9pub enum TaskItemStatus {
10 #[serde(rename = "pending")]
11 #[default]
12 Pending,
13 #[serde(rename = "in_progress")]
14 InProgress,
15 #[serde(rename = "completed")]
16 Completed,
17 #[serde(rename = "blocked")]
18 Blocked,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum TaskPhase {
25 Planning,
26 #[default]
27 Execution,
28 Verification,
29 Handoff,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum TaskPriority {
36 Low,
37 #[default]
38 Medium,
39 High,
40 Critical,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45#[serde(rename_all = "snake_case")]
46pub enum TaskEvidenceKind {
47 #[default]
48 Note,
49 ToolCall,
50 File,
51 Command,
52 Test,
53 Observation,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
58pub struct TaskEvidence {
59 #[serde(default)]
60 pub kind: TaskEvidenceKind,
61 pub summary: String,
62 #[serde(default, skip_serializing_if = "Option::is_none", alias = "ref")]
63 pub reference: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub tool_name: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub tool_call_id: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub round: Option<u32>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub success: Option<bool>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
76#[serde(rename_all = "snake_case")]
77pub enum TaskBlockerKind {
78 UserInput,
79 Dependency,
80 ToolFailure,
81 External,
82 #[default]
83 Unknown,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
88pub struct TaskBlocker {
89 #[serde(default)]
90 pub kind: TaskBlockerKind,
91 pub summary: String,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub waiting_on: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct TaskTransition {
99 pub from_status: TaskItemStatus,
100 pub to_status: TaskItemStatus,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub reason: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub round: Option<u32>,
105 pub changed_at: DateTime<Utc>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct TaskItem {
111 pub id: String,
113 pub description: String,
115 pub status: TaskItemStatus,
117 #[serde(default)]
119 pub depends_on: Vec<String>,
120 #[serde(default)]
122 pub notes: String,
123 #[serde(default, skip_serializing_if = "Option::is_none", alias = "activeForm")]
125 pub active_form: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub parent_id: Option<String>,
129 #[serde(default)]
131 pub phase: TaskPhase,
132 #[serde(default)]
134 pub priority: TaskPriority,
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub completion_criteria: Vec<String>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub evidence: Vec<TaskEvidence>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub blockers: Vec<TaskBlocker>,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub transitions: Vec<TaskTransition>,
147}
148
149impl TaskItem {
150 pub fn append_notes(&mut self, note: &str) {
152 let note = note.trim();
153 if note.is_empty() {
154 return;
155 }
156 if !self.notes.is_empty() {
157 self.notes.push('\n');
158 }
159 self.notes.push_str(note);
160 }
161
162 pub fn effective_active_form(&self) -> Option<&str> {
164 self.active_form.as_deref().or_else(|| {
165 let notes = self.notes.trim();
166 if matches!(self.status, TaskItemStatus::InProgress) && !notes.is_empty() {
167 Some(notes)
168 } else {
169 None
170 }
171 })
172 }
173
174 pub fn push_evidence(&mut self, evidence: TaskEvidence) {
176 if evidence.summary.trim().is_empty() {
177 return;
178 }
179 self.evidence.push(evidence);
180 }
181
182 pub fn add_blocker(&mut self, blocker: TaskBlocker) {
184 if blocker.summary.trim().is_empty() {
185 return;
186 }
187 if self.blockers.iter().any(|existing| {
188 existing.kind == blocker.kind
189 && existing.summary == blocker.summary
190 && existing.waiting_on == blocker.waiting_on
191 }) {
192 return;
193 }
194 self.blockers.push(blocker);
195 }
196
197 pub fn transition_to(
199 &mut self,
200 status: TaskItemStatus,
201 reason: Option<&str>,
202 round: Option<u32>,
203 ) -> bool {
204 let reason = reason.map(str::trim).filter(|value| !value.is_empty());
205
206 if self.status == status {
207 if let Some(reason) = reason {
208 self.append_notes(reason);
209 }
210 return false;
211 }
212
213 let transition = TaskTransition {
214 from_status: self.status.clone(),
215 to_status: status.clone(),
216 reason: reason.map(ToOwned::to_owned),
217 round,
218 changed_at: Utc::now(),
219 };
220 self.status = status;
221 if let Some(reason) = transition.reason.as_deref() {
222 self.append_notes(reason);
223 }
224 self.transitions.push(transition);
225 true
226 }
227
228 pub fn dependencies_ready(&self, completed_ids: &HashSet<String>) -> bool {
230 self.depends_on
231 .iter()
232 .all(|dependency_id| completed_ids.contains(dependency_id))
233 }
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct TaskList {
239 pub session_id: String,
241 pub title: String,
243 pub items: Vec<TaskItem>,
245 pub created_at: DateTime<Utc>,
247 pub updated_at: DateTime<Utc>,
249}
250
251pub fn task_status_label(status: &TaskItemStatus) -> &'static str {
252 match status {
253 TaskItemStatus::Pending => "pending",
254 TaskItemStatus::InProgress => "in_progress",
255 TaskItemStatus::Completed => "completed",
256 TaskItemStatus::Blocked => "blocked",
257 }
258}
259
260pub fn task_status_icon(status: &TaskItemStatus) -> &'static str {
261 match status {
262 TaskItemStatus::Pending => "[ ]",
263 TaskItemStatus::InProgress => "[/]",
264 TaskItemStatus::Completed => "[x]",
265 TaskItemStatus::Blocked => "[!]",
266 }
267}
268
269pub fn task_phase_label(phase: &TaskPhase) -> &'static str {
270 match phase {
271 TaskPhase::Planning => "planning",
272 TaskPhase::Execution => "execution",
273 TaskPhase::Verification => "verification",
274 TaskPhase::Handoff => "handoff",
275 }
276}
277
278pub fn task_priority_label(priority: &TaskPriority) -> &'static str {
279 match priority {
280 TaskPriority::Low => "low",
281 TaskPriority::Medium => "medium",
282 TaskPriority::High => "high",
283 TaskPriority::Critical => "critical",
284 }
285}
286
287pub fn task_evidence_kind_label(kind: &TaskEvidenceKind) -> &'static str {
288 match kind {
289 TaskEvidenceKind::Note => "note",
290 TaskEvidenceKind::ToolCall => "tool_call",
291 TaskEvidenceKind::File => "file",
292 TaskEvidenceKind::Command => "command",
293 TaskEvidenceKind::Test => "test",
294 TaskEvidenceKind::Observation => "observation",
295 }
296}
297
298pub fn task_blocker_kind_label(kind: &TaskBlockerKind) -> &'static str {
299 match kind {
300 TaskBlockerKind::UserInput => "user_input",
301 TaskBlockerKind::Dependency => "dependency",
302 TaskBlockerKind::ToolFailure => "tool_failure",
303 TaskBlockerKind::External => "external",
304 TaskBlockerKind::Unknown => "unknown",
305 }
306}
307
308impl TaskList {
309 pub fn has_active_execution_tasks(&self) -> bool {
313 self.items.iter().any(|item| {
314 matches!(item.status, TaskItemStatus::InProgress)
315 && matches!(item.phase, TaskPhase::Execution | TaskPhase::Verification)
316 })
317 }
318
319 pub fn format_for_prompt(&self) -> String {
321 if self.items.is_empty() {
322 return String::new();
323 }
324
325 let mut output = format!("\n\n## Current Task List: {}\n", self.title);
326
327 for item in &self.items {
328 output.push_str(&format!(
329 "\n{} {}: {}",
330 task_status_icon(&item.status),
331 item.id,
332 item.description
333 ));
334
335 let mut tags = Vec::new();
336 if item.phase != TaskPhase::Execution {
337 tags.push(format!("phase={}", task_phase_label(&item.phase)));
338 }
339 if item.priority != TaskPriority::Medium {
340 tags.push(format!("priority={}", task_priority_label(&item.priority)));
341 }
342 if let Some(parent_id) = item.parent_id.as_deref().filter(|value| !value.is_empty()) {
343 tags.push(format!("parent={parent_id}"));
344 }
345 if !item.depends_on.is_empty() {
346 tags.push(format!("depends_on={}", item.depends_on.join(", ")));
347 }
348 if !tags.is_empty() {
349 output.push_str(&format!(" [{}]", tags.join(" | ")));
350 }
351
352 if matches!(item.status, TaskItemStatus::InProgress) {
353 if let Some(active_form) = item.effective_active_form() {
354 output.push_str(&format!(
355 "\n Active: {}",
356 truncate_for_prompt(active_form, 160)
357 ));
358 }
359 }
360
361 if !item.completion_criteria.is_empty() {
362 let criteria = item
363 .completion_criteria
364 .iter()
365 .take(3)
366 .map(|criterion| truncate_for_prompt(criterion, 60))
367 .collect::<Vec<_>>()
368 .join(" | ");
369 output.push_str(&format!("\n Criteria: {criteria}"));
370 if item.completion_criteria.len() > 3 {
371 output.push_str(&format!(" | +{} more", item.completion_criteria.len() - 3));
372 }
373 }
374
375 if let Some(blocker) = item.blockers.last() {
376 let mut blocker_line = truncate_for_prompt(&blocker.summary, 140);
377 if let Some(waiting_on) = blocker
378 .waiting_on
379 .as_deref()
380 .filter(|value| !value.is_empty())
381 {
382 blocker_line.push_str(&format!(
383 " (waiting_on: {})",
384 truncate_for_prompt(waiting_on, 60)
385 ));
386 }
387 output.push_str(&format!("\n Blocked by: {blocker_line}"));
388 }
389
390 if let Some(evidence) = item.evidence.last() {
391 let mut evidence_line = truncate_for_prompt(&evidence.summary, 140);
392 if let Some(reference) = evidence
393 .reference
394 .as_deref()
395 .filter(|value| !value.is_empty())
396 {
397 evidence_line
398 .push_str(&format!(" [ref: {}]", truncate_for_prompt(reference, 60)));
399 }
400 output.push_str(&format!(
401 "\n Latest evidence [{}]: {}",
402 task_evidence_kind_label(&evidence.kind),
403 evidence_line
404 ));
405 }
406
407 let notes = item.notes.trim();
408 let active_form = item.active_form.as_deref().map(str::trim);
409 if !notes.is_empty() && Some(notes) != active_form {
410 output.push_str(&format!("\n Notes: {}", truncate_for_prompt(notes, 160)));
411 }
412 }
413
414 let completed = self
415 .items
416 .iter()
417 .filter(|item| item.status == TaskItemStatus::Completed)
418 .count();
419 let total = self.items.len();
420 output.push_str(&format!(
421 "\n\nProgress: {}/{} tasks completed",
422 completed, total
423 ));
424
425 output
426 }
427
428 pub fn update_item(
430 &mut self,
431 item_id: &str,
432 status: TaskItemStatus,
433 notes: Option<&str>,
434 ) -> Result<String, String> {
435 if let Some(item) = self.items.iter_mut().find(|item| item.id == item_id) {
436 item.transition_to(status, notes, None);
437 self.updated_at = Utc::now();
438 Ok(format!("Updated item '{}'", item_id))
439 } else {
440 Err(format!("Task item '{}' not found", item_id))
441 }
442 }
443}
444
445fn truncate_for_prompt(value: &str, max_chars: usize) -> String {
446 let trimmed = value.trim().replace('\n', " ");
447 let char_count = trimmed.chars().count();
448 if char_count <= max_chars {
449 return trimmed;
450 }
451
452 let truncated: String = trimmed.chars().take(max_chars).collect();
453 format!("{}…", truncated.trim_end())
454}