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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/mcp/handlers/get_task.rs +3 -0
- package/src/mcp/handlers/import_context.rs +29 -0
- package/src/mcp/handlers/list_tasks.rs +3 -0
- package/src/mcp/handlers/update_task.rs +47 -0
- package/src/mcp/tools.rs +20 -0
- package/src/storage/tasks.rs +162 -1
- package/tests/storage_tasks.rs +3 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "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
|
+
}
|
|
@@ -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
|
},
|
package/src/storage/tasks.rs
CHANGED
|
@@ -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:
|
|
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 {
|