handoff-mcp-server 0.10.0 → 0.12.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.
@@ -4,6 +4,11 @@ use std::path::{Path, PathBuf};
4
4
  use anyhow::{Context, Result};
5
5
  use chrono::Utc;
6
6
  use serde::{Deserialize, Serialize};
7
+ use serde_json::Value;
8
+
9
+ fn is_empty_map(m: &HashMap<String, Value>) -> bool {
10
+ m.is_empty()
11
+ }
7
12
 
8
13
  #[derive(Debug, Clone, Serialize, Deserialize)]
9
14
  pub struct TaskData {
@@ -31,9 +36,13 @@ pub struct TaskData {
31
36
  pub dependencies: Vec<String>,
32
37
  #[serde(default, skip_serializing_if = "Option::is_none")]
33
38
  pub order: Option<u32>,
39
+ #[serde(default, skip_serializing_if = "Option::is_none")]
40
+ pub assignee: Option<String>,
41
+ #[serde(flatten, default, skip_serializing_if = "is_empty_map")]
42
+ pub extra: HashMap<String, Value>,
34
43
  }
35
44
 
36
- #[derive(Debug, Clone, Serialize, Deserialize)]
45
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
37
46
  pub struct Schedule {
38
47
  #[serde(default, skip_serializing_if = "Option::is_none")]
39
48
  pub start_date: Option<String>,
@@ -44,7 +53,11 @@ pub struct Schedule {
44
53
  #[serde(default, skip_serializing_if = "Option::is_none")]
45
54
  pub actual_hours: Option<f64>,
46
55
  #[serde(default, skip_serializing_if = "Option::is_none")]
56
+ pub remaining_hours: Option<f64>,
57
+ #[serde(default, skip_serializing_if = "Option::is_none")]
47
58
  pub milestone: Option<String>,
59
+ #[serde(default, skip_serializing_if = "Option::is_none")]
60
+ pub pinned: Option<bool>,
48
61
  }
49
62
 
50
63
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -65,6 +78,8 @@ pub struct TaskIndex {
65
78
  pub dependencies: Vec<String>,
66
79
  #[serde(default, skip_serializing_if = "Option::is_none")]
67
80
  pub order: Option<u32>,
81
+ #[serde(default, skip_serializing_if = "Option::is_none")]
82
+ pub assignee: Option<String>,
68
83
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
69
84
  pub children: Vec<TaskIndex>,
70
85
  }
@@ -190,11 +205,60 @@ pub fn read_task(task_dir: &Path) -> Result<Option<(TaskData, String)>> {
190
205
  pub fn write_task(task_dir: &Path, status: &str, data: &TaskData) -> Result<()> {
191
206
  let file_path = task_dir.join(format!("_task.{status}.json"));
192
207
  let content = serde_json::to_string_pretty(data).context("Failed to serialize task")?;
193
- std::fs::write(&file_path, content)
208
+ crate::storage::atomic_write(&file_path, content.as_bytes())
194
209
  .with_context(|| format!("Failed to write task: {}", file_path.display()))?;
195
210
  Ok(())
196
211
  }
197
212
 
213
+ /// Read-modify-write a task with optimistic concurrency control.
214
+ ///
215
+ /// Reads the current task, runs `mutate` on a copy, then re-reads just before
216
+ /// writing: if the file's `updated_at` changed since the snapshot (another
217
+ /// writer — e.g. the VSCode extension — won the race), the whole cycle retries
218
+ /// up to `MAX_RETRIES` times. This matches the VSCode side's `updated_at`
219
+ /// protocol (wiki/95-concurrency-safety.md) and prevents lost updates that
220
+ /// atomic_write alone cannot (atomic_write stops *torn* reads, not *lost*
221
+ /// updates).
222
+ ///
223
+ /// `mutate` receives the current `TaskData` and the resolved status, and returns
224
+ /// the new status the task should have after the change (usually unchanged).
225
+ pub fn read_modify_write_task<F>(task_dir: &Path, mut mutate: F) -> Result<()>
226
+ where
227
+ F: FnMut(&mut TaskData, &str) -> Result<String>,
228
+ {
229
+ const MAX_RETRIES: usize = 5;
230
+
231
+ for attempt in 0..=MAX_RETRIES {
232
+ let (mut data, status) = read_task(task_dir)?
233
+ .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
234
+ let snapshot_updated_at = data.updated_at.clone();
235
+
236
+ let new_status = mutate(&mut data, &status)?;
237
+
238
+ // Re-read to detect a concurrent writer before committing.
239
+ let current_updated_at = read_task(task_dir)?.and_then(|(d, _)| d.updated_at);
240
+ if current_updated_at != snapshot_updated_at {
241
+ // Someone else wrote between our read and write. Retry from scratch.
242
+ if attempt == MAX_RETRIES {
243
+ anyhow::bail!(
244
+ "Concurrent modification of task in {} after {} retries; aborting to avoid \
245
+ overwriting another writer's changes.",
246
+ task_dir.display(),
247
+ MAX_RETRIES
248
+ );
249
+ }
250
+ continue;
251
+ }
252
+
253
+ if new_status != status {
254
+ change_status(task_dir, &new_status)?;
255
+ }
256
+ write_task(task_dir, &new_status, &data)?;
257
+ return Ok(());
258
+ }
259
+ unreachable!("loop returns or bails within MAX_RETRIES iterations")
260
+ }
261
+
198
262
  pub fn change_status(task_dir: &Path, new_status: &str) -> Result<()> {
199
263
  if !is_valid_status(new_status) {
200
264
  anyhow::bail!("Invalid status: {new_status}");
@@ -423,6 +487,7 @@ fn build_index_recursive(
423
487
  schedule: data.schedule,
424
488
  dependencies: data.dependencies,
425
489
  order: data.order,
490
+ assignee: data.assignee,
426
491
  children,
427
492
  });
428
493
  }
@@ -525,6 +590,61 @@ pub fn validate_skipped_transition(task_dir: &Path, data: &TaskData) -> Result<(
525
590
  Ok(())
526
591
  }
527
592
 
593
+ /// Returns true if the task directory contains at least one child task
594
+ /// (a non-`_`/`.`-prefixed subdirectory holding a task file).
595
+ pub fn task_has_children(task_dir: &Path) -> Result<bool> {
596
+ if !task_dir.exists() {
597
+ return Ok(false);
598
+ }
599
+ for entry in std::fs::read_dir(task_dir)? {
600
+ let entry = entry?;
601
+ if !entry.file_type()?.is_dir() {
602
+ continue;
603
+ }
604
+ let name = entry.file_name().to_string_lossy().to_string();
605
+ if name.starts_with('_') || name.starts_with('.') {
606
+ continue;
607
+ }
608
+ if find_task_file(&entry.path())?.is_some() {
609
+ return Ok(true);
610
+ }
611
+ }
612
+ Ok(false)
613
+ }
614
+
615
+ /// Whether a task in the given status requires an effort estimate.
616
+ /// Parent tasks (with children) and blocked/skipped tasks are exempt;
617
+ /// this only covers the status dimension.
618
+ pub fn status_requires_estimate(status: &str) -> bool {
619
+ matches!(status, "todo" | "in_progress" | "review" | "done")
620
+ }
621
+
622
+ /// Validate that a leaf task carries an `estimate_hours` when the project
623
+ /// requires it. `has_children` lets the caller skip parent tasks.
624
+ /// Returns an error guiding the caller to add an estimate.
625
+ pub fn validate_estimate_required(
626
+ require_estimate_hours: bool,
627
+ status: &str,
628
+ has_children: bool,
629
+ schedule: Option<&Schedule>,
630
+ ) -> Result<()> {
631
+ if !require_estimate_hours || has_children || !status_requires_estimate(status) {
632
+ return Ok(());
633
+ }
634
+ let has_estimate = schedule
635
+ .and_then(|s| s.estimate_hours)
636
+ .is_some_and(|h| h > 0.0);
637
+ if !has_estimate {
638
+ anyhow::bail!(
639
+ "Task requires an effort estimate: set schedule.estimate_hours (hours, > 0) \
640
+ when creating or updating a leaf task in status '{status}'. \
641
+ Estimate the human-effort hours for this task. \
642
+ To disable this requirement project-wide, set settings.require_estimate_hours = false."
643
+ );
644
+ }
645
+ Ok(())
646
+ }
647
+
528
648
  fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
529
649
  if !task_dir.exists() {
530
650
  return Ok(());