handoff-mcp-server 0.3.0 → 0.4.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.
package/Cargo.lock CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.3.0"
147
+ version = "0.4.0"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -34,6 +34,9 @@ pub fn handle(arguments: &Value) -> Result<String> {
34
34
  "labels": data.labels,
35
35
  "links": data.links,
36
36
  "done_criteria": data.done_criteria,
37
+ "schedule": data.schedule,
38
+ "dependencies": data.dependencies,
39
+ "order": data.order,
37
40
  });
38
41
 
39
42
  serde_json::to_string_pretty(&result).map_err(Into::into)
@@ -235,6 +235,12 @@ fn create_task_recursive(
235
235
  labels: extract_string_array_from(task_val, "labels"),
236
236
  links: extract_string_array_from(task_val, "links"),
237
237
  done_criteria: extract_done_criteria(task_val),
238
+ schedule: extract_schedule(task_val),
239
+ dependencies: extract_string_array_from(task_val, "dependencies"),
240
+ order: task_val
241
+ .get("order")
242
+ .and_then(|v| v.as_u64())
243
+ .map(|v| v as u32),
238
244
  };
239
245
 
240
246
  write_task(&task_dir, status, &data)?;
@@ -308,3 +314,26 @@ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
308
314
  })
309
315
  .unwrap_or_default()
310
316
  }
317
+
318
+ fn extract_schedule(val: &Value) -> Option<Schedule> {
319
+ let sched = val.get("schedule")?;
320
+ if sched.is_null() {
321
+ return None;
322
+ }
323
+ Some(Schedule {
324
+ start_date: sched
325
+ .get("start_date")
326
+ .and_then(|v| v.as_str())
327
+ .map(String::from),
328
+ due_date: sched
329
+ .get("due_date")
330
+ .and_then(|v| v.as_str())
331
+ .map(String::from),
332
+ estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
333
+ actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
334
+ milestone: sched
335
+ .get("milestone")
336
+ .and_then(|v| v.as_str())
337
+ .map(String::from),
338
+ })
339
+ }
@@ -51,6 +51,9 @@ fn filter_tree(
51
51
  id: node.id.clone(),
52
52
  title: node.title.clone(),
53
53
  status: node.status.clone(),
54
+ schedule: node.schedule.clone(),
55
+ dependencies: node.dependencies.clone(),
56
+ order: node.order,
54
57
  children,
55
58
  })
56
59
  } else {
@@ -74,6 +74,11 @@ fn handle_create(
74
74
  let priority = task_val.get("priority").and_then(|v| v.as_str());
75
75
  validate_priority(priority)?;
76
76
 
77
+ let dependencies = extract_string_array(task_val, "dependencies");
78
+ if !dependencies.is_empty() {
79
+ validate_dependencies(tasks_dir, &new_id, &dependencies)?;
80
+ }
81
+
77
82
  let data = TaskData {
78
83
  id: new_id.clone(),
79
84
  title: title.to_string(),
@@ -88,6 +93,12 @@ fn handle_create(
88
93
  labels: extract_string_array(task_val, "labels"),
89
94
  links: extract_string_array(task_val, "links"),
90
95
  done_criteria: extract_done_criteria(task_val),
96
+ schedule: extract_schedule(task_val),
97
+ dependencies,
98
+ order: task_val
99
+ .get("order")
100
+ .and_then(|v| v.as_u64())
101
+ .map(|v| v as u32),
91
102
  };
92
103
 
93
104
  write_task(&task_dir, status, &data)?;
@@ -121,6 +132,19 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
121
132
  if task_val.get("done_criteria").is_some() {
122
133
  data.done_criteria = extract_done_criteria(task_val);
123
134
  }
135
+ if task_val.get("schedule").is_some() {
136
+ data.schedule = extract_schedule(task_val);
137
+ }
138
+ if task_val.get("dependencies").is_some() {
139
+ let new_deps = extract_string_array(task_val, "dependencies");
140
+ if !new_deps.is_empty() {
141
+ validate_dependencies(tasks_dir, task_id, &new_deps)?;
142
+ }
143
+ data.dependencies = new_deps;
144
+ }
145
+ if let Some(order) = task_val.get("order").and_then(|v| v.as_u64()) {
146
+ data.order = Some(order as u32);
147
+ }
124
148
 
125
149
  let new_status = task_val
126
150
  .get("status")
@@ -206,3 +230,26 @@ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
206
230
  })
207
231
  .unwrap_or_default()
208
232
  }
233
+
234
+ fn extract_schedule(val: &Value) -> Option<Schedule> {
235
+ let sched = val.get("schedule")?;
236
+ if sched.is_null() {
237
+ return None;
238
+ }
239
+ Some(Schedule {
240
+ start_date: sched
241
+ .get("start_date")
242
+ .and_then(|v| v.as_str())
243
+ .map(String::from),
244
+ due_date: sched
245
+ .get("due_date")
246
+ .and_then(|v| v.as_str())
247
+ .map(String::from),
248
+ estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
249
+ actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
250
+ milestone: sched
251
+ .get("milestone")
252
+ .and_then(|v| v.as_str())
253
+ .map(String::from),
254
+ })
255
+ }
package/src/mcp/tools.rs CHANGED
@@ -242,6 +242,26 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
242
242
  },
243
243
  "required": ["item"]
244
244
  }
245
+ },
246
+ "schedule": {
247
+ "type": "object",
248
+ "description": "Schedule and effort tracking.",
249
+ "properties": {
250
+ "start_date": { "type": "string", "description": "YYYY-MM-DD" },
251
+ "due_date": { "type": "string", "description": "YYYY-MM-DD" },
252
+ "estimate_hours": { "type": "number" },
253
+ "actual_hours": { "type": "number" },
254
+ "milestone": { "type": "string" }
255
+ }
256
+ },
257
+ "dependencies": {
258
+ "type": "array",
259
+ "description": "Task IDs this task depends on. Circular dependencies are rejected.",
260
+ "items": { "type": "string" }
261
+ },
262
+ "order": {
263
+ "type": "integer",
264
+ "description": "Display order among siblings. 0-based, lower = higher priority."
245
265
  }
246
266
  },
247
267
  },
@@ -1,6 +1,8 @@
1
+ use std::collections::{HashMap, HashSet};
1
2
  use std::path::{Path, PathBuf};
2
3
 
3
4
  use anyhow::{Context, Result};
5
+ use chrono::Utc;
4
6
  use serde::{Deserialize, Serialize};
5
7
 
6
8
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -23,6 +25,26 @@ pub struct TaskData {
23
25
  pub links: Vec<String>,
24
26
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
25
27
  pub done_criteria: Vec<DoneCriterion>,
28
+ #[serde(default, skip_serializing_if = "Option::is_none")]
29
+ pub schedule: Option<Schedule>,
30
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
31
+ pub dependencies: Vec<String>,
32
+ #[serde(default, skip_serializing_if = "Option::is_none")]
33
+ pub order: Option<u32>,
34
+ }
35
+
36
+ #[derive(Debug, Clone, Serialize, Deserialize)]
37
+ pub struct Schedule {
38
+ #[serde(default, skip_serializing_if = "Option::is_none")]
39
+ pub start_date: Option<String>,
40
+ #[serde(default, skip_serializing_if = "Option::is_none")]
41
+ pub due_date: Option<String>,
42
+ #[serde(default, skip_serializing_if = "Option::is_none")]
43
+ pub estimate_hours: Option<f64>,
44
+ #[serde(default, skip_serializing_if = "Option::is_none")]
45
+ pub actual_hours: Option<f64>,
46
+ #[serde(default, skip_serializing_if = "Option::is_none")]
47
+ pub milestone: Option<String>,
26
48
  }
27
49
 
28
50
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -37,6 +59,12 @@ pub struct TaskIndex {
37
59
  pub id: String,
38
60
  pub title: String,
39
61
  pub status: String,
62
+ #[serde(default, skip_serializing_if = "Option::is_none")]
63
+ pub schedule: Option<Schedule>,
64
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
65
+ pub dependencies: Vec<String>,
66
+ #[serde(default, skip_serializing_if = "Option::is_none")]
67
+ pub order: Option<u32>,
40
68
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
41
69
  pub children: Vec<TaskIndex>,
42
70
  }
@@ -45,6 +73,14 @@ pub struct TaskIndex {
45
73
  pub struct TaskSummary {
46
74
  pub total: u32,
47
75
  pub by_status: std::collections::HashMap<String, u32>,
76
+ #[serde(default, skip_serializing_if = "Option::is_none")]
77
+ pub total_estimate_hours: Option<f64>,
78
+ #[serde(default, skip_serializing_if = "Option::is_none")]
79
+ pub total_actual_hours: Option<f64>,
80
+ #[serde(default, skip_serializing_if = "Option::is_none")]
81
+ pub completion_rate: Option<f64>,
82
+ #[serde(default)]
83
+ pub overdue_count: u32,
48
84
  }
49
85
 
50
86
  const VALID_STATUSES: &[&str] = &[
@@ -269,9 +305,17 @@ pub fn build_task_index(
269
305
  let mut tree = Vec::new();
270
306
  let mut summary = TaskSummary {
271
307
  total: 0,
272
- by_status: std::collections::HashMap::new(),
308
+ by_status: HashMap::new(),
309
+ total_estimate_hours: None,
310
+ total_actual_hours: None,
311
+ completion_rate: None,
312
+ overdue_count: 0,
273
313
  };
274
314
  let mut done_count: u32 = 0;
315
+ let mut estimate_sum: f64 = 0.0;
316
+ let mut actual_sum: f64 = 0.0;
317
+ let mut has_hours = false;
318
+ let today = Utc::now().format("%Y-%m-%d").to_string();
275
319
 
276
320
  build_index_recursive(
277
321
  tasks_dir,
@@ -279,17 +323,37 @@ pub fn build_task_index(
279
323
  &mut summary,
280
324
  &mut done_count,
281
325
  done_task_limit,
326
+ &mut estimate_sum,
327
+ &mut actual_sum,
328
+ &mut has_hours,
329
+ &today,
282
330
  )?;
283
331
 
332
+ if has_hours {
333
+ summary.total_estimate_hours = Some(estimate_sum);
334
+ summary.total_actual_hours = Some(actual_sum);
335
+ }
336
+
337
+ if summary.total > 0 {
338
+ let done = *summary.by_status.get("done").unwrap_or(&0) as f64;
339
+ let skipped = *summary.by_status.get("skipped").unwrap_or(&0) as f64;
340
+ summary.completion_rate = Some((done + skipped) / summary.total as f64);
341
+ }
342
+
284
343
  Ok((tree, summary))
285
344
  }
286
345
 
346
+ #[allow(clippy::too_many_arguments)]
287
347
  fn build_index_recursive(
288
348
  dir: &Path,
289
349
  tree: &mut Vec<TaskIndex>,
290
350
  summary: &mut TaskSummary,
291
351
  done_count: &mut u32,
292
352
  done_task_limit: u32,
353
+ estimate_sum: &mut f64,
354
+ actual_sum: &mut f64,
355
+ has_hours: &mut bool,
356
+ today: &str,
293
357
  ) -> Result<()> {
294
358
  if !dir.exists() {
295
359
  return Ok(());
@@ -316,6 +380,22 @@ fn build_index_recursive(
316
380
  summary.total += 1;
317
381
  *summary.by_status.entry(status.clone()).or_insert(0) += 1;
318
382
 
383
+ if let Some(ref sched) = data.schedule {
384
+ if let Some(est) = sched.estimate_hours {
385
+ *estimate_sum += est;
386
+ *has_hours = true;
387
+ }
388
+ if let Some(act) = sched.actual_hours {
389
+ *actual_sum += act;
390
+ *has_hours = true;
391
+ }
392
+ if let Some(ref due) = sched.due_date {
393
+ if !is_terminal_status(&status) && due.as_str() < today {
394
+ summary.overdue_count += 1;
395
+ }
396
+ }
397
+ }
398
+
319
399
  if is_terminal_status(&status) {
320
400
  *done_count += 1;
321
401
  if *done_count > done_task_limit {
@@ -330,12 +410,19 @@ fn build_index_recursive(
330
410
  summary,
331
411
  done_count,
332
412
  done_task_limit,
413
+ estimate_sum,
414
+ actual_sum,
415
+ has_hours,
416
+ today,
333
417
  )?;
334
418
 
335
419
  tree.push(TaskIndex {
336
420
  id: data.id,
337
421
  title: data.title,
338
422
  status,
423
+ schedule: data.schedule,
424
+ dependencies: data.dependencies,
425
+ order: data.order,
339
426
  children,
340
427
  });
341
428
  }
@@ -343,6 +430,80 @@ fn build_index_recursive(
343
430
  Ok(())
344
431
  }
345
432
 
433
+ pub fn validate_dependencies(tasks_dir: &Path, task_id: &str, new_deps: &[String]) -> Result<()> {
434
+ let dep_graph = build_dependency_graph(tasks_dir)?;
435
+
436
+ let mut graph = dep_graph;
437
+ graph.insert(task_id.to_string(), new_deps.to_vec());
438
+
439
+ let mut visited = HashSet::new();
440
+ let mut stack = HashSet::new();
441
+
442
+ if has_cycle(&graph, task_id, &mut visited, &mut stack) {
443
+ anyhow::bail!(
444
+ "Circular dependency detected: setting dependencies {:?} on task {task_id} would create a cycle",
445
+ new_deps
446
+ );
447
+ }
448
+
449
+ Ok(())
450
+ }
451
+
452
+ fn build_dependency_graph(tasks_dir: &Path) -> Result<HashMap<String, Vec<String>>> {
453
+ let mut graph = HashMap::new();
454
+ build_dep_graph_recursive(tasks_dir, &mut graph)?;
455
+ Ok(graph)
456
+ }
457
+
458
+ fn build_dep_graph_recursive(dir: &Path, graph: &mut HashMap<String, Vec<String>>) -> Result<()> {
459
+ if !dir.exists() {
460
+ return Ok(());
461
+ }
462
+ for entry in std::fs::read_dir(dir)? {
463
+ let entry = entry?;
464
+ if !entry.file_type()?.is_dir() {
465
+ continue;
466
+ }
467
+ let name = entry.file_name().to_string_lossy().to_string();
468
+ if name.starts_with('.') || name.starts_with('_') {
469
+ continue;
470
+ }
471
+ let task_dir = entry.path();
472
+ if let Some((data, _)) = read_task(&task_dir)? {
473
+ graph.insert(data.id.clone(), data.dependencies.clone());
474
+ build_dep_graph_recursive(&task_dir, graph)?;
475
+ }
476
+ }
477
+ Ok(())
478
+ }
479
+
480
+ fn has_cycle(
481
+ graph: &HashMap<String, Vec<String>>,
482
+ node: &str,
483
+ visited: &mut HashSet<String>,
484
+ stack: &mut HashSet<String>,
485
+ ) -> bool {
486
+ if stack.contains(node) {
487
+ return true;
488
+ }
489
+ if visited.contains(node) {
490
+ return false;
491
+ }
492
+ visited.insert(node.to_string());
493
+ stack.insert(node.to_string());
494
+
495
+ if let Some(deps) = graph.get(node) {
496
+ for dep in deps {
497
+ if has_cycle(graph, dep, visited, stack) {
498
+ return true;
499
+ }
500
+ }
501
+ }
502
+
503
+ stack.remove(node);
504
+ false
505
+ }
506
+
346
507
  pub fn validate_done_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
347
508
  for criterion in &data.done_criteria {
348
509
  if !criterion.checked {
@@ -24,6 +24,9 @@ fn make_task(id: &str, title: &str) -> TaskData {
24
24
  labels: Vec::new(),
25
25
  links: Vec::new(),
26
26
  done_criteria: Vec::new(),
27
+ schedule: None,
28
+ dependencies: Vec::new(),
29
+ order: None,
27
30
  }
28
31
  }
29
32