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.
@@ -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 filtered_tree = if let Some(filter) = status_filter {
29
- filter_tree(&tree, filter)
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
- fn filter_tree(
43
- tree: &[crate::storage::tasks::TaskIndex],
44
- status: &str,
45
- ) -> Vec<crate::storage::tasks::TaskIndex> {
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, status);
49
- if node.status == status || !children.is_empty() {
50
- Some(crate::storage::tasks::TaskIndex {
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
+ }
@@ -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