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.
- package/Cargo.lock +2 -1
- package/Cargo.toml +2 -1
- package/README.md +112 -32
- package/package.json +2 -1
- package/skills/handoff/SKILL.md +230 -0
- package/skills/handoff-import/SKILL.md +238 -0
- package/skills/handoff-load/SKILL.md +37 -0
- package/skills/handoff-refer/SKILL.md +71 -0
- package/src/mcp/handlers/assignees.rs +254 -0
- package/src/mcp/handlers/auto_schedule.rs +498 -0
- package/src/mcp/handlers/bulk_update.rs +115 -0
- package/src/mcp/handlers/calendar.rs +196 -0
- package/src/mcp/handlers/capacity.rs +331 -0
- package/src/mcp/handlers/config.rs +229 -67
- package/src/mcp/handlers/config_crud.rs +183 -0
- package/src/mcp/handlers/get_session.rs +48 -0
- package/src/mcp/handlers/get_task.rs +1 -0
- package/src/mcp/handlers/import_context.rs +7 -0
- package/src/mcp/handlers/list_sessions.rs +129 -0
- package/src/mcp/handlers/list_tasks.rs +76 -10
- package/src/mcp/handlers/load_context.rs +16 -0
- package/src/mcp/handlers/log_time.rs +70 -0
- package/src/mcp/handlers/metrics.rs +196 -0
- package/src/mcp/handlers/milestones.rs +102 -0
- package/src/mcp/handlers/mod.rs +30 -0
- package/src/mcp/handlers/referrals.rs +20 -1
- package/src/mcp/handlers/update_session.rs +1 -1
- package/src/mcp/handlers/update_task.rs +99 -6
- package/src/mcp/tools.rs +357 -3
- package/src/storage/config.rs +162 -1
- package/src/storage/mod.rs +42 -0
- package/src/storage/referrals.rs +39 -1
- package/src/storage/sessions.rs +95 -22
- package/src/storage/tasks.rs +122 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use serde_json::{json, Value};
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::ensure_handoff_exists;
|
|
6
|
+
|
|
7
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
8
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
9
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
10
|
+
let sessions_dir = handoff.join("sessions");
|
|
11
|
+
|
|
12
|
+
let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
|
|
13
|
+
let limit = arguments
|
|
14
|
+
.get("limit")
|
|
15
|
+
.and_then(|v| v.as_u64())
|
|
16
|
+
.unwrap_or(20) as usize;
|
|
17
|
+
|
|
18
|
+
if !sessions_dir.exists() {
|
|
19
|
+
return serde_json::to_string_pretty(&json!([])).map_err(Into::into);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let mut sessions: Vec<Value> = Vec::new();
|
|
23
|
+
|
|
24
|
+
for entry in std::fs::read_dir(&sessions_dir)
|
|
25
|
+
.with_context(|| format!("Failed to read sessions dir: {}", sessions_dir.display()))?
|
|
26
|
+
{
|
|
27
|
+
let entry = entry?;
|
|
28
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
29
|
+
if !name.ends_with(".json") {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let status = extract_session_status(&name);
|
|
34
|
+
if let Some(filter) = status_filter {
|
|
35
|
+
if status != filter {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let content = match std::fs::read_to_string(entry.path()) {
|
|
41
|
+
Ok(c) => c,
|
|
42
|
+
Err(_) => continue,
|
|
43
|
+
};
|
|
44
|
+
let data: Value = match serde_json::from_str(&content) {
|
|
45
|
+
Ok(d) => d,
|
|
46
|
+
Err(_) => continue,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let id = data
|
|
50
|
+
.get("id")
|
|
51
|
+
.and_then(|v| v.as_str())
|
|
52
|
+
.map(String::from)
|
|
53
|
+
.unwrap_or_else(|| synthesize_id_from_filename(&name));
|
|
54
|
+
|
|
55
|
+
let summary = data.get("summary").and_then(|v| v.as_str()).unwrap_or("");
|
|
56
|
+
let started_at = data.get("started_at").and_then(|v| v.as_str());
|
|
57
|
+
let ended_at = data.get("ended_at").and_then(|v| v.as_str());
|
|
58
|
+
let branch = data.get("branch").and_then(|v| v.as_str());
|
|
59
|
+
let commit = data.get("commit").and_then(|v| v.as_str());
|
|
60
|
+
|
|
61
|
+
let decisions_count = data
|
|
62
|
+
.get("decisions")
|
|
63
|
+
.and_then(|v| v.as_array())
|
|
64
|
+
.map(|a| a.len())
|
|
65
|
+
.unwrap_or(0);
|
|
66
|
+
|
|
67
|
+
let checklist = data.get("checklist").and_then(|v| v.as_array());
|
|
68
|
+
let checklist_count = checklist.map(|a| a.len()).unwrap_or(0);
|
|
69
|
+
let checklist_checked = checklist
|
|
70
|
+
.map(|a| {
|
|
71
|
+
a.iter()
|
|
72
|
+
.filter(|item| {
|
|
73
|
+
item.get("checked")
|
|
74
|
+
.and_then(|v| v.as_bool())
|
|
75
|
+
.unwrap_or(false)
|
|
76
|
+
})
|
|
77
|
+
.count()
|
|
78
|
+
})
|
|
79
|
+
.unwrap_or(0);
|
|
80
|
+
|
|
81
|
+
sessions.push(json!({
|
|
82
|
+
"id": id,
|
|
83
|
+
"status": status,
|
|
84
|
+
"summary": summary,
|
|
85
|
+
"started_at": started_at,
|
|
86
|
+
"ended_at": ended_at,
|
|
87
|
+
"branch": branch,
|
|
88
|
+
"commit": commit,
|
|
89
|
+
"decisions_count": decisions_count,
|
|
90
|
+
"checklist_progress": format!("{}/{}", checklist_checked, checklist_count),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
sessions.sort_by(|a, b| {
|
|
95
|
+
let a_time = a.get("ended_at").and_then(|v| v.as_str()).unwrap_or("");
|
|
96
|
+
let b_time = b.get("ended_at").and_then(|v| v.as_str()).unwrap_or("");
|
|
97
|
+
b_time.cmp(a_time)
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
sessions.truncate(limit);
|
|
101
|
+
|
|
102
|
+
serde_json::to_string_pretty(&sessions).map_err(Into::into)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn extract_session_status(filename: &str) -> &str {
|
|
106
|
+
let name = filename.strip_suffix(".json").unwrap_or(filename);
|
|
107
|
+
if name.ends_with(".open") {
|
|
108
|
+
"open"
|
|
109
|
+
} else if name.ends_with(".active") {
|
|
110
|
+
"active"
|
|
111
|
+
} else if name.ends_with(".paused") {
|
|
112
|
+
"paused"
|
|
113
|
+
} else if name.ends_with(".closed") {
|
|
114
|
+
"closed"
|
|
115
|
+
} else {
|
|
116
|
+
"unknown"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn synthesize_id_from_filename(filename: &str) -> String {
|
|
121
|
+
let name = filename.strip_suffix(".json").unwrap_or(filename);
|
|
122
|
+
let base = name
|
|
123
|
+
.strip_suffix(".open")
|
|
124
|
+
.or_else(|| name.strip_suffix(".active"))
|
|
125
|
+
.or_else(|| name.strip_suffix(".paused"))
|
|
126
|
+
.or_else(|| name.strip_suffix(".closed"))
|
|
127
|
+
.unwrap_or(name);
|
|
128
|
+
format!("s-{}", &base[..std::cmp::min(base.len(), 20)])
|
|
129
|
+
}
|
|
@@ -4,7 +4,7 @@ use serde_json::Value;
|
|
|
4
4
|
use super::resolve_project_dir;
|
|
5
5
|
use crate::storage::config::read_config;
|
|
6
6
|
use crate::storage::ensure_handoff_exists;
|
|
7
|
-
use crate::storage::tasks::build_task_index;
|
|
7
|
+
use crate::storage::tasks::{build_task_index, TaskIndex};
|
|
8
8
|
|
|
9
9
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
10
10
|
let project_dir = resolve_project_dir(arguments)?;
|
|
@@ -24,9 +24,21 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
24
24
|
let (tree, summary) = build_task_index(&tasks_dir, done_task_limit)?;
|
|
25
25
|
|
|
26
26
|
let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
|
|
27
|
+
let assignee_filter = arguments.get("assignee_filter").and_then(|v| v.as_str());
|
|
28
|
+
let milestone_filter = arguments.get("milestone_filter").and_then(|v| v.as_str());
|
|
29
|
+
let priority_filter = arguments.get("priority_filter").and_then(|v| v.as_str());
|
|
30
|
+
let label_filter = arguments.get("label_filter").and_then(|v| v.as_str());
|
|
27
31
|
|
|
28
|
-
let
|
|
29
|
-
|
|
32
|
+
let filters = Filters {
|
|
33
|
+
status: status_filter,
|
|
34
|
+
assignee: assignee_filter,
|
|
35
|
+
milestone: milestone_filter,
|
|
36
|
+
priority: priority_filter,
|
|
37
|
+
label: label_filter,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let filtered_tree = if filters.any_active() {
|
|
41
|
+
filter_tree(&tree, &filters)
|
|
30
42
|
} else {
|
|
31
43
|
tree
|
|
32
44
|
};
|
|
@@ -39,21 +51,75 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
39
51
|
serde_json::to_string_pretty(&result).context("Failed to serialize task list")
|
|
40
52
|
}
|
|
41
53
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
struct Filters<'a> {
|
|
55
|
+
status: Option<&'a str>,
|
|
56
|
+
assignee: Option<&'a str>,
|
|
57
|
+
milestone: Option<&'a str>,
|
|
58
|
+
priority: Option<&'a str>,
|
|
59
|
+
label: Option<&'a str>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl Filters<'_> {
|
|
63
|
+
fn any_active(&self) -> bool {
|
|
64
|
+
self.status.is_some()
|
|
65
|
+
|| self.assignee.is_some()
|
|
66
|
+
|| self.milestone.is_some()
|
|
67
|
+
|| self.priority.is_some()
|
|
68
|
+
|| self.label.is_some()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn matches(&self, node: &TaskIndex, data: Option<&crate::storage::tasks::TaskData>) -> bool {
|
|
72
|
+
if let Some(status) = self.status {
|
|
73
|
+
if node.status != status {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if let Some(assignee) = self.assignee {
|
|
78
|
+
if node.assignee.as_deref() != Some(assignee) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if let Some(milestone) = self.milestone {
|
|
83
|
+
let ms = node.schedule.as_ref().and_then(|s| s.milestone.as_deref());
|
|
84
|
+
if ms != Some(milestone) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if let Some(priority) = self.priority {
|
|
89
|
+
if let Some(d) = data {
|
|
90
|
+
if d.priority.as_deref() != Some(priority) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if let Some(label) = self.label {
|
|
98
|
+
if let Some(d) = data {
|
|
99
|
+
if !d.labels.iter().any(|l| l == label) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn filter_tree(tree: &[TaskIndex], filters: &Filters) -> Vec<TaskIndex> {
|
|
46
111
|
tree.iter()
|
|
47
112
|
.filter_map(|node| {
|
|
48
|
-
let children = filter_tree(&node.children,
|
|
49
|
-
if node
|
|
50
|
-
Some(
|
|
113
|
+
let children = filter_tree(&node.children, filters);
|
|
114
|
+
if filters.matches(node, None) || !children.is_empty() {
|
|
115
|
+
Some(TaskIndex {
|
|
51
116
|
id: node.id.clone(),
|
|
52
117
|
title: node.title.clone(),
|
|
53
118
|
status: node.status.clone(),
|
|
54
119
|
schedule: node.schedule.clone(),
|
|
55
120
|
dependencies: node.dependencies.clone(),
|
|
56
121
|
order: node.order,
|
|
122
|
+
assignee: node.assignee.clone(),
|
|
57
123
|
children,
|
|
58
124
|
})
|
|
59
125
|
} else {
|
|
@@ -209,6 +209,22 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
209
209
|
result["referrals"] = serde_json::to_value(&open_referrals)?;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
let current_open = read_open_sessions(&sessions_dir)?;
|
|
213
|
+
if !current_open.is_empty() {
|
|
214
|
+
let summaries: Vec<Value> = current_open
|
|
215
|
+
.iter()
|
|
216
|
+
.map(|s| {
|
|
217
|
+
serde_json::json!({
|
|
218
|
+
"id": s.id,
|
|
219
|
+
"summary": s.summary,
|
|
220
|
+
"ended_at": s.ended_at,
|
|
221
|
+
"branch": s.branch,
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
.collect();
|
|
225
|
+
result["open_sessions"] = serde_json::json!(summaries);
|
|
226
|
+
}
|
|
227
|
+
|
|
212
228
|
let current_paused = read_paused_sessions(&sessions_dir)?;
|
|
213
229
|
if !current_paused.is_empty() {
|
|
214
230
|
let summaries: Vec<Value> = current_paused
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
use std::cell::Cell;
|
|
2
|
+
|
|
3
|
+
use anyhow::Result;
|
|
4
|
+
use chrono::Utc;
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
|
|
7
|
+
use super::resolve_project_dir;
|
|
8
|
+
use crate::storage::ensure_handoff_exists;
|
|
9
|
+
use crate::storage::tasks::{find_task_dir_by_id, read_modify_write_task};
|
|
10
|
+
|
|
11
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
12
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
13
|
+
|
|
14
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
15
|
+
let tasks_dir = handoff.join("tasks");
|
|
16
|
+
|
|
17
|
+
let task_id = arguments
|
|
18
|
+
.get("task_id")
|
|
19
|
+
.and_then(|v| v.as_str())
|
|
20
|
+
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
21
|
+
|
|
22
|
+
let hours = arguments
|
|
23
|
+
.get("hours")
|
|
24
|
+
.and_then(|v| v.as_f64())
|
|
25
|
+
.ok_or_else(|| anyhow::anyhow!("'hours' parameter is required (number)"))?;
|
|
26
|
+
|
|
27
|
+
if hours <= 0.0 {
|
|
28
|
+
anyhow::bail!("'hours' must be positive");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?.ok_or_else(|| {
|
|
32
|
+
anyhow::anyhow!(
|
|
33
|
+
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
34
|
+
)
|
|
35
|
+
})?;
|
|
36
|
+
|
|
37
|
+
// Capture the post-update values for the response message. read_modify_write
|
|
38
|
+
// re-runs the closure on a concurrent-write retry, so the cells always hold
|
|
39
|
+
// the values from the committed attempt.
|
|
40
|
+
let new_actual = Cell::new(0.0_f64);
|
|
41
|
+
let new_remaining: Cell<Option<f64>> = Cell::new(None);
|
|
42
|
+
|
|
43
|
+
read_modify_write_task(&task_dir, |data, status| {
|
|
44
|
+
let schedule = data.schedule.get_or_insert_with(Default::default);
|
|
45
|
+
let actual = schedule.actual_hours.unwrap_or(0.0) + hours;
|
|
46
|
+
schedule.actual_hours = Some(actual);
|
|
47
|
+
new_actual.set(actual);
|
|
48
|
+
|
|
49
|
+
if let Some(rem) = schedule.remaining_hours {
|
|
50
|
+
let r = (rem - hours).max(0.0);
|
|
51
|
+
schedule.remaining_hours = Some(r);
|
|
52
|
+
new_remaining.set(Some(r));
|
|
53
|
+
} else {
|
|
54
|
+
new_remaining.set(None);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
58
|
+
Ok(status.to_string())
|
|
59
|
+
})?;
|
|
60
|
+
|
|
61
|
+
let remaining_msg = match new_remaining.get() {
|
|
62
|
+
Some(r) => format!(", remaining={r:.1}h"),
|
|
63
|
+
None => String::new(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
Ok(format!(
|
|
67
|
+
"Logged {hours:.1}h on {task_id}: actual={:.1}h{remaining_msg}",
|
|
68
|
+
new_actual.get()
|
|
69
|
+
))
|
|
70
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
|
|
3
|
+
use anyhow::Result;
|
|
4
|
+
use chrono::Utc;
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
|
+
|
|
7
|
+
use super::resolve_project_dir;
|
|
8
|
+
use crate::storage::config::read_config;
|
|
9
|
+
use crate::storage::ensure_handoff_exists;
|
|
10
|
+
use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
|
|
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 tasks_dir = handoff.join("tasks");
|
|
16
|
+
|
|
17
|
+
let assignee_filter = arguments.get("assignee").and_then(|v| v.as_str());
|
|
18
|
+
|
|
19
|
+
let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
|
|
20
|
+
|
|
21
|
+
let today = Utc::now().format("%Y-%m-%d").to_string();
|
|
22
|
+
|
|
23
|
+
let mut total: u32 = 0;
|
|
24
|
+
let mut by_status: HashMap<String, u32> = HashMap::new();
|
|
25
|
+
let mut estimate_sum: f64 = 0.0;
|
|
26
|
+
let mut actual_sum: f64 = 0.0;
|
|
27
|
+
let mut remaining_sum: f64 = 0.0;
|
|
28
|
+
let mut overdue_tasks: Vec<Value> = Vec::new();
|
|
29
|
+
let mut milestones: HashMap<String, (u32, u32, f64, f64)> = HashMap::new();
|
|
30
|
+
|
|
31
|
+
collect_metrics(
|
|
32
|
+
&tree,
|
|
33
|
+
assignee_filter,
|
|
34
|
+
&today,
|
|
35
|
+
&mut total,
|
|
36
|
+
&mut by_status,
|
|
37
|
+
&mut estimate_sum,
|
|
38
|
+
&mut actual_sum,
|
|
39
|
+
&mut remaining_sum,
|
|
40
|
+
&mut overdue_tasks,
|
|
41
|
+
&mut milestones,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
let done_count = *by_status.get("done").unwrap_or(&0);
|
|
45
|
+
let skipped_count = *by_status.get("skipped").unwrap_or(&0);
|
|
46
|
+
let completion_percent = if total > 0 {
|
|
47
|
+
((done_count + skipped_count) as f64 / total as f64) * 100.0
|
|
48
|
+
} else {
|
|
49
|
+
0.0
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let budget = read_budget(&handoff);
|
|
53
|
+
|
|
54
|
+
// AI-effort multiplier: the raw estimate is the human-effort estimate;
|
|
55
|
+
// multiplying by ai_estimate_multiplier yields the expected AI-effort hours.
|
|
56
|
+
// Raw estimates are preserved; only the adjusted view is derived here.
|
|
57
|
+
let multiplier = read_config(&handoff.join("config.toml"))
|
|
58
|
+
.map(|c| c.settings.ai_estimate_multiplier)
|
|
59
|
+
.unwrap_or(0.2);
|
|
60
|
+
|
|
61
|
+
let milestone_list: Vec<Value> = milestones
|
|
62
|
+
.into_iter()
|
|
63
|
+
.map(|(name, (done, ms_total, est, act))| {
|
|
64
|
+
json!({
|
|
65
|
+
"name": name,
|
|
66
|
+
"done": done,
|
|
67
|
+
"total": ms_total,
|
|
68
|
+
"estimate_hours": est,
|
|
69
|
+
"adjusted_estimate_hours": est * multiplier,
|
|
70
|
+
"actual_hours": act,
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
.collect();
|
|
74
|
+
|
|
75
|
+
let result = json!({
|
|
76
|
+
"total": total,
|
|
77
|
+
"by_status": by_status,
|
|
78
|
+
"completion_percent": (completion_percent * 10.0).round() / 10.0,
|
|
79
|
+
"total_estimate_hours": estimate_sum,
|
|
80
|
+
"ai_estimate_multiplier": multiplier,
|
|
81
|
+
"total_adjusted_estimate_hours": estimate_sum * multiplier,
|
|
82
|
+
"total_actual_hours": actual_sum,
|
|
83
|
+
"total_remaining_hours": remaining_sum,
|
|
84
|
+
"overdue_count": overdue_tasks.len(),
|
|
85
|
+
"overdue_tasks": overdue_tasks,
|
|
86
|
+
"budget": budget,
|
|
87
|
+
"milestones": milestone_list,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[allow(clippy::too_many_arguments)]
|
|
94
|
+
fn collect_metrics(
|
|
95
|
+
tree: &[TaskIndex],
|
|
96
|
+
assignee_filter: Option<&str>,
|
|
97
|
+
today: &str,
|
|
98
|
+
total: &mut u32,
|
|
99
|
+
by_status: &mut HashMap<String, u32>,
|
|
100
|
+
estimate_sum: &mut f64,
|
|
101
|
+
actual_sum: &mut f64,
|
|
102
|
+
remaining_sum: &mut f64,
|
|
103
|
+
overdue_tasks: &mut Vec<Value>,
|
|
104
|
+
milestones: &mut HashMap<String, (u32, u32, f64, f64)>,
|
|
105
|
+
) {
|
|
106
|
+
for node in tree {
|
|
107
|
+
let task_assignee = node.assignee.as_deref();
|
|
108
|
+
let include = match assignee_filter {
|
|
109
|
+
Some(f) => task_assignee == Some(f),
|
|
110
|
+
None => true,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if include {
|
|
114
|
+
*total += 1;
|
|
115
|
+
*by_status.entry(node.status.clone()).or_insert(0) += 1;
|
|
116
|
+
|
|
117
|
+
if let Some(ref sched) = node.schedule {
|
|
118
|
+
if let Some(est) = sched.estimate_hours {
|
|
119
|
+
*estimate_sum += est;
|
|
120
|
+
}
|
|
121
|
+
if let Some(act) = sched.actual_hours {
|
|
122
|
+
*actual_sum += act;
|
|
123
|
+
}
|
|
124
|
+
if let Some(rem) = sched.remaining_hours {
|
|
125
|
+
*remaining_sum += rem;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if let Some(ref due) = sched.due_date {
|
|
129
|
+
if !is_terminal_status(&node.status) && due.as_str() < today {
|
|
130
|
+
let days_overdue = days_between(due, today).unwrap_or(0);
|
|
131
|
+
overdue_tasks.push(json!({
|
|
132
|
+
"id": node.id,
|
|
133
|
+
"title": node.title,
|
|
134
|
+
"due_date": due,
|
|
135
|
+
"days_overdue": days_overdue,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if let Some(ref ms) = sched.milestone {
|
|
141
|
+
let entry = milestones.entry(ms.clone()).or_insert((0, 0, 0.0, 0.0));
|
|
142
|
+
entry.1 += 1;
|
|
143
|
+
if is_terminal_status(&node.status) {
|
|
144
|
+
entry.0 += 1;
|
|
145
|
+
}
|
|
146
|
+
if let Some(est) = sched.estimate_hours {
|
|
147
|
+
entry.2 += est;
|
|
148
|
+
}
|
|
149
|
+
if let Some(act) = sched.actual_hours {
|
|
150
|
+
entry.3 += act;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
collect_metrics(
|
|
157
|
+
&node.children,
|
|
158
|
+
assignee_filter,
|
|
159
|
+
today,
|
|
160
|
+
total,
|
|
161
|
+
by_status,
|
|
162
|
+
estimate_sum,
|
|
163
|
+
actual_sum,
|
|
164
|
+
remaining_sum,
|
|
165
|
+
overdue_tasks,
|
|
166
|
+
milestones,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
fn days_between(from: &str, to: &str) -> Option<i64> {
|
|
172
|
+
let from_date = chrono::NaiveDate::parse_from_str(from, "%Y-%m-%d").ok()?;
|
|
173
|
+
let to_date = chrono::NaiveDate::parse_from_str(to, "%Y-%m-%d").ok()?;
|
|
174
|
+
Some((to_date - from_date).num_days())
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fn read_budget(handoff: &std::path::Path) -> Value {
|
|
178
|
+
let config_path = handoff.join("config.toml");
|
|
179
|
+
let content = match std::fs::read_to_string(&config_path) {
|
|
180
|
+
Ok(c) => c,
|
|
181
|
+
Err(_) => return Value::Null,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
for line in content.lines() {
|
|
185
|
+
let trimmed = line.trim();
|
|
186
|
+
if trimmed.starts_with("total_hours") {
|
|
187
|
+
if let Some(val_str) = trimmed.split('=').nth(1) {
|
|
188
|
+
if let Ok(total) = val_str.trim().parse::<f64>() {
|
|
189
|
+
return json!({ "total_hours": total });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
Value::Null
|
|
196
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//! Milestone CRUD over the `[milestones.<name>]` config.toml section.
|
|
2
|
+
//! Mirrors the VSCode extension's addMilestone/updateMilestone/removeMilestone.
|
|
3
|
+
|
|
4
|
+
use anyhow::Result;
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
|
+
use toml_edit::{Item, Table};
|
|
7
|
+
|
|
8
|
+
use super::config_crud::{
|
|
9
|
+
config_path, ensure_subtable, ensure_table, load_doc, require_str, save_doc, set_opt_str,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/// handoff_list_milestones — return every `[milestones.*]` entry.
|
|
13
|
+
pub fn handle_list(arguments: &Value) -> Result<String> {
|
|
14
|
+
let path = config_path(arguments)?;
|
|
15
|
+
let doc = load_doc(&path)?;
|
|
16
|
+
|
|
17
|
+
let mut out = serde_json::Map::new();
|
|
18
|
+
if let Some(table) = doc.get("milestones").and_then(|v| v.as_table()) {
|
|
19
|
+
for (name, item) in table.iter() {
|
|
20
|
+
let sub = match item.as_table() {
|
|
21
|
+
Some(t) => t,
|
|
22
|
+
None => continue,
|
|
23
|
+
};
|
|
24
|
+
out.insert(
|
|
25
|
+
name.to_string(),
|
|
26
|
+
json!({
|
|
27
|
+
"date": sub.get("date").and_then(|v| v.as_str()),
|
|
28
|
+
"color": sub.get("color").and_then(|v| v.as_str()),
|
|
29
|
+
"description": sub.get("description").and_then(|v| v.as_str()),
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
serde_json::to_string_pretty(&json!({ "milestones": out })).map_err(Into::into)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// handoff_add_milestone — create a `[milestones.<name>]` entry. Fails if it exists.
|
|
38
|
+
pub fn handle_add(arguments: &Value) -> Result<String> {
|
|
39
|
+
let path = config_path(arguments)?;
|
|
40
|
+
let name = require_str(arguments, "name")?;
|
|
41
|
+
let mut doc = load_doc(&path)?;
|
|
42
|
+
|
|
43
|
+
let table = ensure_table(&mut doc, "milestones")?;
|
|
44
|
+
table.set_implicit(true);
|
|
45
|
+
if table.contains_key(name) {
|
|
46
|
+
anyhow::bail!(
|
|
47
|
+
"Milestone '{name}' already exists. Use handoff_update_milestone to modify it."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
let mut sub = Table::new();
|
|
51
|
+
apply_fields(&mut sub, arguments);
|
|
52
|
+
doc["milestones"][name] = Item::Table(sub);
|
|
53
|
+
|
|
54
|
+
save_doc(&path, &doc)?;
|
|
55
|
+
Ok(format!("Added milestone '{name}'"))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// handoff_update_milestone — patch an existing `[milestones.<name>]` entry.
|
|
59
|
+
pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
60
|
+
let path = config_path(arguments)?;
|
|
61
|
+
let name = require_str(arguments, "name")?;
|
|
62
|
+
let mut doc = load_doc(&path)?;
|
|
63
|
+
|
|
64
|
+
let exists = doc
|
|
65
|
+
.get("milestones")
|
|
66
|
+
.and_then(|v| v.as_table())
|
|
67
|
+
.map(|t| t.contains_key(name))
|
|
68
|
+
.unwrap_or(false);
|
|
69
|
+
if !exists {
|
|
70
|
+
anyhow::bail!("Milestone '{name}' not found. Use handoff_add_milestone to create it.");
|
|
71
|
+
}
|
|
72
|
+
let sub = ensure_subtable(&mut doc, "milestones", name)?;
|
|
73
|
+
apply_fields(sub, arguments);
|
|
74
|
+
|
|
75
|
+
save_doc(&path, &doc)?;
|
|
76
|
+
Ok(format!("Updated milestone '{name}'"))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// handoff_remove_milestone — delete a `[milestones.<name>]` entry.
|
|
80
|
+
pub fn handle_remove(arguments: &Value) -> Result<String> {
|
|
81
|
+
let path = config_path(arguments)?;
|
|
82
|
+
let name = require_str(arguments, "name")?;
|
|
83
|
+
let mut doc = load_doc(&path)?;
|
|
84
|
+
|
|
85
|
+
let removed = doc
|
|
86
|
+
.get_mut("milestones")
|
|
87
|
+
.and_then(|v| v.as_table_mut())
|
|
88
|
+
.map(|t| t.remove(name).is_some())
|
|
89
|
+
.unwrap_or(false);
|
|
90
|
+
if !removed {
|
|
91
|
+
anyhow::bail!("Milestone '{name}' not found.");
|
|
92
|
+
}
|
|
93
|
+
save_doc(&path, &doc)?;
|
|
94
|
+
Ok(format!("Removed milestone '{name}'"))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn apply_fields(table: &mut Table, arguments: &Value) {
|
|
98
|
+
table.set_implicit(false);
|
|
99
|
+
set_opt_str(table, "date", arguments.get("date"));
|
|
100
|
+
set_opt_str(table, "color", arguments.get("color"));
|
|
101
|
+
set_opt_str(table, "description", arguments.get("description"));
|
|
102
|
+
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
+
pub mod assignees;
|
|
2
|
+
pub mod auto_schedule;
|
|
3
|
+
pub mod bulk_update;
|
|
4
|
+
pub mod calendar;
|
|
5
|
+
pub mod capacity;
|
|
1
6
|
pub mod check_criterion;
|
|
2
7
|
pub mod config;
|
|
8
|
+
pub mod config_crud;
|
|
3
9
|
pub mod dashboard;
|
|
10
|
+
pub mod get_session;
|
|
4
11
|
pub mod get_task;
|
|
5
12
|
pub mod import_context;
|
|
6
13
|
pub mod init;
|
|
14
|
+
pub mod list_sessions;
|
|
7
15
|
pub mod list_tasks;
|
|
8
16
|
pub mod load_context;
|
|
17
|
+
pub mod log_time;
|
|
18
|
+
pub mod metrics;
|
|
19
|
+
pub mod milestones;
|
|
9
20
|
pub mod refer;
|
|
10
21
|
pub mod referrals;
|
|
11
22
|
pub mod save_context;
|
|
@@ -42,8 +53,27 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
42
53
|
"handoff_import_context" => import_context::handle(arguments),
|
|
43
54
|
"handoff_refer" => refer::handle(arguments),
|
|
44
55
|
"handoff_list_referrals" => referrals::handle_list(arguments),
|
|
56
|
+
"handoff_get_referral" => referrals::handle_get(arguments),
|
|
45
57
|
"handoff_update_referral" => referrals::handle_update(arguments),
|
|
46
58
|
"handoff_update_session" => update_session::handle(arguments),
|
|
59
|
+
"handoff_log_time" => log_time::handle(arguments),
|
|
60
|
+
"handoff_get_metrics" => metrics::handle(arguments),
|
|
61
|
+
"handoff_list_sessions" => list_sessions::handle(arguments),
|
|
62
|
+
"handoff_list_assignees" => assignees::handle(arguments),
|
|
63
|
+
"handoff_bulk_update_tasks" => bulk_update::handle(arguments),
|
|
64
|
+
"handoff_get_session" => get_session::handle(arguments),
|
|
65
|
+
"handoff_get_capacity" => capacity::handle(arguments),
|
|
66
|
+
"handoff_auto_schedule" => auto_schedule::handle(arguments),
|
|
67
|
+
"handoff_add_assignee" => assignees::handle_add(arguments),
|
|
68
|
+
"handoff_update_assignee" => assignees::handle_update(arguments),
|
|
69
|
+
"handoff_remove_assignee" => assignees::handle_remove(arguments),
|
|
70
|
+
"handoff_list_milestones" => milestones::handle_list(arguments),
|
|
71
|
+
"handoff_add_milestone" => milestones::handle_add(arguments),
|
|
72
|
+
"handoff_update_milestone" => milestones::handle_update(arguments),
|
|
73
|
+
"handoff_remove_milestone" => milestones::handle_remove(arguments),
|
|
74
|
+
"handoff_update_calendar" => calendar::handle_update_calendar(arguments),
|
|
75
|
+
"handoff_update_labels" => calendar::handle_update_labels(arguments),
|
|
76
|
+
"handoff_start_project" => calendar::handle_start_project(arguments),
|
|
47
77
|
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
48
78
|
};
|
|
49
79
|
|