handoff-mcp-server 0.8.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,498 @@
1
+ use std::collections::HashMap;
2
+
3
+ use anyhow::{Context, Result};
4
+ use chrono::{Datelike, Duration, NaiveDate, Utc};
5
+ use serde_json::{json, Value};
6
+ use toml_edit::DocumentMut;
7
+
8
+ use super::resolve_project_dir;
9
+ use crate::storage::ensure_handoff_exists;
10
+ use crate::storage::tasks::*;
11
+
12
+ pub fn handle(arguments: &Value) -> Result<String> {
13
+ let project_dir = resolve_project_dir(arguments)?;
14
+ let handoff = ensure_handoff_exists(&project_dir)?;
15
+ let config_path = handoff.join("config.toml");
16
+ let tasks_dir = handoff.join("tasks");
17
+
18
+ let dry_run = arguments
19
+ .get("dry_run")
20
+ .and_then(|v| v.as_bool())
21
+ .unwrap_or(true);
22
+ let assignee_filter = arguments.get("assignee_filter").and_then(|v| v.as_str());
23
+
24
+ let calendar = parse_project_calendar(&config_path)?;
25
+ let assignee_calendars = parse_assignee_calendars(&config_path)?;
26
+
27
+ let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
28
+
29
+ // Collect schedulable tasks (non-terminal, not pinned)
30
+ let mut schedulable: Vec<SchedulableTask> = Vec::new();
31
+ collect_schedulable(&tree, &tasks_dir, assignee_filter, &mut schedulable)?;
32
+
33
+ // Sort by dependencies then order
34
+ sort_by_deps(&mut schedulable);
35
+
36
+ // Schedule each task. The anchor defaults to today (UTC) but can be pinned
37
+ // via `start_date` for planning a future start (and for deterministic tests).
38
+ let start_date = match arguments.get("start_date").and_then(|v| v.as_str()) {
39
+ Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
40
+ .map_err(|_| anyhow::anyhow!("Invalid start_date '{s}' (expected YYYY-MM-DD)"))?,
41
+ None => {
42
+ let today = Utc::now().format("%Y-%m-%d").to_string();
43
+ NaiveDate::parse_from_str(&today, "%Y-%m-%d")?
44
+ }
45
+ };
46
+
47
+ let mut assignee_next_date: HashMap<String, NaiveDate> = HashMap::new();
48
+ let mut project_next_date = start_date;
49
+ let mut changes: Vec<Value> = Vec::new();
50
+
51
+ for task in &schedulable {
52
+ let cal = task
53
+ .assignee
54
+ .as_ref()
55
+ .and_then(|a| assignee_calendars.get(a))
56
+ .unwrap_or(&calendar);
57
+
58
+ let earliest = match &task.assignee {
59
+ Some(a) => assignee_next_date.get(a).copied().unwrap_or(start_date),
60
+ None => project_next_date,
61
+ };
62
+
63
+ // Also respect dependency completion dates
64
+ let dep_earliest = task
65
+ .dep_due_dates
66
+ .iter()
67
+ .filter_map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
68
+ .max()
69
+ .map(|d| d + Duration::days(1))
70
+ .unwrap_or(start_date);
71
+
72
+ let task_start = earliest.max(dep_earliest);
73
+
74
+ // Find next work day from task_start
75
+ let actual_start = next_work_day(task_start, cal);
76
+
77
+ // Calculate end date based on estimate_hours, drawing each day's capacity
78
+ // from the calendar's per-day hours (day_hours overrides).
79
+ let hours = task.estimate_hours.unwrap_or(8.0);
80
+ let actual_end = advance_by_hours(actual_start, hours, cal);
81
+
82
+ let new_start = actual_start.format("%Y-%m-%d").to_string();
83
+ let new_due = actual_end.format("%Y-%m-%d").to_string();
84
+
85
+ if task.old_start.as_deref() != Some(&new_start)
86
+ || task.old_due.as_deref() != Some(&new_due)
87
+ {
88
+ changes.push(json!({
89
+ "task_id": task.id,
90
+ "old_start": task.old_start,
91
+ "new_start": new_start,
92
+ "old_due": task.old_due,
93
+ "new_due": new_due,
94
+ }));
95
+ }
96
+
97
+ // Advance the next available date for this assignee
98
+ let next_available = actual_end + Duration::days(1);
99
+ match &task.assignee {
100
+ Some(a) => {
101
+ assignee_next_date.insert(a.clone(), next_available);
102
+ }
103
+ None => {
104
+ project_next_date = next_available;
105
+ }
106
+ }
107
+
108
+ // Apply if not dry_run
109
+ if !dry_run {
110
+ if let Some(task_dir) = find_task_dir_by_id(&tasks_dir, &task.id)? {
111
+ if let Some((mut data, status)) = read_task(&task_dir)? {
112
+ let schedule = data.schedule.get_or_insert_with(Default::default);
113
+ schedule.start_date = Some(new_start);
114
+ schedule.due_date = Some(new_due);
115
+ data.updated_at = Some(Utc::now().to_rfc3339());
116
+
117
+ if let Some((old_path, _)) = find_task_file(&task_dir)? {
118
+ std::fs::remove_file(&old_path)?;
119
+ }
120
+ write_task(&task_dir, &status, &data)?;
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // Summarize the assignee calendars that fed the computation (applied conditions).
127
+ let assignee_capacity: serde_json::Map<String, Value> = assignee_calendars
128
+ .iter()
129
+ .map(|(k, c)| {
130
+ (
131
+ k.clone(),
132
+ json!({
133
+ "work_hours_per_day": c.work_hours_per_day,
134
+ "closed_weekdays": c.closed_weekdays,
135
+ }),
136
+ )
137
+ })
138
+ .collect();
139
+
140
+ // When changes are actually applied, record them on the active session(s) so
141
+ // the decision is part of the audit trail, not just this response.
142
+ let mut decision_recorded_in = 0usize;
143
+ if !dry_run && !changes.is_empty() {
144
+ let assignees_touched: std::collections::BTreeSet<&str> = schedulable
145
+ .iter()
146
+ .filter_map(|t| t.assignee.as_deref())
147
+ .collect();
148
+ let summary = format!(
149
+ "Auto-scheduled {} task(s) across {} assignee(s) from {}",
150
+ changes.len(),
151
+ assignees_touched.len().max(1),
152
+ start_date.format("%Y-%m-%d")
153
+ );
154
+ let decision = json!({
155
+ "decision": summary,
156
+ "reason": "handoff_auto_schedule applied computed start/due dates",
157
+ "confidence": "confirmed",
158
+ });
159
+ let sessions_dir = handoff.join("sessions");
160
+ decision_recorded_in =
161
+ crate::storage::sessions::append_decision_to_active_sessions(&sessions_dir, decision)?;
162
+ }
163
+
164
+ let result = json!({
165
+ "dry_run": dry_run,
166
+ "scheduled_count": schedulable.len(),
167
+ "changed_count": changes.len(),
168
+ "changes": changes,
169
+ "decision_recorded_in_sessions": decision_recorded_in,
170
+ "calendar_config": {
171
+ "work_hours_per_day": calendar.work_hours_per_day,
172
+ "closed_weekdays": calendar.closed_weekdays,
173
+ "day_hours": calendar.day_hours,
174
+ },
175
+ "assignee_capacity": assignee_capacity,
176
+ });
177
+
178
+ serde_json::to_string_pretty(&result).map_err(Into::into)
179
+ }
180
+
181
+ struct SchedulableTask {
182
+ id: String,
183
+ assignee: Option<String>,
184
+ estimate_hours: Option<f64>,
185
+ old_start: Option<String>,
186
+ old_due: Option<String>,
187
+ dependencies: Vec<String>,
188
+ dep_due_dates: Vec<String>,
189
+ order: Option<u32>,
190
+ }
191
+
192
+ fn collect_schedulable(
193
+ tree: &[TaskIndex],
194
+ tasks_dir: &std::path::Path,
195
+ assignee_filter: Option<&str>,
196
+ result: &mut Vec<SchedulableTask>,
197
+ ) -> Result<()> {
198
+ for node in tree {
199
+ if is_terminal_status(&node.status) {
200
+ collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
201
+ continue;
202
+ }
203
+
204
+ // Check if pinned
205
+ let pinned = node
206
+ .schedule
207
+ .as_ref()
208
+ .and_then(|s| s.pinned)
209
+ .unwrap_or(false);
210
+ if pinned {
211
+ collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
212
+ continue;
213
+ }
214
+
215
+ // Check assignee filter
216
+ if let Some(filter) = assignee_filter {
217
+ if node.assignee.as_deref() != Some(filter) {
218
+ collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
219
+ continue;
220
+ }
221
+ }
222
+
223
+ // Get dependency due dates
224
+ let dep_due_dates: Vec<String> = node
225
+ .dependencies
226
+ .iter()
227
+ .filter_map(|dep_id| {
228
+ find_task_dir_by_id(tasks_dir, dep_id)
229
+ .ok()
230
+ .flatten()
231
+ .and_then(|dir| read_task(&dir).ok().flatten())
232
+ .and_then(|(data, _)| data.schedule.and_then(|s| s.due_date))
233
+ })
234
+ .collect();
235
+
236
+ result.push(SchedulableTask {
237
+ id: node.id.clone(),
238
+ assignee: node.assignee.clone(),
239
+ estimate_hours: node.schedule.as_ref().and_then(|s| s.estimate_hours),
240
+ old_start: node.schedule.as_ref().and_then(|s| s.start_date.clone()),
241
+ old_due: node.schedule.as_ref().and_then(|s| s.due_date.clone()),
242
+ dependencies: node.dependencies.clone(),
243
+ dep_due_dates,
244
+ order: node.order,
245
+ });
246
+
247
+ collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
248
+ }
249
+ Ok(())
250
+ }
251
+
252
+ fn sort_by_deps(tasks: &mut [SchedulableTask]) {
253
+ // Simple topological sort: tasks with no deps first, then by order
254
+ tasks.sort_by(|a, b| {
255
+ let a_has_deps = !a.dependencies.is_empty();
256
+ let b_has_deps = !b.dependencies.is_empty();
257
+ a_has_deps
258
+ .cmp(&b_has_deps)
259
+ .then_with(|| {
260
+ a.order
261
+ .unwrap_or(u32::MAX)
262
+ .cmp(&b.order.unwrap_or(u32::MAX))
263
+ })
264
+ .then_with(|| a.id.cmp(&b.id))
265
+ });
266
+ }
267
+
268
+ struct Calendar {
269
+ work_hours_per_day: f64,
270
+ closed_weekdays: Vec<u32>,
271
+ closed_dates: Vec<String>,
272
+ open_dates: Vec<String>,
273
+ /// Per-weekday-name or per-YYYY-MM-DD working-hour overrides.
274
+ day_hours: HashMap<String, f64>,
275
+ }
276
+
277
+ const WEEKDAY_NAMES: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
278
+
279
+ impl Calendar {
280
+ /// Effective working hours for a specific date. A date-specific override in
281
+ /// `day_hours` takes precedence over a weekday-name override, which takes
282
+ /// precedence over `work_hours_per_day`. Mirrors capacity.rs.
283
+ fn hours_for(&self, date: &NaiveDate) -> f64 {
284
+ let date_str = date.format("%Y-%m-%d").to_string();
285
+ if let Some(h) = self.day_hours.get(&date_str) {
286
+ return *h;
287
+ }
288
+ let weekday_num = weekday_index(date);
289
+ let name = WEEKDAY_NAMES[weekday_num as usize];
290
+ if let Some(h) = self.day_hours.get(name) {
291
+ return *h;
292
+ }
293
+ self.work_hours_per_day
294
+ }
295
+
296
+ fn is_work_day(&self, date: &NaiveDate) -> bool {
297
+ let date_str = date.format("%Y-%m-%d").to_string();
298
+
299
+ if self.closed_dates.contains(&date_str) {
300
+ return false;
301
+ }
302
+
303
+ if self.open_dates.contains(&date_str) {
304
+ return true;
305
+ }
306
+
307
+ !self.closed_weekdays.contains(&weekday_index(date))
308
+ }
309
+ }
310
+
311
+ fn weekday_index(date: &NaiveDate) -> u32 {
312
+ match date.weekday() {
313
+ chrono::Weekday::Sun => 0,
314
+ chrono::Weekday::Mon => 1,
315
+ chrono::Weekday::Tue => 2,
316
+ chrono::Weekday::Wed => 3,
317
+ chrono::Weekday::Thu => 4,
318
+ chrono::Weekday::Fri => 5,
319
+ chrono::Weekday::Sat => 6,
320
+ }
321
+ }
322
+
323
+ fn next_work_day(from: NaiveDate, cal: &Calendar) -> NaiveDate {
324
+ let mut date = from;
325
+ while !cal.is_work_day(&date) {
326
+ date += Duration::days(1);
327
+ }
328
+ date
329
+ }
330
+
331
+ /// Advance from `start` (assumed to be a work day) consuming `hours` of effort,
332
+ /// drawing each day's capacity from `cal.hours_for(date)`. Returns the last work
333
+ /// day the task occupies. Respects per-day hour overrides (day_hours), so a task
334
+ /// spanning a half-capacity Friday takes an extra day. (referral ref-...004309 §5)
335
+ fn advance_by_hours(start: NaiveDate, hours: f64, cal: &Calendar) -> NaiveDate {
336
+ let mut date = start;
337
+ // Consume the first day's capacity.
338
+ let mut remaining = hours - cal.hours_for(&date).max(0.0);
339
+ // Guard against a zero-capacity calendar (would otherwise loop forever).
340
+ let mut guard = 0;
341
+ while remaining > 1e-9 && guard < 10_000 {
342
+ date = next_work_day(date + Duration::days(1), cal);
343
+ remaining -= cal.hours_for(&date).max(0.0);
344
+ guard += 1;
345
+ }
346
+ date
347
+ }
348
+
349
+ fn parse_project_calendar(config_path: &std::path::Path) -> Result<Calendar> {
350
+ let mut cal = Calendar {
351
+ work_hours_per_day: 8.0,
352
+ closed_weekdays: vec![0, 6], // Sun, Sat
353
+ closed_dates: Vec::new(),
354
+ open_dates: Vec::new(),
355
+ day_hours: HashMap::new(),
356
+ };
357
+
358
+ if !config_path.exists() {
359
+ return Ok(cal);
360
+ }
361
+
362
+ let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config")?;
363
+ let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config")?;
364
+
365
+ if let Some(calendar) = doc.get("calendar").and_then(|v| v.as_table()) {
366
+ if let Some(h) = calendar
367
+ .get("work_hours_per_day")
368
+ .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
369
+ {
370
+ cal.work_hours_per_day = h;
371
+ }
372
+ if let Some(arr) = calendar.get("closed_weekdays").and_then(|v| v.as_array()) {
373
+ cal.closed_weekdays = arr
374
+ .iter()
375
+ .filter_map(|v| {
376
+ v.as_integer()
377
+ .map(|i| i as u32)
378
+ .or_else(|| v.as_str().and_then(weekday_to_num))
379
+ })
380
+ .collect();
381
+ }
382
+ if let Some(arr) = calendar.get("closed_dates").and_then(|v| v.as_array()) {
383
+ cal.closed_dates = arr
384
+ .iter()
385
+ .filter_map(|v| v.as_str().map(String::from))
386
+ .collect();
387
+ }
388
+ if let Some(arr) = calendar.get("open_dates").and_then(|v| v.as_array()) {
389
+ cal.open_dates = arr
390
+ .iter()
391
+ .filter_map(|v| v.as_str().map(String::from))
392
+ .collect();
393
+ }
394
+ if let Some(dh) = calendar.get("day_hours").and_then(|v| v.as_table()) {
395
+ cal.day_hours = parse_day_hours(dh);
396
+ }
397
+ }
398
+
399
+ Ok(cal)
400
+ }
401
+
402
+ /// Parse a `[*.day_hours]` table into a map of weekday-name/date -> hours.
403
+ fn parse_day_hours(table: &toml_edit::Table) -> HashMap<String, f64> {
404
+ let mut out = HashMap::new();
405
+ for (key, item) in table.iter() {
406
+ if let Some(h) = item
407
+ .as_value()
408
+ .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
409
+ {
410
+ out.insert(key.to_string(), h);
411
+ }
412
+ }
413
+ out
414
+ }
415
+
416
+ fn parse_assignee_calendars(config_path: &std::path::Path) -> Result<HashMap<String, Calendar>> {
417
+ let mut result = HashMap::new();
418
+
419
+ if !config_path.exists() {
420
+ return Ok(result);
421
+ }
422
+
423
+ let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config")?;
424
+ let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config")?;
425
+
426
+ let base = parse_project_calendar(config_path)?;
427
+
428
+ if let Some(assignees) = doc.get("assignees").and_then(|v| v.as_table()) {
429
+ for (key, item) in assignees.iter() {
430
+ let a = match item.as_table() {
431
+ Some(t) => t,
432
+ None => continue,
433
+ };
434
+
435
+ let mut cal = Calendar {
436
+ work_hours_per_day: base.work_hours_per_day,
437
+ closed_weekdays: base.closed_weekdays.clone(),
438
+ closed_dates: base.closed_dates.clone(),
439
+ open_dates: base.open_dates.clone(),
440
+ day_hours: base.day_hours.clone(),
441
+ };
442
+
443
+ if let Some(h) = a
444
+ .get("work_hours_per_day")
445
+ .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
446
+ {
447
+ cal.work_hours_per_day = h;
448
+ }
449
+ if let Some(arr) = a.get("closed_weekdays").and_then(|v| v.as_array()) {
450
+ cal.closed_weekdays = arr
451
+ .iter()
452
+ .filter_map(|v| {
453
+ v.as_integer()
454
+ .map(|i| i as u32)
455
+ .or_else(|| v.as_str().and_then(weekday_to_num))
456
+ })
457
+ .collect();
458
+ }
459
+ if let Some(arr) = a.get("closed_dates").and_then(|v| v.as_array()) {
460
+ for item in arr.iter() {
461
+ if let Some(s) = item.as_str() {
462
+ cal.closed_dates.push(s.to_string());
463
+ }
464
+ }
465
+ }
466
+ if let Some(arr) = a.get("open_dates").and_then(|v| v.as_array()) {
467
+ for item in arr.iter() {
468
+ if let Some(s) = item.as_str() {
469
+ cal.open_dates.push(s.to_string());
470
+ }
471
+ }
472
+ }
473
+ // Per-assignee day_hours override the inherited project values key-by-key.
474
+ if let Some(dh) = a.get("day_hours").and_then(|v| v.as_table()) {
475
+ for (k, v) in parse_day_hours(dh) {
476
+ cal.day_hours.insert(k, v);
477
+ }
478
+ }
479
+
480
+ result.insert(key.to_string(), cal);
481
+ }
482
+ }
483
+
484
+ Ok(result)
485
+ }
486
+
487
+ fn weekday_to_num(s: &str) -> Option<u32> {
488
+ match s.to_lowercase().as_str() {
489
+ "sun" | "sunday" => Some(0),
490
+ "mon" | "monday" => Some(1),
491
+ "tue" | "tuesday" => Some(2),
492
+ "wed" | "wednesday" => Some(3),
493
+ "thu" | "thursday" => Some(4),
494
+ "fri" | "friday" => Some(5),
495
+ "sat" | "saturday" => Some(6),
496
+ _ => None,
497
+ }
498
+ }
@@ -0,0 +1,115 @@
1
+ use anyhow::Result;
2
+ use chrono::Utc;
3
+ use serde_json::{json, Value};
4
+
5
+ use super::resolve_project_dir;
6
+ use crate::storage::ensure_handoff_exists;
7
+ use crate::storage::tasks::*;
8
+
9
+ pub fn handle(arguments: &Value) -> Result<String> {
10
+ let project_dir = resolve_project_dir(arguments)?;
11
+ let handoff = ensure_handoff_exists(&project_dir)?;
12
+ let tasks_dir = handoff.join("tasks");
13
+
14
+ let updates = arguments
15
+ .get("updates")
16
+ .and_then(|v| v.as_array())
17
+ .ok_or_else(|| anyhow::anyhow!("'updates' parameter is required (array)"))?;
18
+
19
+ let mut applied: u32 = 0;
20
+ let mut errors: Vec<Value> = Vec::new();
21
+
22
+ for update in updates {
23
+ let task_id = match update.get("task_id").and_then(|v| v.as_str()) {
24
+ Some(id) => id,
25
+ None => {
26
+ errors.push(json!({"task_id": null, "error": "missing task_id"}));
27
+ continue;
28
+ }
29
+ };
30
+
31
+ if let Err(e) = apply_single_update(&tasks_dir, task_id, update) {
32
+ errors.push(json!({"task_id": task_id, "error": e.to_string()}));
33
+ } else {
34
+ applied += 1;
35
+ }
36
+ }
37
+
38
+ let result = json!({
39
+ "applied": applied,
40
+ "errors": errors,
41
+ });
42
+
43
+ serde_json::to_string_pretty(&result).map_err(Into::into)
44
+ }
45
+
46
+ fn apply_single_update(tasks_dir: &std::path::Path, task_id: &str, update: &Value) -> Result<()> {
47
+ let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
48
+ .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
49
+
50
+ let (mut data, current_status) = read_task(&task_dir)?
51
+ .ok_or_else(|| anyhow::anyhow!("Task file not found for {task_id}"))?;
52
+
53
+ if let Some(priority) = update.get("priority").and_then(|v| v.as_str()) {
54
+ validate_priority(Some(priority))?;
55
+ data.priority = Some(priority.to_string());
56
+ }
57
+
58
+ if update.get("assignee").is_some() {
59
+ data.assignee = update
60
+ .get("assignee")
61
+ .and_then(|v| v.as_str())
62
+ .map(String::from);
63
+ }
64
+
65
+ if let Some(sched_val) = update.get("schedule") {
66
+ let schedule = data.schedule.get_or_insert_with(Default::default);
67
+ if let Some(sd) = sched_val.get("start_date").and_then(|v| v.as_str()) {
68
+ schedule.start_date = Some(sd.to_string());
69
+ }
70
+ if let Some(dd) = sched_val.get("due_date").and_then(|v| v.as_str()) {
71
+ schedule.due_date = Some(dd.to_string());
72
+ }
73
+ if let Some(eh) = sched_val.get("estimate_hours").and_then(|v| v.as_f64()) {
74
+ schedule.estimate_hours = Some(eh);
75
+ }
76
+ if let Some(ah) = sched_val.get("actual_hours").and_then(|v| v.as_f64()) {
77
+ schedule.actual_hours = Some(ah);
78
+ }
79
+ if let Some(rh) = sched_val.get("remaining_hours").and_then(|v| v.as_f64()) {
80
+ schedule.remaining_hours = Some(rh);
81
+ }
82
+ if let Some(ms) = sched_val.get("milestone").and_then(|v| v.as_str()) {
83
+ schedule.milestone = Some(ms.to_string());
84
+ }
85
+ if let Some(p) = sched_val.get("pinned").and_then(|v| v.as_bool()) {
86
+ schedule.pinned = Some(p);
87
+ }
88
+ }
89
+
90
+ let new_status = update
91
+ .get("status")
92
+ .and_then(|v| v.as_str())
93
+ .unwrap_or(&current_status);
94
+
95
+ if !is_valid_status(new_status) {
96
+ anyhow::bail!("Invalid status: {new_status}");
97
+ }
98
+
99
+ if new_status == "done" && current_status != "done" {
100
+ validate_done_transition(&task_dir, &data)?;
101
+ data.completed_at = Some(Utc::now().to_rfc3339());
102
+ }
103
+ if new_status == "skipped" && current_status != "skipped" {
104
+ validate_skipped_transition(&task_dir, &data)?;
105
+ }
106
+
107
+ data.updated_at = Some(Utc::now().to_rfc3339());
108
+
109
+ if let Some((old_path, _)) = find_task_file(&task_dir)? {
110
+ std::fs::remove_file(&old_path)?;
111
+ }
112
+ write_task(&task_dir, new_status, &data)?;
113
+
114
+ Ok(())
115
+ }