1use std::fmt;
10
11use async_trait::async_trait;
12use serde_json::{Value, json};
13
14use crate::{AgentTool, AgentToolResult, ToolContext, ToolError};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TodoStatus {
22 Pending,
24 InProgress,
26 Completed,
28 Abandoned,
30 Blocked,
33}
34
35impl TodoStatus {
36 pub fn icon(self) -> &'static str {
38 match self {
39 Self::Pending => "\u{2610}", Self::InProgress => "\u{25B6}", Self::Completed => "\u{2611}", Self::Abandoned => "\u{2717}", Self::Blocked => "\u{23F8}", }
45 }
46
47 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Pending => "pending",
51 Self::InProgress => "in_progress",
52 Self::Completed => "completed",
53 Self::Abandoned => "abandoned",
54 Self::Blocked => "blocked",
55 }
56 }
57}
58
59impl fmt::Display for TodoStatus {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.write_str(self.as_str())
62 }
63}
64
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67pub struct TodoItem {
68 pub content: String,
70 pub status: TodoStatus,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub notes: Option<Vec<String>>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub block_reason: Option<String>,
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82pub struct TodoPhase {
83 pub name: String,
85 pub tasks: Vec<TodoItem>,
87}
88
89#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
91#[serde(tag = "op", rename_all = "snake_case")]
92pub enum TodoOp {
93 Init {
95 #[serde(default)]
97 list: Option<Vec<InitListEntry>>,
98 #[serde(default)]
100 items: Option<Vec<String>>,
101 },
102 Start {
104 #[serde(default)]
106 task: Option<String>,
107 #[serde(default)]
109 phase: Option<String>,
110 },
111 Done {
113 #[serde(default)]
115 task: Option<String>,
116 #[serde(default)]
118 phase: Option<String>,
119 },
120 Drop {
122 #[serde(default)]
124 task: Option<String>,
125 #[serde(default)]
127 phase: Option<String>,
128 },
129 Rm {
131 #[serde(default)]
133 task: Option<String>,
134 #[serde(default)]
136 phase: Option<String>,
137 },
138 Append {
140 phase: String,
142 items: Vec<String>,
144 },
145 Block {
148 #[serde(default)]
150 task: Option<String>,
151 #[serde(default)]
153 phase: Option<String>,
154 #[serde(default)]
156 reason: Option<String>,
157 },
158 Unblock {
160 #[serde(default)]
162 task: Option<String>,
163 #[serde(default)]
165 phase: Option<String>,
166 },
167 View,
169}
170
171#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
173pub struct InitListEntry {
174 pub phase: String,
176 pub items: Vec<String>,
178}
179
180#[derive(Debug, Clone, serde::Serialize)]
182pub struct TodoCompletionTransition {
183 pub phase: String,
185 pub content: String,
187}
188
189#[derive(Debug, Clone, serde::Serialize)]
191pub struct TodoUpdateResult {
192 pub phases: Vec<TodoPhase>,
194 pub completed_tasks: Vec<TodoCompletionTransition>,
196 pub errors: Vec<String>,
198}
199
200fn apply_entry(phases: &mut Vec<TodoPhase>, op: &TodoOp, errors: &mut Vec<String>) {
204 match op {
205 TodoOp::Init { list, items } => {
206 *phases = init_phases(list.as_deref(), items.as_deref(), errors);
207 }
208 TodoOp::Start { task, phase } => {
209 let targets = resolve_targets(phases, task.as_deref(), phase.as_deref(), errors);
210 for (phase_idx, task_idx) in targets {
211 phases[phase_idx].tasks[task_idx].status = TodoStatus::InProgress;
212 }
213 }
214 TodoOp::Done { task, phase } => {
215 transition_status(
216 phases,
217 task.as_deref(),
218 phase.as_deref(),
219 TodoStatus::Completed,
220 errors,
221 );
222 }
223 TodoOp::Drop { task, phase } => {
224 transition_status(
225 phases,
226 task.as_deref(),
227 phase.as_deref(),
228 TodoStatus::Abandoned,
229 errors,
230 );
231 }
232 TodoOp::Rm { task, phase } => {
233 remove_tasks(phases, task.as_deref(), phase.as_deref(), errors);
234 }
235 TodoOp::Append { phase, items } => {
236 append_items(phases, phase, items);
237 }
238 TodoOp::Block {
239 task,
240 phase,
241 reason,
242 } => {
243 block_tasks(
244 phases,
245 task.as_deref(),
246 phase.as_deref(),
247 reason.as_deref(),
248 errors,
249 );
250 }
251 TodoOp::Unblock { task, phase } => {
252 unblock_tasks(phases, task.as_deref(), phase.as_deref(), errors);
253 }
254 TodoOp::View => {} }
256}
257
258const DEFAULT_INIT_PHASE: &str = "Tasks";
259
260fn init_phases(
261 list: Option<&[InitListEntry]>,
262 items: Option<&[String]>,
263 errors: &mut Vec<String>,
264) -> Vec<TodoPhase> {
265 if let Some(list) = list {
266 list.iter()
267 .map(|entry| TodoPhase {
268 name: entry.phase.clone(),
269 tasks: entry
270 .items
271 .iter()
272 .map(|c| TodoItem {
273 content: c.clone(),
274 status: TodoStatus::Pending,
275 notes: None,
276 block_reason: None,
277 })
278 .collect(),
279 })
280 .collect()
281 } else if let Some(items) = items {
282 vec![TodoPhase {
283 name: DEFAULT_INIT_PHASE.into(),
284 tasks: items
285 .iter()
286 .map(|c| TodoItem {
287 content: c.clone(),
288 status: TodoStatus::Pending,
289 notes: None,
290 block_reason: None,
291 })
292 .collect(),
293 }]
294 } else {
295 errors.push("init requires either 'list' or 'items'".into());
296 Vec::new()
297 }
298}
299
300fn resolve_targets(
301 phases: &[TodoPhase],
302 task: Option<&str>,
303 phase: Option<&str>,
304 errors: &mut Vec<String>,
305) -> Vec<(usize, usize)> {
306 let mut out = Vec::new();
307 for (pi, p) in phases.iter().enumerate() {
308 if phase.is_some_and(|phase_name| p.name != phase_name) {
309 continue;
310 }
311 for (ti, t) in p.tasks.iter().enumerate() {
312 if task.is_some_and(|task_content| t.content != task_content) {
313 continue;
314 }
315 out.push((pi, ti));
316 }
317 }
318 if out.is_empty() {
319 let target = match (phase, task) {
320 (Some(p), Some(t)) => format!("phase '{}' task '{}'", p, t),
321 (Some(p), None) => format!("phase '{}'", p),
322 (None, Some(t)) => format!("task '{}'", t),
323 (None, None) => "any task".to_string(),
324 };
325 errors.push(format!("No matching {} found", target));
326 }
327 out
328}
329
330pub fn tokenize_quoted(input: &str) -> Vec<String> {
334 let mut tokens = Vec::new();
335 let mut current = String::new();
336 let mut in_quote = false;
337 let mut chars = input.chars().peekable();
338 while let Some(ch) = chars.next() {
339 if ch == '\\' {
340 if let Some(next) = chars.next() {
341 current.push(next);
342 }
343 continue;
344 }
345 if ch == '"' {
346 in_quote = !in_quote;
347 continue;
348 }
349 if !in_quote && ch.is_whitespace() {
350 if !current.is_empty() {
351 tokens.push(std::mem::take(&mut current));
352 }
353 continue;
354 }
355 current.push(ch);
356 }
357 if !current.is_empty() {
358 tokens.push(current);
359 }
360 tokens
361}
362
363pub fn find_phase_fuzzy<'a>(phases: &'a [TodoPhase], query: &str) -> Option<&'a TodoPhase> {
367 let q = query.trim().to_lowercase();
368 if q.is_empty() {
369 return None;
370 }
371 if let Some(p) = phases.iter().find(|p| p.name.to_lowercase() == q) {
372 return Some(p);
373 }
374 let prefix: Vec<&TodoPhase> = phases
375 .iter()
376 .filter(|p| p.name.to_lowercase().starts_with(&q))
377 .collect();
378 if prefix.len() == 1 {
379 return Some(prefix[0]);
380 }
381 let sub: Vec<&TodoPhase> = phases
382 .iter()
383 .filter(|p| p.name.to_lowercase().contains(&q))
384 .collect();
385 if sub.len() == 1 { Some(sub[0]) } else { None }
386}
387
388pub fn find_task_fuzzy<'a>(
392 phases: &'a [TodoPhase],
393 query: &str,
394) -> Option<(&'a TodoItem, &'a TodoPhase)> {
395 let q = query.trim().to_lowercase();
396 if q.is_empty() {
397 return None;
398 }
399 for phase in phases {
400 for task in &phase.tasks {
401 if task.content.to_lowercase() == q {
402 return Some((task, phase));
403 }
404 }
405 }
406 let matches: Vec<(&TodoItem, &TodoPhase)> = phases
407 .iter()
408 .flat_map(|phase| phase.tasks.iter().map(move |t| (t, phase)))
409 .filter(|(t, _)| t.content.to_lowercase().contains(&q))
410 .collect();
411 if matches.len() == 1 {
412 return Some(matches[0]);
413 }
414 let active: Vec<(&TodoItem, &TodoPhase)> = matches
415 .into_iter()
416 .filter(|(t, _)| matches!(t.status, TodoStatus::InProgress | TodoStatus::Pending))
417 .collect();
418 if active.len() == 1 {
419 Some(active[0])
420 } else {
421 None
422 }
423}
424
425fn transition_status(
426 phases: &mut [TodoPhase],
427 task: Option<&str>,
428 phase: Option<&str>,
429 new_status: TodoStatus,
430 errors: &mut Vec<String>,
431) {
432 let targets = resolve_targets(phases, task, phase, errors);
433 for (pi, ti) in targets {
434 phases[pi].tasks[ti].status = new_status;
435 }
436}
437
438fn block_tasks(
442 phases: &mut [TodoPhase],
443 task: Option<&str>,
444 phase: Option<&str>,
445 reason: Option<&str>,
446 errors: &mut Vec<String>,
447) {
448 let targets = resolve_targets(phases, task, phase, errors);
449 for (pi, ti) in targets {
450 let t = &mut phases[pi].tasks[ti];
451 if matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned) {
452 continue;
453 }
454 t.status = TodoStatus::Blocked;
455 t.block_reason = reason.map(String::from);
456 }
457}
458
459fn unblock_tasks(
462 phases: &mut [TodoPhase],
463 task: Option<&str>,
464 phase: Option<&str>,
465 errors: &mut Vec<String>,
466) {
467 let targets = resolve_targets(phases, task, phase, errors);
468 for (pi, ti) in targets {
469 let t = &mut phases[pi].tasks[ti];
470 if t.status == TodoStatus::Blocked {
471 t.status = TodoStatus::Pending;
472 t.block_reason = None;
473 }
474 }
475}
476
477fn append_items(phases: &mut Vec<TodoPhase>, phase_name: &str, items: &[String]) {
478 let phase = if let Some(p) = phases.iter_mut().find(|p| p.name == phase_name) {
479 p
480 } else {
481 phases.push(TodoPhase {
482 name: phase_name.into(),
483 tasks: Vec::new(),
484 });
485 match phases.last_mut() {
486 Some(last) => last,
487 None => return,
488 }
489 };
490 for content in items {
491 phase.tasks.push(TodoItem {
492 content: content.clone(),
493 status: TodoStatus::Pending,
494 notes: None,
495 block_reason: None,
496 });
497 }
498}
499
500fn remove_tasks(
501 phases: &mut Vec<TodoPhase>,
502 task: Option<&str>,
503 phase: Option<&str>,
504 errors: &mut Vec<String>,
505) {
506 if task.is_none() && phase.is_none() {
507 phases.clear();
509 return;
510 }
511 let mut errors_local = Vec::new();
512 let targets = resolve_targets(phases, task, phase, &mut errors_local);
513 errors.extend(errors_local);
514 let mut to_remove: Vec<(usize, usize)> = targets;
516 to_remove.sort_by(|a, b| b.cmp(a));
517 for (pi, ti) in to_remove {
518 if pi < phases.len() && ti < phases[pi].tasks.len() {
519 phases[pi].tasks.remove(ti);
520 }
521 }
522 phases.retain(|p| !p.tasks.is_empty());
524}
525
526fn normalize_in_progress(phases: &mut [TodoPhase]) {
531 let mut found = false;
532 for phase in phases.iter_mut().rev() {
533 for task in &mut phase.tasks {
534 if task.status == TodoStatus::InProgress {
535 if found {
536 task.status = TodoStatus::Pending;
537 } else {
538 found = true;
539 }
540 }
541 }
542 }
543}
544
545fn auto_promote_next(phases: &mut [TodoPhase]) {
550 let has_in_progress = phases
551 .iter()
552 .any(|p| p.tasks.iter().any(|t| t.status == TodoStatus::InProgress));
553 if has_in_progress {
554 return;
555 }
556 for phase in phases {
557 for task in &mut phase.tasks {
558 if task.status == TodoStatus::Pending {
559 task.status = TodoStatus::InProgress;
560 return;
561 }
562 }
563 }
564}
565
566const COLLAPSED_CLOSED_CONTEXT: usize = 1;
572
573pub struct CollapsedSelection<'a> {
576 pub items: Vec<&'a TodoItem>,
578 pub summary: Option<String>,
580}
581
582fn is_closed(t: &TodoItem) -> bool {
583 matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned)
584}
585
586fn is_active(t: &TodoItem, is_matched: &impl Fn(&TodoItem) -> bool) -> bool {
587 t.status == TodoStatus::InProgress || (t.status == TodoStatus::Pending && is_matched(t))
588}
589
590fn select_within_cap<'a>(
591 base: &[&'a TodoItem],
592 is_matched: &impl Fn(&TodoItem) -> bool,
593 cap: usize,
594) -> CollapsedSelection<'a> {
595 if base.len() <= cap {
596 return CollapsedSelection {
597 items: base.to_vec(),
598 summary: None,
599 };
600 }
601 let active: Vec<&'a TodoItem> = base
602 .iter()
603 .copied()
604 .filter(|t| is_active(t, is_matched))
605 .collect();
606 if active.len() > cap {
607 let hidden = active.len() - cap;
608 return CollapsedSelection {
609 items: active.into_iter().take(cap).collect(),
610 summary: Some(format!(
611 "… {hidden} more active todo{}",
612 if hidden == 1 { "" } else { "s" }
613 )),
614 };
615 }
616 let first_active_idx = active
617 .first()
618 .and_then(|f| base.iter().position(|t| std::ptr::eq(*t, *f)))
619 .unwrap_or(0);
620 let mut items = active.clone();
621 for &t in base.iter().skip(first_active_idx) {
622 if items.len() >= cap {
623 break;
624 }
625 if !is_active(t, is_matched) && !items.iter().any(|x| std::ptr::eq(*x, t)) {
626 items.push(t);
627 }
628 }
629 let hidden = base.len() - items.len();
630 let summary =
631 (hidden > 0).then(|| format!("… {hidden} more todo{}", if hidden == 1 { "" } else { "s" }));
632 CollapsedSelection { items, summary }
633}
634
635pub fn select_collapsed_todos<'a>(
638 tasks: &'a [TodoItem],
639 is_matched: impl Fn(&TodoItem) -> bool,
640 cap: usize,
641) -> CollapsedSelection<'a> {
642 let open: Vec<&'a TodoItem> = tasks.iter().filter(|t| !is_closed(t)).collect();
643 if open.is_empty() {
644 let all: Vec<&'a TodoItem> = tasks.iter().collect();
645 return select_within_cap(&all, &is_matched, cap);
646 }
647 let mut lead: Vec<&'a TodoItem> = tasks
648 .iter()
649 .filter(|t| is_closed(t))
650 .rev()
651 .take(COLLAPSED_CLOSED_CONTEXT)
652 .collect();
653 lead.reverse();
654 let selected = select_within_cap(&open, &is_matched, cap);
655 lead.extend(selected.items);
656 CollapsedSelection {
657 items: lead,
658 summary: selected.summary,
659 }
660}
661
662fn get_completion_transitions(
665 previous: &[TodoPhase],
666 updated: &[TodoPhase],
667) -> Vec<TodoCompletionTransition> {
668 let mut out = Vec::new();
669 for new_phase in updated {
670 let old_phase = previous.iter().find(|p| p.name == new_phase.name);
671 for new_task in &new_phase.tasks {
672 if new_task.status != TodoStatus::Completed {
673 continue;
674 }
675 let was_completed = old_phase
676 .and_then(|p| p.tasks.iter().find(|t| t.content == new_task.content))
677 .is_some_and(|t| t.status == TodoStatus::Completed);
678 if !was_completed {
679 out.push(TodoCompletionTransition {
680 phase: new_phase.name.clone(),
681 content: new_task.content.clone(),
682 });
683 }
684 }
685 }
686 out
687}
688
689pub fn todo_matches_any_description(content: &str, descriptions: &[String]) -> bool {
692 let normalized = normalize_for_match(content);
693 if normalized.len() < 6 {
694 return false;
695 }
696 descriptions.iter().any(|d| {
697 let d_norm = normalize_for_match(d);
698 d_norm.contains(&normalized) || normalized.contains(&d_norm)
699 })
700}
701
702pub fn reconcile_with_subagents(
708 phases: &[TodoPhase],
709 completed_descriptions: &[String],
710) -> (Vec<TodoPhase>, bool) {
711 if completed_descriptions.is_empty() {
712 return (phases.to_vec(), false);
713 }
714 let mut mutated = false;
715 let updated = phases
716 .iter()
717 .map(|phase| TodoPhase {
718 name: phase.name.clone(),
719 tasks: phase
720 .tasks
721 .iter()
722 .map(|task| {
723 if !matches!(
724 task.status,
725 TodoStatus::Pending | TodoStatus::InProgress | TodoStatus::Blocked
726 ) {
727 return task.clone();
728 }
729 if !todo_matches_any_description(&task.content, completed_descriptions) {
730 return task.clone();
731 }
732 mutated = true;
733 TodoItem {
734 content: task.content.clone(),
735 status: TodoStatus::Completed,
736 notes: task.notes.clone(),
737 block_reason: None,
738 }
739 })
740 .collect(),
741 })
742 .collect();
743 (updated, mutated)
744}
745
746fn normalize_for_match(s: &str) -> String {
747 let mut out = String::with_capacity(s.len());
748 let mut prev_space = false;
749 for c in s.chars() {
750 let lc = c.to_ascii_lowercase();
751 if lc.is_whitespace() {
752 if !prev_space {
753 out.push(' ');
754 }
755 prev_space = true;
756 } else {
757 out.push(lc);
758 prev_space = false;
759 }
760 }
761 out.trim().to_string()
762}
763
764pub fn phases_to_markdown(phases: &[TodoPhase]) -> String {
768 let mut out = String::new();
769 for (i, phase) in phases.iter().enumerate() {
770 if phases.len() > 1 {
771 out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
772 }
773 for task in &phase.tasks {
774 let marker = match task.status {
775 TodoStatus::Completed => "- [x]",
776 TodoStatus::Abandoned => "- [-]",
777 TodoStatus::Blocked => "- [!]",
778 _ => "- [ ]",
779 };
780 out.push_str(&format!(" {} {}\n", marker, task.content));
781 }
782 }
783 out
784}
785
786const ROMAN_PAIRS: &[(u32, &str)] = &[
787 (1000, "M"),
788 (900, "CM"),
789 (500, "D"),
790 (400, "CD"),
791 (100, "C"),
792 (90, "XC"),
793 (50, "L"),
794 (40, "XL"),
795 (10, "X"),
796 (9, "IX"),
797 (5, "V"),
798 (4, "IV"),
799 (1, "I"),
800];
801
802pub fn roman_numeral(mut n: usize) -> String {
805 let mut out = String::new();
806 for &(value, sym) in ROMAN_PAIRS {
807 while n >= value as usize {
808 out.push_str(sym);
809 n -= value as usize;
810 }
811 }
812 out
813}
814
815pub fn markdown_to_phases(md: &str) -> Result<Vec<TodoPhase>, String> {
818 let mut phases: Vec<TodoPhase> = Vec::new();
819 let mut current_phase: Option<TodoPhase> = None;
820
821 for line in md.lines() {
822 let trimmed = line.trim_end();
823 if let Some(name) = parse_phase_header(trimmed) {
824 if let Some(p) = current_phase.take() {
825 phases.push(p);
826 }
827 current_phase = Some(TodoPhase {
828 name,
829 tasks: Vec::new(),
830 });
831 } else if let Some((status, content)) = parse_task_line(trimmed) {
832 let target = current_phase.get_or_insert_with(|| TodoPhase {
833 name: DEFAULT_INIT_PHASE.into(),
834 tasks: Vec::new(),
835 });
836 target.tasks.push(TodoItem {
837 content,
838 status,
839 notes: None,
840 block_reason: None,
841 });
842 }
843 }
844 if let Some(p) = current_phase {
845 phases.push(p);
846 }
847 Ok(phases)
848}
849
850fn parse_phase_header(line: &str) -> Option<String> {
851 let t = line.trim();
852 if let Some(rest) = t.strip_prefix("## ") {
854 return Some(rest.trim().to_string());
855 }
856 for prefix_len in 1..=6 {
858 if t.len() <= prefix_len {
859 break;
860 }
861 let prefix = &t[..prefix_len];
862 if prefix.ends_with('.')
863 && prefix[..prefix_len - 1]
864 .chars()
865 .all(|c| c.is_ascii_uppercase())
866 {
867 let rest = t[prefix_len..].trim();
868 if !rest.is_empty() {
869 return Some(rest.to_string());
870 }
871 }
872 }
873 None
874}
875
876fn parse_task_line(line: &str) -> Option<(TodoStatus, String)> {
877 let t = line.trim();
878 if let Some(rest) = t.strip_prefix("- [x] ") {
879 return Some((TodoStatus::Completed, rest.to_string()));
880 }
881 if let Some(rest) = t.strip_prefix("- [X] ") {
882 return Some((TodoStatus::Completed, rest.to_string()));
883 }
884 if let Some(rest) = t.strip_prefix("- [-] ") {
885 return Some((TodoStatus::Abandoned, rest.to_string()));
886 }
887 if let Some(rest) = t.strip_prefix("- [!] ") {
888 return Some((TodoStatus::Blocked, rest.to_string()));
889 }
890 if let Some(rest) = t.strip_prefix("- [ ] ") {
891 return Some((TodoStatus::Pending, rest.to_string()));
892 }
893 None
894}
895
896pub fn format_summary(phases: &[TodoPhase], errors: &[String], read_only: bool) -> String {
900 let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
901 let done: usize = phases
902 .iter()
903 .map(|p| {
904 p.tasks
905 .iter()
906 .filter(|t| t.status == TodoStatus::Completed)
907 .count()
908 })
909 .sum();
910 let blocked: usize = phases
911 .iter()
912 .map(|p| {
913 p.tasks
914 .iter()
915 .filter(|t| t.status == TodoStatus::Blocked)
916 .count()
917 })
918 .sum();
919 let blocked_suffix = if blocked > 0 {
920 format!(", {} blocked", blocked)
921 } else {
922 String::new()
923 };
924
925 let mut out = if read_only {
926 format!(
927 "\u{1F4CB} Todo list (read-only) — {}/{} done{blocked_suffix}\n\n",
928 done, total
929 )
930 } else if errors.is_empty() {
931 format!(
932 "\u{2713} Todo updated — {}/{} done{blocked_suffix}\n\n",
933 done, total
934 )
935 } else {
936 format!(
937 "\u{26A0} Todo updated with {} error(s) — {}/{} done{blocked_suffix}\n\n",
938 errors.len(),
939 done,
940 total
941 )
942 };
943
944 for (i, phase) in phases.iter().enumerate() {
945 if phases.len() > 1 {
946 out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
947 }
948 for task in &phase.tasks {
949 out.push_str(&format!(" {} {}\n", task.status.icon(), task.content));
950 if task.status == TodoStatus::Blocked
951 && let Some(reason) = &task.block_reason
952 {
953 out.push_str(&format!(" \u{23F8} {reason}\n"));
954 }
955 }
956 }
957
958 for err in errors {
959 out.push_str(&format!(" \u{26A0} {}\n", err));
960 }
961
962 out
963}
964
965pub fn apply_ops(phases: &mut Vec<TodoPhase>, ops: &[TodoOp]) -> TodoUpdateResult {
969 let old_phases = phases.clone();
970 let mut errors = Vec::new();
971 let had_done = ops.iter().any(|op| matches!(op, TodoOp::Done { .. }));
972 for op in ops {
973 apply_entry(phases, op, &mut errors);
974 }
975 normalize_in_progress(phases);
976 if had_done {
979 auto_promote_next(phases);
980 }
981 let completed_tasks = get_completion_transitions(&old_phases, phases);
982 TodoUpdateResult {
983 phases: phases.clone(),
984 completed_tasks,
985 errors,
986 }
987}
988
989pub const MAX_TODO_STOP_REMINDERS: u32 = 3;
995
996#[derive(Debug, Default)]
1000pub struct StopReminderState {
1001 last_signature: Option<String>,
1002 count: u32,
1003}
1004
1005impl StopReminderState {
1006 pub fn count(&self) -> u32 {
1008 self.count
1009 }
1010}
1011
1012const MID_RUN_NUDGE_MUTATION_THRESHOLD: u32 = 12;
1016const MID_RUN_NUDGE_MAX_PER_CYCLE: u32 = 2;
1019const MUTATING_TOOLS: &[&str] = &["bash", "eval", "edit", "write", "ast_edit"];
1021
1022#[derive(Debug, Default)]
1026pub struct MidRunNudgeState {
1027 mutations_since_touch: u32,
1028 nudge_count: u32,
1029}
1030
1031impl MidRunNudgeState {
1032 pub fn record_tool_result(&mut self, tool_name: &str, is_error: bool) {
1035 if tool_name == "todo" {
1036 self.mutations_since_touch = 0;
1037 } else if !is_error && MUTATING_TOOLS.contains(&tool_name) {
1038 self.mutations_since_touch += 1;
1039 }
1040 }
1041
1042 pub fn should_nudge(&self) -> bool {
1044 self.mutations_since_touch >= MID_RUN_NUDGE_MUTATION_THRESHOLD
1045 && self.nudge_count < MID_RUN_NUDGE_MAX_PER_CYCLE
1046 }
1047
1048 pub fn take_nudge(&mut self) -> Option<String> {
1051 if !self.should_nudge() {
1052 return None;
1053 }
1054 self.nudge_count += 1;
1055 self.mutations_since_touch = 0;
1056 Some(
1057 "You've made several file changes without touching your todo list. \
1058 Reconcile it now: mark finished tasks done, update in-progress ones, \
1059 and add anything new before continuing."
1060 .to_string(),
1061 )
1062 }
1063}
1064
1065pub fn build_stop_reminder(
1076 phases: &[TodoPhase],
1077 state: &mut StopReminderState,
1078 max: u32,
1079) -> Option<String> {
1080 let open: Vec<&str> = phases
1081 .iter()
1082 .flat_map(|p| {
1083 p.tasks
1084 .iter()
1085 .filter(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
1086 .map(|t| t.content.as_str())
1087 })
1088 .collect();
1089 if open.is_empty() {
1090 return None;
1091 }
1092 let signature = open.join("\u{1}");
1095 if state.last_signature.as_deref() == Some(signature.as_str()) {
1096 return None;
1097 }
1098 if state.count >= max {
1099 return None;
1100 }
1101 state.last_signature = Some(signature);
1102 state.count += 1;
1103
1104 let mut msg = format!("You still have {} incomplete todo task(s):\n", open.len());
1105 for content in &open {
1106 msg.push_str(&format!("- {}\n", content));
1107 }
1108 msg.push_str(
1109 "Continue working through them, or mark each done/dropped/blocked as \
1110 appropriate. Do not treat the overall request as complete while \
1111 these tasks remain open.",
1112 );
1113 Some(msg)
1114}
1115
1116pub struct TodoTool;
1120
1121#[async_trait]
1122impl AgentTool for TodoTool {
1123 fn name(&self) -> &str {
1124 "todo"
1125 }
1126
1127 fn label(&self) -> &str {
1128 "Todo"
1129 }
1130
1131 fn essential(&self) -> bool {
1132 false
1133 }
1134
1135 fn description(&self) -> &str {
1136 "Phased todo list manager. Use init to create a plan, start/done/drop \
1137 to transition tasks, block/unblock to gate tasks on external input, \
1138 append to add, rm to remove, view to read. On each completion the \
1139 earliest still-open task auto-promotes to in_progress. Tasks should \
1140 be 5-10 words describing WHAT not HOW."
1141 }
1142
1143 fn parameters_schema(&self) -> Value {
1144 json!({
1145 "type": "object",
1146 "properties": {
1147 "ops": {
1148 "type": "array",
1149 "minItems": 1,
1150 "items": {
1151 "type": "object",
1152 "properties": {
1153 "op": {
1154 "type": "string",
1155 "enum": ["init", "start", "done", "drop", "block", "unblock", "rm", "append", "view"]
1156 },
1157 "task": {"type": "string", "description": "Task content (verbatim)"},
1158 "phase": {"type": "string", "description": "Phase name"},
1159 "reason": {"type": "string", "description": "Why the task is blocked (block op only)"},
1160 "items": {"type": "array", "items": {"type": "string"}},
1161 "list": {
1162 "type": "array",
1163 "items": {
1164 "type": "object",
1165 "properties": {
1166 "phase": {"type": "string"},
1167 "items": {"type": "array", "items": {"type": "string"}}
1168 }
1169 }
1170 }
1171 },
1172 "required": ["op"]
1173 }
1174 }
1175 },
1176 "required": ["ops"]
1177 })
1178 }
1179
1180 async fn execute(
1181 &self,
1182 _tool_call_id: &str,
1183 params: Value,
1184 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
1185 ctx: &ToolContext,
1186 ) -> Result<AgentToolResult, ToolError> {
1187 let provider = ctx.todo.as_ref().ok_or("Todo not configured")?;
1189
1190 let ops_value = params
1191 .get("ops")
1192 .cloned()
1193 .ok_or_else(|| "Missing required parameter: ops".to_string())?;
1194
1195 let ops: Vec<TodoOp> =
1196 serde_json::from_value(ops_value).map_err(|e| format!("Invalid ops format: {}", e))?;
1197
1198 let result = provider.apply_ops(ops).await?;
1199
1200 let summary = format_summary(&result.phases, &result.errors, false);
1201 Ok(AgentToolResult::success(summary))
1202 }
1203}
1204
1205#[cfg(test)]
1208mod tests {
1209 use super::*;
1210
1211 fn make_task(content: &str, status: TodoStatus) -> TodoItem {
1212 TodoItem {
1213 content: content.into(),
1214 status,
1215 notes: None,
1216 block_reason: None,
1217 }
1218 }
1219
1220 #[test]
1221 fn select_collapsed_todos_leads_with_in_progress_then_pending() {
1222 let tasks = vec![
1223 make_task("a", TodoStatus::Completed),
1224 make_task("b", TodoStatus::InProgress),
1225 make_task("c", TodoStatus::Pending),
1226 make_task("d", TodoStatus::Pending),
1227 make_task("e", TodoStatus::Pending),
1228 make_task("f", TodoStatus::Pending),
1229 ];
1230 let sel = select_collapsed_todos(&tasks, |_| false, 3);
1231 let contents: Vec<&str> = sel.items.iter().map(|t| t.content.as_str()).collect();
1232 assert_eq!(contents, vec!["a", "b", "c", "d"]);
1233 assert_eq!(sel.summary.as_deref(), Some("… 2 more todos"));
1234 }
1235
1236 #[test]
1237 fn select_collapsed_todos_all_closed_falls_back_to_closed_tasks() {
1238 let tasks = vec![
1239 make_task("a", TodoStatus::Completed),
1240 make_task("b", TodoStatus::Abandoned),
1241 ];
1242 let sel = select_collapsed_todos(&tasks, |_| false, 5);
1243 assert_eq!(sel.items.len(), 2);
1244 assert!(sel.summary.is_none());
1245 }
1246
1247 #[test]
1248 fn select_collapsed_todos_matched_pending_counts_as_active() {
1249 let tasks = vec![
1250 make_task("a", TodoStatus::Pending),
1251 make_task("b", TodoStatus::Pending),
1252 make_task("c", TodoStatus::Pending),
1253 ];
1254 let sel = select_collapsed_todos(&tasks, |t| t.content == "b", 2);
1255 let contents: Vec<&str> = sel.items.iter().map(|t| t.content.as_str()).collect();
1256 assert_eq!(contents, vec!["b", "c"]);
1257 assert_eq!(sel.summary.as_deref(), Some("… 1 more todo"));
1258 }
1259
1260 #[test]
1261 fn select_collapsed_todos_no_cap_overflow_returns_everything() {
1262 let tasks = vec![
1263 make_task("a", TodoStatus::Pending),
1264 make_task("b", TodoStatus::Pending),
1265 ];
1266 let sel = select_collapsed_todos(&tasks, |_| false, 5);
1267 assert_eq!(sel.items.len(), 2);
1268 assert!(sel.summary.is_none());
1269 }
1270
1271 #[test]
1272 fn reconcile_closes_matching_open_task() {
1273 let phases = vec![TodoPhase {
1274 name: "Auth".into(),
1275 tasks: vec![make_task(
1276 "implement authentication module",
1277 TodoStatus::Pending,
1278 )],
1279 }];
1280 let (updated, mutated) =
1281 reconcile_with_subagents(&phases, &["authentication module".to_string()]);
1282 assert!(mutated);
1283 assert_eq!(updated[0].tasks[0].status, TodoStatus::Completed);
1284 }
1285
1286 #[test]
1287 fn reconcile_clears_block_reason_on_close() {
1288 let mut t = make_task("implement authentication module", TodoStatus::Blocked);
1289 t.block_reason = Some("waiting on subagent".into());
1290 let phases = vec![TodoPhase {
1291 name: "Auth".into(),
1292 tasks: vec![t],
1293 }];
1294 let (updated, mutated) =
1295 reconcile_with_subagents(&phases, &["authentication module".to_string()]);
1296 assert!(mutated);
1297 assert_eq!(updated[0].tasks[0].status, TodoStatus::Completed);
1298 assert!(updated[0].tasks[0].block_reason.is_none());
1299 }
1300
1301 #[test]
1302 fn reconcile_does_not_touch_already_closed_or_unmatched() {
1303 let phases = vec![TodoPhase {
1304 name: "Auth".into(),
1305 tasks: vec![
1306 make_task("unrelated task", TodoStatus::Pending),
1307 make_task("done already", TodoStatus::Completed),
1308 ],
1309 }];
1310 let (_updated, mutated) =
1311 reconcile_with_subagents(&phases, &["authentication module".to_string()]);
1312 assert!(!mutated);
1313 }
1314
1315 #[test]
1316 fn tokenize_quoted_respects_double_quotes() {
1317 assert_eq!(
1318 tokenize_quoted(r#"auth "wire oauth" now"#),
1319 vec!["auth", "wire oauth", "now"]
1320 );
1321 }
1322
1323 #[test]
1324 fn tokenize_quoted_handles_escaped_chars() {
1325 assert_eq!(tokenize_quoted(r#"a\ b"#), vec!["a b"]);
1326 }
1327
1328 #[test]
1329 fn find_phase_fuzzy_prefers_exact_then_prefix_then_substring() {
1330 let phases = vec![
1331 TodoPhase {
1332 name: "Authentication".into(),
1333 tasks: vec![],
1334 },
1335 TodoPhase {
1336 name: "Auth UI".into(),
1337 tasks: vec![],
1338 },
1339 ];
1340 assert_eq!(
1341 find_phase_fuzzy(&phases, "Authentication").unwrap().name,
1342 "Authentication"
1343 );
1344 assert!(find_phase_fuzzy(&phases, "auth ").is_none());
1347 }
1348
1349 #[test]
1350 fn find_task_fuzzy_prefers_single_substring_match() {
1351 let phases = vec![TodoPhase {
1352 name: "Auth".into(),
1353 tasks: vec![make_task("Wire OAuth providers", TodoStatus::Pending)],
1354 }];
1355 let (t, p) = find_task_fuzzy(&phases, "oauth").unwrap();
1356 assert_eq!(t.content, "Wire OAuth providers");
1357 assert_eq!(p.name, "Auth");
1358 }
1359
1360 #[test]
1361 fn find_task_fuzzy_ambiguous_prefers_active_status() {
1362 let phases = vec![TodoPhase {
1363 name: "Auth".into(),
1364 tasks: vec![
1365 make_task("Wire OAuth providers", TodoStatus::Completed),
1366 make_task("Wire OAuth refresh", TodoStatus::InProgress),
1367 ],
1368 }];
1369 let (t, _) = find_task_fuzzy(&phases, "wire oauth").unwrap();
1370 assert_eq!(t.content, "Wire OAuth refresh");
1371 }
1372
1373 #[test]
1374 fn init_with_phased_list() {
1375 let mut phases = vec![];
1376 let mut errors = vec![];
1377 apply_entry(
1378 &mut phases,
1379 &TodoOp::Init {
1380 list: Some(vec![
1381 InitListEntry {
1382 phase: "A".into(),
1383 items: vec!["a1".into(), "a2".into()],
1384 },
1385 InitListEntry {
1386 phase: "B".into(),
1387 items: vec!["b1".into()],
1388 },
1389 ]),
1390 items: None,
1391 },
1392 &mut errors,
1393 );
1394 assert_eq!(phases.len(), 2);
1395 assert_eq!(phases[0].name, "A");
1396 assert_eq!(phases[0].tasks.len(), 2);
1397 assert_eq!(phases[1].name, "B");
1398 assert!(errors.is_empty());
1399 }
1400
1401 #[test]
1402 fn init_with_flat_items_uses_default_phase() {
1403 let mut phases = vec![];
1404 let mut errors = vec![];
1405 apply_entry(
1406 &mut phases,
1407 &TodoOp::Init {
1408 list: None,
1409 items: Some(vec!["task1".into(), "task2".into()]),
1410 },
1411 &mut errors,
1412 );
1413 assert_eq!(phases.len(), 1);
1414 assert_eq!(phases[0].name, "Tasks");
1415 assert_eq!(phases[0].tasks.len(), 2);
1416 }
1417
1418 #[test]
1419 fn init_without_list_or_items_errors() {
1420 let mut phases = vec![];
1421 let mut errors = vec![];
1422 apply_entry(
1423 &mut phases,
1424 &TodoOp::Init {
1425 list: None,
1426 items: None,
1427 },
1428 &mut errors,
1429 );
1430 assert_eq!(errors.len(), 1);
1431 }
1432
1433 #[test]
1434 fn start_normalizes_other_in_progress() {
1435 let mut phases = vec![TodoPhase {
1436 name: "A".into(),
1437 tasks: vec![
1438 make_task("a1", TodoStatus::Pending),
1439 make_task("a2", TodoStatus::Pending),
1440 ],
1441 }];
1442
1443 let result = apply_ops(
1444 &mut phases,
1445 &[
1446 TodoOp::Start {
1447 task: Some("a1".into()),
1448 phase: None,
1449 },
1450 TodoOp::Start {
1451 task: Some("a2".into()),
1452 phase: None,
1453 },
1454 ],
1455 );
1456 assert!(result.errors.is_empty());
1457 let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1459 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1460 assert_eq!(a1.status, TodoStatus::InProgress);
1461 assert_eq!(a2.status, TodoStatus::Pending);
1462 }
1463
1464 #[test]
1465 fn completion_transition_detects_newly_completed() {
1466 let old = vec![TodoPhase {
1467 name: "A".into(),
1468 tasks: vec![make_task("a1", TodoStatus::InProgress)],
1469 }];
1470 let updated = vec![TodoPhase {
1471 name: "A".into(),
1472 tasks: vec![make_task("a1", TodoStatus::Completed)],
1473 }];
1474 let transitions = get_completion_transitions(&old, &updated);
1475 assert_eq!(transitions.len(), 1);
1476 assert_eq!(transitions[0].content, "a1");
1477 }
1478
1479 #[test]
1480 fn completion_transition_excludes_already_completed() {
1481 let old = vec![TodoPhase {
1482 name: "A".into(),
1483 tasks: vec![make_task("a1", TodoStatus::Completed)],
1484 }];
1485 let updated = old.clone();
1486 let transitions = get_completion_transitions(&old, &updated);
1487 assert!(transitions.is_empty());
1488 }
1489
1490 #[test]
1491 fn todo_matches_subagent_description() {
1492 assert!(todo_matches_any_description(
1494 "implement authentication module",
1495 &["authentication module".into()]
1496 ));
1497 assert!(!todo_matches_any_description(
1498 "fix",
1499 &["fix the bug".into()] ));
1501 assert!(!todo_matches_any_description(
1502 "implement auth",
1503 &["authentication module".into()] ));
1505 }
1506
1507 #[test]
1508 fn markdown_roundtrip_preserves_state() {
1509 let phases = vec![TodoPhase {
1510 name: "Test".into(),
1511 tasks: vec![make_task("Run tests", TodoStatus::Completed)],
1512 }];
1513 let md = phases_to_markdown(&phases);
1514 let parsed = markdown_to_phases(&md).unwrap();
1515 assert_eq!(parsed[0].tasks[0].status, TodoStatus::Completed);
1516 }
1517
1518 #[test]
1519 fn roman_numeral_correct() {
1520 assert_eq!(roman_numeral(1), "I");
1521 assert_eq!(roman_numeral(4), "IV");
1522 assert_eq!(roman_numeral(9), "IX");
1523 assert_eq!(roman_numeral(42), "XLII");
1524 assert_eq!(roman_numeral(1994), "MCMXCIV");
1525 }
1526
1527 #[test]
1528 fn append_creates_phase_if_missing() {
1529 let mut phases = vec![];
1530 let mut errors = vec![];
1531 apply_entry(
1532 &mut phases,
1533 &TodoOp::Append {
1534 phase: "New".into(),
1535 items: vec!["a".into(), "b".into()],
1536 },
1537 &mut errors,
1538 );
1539 assert_eq!(phases.len(), 1);
1540 assert_eq!(phases[0].name, "New");
1541 assert_eq!(phases[0].tasks.len(), 2);
1542 }
1543
1544 #[test]
1545 fn rm_with_neither_clears_all() {
1546 let mut phases = vec![TodoPhase {
1547 name: "X".into(),
1548 tasks: vec![make_task("a", TodoStatus::Pending)],
1549 }];
1550 let mut errors = vec![];
1551 apply_entry(
1552 &mut phases,
1553 &TodoOp::Rm {
1554 task: None,
1555 phase: None,
1556 },
1557 &mut errors,
1558 );
1559 assert!(phases.is_empty());
1560 }
1561
1562 #[test]
1563 fn done_marks_completed() {
1564 let mut phases = vec![TodoPhase {
1565 name: "A".into(),
1566 tasks: vec![make_task("a1", TodoStatus::Pending)],
1567 }];
1568 let result = apply_ops(
1569 &mut phases,
1570 &[TodoOp::Done {
1571 task: Some("a1".into()),
1572 phase: None,
1573 }],
1574 );
1575 assert!(result.errors.is_empty());
1576 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1577 assert_eq!(result.completed_tasks.len(), 1);
1578 }
1579
1580 #[test]
1581 fn drop_marks_abandoned() {
1582 let mut phases = vec![TodoPhase {
1583 name: "A".into(),
1584 tasks: vec![make_task("a1", TodoStatus::Pending)],
1585 }];
1586 let result = apply_ops(
1587 &mut phases,
1588 &[TodoOp::Drop {
1589 task: Some("a1".into()),
1590 phase: None,
1591 }],
1592 );
1593 assert!(result.errors.is_empty());
1594 assert_eq!(phases[0].tasks[0].status, TodoStatus::Abandoned);
1595 }
1596
1597 #[test]
1598 fn block_marks_blocked_with_reason() {
1599 let mut phases = vec![TodoPhase {
1600 name: "A".into(),
1601 tasks: vec![make_task("a1", TodoStatus::Pending)],
1602 }];
1603 let result = apply_ops(
1604 &mut phases,
1605 &[TodoOp::Block {
1606 task: Some("a1".into()),
1607 phase: None,
1608 reason: Some("waiting on user".into()),
1609 }],
1610 );
1611 assert!(result.errors.is_empty());
1612 assert_eq!(phases[0].tasks[0].status, TodoStatus::Blocked);
1613 assert_eq!(
1614 phases[0].tasks[0].block_reason.as_deref(),
1615 Some("waiting on user")
1616 );
1617 }
1618
1619 #[test]
1620 fn block_skips_terminal_states() {
1621 let mut phases = vec![TodoPhase {
1622 name: "A".into(),
1623 tasks: vec![make_task("done", TodoStatus::Completed)],
1624 }];
1625 apply_ops(
1626 &mut phases,
1627 &[TodoOp::Block {
1628 task: Some("done".into()),
1629 phase: None,
1630 reason: None,
1631 }],
1632 );
1633 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1635 }
1636
1637 #[test]
1638 fn unblock_returns_to_pending() {
1639 let mut phases = vec![TodoPhase {
1640 name: "A".into(),
1641 tasks: vec![TodoItem {
1642 content: "a1".into(),
1643 status: TodoStatus::Blocked,
1644 notes: None,
1645 block_reason: Some("blocked earlier".into()),
1646 }],
1647 }];
1648 let result = apply_ops(
1649 &mut phases,
1650 &[TodoOp::Unblock {
1651 task: Some("a1".into()),
1652 phase: None,
1653 }],
1654 );
1655 assert!(result.errors.is_empty());
1656 assert_eq!(phases[0].tasks[0].status, TodoStatus::Pending);
1657 assert!(phases[0].tasks[0].block_reason.is_none());
1658 }
1659
1660 #[test]
1661 fn unblock_is_idempotent_on_nonblocked() {
1662 let mut phases = vec![TodoPhase {
1663 name: "A".into(),
1664 tasks: vec![make_task("a1", TodoStatus::Pending)],
1665 }];
1666 apply_ops(
1667 &mut phases,
1668 &[TodoOp::Unblock {
1669 task: Some("a1".into()),
1670 phase: None,
1671 }],
1672 );
1673 assert_eq!(phases[0].tasks[0].status, TodoStatus::Pending);
1675 }
1676
1677 #[test]
1678 fn done_auto_promotes_next_pending() {
1679 let mut phases = vec![TodoPhase {
1680 name: "A".into(),
1681 tasks: vec![
1682 make_task("a1", TodoStatus::InProgress),
1683 make_task("a2", TodoStatus::Pending),
1684 ],
1685 }];
1686 let result = apply_ops(
1687 &mut phases,
1688 &[TodoOp::Done {
1689 task: Some("a1".into()),
1690 phase: None,
1691 }],
1692 );
1693 assert!(result.errors.is_empty());
1694 let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1695 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1696 assert_eq!(a1.status, TodoStatus::Completed);
1697 assert_eq!(a2.status, TodoStatus::InProgress);
1699 }
1700
1701 #[test]
1702 fn done_promotion_skips_blocked() {
1703 let mut phases = vec![TodoPhase {
1704 name: "A".into(),
1705 tasks: vec![
1706 make_task("a1", TodoStatus::InProgress),
1707 make_task("a2", TodoStatus::Blocked),
1708 make_task("a3", TodoStatus::Pending),
1709 ],
1710 }];
1711 apply_ops(
1712 &mut phases,
1713 &[TodoOp::Done {
1714 task: Some("a1".into()),
1715 phase: None,
1716 }],
1717 );
1718 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1719 let a3 = phases[0].tasks.iter().find(|t| t.content == "a3").unwrap();
1720 assert_eq!(a2.status, TodoStatus::Blocked);
1722 assert_eq!(a3.status, TodoStatus::InProgress);
1723 }
1724
1725 #[test]
1726 fn done_with_no_open_task_does_not_promote() {
1727 let mut phases = vec![TodoPhase {
1728 name: "A".into(),
1729 tasks: vec![make_task("only", TodoStatus::Pending)],
1730 }];
1731 apply_ops(
1732 &mut phases,
1733 &[TodoOp::Done {
1734 task: Some("only".into()),
1735 phase: None,
1736 }],
1737 );
1738 assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1739 assert!(
1741 phases[0]
1742 .tasks
1743 .iter()
1744 .all(|t| t.status != TodoStatus::InProgress)
1745 );
1746 }
1747
1748 #[test]
1749 fn start_does_not_auto_promote() {
1750 let mut phases = vec![TodoPhase {
1752 name: "A".into(),
1753 tasks: vec![
1754 make_task("a1", TodoStatus::Pending),
1755 make_task("a2", TodoStatus::Pending),
1756 ],
1757 }];
1758 apply_ops(
1759 &mut phases,
1760 &[TodoOp::Start {
1761 task: Some("a1".into()),
1762 phase: None,
1763 }],
1764 );
1765 let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1766 let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1767 assert_eq!(a1.status, TodoStatus::InProgress);
1768 assert_eq!(a2.status, TodoStatus::Pending);
1769 }
1770
1771 #[test]
1772 fn markdown_roundtrip_blocked() {
1773 let phases = vec![TodoPhase {
1774 name: "Test".into(),
1775 tasks: vec![make_task("blocked task", TodoStatus::Blocked)],
1776 }];
1777 let md = phases_to_markdown(&phases);
1778 let parsed = markdown_to_phases(&md).unwrap();
1779 assert_eq!(parsed[0].tasks[0].status, TodoStatus::Blocked);
1780 }
1781
1782 fn open_task_phases() -> Vec<TodoPhase> {
1783 vec![TodoPhase {
1784 name: "Work".into(),
1785 tasks: vec![
1786 make_task("done task", TodoStatus::Completed),
1787 make_task("active task", TodoStatus::InProgress),
1788 make_task("open task", TodoStatus::Pending),
1789 make_task("blocked task", TodoStatus::Blocked),
1790 make_task("dropped task", TodoStatus::Abandoned),
1791 ],
1792 }]
1793 }
1794
1795 #[test]
1796 fn stop_reminder_lists_only_open_tasks() {
1797 let mut state = StopReminderState::default();
1798 let msg = build_stop_reminder(&open_task_phases(), &mut state, MAX_TODO_STOP_REMINDERS)
1799 .expect("open tasks should yield a reminder");
1800 assert!(msg.contains("active task"));
1802 assert!(msg.contains("open task"));
1803 assert!(!msg.contains("done task"));
1804 assert!(!msg.contains("blocked task"));
1805 assert!(!msg.contains("dropped task"));
1806 assert_eq!(state.count(), 1);
1807 }
1808
1809 #[test]
1810 fn stop_reminder_none_when_all_closed() {
1811 let mut state = StopReminderState::default();
1812 let phases = vec![TodoPhase {
1813 name: "A".into(),
1814 tasks: vec![
1815 make_task("x", TodoStatus::Completed),
1816 make_task("y", TodoStatus::Abandoned),
1817 make_task("z", TodoStatus::Blocked),
1818 ],
1819 }];
1820 assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1821 assert_eq!(state.count(), 0);
1822 }
1823
1824 #[test]
1825 fn stop_reminder_dedups_unchanged_open_set() {
1826 let mut state = StopReminderState::default();
1827 let phases = open_task_phases();
1828 let first = build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS);
1829 let second = build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS);
1831 assert!(first.is_some());
1832 assert!(second.is_none());
1833 assert_eq!(state.count(), 1);
1834 }
1835
1836 #[test]
1837 fn mid_run_nudge_fires_after_threshold_mutations_without_todo_touch() {
1838 let mut state = MidRunNudgeState::default();
1839 for _ in 0..11 {
1840 state.record_tool_result("edit", false);
1841 assert!(!state.should_nudge());
1842 }
1843 state.record_tool_result("edit", false);
1844 assert!(state.should_nudge());
1845 }
1846
1847 #[test]
1848 fn mid_run_nudge_resets_on_todo_touch() {
1849 let mut state = MidRunNudgeState::default();
1850 for _ in 0..12 {
1851 state.record_tool_result("edit", false);
1852 }
1853 assert!(state.should_nudge());
1854 state.record_tool_result("todo", false);
1855 assert!(!state.should_nudge());
1856 }
1857
1858 #[test]
1859 fn mid_run_nudge_caps_at_two_per_cycle() {
1860 let mut state = MidRunNudgeState::default();
1861 for _ in 0..12 {
1862 state.record_tool_result("edit", false);
1863 }
1864 assert!(state.take_nudge().is_some());
1865 for _ in 0..12 {
1866 state.record_tool_result("edit", false);
1867 }
1868 assert!(state.take_nudge().is_some());
1869 for _ in 0..12 {
1870 state.record_tool_result("edit", false);
1871 }
1872 assert!(state.take_nudge().is_none());
1873 }
1874
1875 #[test]
1876 fn stop_reminder_re_entitles_after_progress() {
1877 let mut state = StopReminderState::default();
1878 let phases = vec![TodoPhase {
1879 name: "A".into(),
1880 tasks: vec![make_task("a", TodoStatus::Pending)],
1881 }];
1882 assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_some());
1883 assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1885 let phases2 = vec![TodoPhase {
1887 name: "A".into(),
1888 tasks: vec![
1889 make_task("a", TodoStatus::Completed),
1890 make_task("b", TodoStatus::Pending),
1891 ],
1892 }];
1893 assert!(build_stop_reminder(&phases2, &mut state, MAX_TODO_STOP_REMINDERS).is_some());
1894 assert_eq!(state.count(), 2);
1895 }
1896
1897 #[test]
1898 fn stop_reminder_caps_at_max() {
1899 let mut state = StopReminderState::default();
1900 for i in 0..MAX_TODO_STOP_REMINDERS {
1903 let phases = vec![TodoPhase {
1904 name: "A".into(),
1905 tasks: vec![make_task(&format!("task {i}"), TodoStatus::Pending)],
1906 }];
1907 assert!(
1908 build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_some(),
1909 "reminder {i} should fire"
1910 );
1911 }
1912 let phases = vec![TodoPhase {
1914 name: "A".into(),
1915 tasks: vec![make_task("task beyond cap", TodoStatus::Pending)],
1916 }];
1917 assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1918 assert_eq!(state.count(), MAX_TODO_STOP_REMINDERS);
1919 }
1920}