1use chrono::{DateTime, Local, NaiveDate};
11use regex::Regex;
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TasksFormat {
18 Enhanced,
20 Checkbox,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TaskStatus {
27 Pending,
29 InProgress,
31 Complete,
33 Shelved,
35}
36
37impl TaskStatus {
38 pub fn as_enhanced_label(self) -> &'static str {
40 match self {
41 TaskStatus::Pending => "pending",
42 TaskStatus::InProgress => "in-progress",
43 TaskStatus::Complete => "complete",
44 TaskStatus::Shelved => "shelved",
45 }
46 }
47
48 pub fn from_enhanced_label(s: &str) -> Option<Self> {
50 match s {
51 "pending" => Some(TaskStatus::Pending),
52 "in-progress" => Some(TaskStatus::InProgress),
53 "complete" => Some(TaskStatus::Complete),
54 "shelved" => Some(TaskStatus::Shelved),
55 _ => None,
56 }
57 }
58
59 pub fn is_done(self) -> bool {
61 match self {
62 TaskStatus::Pending => false,
63 TaskStatus::InProgress => false,
64 TaskStatus::Complete => true,
65 TaskStatus::Shelved => true,
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct TaskDiagnostic {
73 pub level: DiagnosticLevel,
75 pub message: String,
77 pub task_id: Option<String>,
79 pub line: Option<usize>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum DiagnosticLevel {
86 Error,
88 Warning,
90}
91
92impl DiagnosticLevel {
93 pub fn as_str(self) -> &'static str {
95 match self {
96 DiagnosticLevel::Error => "error",
97 DiagnosticLevel::Warning => "warning",
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct TaskItem {
105 pub id: String,
107 pub name: String,
109 pub wave: Option<u32>,
111 pub status: TaskStatus,
113 pub updated_at: Option<String>,
115 pub dependencies: Vec<String>,
117 pub files: Vec<String>,
119 pub action: String,
121 pub verify: Option<String>,
123 pub done_when: Option<String>,
125 pub kind: TaskKind,
127 pub header_line_index: usize,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum TaskKind {
134 #[default]
135 Normal,
137 Checkpoint,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ProgressInfo {
144 pub total: usize,
146 pub complete: usize,
148 pub shelved: usize,
150 pub in_progress: usize,
152 pub pending: usize,
154 pub remaining: usize,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct WaveInfo {
161 pub wave: u32,
163 pub depends_on: Vec<u32>,
165 pub header_line_index: usize,
167 pub depends_on_line_index: Option<usize>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct TasksParseResult {
174 pub format: TasksFormat,
176 pub tasks: Vec<TaskItem>,
178 pub waves: Vec<WaveInfo>,
180 pub diagnostics: Vec<TaskDiagnostic>,
182 pub progress: ProgressInfo,
184}
185
186impl TasksParseResult {
187 pub fn empty() -> Self {
189 Self {
190 format: TasksFormat::Checkbox,
191 tasks: Vec::new(),
192 waves: Vec::new(),
193 diagnostics: Vec::new(),
194 progress: ProgressInfo {
195 total: 0,
196 complete: 0,
197 shelved: 0,
198 in_progress: 0,
199 pending: 0,
200 remaining: 0,
201 },
202 }
203 }
204}
205
206pub fn enhanced_tasks_template(change_id: &str, now: DateTime<Local>) -> String {
208 let date = now.format("%Y-%m-%d").to_string();
209 format!(
210 "# Tasks for: {change_id}\n\n## Execution Notes\n\n- **Tool**: Any (OpenCode, Codex, Claude Code)\n- **Mode**: Sequential (or parallel if tool supports)\n- **Template**: Enhanced task format with waves, verification, and status tracking\n- **Tracking**: Prefer the tasks CLI to drive status updates and pick work\n\n```bash\nito tasks status {change_id}\nito tasks next {change_id}\nito tasks start {change_id} 1.1\nito tasks complete {change_id} 1.1\nito tasks shelve {change_id} 1.1\nito tasks unshelve {change_id} 1.1\nito tasks show {change_id}\n```\n\n______________________________________________________________________\n\n## Wave 1\n\n- **Depends On**: None\n\n### Task 1.1: [Task Name]\n\n- **Files**: `path/to/file.rs`\n- **Dependencies**: None\n- **Action**:\n [Describe what needs to be done]\n- **Verify**: `cargo test --workspace`\n- **Done When**: [Success criteria]\n- **Updated At**: {date}\n- **Status**: [ ] pending\n\n______________________________________________________________________\n\n## Checkpoints\n\n### Checkpoint: Review Implementation\n\n- **Type**: checkpoint (requires human approval)\n- **Dependencies**: All Wave 1 tasks\n- **Action**: Review the implementation before proceeding\n- **Done When**: User confirms implementation is correct\n- **Updated At**: {date}\n- **Status**: [ ] pending\n"
211 )
212}
213
214pub fn detect_tasks_format(contents: &str) -> TasksFormat {
216 let enhanced_heading = Regex::new(r"(?m)^###\s+(Task\s+)?[^:]+:\s+.+$").unwrap();
217 let has_status = contents.contains("- **Status**:");
218 if enhanced_heading.is_match(contents) && has_status {
219 return TasksFormat::Enhanced;
220 }
221 let checkbox = Regex::new(r"(?m)^\s*[-*]\s+\[[ xX~>]\]").unwrap();
222 if checkbox.is_match(contents) {
223 return TasksFormat::Checkbox;
224 }
225 TasksFormat::Checkbox
226}
227
228pub fn parse_tasks_tracking_file(contents: &str) -> TasksParseResult {
230 match detect_tasks_format(contents) {
231 TasksFormat::Enhanced => parse_enhanced_tasks(contents),
232 TasksFormat::Checkbox => parse_checkbox_tasks(contents),
233 }
234}
235
236fn parse_checkbox_tasks(contents: &str) -> TasksParseResult {
237 let mut tasks: Vec<TaskItem> = Vec::new();
239 for (line_idx, line) in contents.lines().enumerate() {
240 let l = line.trim_start();
241 let bytes = l.as_bytes();
242 if bytes.len() < 6 {
243 continue;
244 }
245 let bullet = bytes[0] as char;
246 if bullet != '-' && bullet != '*' {
247 continue;
248 }
249 if bytes[1] != b' ' || bytes[2] != b'[' || bytes[4] != b']' || bytes[5] != b' ' {
250 continue;
251 }
252 let marker = bytes[3] as char;
253 let status = match marker {
254 'x' | 'X' => TaskStatus::Complete,
255 ' ' => TaskStatus::Pending,
256 '~' | '>' => TaskStatus::InProgress,
257 _ => continue,
258 };
259 let rest = &l[6..];
260 tasks.push(TaskItem {
261 id: (tasks.len() + 1).to_string(),
262 name: rest.trim().to_string(),
263 wave: None,
264 status,
265 updated_at: None,
266 dependencies: Vec::new(),
267 files: Vec::new(),
268 action: String::new(),
269 verify: None,
270 done_when: None,
271 kind: TaskKind::Normal,
272 header_line_index: line_idx,
273 });
274 }
275 let progress = compute_progress(&tasks);
276 TasksParseResult {
277 format: TasksFormat::Checkbox,
278 tasks,
279 waves: Vec::new(),
280 diagnostics: Vec::new(),
281 progress,
282 }
283}
284
285fn parse_enhanced_tasks(contents: &str) -> TasksParseResult {
286 let mut diagnostics: Vec<TaskDiagnostic> = Vec::new();
287 let mut tasks: Vec<TaskItem> = Vec::new();
288
289 let wave_re = Regex::new(r"^##\s+Wave\s+(\d+)\s*$").unwrap();
290 let wave_dep_re = Regex::new(r"^\s*[-*]\s+\*\*Depends On\*\*:\s*(.+?)\s*$").unwrap();
291 let task_re = Regex::new(r"^###\s+(?:Task\s+)?([^:]+):\s+(.+?)\s*$").unwrap();
292 let deps_re = Regex::new(r"\*\*Dependencies\*\*:\s*(.+?)\s*$").unwrap();
293 let status_re = Regex::new(
294 r"\*\*Status\*\*:\s*\[([ xX\-~])\]\s+(pending|in-progress|complete|shelved)\s*$",
295 )
296 .unwrap();
297 let updated_at_re = Regex::new(r"\*\*Updated At\*\*:\s*(\d{4}-\d{2}-\d{2})\s*$").unwrap();
298 let files_re = Regex::new(r"\*\*Files\*\*:\s*`([^`]+)`\s*$").unwrap();
299 let verify_re = Regex::new(r"\*\*Verify\*\*:\s*`([^`]+)`\s*$").unwrap();
300 let done_when_re = Regex::new(r"\*\*Done When\*\*:\s*(.+?)\s*$").unwrap();
301
302 let mut current_wave: Option<u32> = None;
303 let mut in_checkpoints = false;
304
305 #[derive(Debug, Default, Clone)]
306 struct WaveBuilder {
307 header_line_index: usize,
308 depends_on_raw: Option<String>,
309 depends_on_line_index: Option<usize>,
310 }
311
312 let mut waves: BTreeMap<u32, WaveBuilder> = BTreeMap::new();
313
314 #[derive(Debug, Default)]
315 struct CurrentTask {
316 id: Option<String>,
317 desc: Option<String>,
318 wave: Option<u32>,
319 header_line_index: usize,
320 kind: TaskKind,
321 deps_raw: Option<String>,
322 updated_at_raw: Option<String>,
323 status_raw: Option<String>,
324 status_marker_raw: Option<char>,
325 files: Vec<String>,
326 action_lines: Vec<String>,
327 verify: Option<String>,
328 done_when: Option<String>,
329 }
330
331 fn flush_current(
332 current: &mut CurrentTask,
333 tasks: &mut Vec<TaskItem>,
334 diagnostics: &mut Vec<TaskDiagnostic>,
335 ) {
336 let Some(id) = current.id.take() else {
337 current.desc = None;
338 current.deps_raw = None;
339 current.updated_at_raw = None;
340 current.status_raw = None;
341 current.kind = TaskKind::Normal;
342 return;
343 };
344 let desc = current.desc.take().unwrap_or_default();
345 let wave = current.wave.take();
346 let header_line_index = current.header_line_index;
347 let deps_raw = current.deps_raw.take().unwrap_or_default();
348 let updated_at_raw = current.updated_at_raw.take();
349 let status_raw = current.status_raw.take();
350 let status_marker_raw = current.status_marker_raw.take();
351 let files = std::mem::take(&mut current.files);
352 let action = std::mem::take(&mut current.action_lines)
353 .join("\n")
354 .trim()
355 .to_string();
356 let verify = current.verify.take();
357 let done_when = current.done_when.take();
358
359 let status = match status_raw
360 .as_deref()
361 .and_then(TaskStatus::from_enhanced_label)
362 {
363 Some(s) => s,
364 None => {
365 diagnostics.push(TaskDiagnostic {
366 level: DiagnosticLevel::Error,
367 message: "Invalid or missing status".to_string(),
368 task_id: Some(id.clone()),
369 line: Some(header_line_index + 1),
370 });
371 TaskStatus::Pending
372 }
373 };
374
375 if let Some(marker) = status_marker_raw {
379 match status {
380 TaskStatus::Complete => {
381 if marker != 'x' && marker != 'X' {
382 diagnostics.push(TaskDiagnostic {
383 level: DiagnosticLevel::Warning,
384 message: "Status marker for complete should be [x]".to_string(),
385 task_id: Some(id.clone()),
386 line: Some(header_line_index + 1),
387 });
388 }
389 }
390 TaskStatus::Shelved => {
391 if marker != '-' && marker != '~' {
392 diagnostics.push(TaskDiagnostic {
393 level: DiagnosticLevel::Warning,
394 message: "Status marker for shelved should be [-]".to_string(),
395 task_id: Some(id.clone()),
396 line: Some(header_line_index + 1),
397 });
398 }
399 }
400 TaskStatus::Pending | TaskStatus::InProgress => {
401 if marker == 'x' || marker == 'X' {
402 diagnostics.push(TaskDiagnostic {
403 level: DiagnosticLevel::Warning,
404 message: "Only complete tasks should use [x]".to_string(),
405 task_id: Some(id.clone()),
406 line: Some(header_line_index + 1),
407 });
408 }
409 }
410 }
411 }
412 let deps = parse_dependencies(&deps_raw);
413
414 let updated_at = match updated_at_raw.as_deref() {
415 Some(s) => {
416 if NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
417 Some(s.to_string())
418 } else {
419 diagnostics.push(TaskDiagnostic {
420 level: DiagnosticLevel::Error,
421 message: format!("Invalid Updated At date: {s} (expected YYYY-MM-DD)"),
422 task_id: Some(id.clone()),
423 line: Some(header_line_index + 1),
424 });
425 None
426 }
427 }
428 None => {
429 diagnostics.push(TaskDiagnostic {
430 level: DiagnosticLevel::Error,
431 message: "Missing Updated At field (expected YYYY-MM-DD)".to_string(),
432 task_id: Some(id.clone()),
433 line: Some(header_line_index + 1),
434 });
435 None
436 }
437 };
438
439 tasks.push(TaskItem {
440 id,
441 name: desc,
442 wave,
443 status,
444 updated_at,
445 dependencies: deps,
446 files,
447 action,
448 verify,
449 done_when,
450 kind: current.kind,
451 header_line_index,
452 });
453 current.kind = TaskKind::Normal;
454 }
455
456 let mut current_task = CurrentTask {
457 id: None,
458 desc: None,
459 wave: None,
460 header_line_index: 0,
461 kind: TaskKind::Normal,
462 deps_raw: None,
463 updated_at_raw: None,
464 status_raw: None,
465 status_marker_raw: None,
466 files: Vec::new(),
467 action_lines: Vec::new(),
468 verify: None,
469 done_when: None,
470 };
471
472 let mut in_action = false;
473
474 for (line_idx, line) in contents.lines().enumerate() {
475 if in_action && current_task.id.is_some() {
476 if line.starts_with("- **") || line.starts_with("### ") || line.starts_with("## ") {
477 in_action = false;
478 } else {
480 let trimmed = line.trim();
481 if !trimmed.is_empty() {
482 current_task.action_lines.push(trimmed.to_string());
483 }
484 continue;
485 }
486 }
487
488 if let Some(cap) = wave_re.captures(line) {
489 flush_current(&mut current_task, &mut tasks, &mut diagnostics);
490 current_wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
491 in_checkpoints = false;
492 if let Some(w) = current_wave {
493 waves.entry(w).or_insert_with(|| WaveBuilder {
494 header_line_index: line_idx,
495 depends_on_raw: None,
496 depends_on_line_index: None,
497 });
498 }
499 continue;
500 }
501 if line.trim() == "## Checkpoints" {
502 flush_current(&mut current_task, &mut tasks, &mut diagnostics);
503 current_wave = None;
504 in_checkpoints = true;
505 continue;
506 }
507
508 if current_task.id.is_none()
509 && let Some(w) = current_wave
510 && let Some(cap) = wave_dep_re.captures(line)
511 {
512 let raw = cap[1].trim().to_string();
513 let entry = waves.entry(w).or_insert_with(|| WaveBuilder {
514 header_line_index: line_idx,
515 depends_on_raw: None,
516 depends_on_line_index: None,
517 });
518 if entry.depends_on_raw.is_some() {
519 diagnostics.push(TaskDiagnostic {
520 level: DiagnosticLevel::Warning,
521 message: format!("Wave {w}: duplicate Depends On line; using the first one"),
522 task_id: None,
523 line: Some(line_idx + 1),
524 });
525 } else {
526 entry.depends_on_raw = Some(raw);
527 entry.depends_on_line_index = Some(line_idx);
528 }
529 continue;
530 }
531
532 if let Some(cap) = task_re.captures(line) {
533 flush_current(&mut current_task, &mut tasks, &mut diagnostics);
534 let id = cap[1].trim().to_string();
535 let desc = cap[2].trim().to_string();
536 current_task.id = Some(id.clone());
537 current_task.desc = Some(desc);
538 current_task.wave = current_wave;
539 current_task.header_line_index = line_idx;
540 current_task.kind = TaskKind::Normal;
541 current_task.deps_raw = None;
542 current_task.updated_at_raw = None;
543 current_task.status_raw = None;
544 current_task.status_marker_raw = None;
545 current_task.files.clear();
546 current_task.action_lines.clear();
547 current_task.verify = None;
548 current_task.done_when = None;
549 in_action = false;
550
551 if current_wave.is_none() && !in_checkpoints {
552 diagnostics.push(TaskDiagnostic {
553 level: DiagnosticLevel::Warning,
554 message: format!(
555 "{id}: Task '{id}' appears outside any Wave section; wave gating may not behave as expected"
556 ),
557 task_id: None,
558 line: Some(line_idx + 1),
559 });
560 }
561 continue;
562 }
563
564 if current_task.id.is_some() {
565 if line.trim() == "- **Action**:" {
566 in_action = true;
567 current_task.action_lines.clear();
568 continue;
569 }
570 if let Some(cap) = deps_re.captures(line) {
571 current_task.deps_raw = Some(cap[1].trim().to_string());
572 continue;
573 }
574 if let Some(cap) = updated_at_re.captures(line) {
575 current_task.updated_at_raw = Some(cap[1].trim().to_string());
576 continue;
577 }
578 if let Some(cap) = status_re.captures(line) {
579 let marker = cap
580 .get(1)
581 .and_then(|m| m.as_str().chars().next())
582 .unwrap_or(' ');
583 current_task.status_marker_raw = Some(marker);
584 current_task.status_raw = Some(cap[2].trim().to_string());
585 continue;
586 }
587 if let Some(cap) = files_re.captures(line) {
588 let inner = cap[1].trim();
589 current_task.files = inner
590 .split(',')
591 .map(|s| s.trim().to_string())
592 .filter(|s| !s.is_empty())
593 .collect();
594 continue;
595 }
596 if let Some(cap) = verify_re.captures(line) {
597 current_task.verify = Some(cap[1].trim().to_string());
598 continue;
599 }
600 if let Some(cap) = done_when_re.captures(line) {
601 current_task.done_when = Some(cap[1].trim().to_string());
602 continue;
603 }
604 }
605 }
606
607 flush_current(&mut current_task, &mut tasks, &mut diagnostics);
608
609 let mut wave_nums: Vec<u32> = waves.keys().copied().collect();
611 wave_nums.sort();
612 wave_nums.dedup();
613 let wave_set: std::collections::BTreeSet<u32> = wave_nums.iter().copied().collect();
614
615 let mut waves_out: Vec<WaveInfo> = Vec::new();
616 for w in &wave_nums {
617 let builder = waves.get(w).cloned().unwrap_or_default();
618
619 let mut depends_on: Vec<u32> = Vec::new();
620 if let Some(raw) = builder.depends_on_raw.as_deref() {
621 let trimmed = raw.trim();
622 if trimmed.is_empty() {
623 diagnostics.push(TaskDiagnostic {
624 level: DiagnosticLevel::Error,
625 message: format!("Wave {w}: Depends On is empty"),
626 task_id: None,
627 line: Some(builder.header_line_index + 1),
628 });
629 } else if trimmed.eq_ignore_ascii_case("none") {
630 } else {
632 for part in trimmed.split(',') {
633 let p = part.trim();
634 if p.is_empty() {
635 continue;
636 }
637 let p2 = if p.to_ascii_lowercase().starts_with("wave ") {
638 p[5..].trim()
639 } else {
640 p
641 };
642 match p2.parse::<u32>() {
643 Ok(n) => depends_on.push(n),
644 Err(_) => diagnostics.push(TaskDiagnostic {
645 level: DiagnosticLevel::Error,
646 message: format!("Wave {w}: invalid Depends On entry '{p}'"),
647 task_id: None,
648 line: Some(
649 builder
650 .depends_on_line_index
651 .unwrap_or(builder.header_line_index)
652 + 1,
653 ),
654 }),
655 }
656 }
657 }
658 } else {
659 diagnostics.push(TaskDiagnostic {
660 level: DiagnosticLevel::Error,
661 message: format!("Wave {w}: missing Depends On line"),
662 task_id: None,
663 line: Some(builder.header_line_index + 1),
664 });
665
666 depends_on = wave_nums.iter().copied().filter(|n| *n < *w).collect();
668 }
669
670 depends_on.sort();
671 depends_on.dedup();
672
673 for dep_wave in &depends_on {
674 if dep_wave == w {
675 diagnostics.push(TaskDiagnostic {
676 level: DiagnosticLevel::Error,
677 message: format!("Wave {w}: cannot depend on itself"),
678 task_id: None,
679 line: Some(
680 builder
681 .depends_on_line_index
682 .unwrap_or(builder.header_line_index)
683 + 1,
684 ),
685 });
686 continue;
687 }
688 if !wave_set.contains(dep_wave) {
689 diagnostics.push(TaskDiagnostic {
690 level: DiagnosticLevel::Error,
691 message: format!("Wave {w}: depends on missing Wave {dep_wave}"),
692 task_id: None,
693 line: Some(
694 builder
695 .depends_on_line_index
696 .unwrap_or(builder.header_line_index)
697 + 1,
698 ),
699 });
700 }
701 }
702
703 waves_out.push(WaveInfo {
704 wave: *w,
705 depends_on,
706 header_line_index: builder.header_line_index,
707 depends_on_line_index: builder.depends_on_line_index,
708 });
709 }
710
711 diagnostics.extend(super::relational::validate_relational(&tasks, &waves_out));
713
714 let progress = compute_progress(&tasks);
715
716 TasksParseResult {
717 format: TasksFormat::Enhanced,
718 tasks,
719 waves: waves_out,
720 diagnostics,
721 progress,
722 }
723}
724
725fn parse_dependencies(raw: &str) -> Vec<String> {
726 parse_dependencies_with_checkpoint(raw, TaskKind::Normal).0
727}
728
729fn parse_dependencies_with_checkpoint(raw: &str, kind: TaskKind) -> (Vec<String>, Option<u32>) {
730 let r = raw.trim();
731 if r.is_empty() {
732 return (Vec::new(), None);
733 }
734 let lower = r.to_ascii_lowercase();
735 if lower == "none" {
736 return (Vec::new(), None);
737 }
738
739 let all_wave_capture = Regex::new(r"(?i)^all\s+wave\s+(\d+)\s+tasks$").unwrap();
741 if let Some(cap) = all_wave_capture.captures(r) {
742 let wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
743 if kind == TaskKind::Checkpoint {
744 return (Vec::new(), wave);
745 }
746 return (Vec::new(), None);
747 }
748 if lower == "all previous waves" {
749 return (Vec::new(), None);
751 }
752
753 let deps = r
754 .split(',')
755 .map(|s| s.trim())
756 .filter(|s| !s.is_empty())
757 .map(|s| s.strip_prefix("Task ").unwrap_or(s).trim().to_string())
758 .collect();
759 (deps, None)
760}
761
762fn compute_progress(tasks: &[TaskItem]) -> ProgressInfo {
763 let total = tasks.len();
764 let complete = tasks
765 .iter()
766 .filter(|t| t.status == TaskStatus::Complete)
767 .count();
768 let shelved = tasks
769 .iter()
770 .filter(|t| t.status == TaskStatus::Shelved)
771 .count();
772 let in_progress = tasks
773 .iter()
774 .filter(|t| t.status == TaskStatus::InProgress)
775 .count();
776 let pending = tasks
777 .iter()
778 .filter(|t| t.status == TaskStatus::Pending)
779 .count();
780 let done = tasks.iter().filter(|t| t.status.is_done()).count();
781 let remaining = total.saturating_sub(done);
782 ProgressInfo {
783 total,
784 complete,
785 shelved,
786 in_progress,
787 pending,
788 remaining,
789 }
790}
791
792pub fn tasks_path(ito_path: &Path, change_id: &str) -> PathBuf {
794 ito_path.join("changes").join(change_id).join("tasks.md")
795}