Skip to main content

oxicode_agent/tools/
todo.rs

1//! Todo tool — phased task management with 7 ops.
2//!
3//! omp `tools/todo.ts` (938줄) 계약 이식:
4//! - 7 ops: init, start, done, drop, rm, append, view
5//! - 3상태 정규화 (in_progress는 한 phase에 하나)
6//! - Markdown 라운드트립
7//! - sub-agent 매칭 헬퍼 (⑥ 연동 후 활성화)
8
9use std::fmt;
10
11use async_trait::async_trait;
12use serde_json::{Value, json};
13
14use crate::{AgentTool, AgentToolResult, ToolContext, ToolError};
15
16// ── Types ─────────────────────────────────────────────────────────────
17
18/// Status of a single todo task.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TodoStatus {
22    /// Task has not yet been started.
23    Pending,
24    /// Task is currently being worked on (at most one per phase after normalization).
25    InProgress,
26    /// Task has been finished.
27    Completed,
28    /// Task was cancelled or deemed unnecessary.
29    Abandoned,
30    /// Task is waiting on external input (a user decision, another agent, an
31    /// external service). Excluded from the stop-time incomplete-todo reminder.
32    Blocked,
33}
34
35impl TodoStatus {
36    /// Return a status-specific glyph for display.
37    pub fn icon(self) -> &'static str {
38        match self {
39            Self::Pending => "\u{2610}",    // ☐
40            Self::InProgress => "\u{25B6}", // ▶
41            Self::Completed => "\u{2611}",  // ☑
42            Self::Abandoned => "\u{2717}",  // ✗
43            Self::Blocked => "\u{23F8}",    // ⏸
44        }
45    }
46
47    /// Return the serialized snake_case name of this status.
48    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/// A single task within a phase.
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67pub struct TodoItem {
68    /// Human-readable description of the task.
69    pub content: String,
70    /// Current lifecycle status of the task.
71    pub status: TodoStatus,
72    /// Optional free-form notes attached to the task.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub notes: Option<Vec<String>>,
75    /// Optional reason a task is blocked (set by the `block` op).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub block_reason: Option<String>,
78}
79
80/// A named group of related tasks within a todo list.
81#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82pub struct TodoPhase {
83    /// Display name of the phase.
84    pub name: String,
85    /// Tasks belonging to this phase, in order.
86    pub tasks: Vec<TodoItem>,
87}
88
89/// Operations that can be applied to a todo list.
90#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
91#[serde(tag = "op", rename_all = "snake_case")]
92pub enum TodoOp {
93    /// Initialize or replace the todo list.
94    Init {
95        /// Optional structured phase definitions.
96        #[serde(default)]
97        list: Option<Vec<InitListEntry>>,
98        /// Optional flat list of task contents.
99        #[serde(default)]
100        items: Option<Vec<String>>,
101    },
102    /// Mark matching tasks as in progress.
103    Start {
104        /// Task content filter.
105        #[serde(default)]
106        task: Option<String>,
107        /// Phase name filter.
108        #[serde(default)]
109        phase: Option<String>,
110    },
111    /// Mark matching tasks as completed.
112    Done {
113        /// Task content filter.
114        #[serde(default)]
115        task: Option<String>,
116        /// Phase name filter.
117        #[serde(default)]
118        phase: Option<String>,
119    },
120    /// Mark matching tasks as abandoned.
121    Drop {
122        /// Task content filter.
123        #[serde(default)]
124        task: Option<String>,
125        /// Phase name filter.
126        #[serde(default)]
127        phase: Option<String>,
128    },
129    /// Remove matching tasks entirely.
130    Rm {
131        /// Task content filter.
132        #[serde(default)]
133        task: Option<String>,
134        /// Phase name filter.
135        #[serde(default)]
136        phase: Option<String>,
137    },
138    /// Append tasks to a phase, creating it if it does not exist.
139    Append {
140        /// Name of the target phase.
141        phase: String,
142        /// Task contents to append.
143        items: Vec<String>,
144    },
145    /// Mark matching tasks as blocked (waiting on external input).
146    /// Terminal states (Completed/Abandoned) are left untouched.
147    Block {
148        /// Task content filter.
149        #[serde(default)]
150        task: Option<String>,
151        /// Phase name filter.
152        #[serde(default)]
153        phase: Option<String>,
154        /// Optional human-readable reason the task is blocked.
155        #[serde(default)]
156        reason: Option<String>,
157    },
158    /// Return matching blocked tasks to `pending`.
159    Unblock {
160        /// Task content filter.
161        #[serde(default)]
162        task: Option<String>,
163        /// Phase name filter.
164        #[serde(default)]
165        phase: Option<String>,
166    },
167    /// Return the current state without modifying it.
168    View,
169}
170
171/// A phase seed supplied to the `init` op.
172#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
173pub struct InitListEntry {
174    /// Display name of the phase.
175    pub phase: String,
176    /// Initial task contents for the phase.
177    pub items: Vec<String>,
178}
179
180/// Describes a task that newly transitioned to completed.
181#[derive(Debug, Clone, serde::Serialize)]
182pub struct TodoCompletionTransition {
183    /// Name of the phase containing the task.
184    pub phase: String,
185    /// Content of the completed task.
186    pub content: String,
187}
188
189/// Result of applying a batch of todo ops.
190#[derive(Debug, Clone, serde::Serialize)]
191pub struct TodoUpdateResult {
192    /// Full phase list after the ops were applied.
193    pub phases: Vec<TodoPhase>,
194    /// Tasks that transitioned to completed during this update.
195    pub completed_tasks: Vec<TodoCompletionTransition>,
196    /// Non-fatal errors collected while applying the ops.
197    pub errors: Vec<String>,
198}
199
200// ── Op dispatch (omp `applyEntry` 계약) ─────────────────────────────
201
202/// Apply a single op to the phases vec. Errors are collected, not fatal.
203fn 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 => {} // read-only
255    }
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
330/// Quote-aware tokenizer: splits on whitespace, respects `"…"` groups, and
331/// honors backslash escapes. Ports omp's `tokenize`
332/// (`todo-command-controller.ts:37-58`).
333pub 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
363/// Exact (case-insensitive) -> unique prefix -> unique substring. Ambiguous
364/// or no match -> `None`. Ports omp's `findPhaseFuzzy`
365/// (`todo-command-controller.ts:81-92`).
366pub 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
388/// Exact content match -> unique substring match -> if ambiguous, prefer a
389/// single in_progress/pending hit. Ports omp's `findTaskFuzzy`
390/// (`todo-command-controller.ts:94-113`).
391pub 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
438/// Mark matching tasks as `Blocked`, recording an optional reason. Tasks in a
439/// terminal state (`Completed`/`Abandoned`) are left untouched — blocking a
440/// finished task is a no-op rather than a silent reopening.
441fn 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
459/// Return matching `Blocked` tasks to `Pending` and clear their reason. Tasks
460/// not currently blocked are left as-is, making `unblock` idempotent.
461fn 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        // 둘 다 생략 → 전체 삭제
508        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    // 역순 제거 (인덱스 보존)
515    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    // 빈 phase 제거
523    phases.retain(|p| !p.tasks.is_empty());
524}
525
526// ── 정규화 & 완료 전환 ──────────────────────────────────────────────
527
528/// 한 phase에 in_progress task가 2개 이상이면 첫 번째만 유지.
529/// omp `normalizeInProgressTask` 계약.
530fn 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
545/// After a completion, if no task is `InProgress`, promote the earliest
546/// `Pending` task (in phase order, then task order) to `InProgress`. Blocked
547/// tasks are skipped — they wait on external input and cannot be worked on.
548/// omp "earliest still-open task auto-promotes" contract.
549fn 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
566// ── Collapsed-viewport selection (omp `selectCollapsedTodos` contract) ────
567
568/// One prior closed task stays visible above the open window so a completion
569/// is seen as it happens, not silently dropped. Ports omp's
570/// `COLLAPSED_CLOSED_CONTEXT` (`todo.ts:275`).
571const COLLAPSED_CLOSED_CONTEXT: usize = 1;
572
573/// Result of [`select_collapsed_todos`]: the rows to render plus an optional
574/// "… N more" summary line.
575pub struct CollapsedSelection<'a> {
576    /// The selected rows to render (already collapsed to the cap).
577    pub items: Vec<&'a TodoItem>,
578    /// Optional "… N more" line when rows were dropped.
579    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
635/// Walking-viewport selection for a phase's collapsed todo preview. Ports
636/// omp's `selectCollapsedTodos` (`todo.ts:332-350`).
637pub 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
662/// 이전/이후 phase 배열을 비교해 새로 Completed가 된 task 목록.
663/// TUI 스트라이크루 애니메이션 트리거용.
664fn 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
689/// todo 내용과 서브에이전트 설명이 같은 작업을 가리키는지.
690/// 6자 이상 중복 정규화 매칭 (omp TODO_DESCRIPTION_MIN_OVERLAP).
691pub 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
702/// Auto-complete open todos whose content matches a subagent that finished
703/// successfully. Ports omp's `#reconcileTodosWithSubagents`
704/// (`interactive-mode.ts:2369-2404`). Idempotent: never touches an already
705/// closed task. Failed/aborted subagents are the caller's responsibility to
706/// exclude from `completed_descriptions` — this function only matches.
707pub 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
764// ── Markdown 라운드트립 ──────────────────────────────────────────────
765
766/// phases → Markdown 체크리스트. 다중 phase면 로마 숫자 헤더.
767pub 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
802/// Convert a small integer (1–3999) to its uppercase Roman-numeral string
803/// (e.g. `4` → `"IV"`, `6` → `"VI"`). Used for phase display names.
804pub 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
815/// Markdown 체크리스트 → phases. 헤더 (`## Phase` 또는 `N. Phase`)와 체크박스 파싱.
816/// omp `markdownToPhases` 계약.
817pub 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    // ## Phase Name
853    if let Some(rest) = t.strip_prefix("## ") {
854        return Some(rest.trim().to_string());
855    }
856    // I. Phase Name  /  II. Phase Name
857    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
896// ── 요약 포맷 ────────────────────────────────────────────────────────
897
898/// Render a human-readable summary of the todo list for display.
899pub 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
965// ── Apply ops helper ─────────────────────────────────────────────────
966
967/// Apply a sequence of ops, returning the result + transitions + errors.
968pub 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    // omp: on each completion the earliest still-open task auto-promotes to
977    // in_progress, so the list always points at what to work on next.
978    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
989// ── Stop-time incomplete-todo reminder ───────────────────────────────
990
991/// Maximum stop-reminder injections per agent run. A hard cap so a
992/// misbehaving agent (e.g. one that keeps adding todos and then stopping)
993/// cannot loop indefinitely.
994pub const MAX_TODO_STOP_REMINDERS: u32 = 3;
995
996/// State for the stop-time incomplete-todo reminder, scoped to a single
997/// agent run. [`build_stop_reminder`] mutates it to dedup unchanged
998/// open-task sets and cap total reminders.
999#[derive(Debug, Default)]
1000pub struct StopReminderState {
1001    last_signature: Option<String>,
1002    count: u32,
1003}
1004
1005impl StopReminderState {
1006    /// Reminders emitted so far this run.
1007    pub fn count(&self) -> u32 {
1008        self.count
1009    }
1010}
1011
1012/// Number of mutating tool calls (edit/write/bash/…) since the last `todo`
1013/// touch before a mid-run reconciliation nudge fires. Ports omp's
1014/// `MID_RUN_NUDGE_MUTATION_THRESHOLD` (`todo-tracker.ts:15`).
1015const MID_RUN_NUDGE_MUTATION_THRESHOLD: u32 = 12;
1016/// Max mid-run nudges per cycle (per run). Ports omp's
1017/// `MID_RUN_NUDGE_MAX_PER_CYCLE` (`todo-tracker.ts:16`).
1018const MID_RUN_NUDGE_MAX_PER_CYCLE: u32 = 2;
1019/// Tools that count as "making progress without touching the todo list".
1020const MUTATING_TOOLS: &[&str] = &["bash", "eval", "edit", "write", "ast_edit"];
1021
1022/// Tracks mutating-tool-call volume since the last `todo` touch, to fire a
1023/// hidden mid-run reconciliation nudge. Ports omp's `TodoTracker`'s nudge
1024/// half (`todo-tracker.ts:15-16, 110-116`).
1025#[derive(Debug, Default)]
1026pub struct MidRunNudgeState {
1027    mutations_since_touch: u32,
1028    nudge_count: u32,
1029}
1030
1031impl MidRunNudgeState {
1032    /// Record a completed tool result. A `todo` call resets the counter; a
1033    /// mutating non-error tool increments it.
1034    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    /// Whether a nudge is currently due (threshold reached, budget remains).
1043    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    /// Consume the nudge budget, returning the hidden reminder text to inject
1049    /// (or `None` if not due).
1050    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
1065/// Build a stop-time reminder when the todo list has open tasks.
1066///
1067/// "Open" = `Pending` or `InProgress`. `Blocked` tasks are excluded — they
1068/// wait on external input and are not actionable — as are `Completed` and
1069/// `Abandoned`.
1070///
1071/// Returns `None` (leaving `state` untouched) when there is nothing open,
1072/// the open set is unchanged since the last reminder, or `max` reminders
1073/// have already been emitted. This bounds the agent loop's extra turns:
1074/// at most `max` per run, never two in a row without the open set changing.
1075pub 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    // Signature = open task contents in order. Any change (progress,
1093    // reorder, or new open tasks) re-entitles a single fresh reminder.
1094    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
1116// ── TodoTool (AgentTool 구현) ─────────────────────────────────────────
1117
1118/// `todo` agent tool. 상태 비저장 (상태는 `TodoStateProvider`가 보유).
1119pub 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        // v2: 능력 특성 주입 (ToolContext.todo)
1188        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// ── Tests ────────────────────────────────────────────────────────────
1206
1207#[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        // "auth " matches both "Auth UI" (prefix) and "Authentication"
1345        // (substring) -> ambiguous -> None.
1346        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        // omp 동작: 단일 phase에서 첫 task가 in_progress 유지, 이후는 pending으로 리셋.
1458        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        // 동일 substring 매칭: 길이 ≥ 6.
1493        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()] // 6자 미만 정규화 → 매칭 안 됨
1500        ));
1501        assert!(!todo_matches_any_description(
1502            "implement auth",
1503            &["authentication module".into()] // 서로 substring 아님
1504        ));
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        // Completed must not be silently reopened as Blocked.
1634        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        // Pending task stays pending; no error.
1674        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        // omp: completing a1 auto-promotes the earliest still-open task (a2).
1698        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        // Blocked a2 is skipped; a3 (the earliest Pending) is promoted.
1721        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        // Nothing left to promote; no phantom in_progress.
1740        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        // init + start must NOT trigger promotion — only done does (omp).
1751        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        // InProgress + Pending only; Blocked/Completed/Abandoned excluded.
1801        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        // Same open set → no second reminder.
1830        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        // Same set again → deduped.
1884        assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1885        // Agent completes `a`, leaving a new open task `b` → fresh reminder.
1886        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        // Each iteration changes the open set so dedup never triggers; the
1901        // hard cap must still bound the count.
1902        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        // Beyond the cap — even with a brand-new open set — no more reminders.
1913        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}