handoff-mcp-server 0.26.0 → 0.28.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.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. package/templates/claude-md-section.md +0 -12
@@ -1,197 +0,0 @@
1
- use anyhow::{Context, Result};
2
- use serde_json::Value;
3
-
4
- use super::resolve_project_dir;
5
- use crate::storage::ensure_handoff_exists;
6
- use crate::storage::sessions::{read_active_sessions, SessionData};
7
-
8
- pub fn handle(arguments: &Value) -> Result<String> {
9
- let project_dir = resolve_project_dir(arguments)?;
10
- let handoff = ensure_handoff_exists(&project_dir)?;
11
- let sessions_dir = handoff.join("sessions");
12
-
13
- let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
14
-
15
- let active = read_active_sessions(&sessions_dir)?;
16
- if active.is_empty() {
17
- anyhow::bail!(
18
- "No active session. Call save_context with session_status='active' to create one first."
19
- );
20
- }
21
-
22
- let session = if let Some(tid) = target_session_id {
23
- active
24
- .iter()
25
- .find(|s| {
26
- s.id.as_deref()
27
- .is_some_and(|id| id == tid || id.starts_with(tid) || tid.starts_with(id))
28
- })
29
- .ok_or_else(|| anyhow::anyhow!("session_id '{tid}' not found among active sessions"))?
30
- } else if active.len() == 1 {
31
- &active[0]
32
- } else {
33
- active.last().unwrap()
34
- };
35
- let sid = session.id.as_deref().unwrap_or("");
36
-
37
- let checklist_index = arguments
38
- .get("checklist_index")
39
- .and_then(|v| v.as_u64())
40
- .map(|v| v as usize);
41
- let checklist_checked = arguments.get("checklist_checked").and_then(|v| v.as_bool());
42
- let add_checklist_item = arguments.get("add_checklist_item").and_then(|v| v.as_str());
43
- let add_decision = arguments.get("add_decision");
44
- let add_handoff_note = arguments.get("add_handoff_note");
45
- let add_context_pointer = arguments.get("add_context_pointer");
46
-
47
- let mut data = session.clone();
48
- let mut changes = Vec::new();
49
-
50
- if let Some(idx) = checklist_index {
51
- let checked = checklist_checked.unwrap_or(true);
52
- if idx >= data.checklist.len() {
53
- anyhow::bail!(
54
- "checklist_index {idx} out of range (session has {} items)",
55
- data.checklist.len()
56
- );
57
- }
58
- if let Some(item) = data.checklist.get_mut(idx) {
59
- item["checked"] = serde_json::json!(checked);
60
- let item_text = item
61
- .get("item")
62
- .and_then(|v| v.as_str())
63
- .unwrap_or("(unknown)");
64
- changes.push(format!(
65
- "checklist[{idx}] '{}' → {}",
66
- item_text,
67
- if checked { "checked" } else { "unchecked" }
68
- ));
69
- }
70
- }
71
-
72
- if let Some(item_text) = add_checklist_item {
73
- let owner = arguments
74
- .get("checklist_owner")
75
- .and_then(|v| v.as_str())
76
- .unwrap_or("ai");
77
- data.checklist.push(serde_json::json!({
78
- "item": item_text,
79
- "checked": false,
80
- "owner": owner
81
- }));
82
- changes.push(format!("added checklist item: '{item_text}'"));
83
- }
84
-
85
- if let Some(decision) = add_decision {
86
- if decision.is_object() {
87
- data.decisions.push(decision.clone());
88
- let desc = decision
89
- .get("decision")
90
- .and_then(|v| v.as_str())
91
- .unwrap_or("(decision)");
92
- changes.push(format!("added decision: '{desc}'"));
93
- } else {
94
- anyhow::bail!("add_decision must be an object with at least a 'decision' field");
95
- }
96
- }
97
-
98
- if let Some(note) = add_handoff_note {
99
- if note.is_object() {
100
- data.handoff_notes.push(note.clone());
101
- let text = note
102
- .get("note")
103
- .and_then(|v| v.as_str())
104
- .unwrap_or("(note)");
105
- changes.push(format!("added handoff_note: '{}'", truncate(text, 60)));
106
- } else {
107
- anyhow::bail!("add_handoff_note must be an object with at least a 'note' field");
108
- }
109
- }
110
-
111
- if let Some(pointer) = add_context_pointer {
112
- if pointer.is_object() {
113
- data.context_pointers.push(pointer.clone());
114
- let path = pointer
115
- .get("path")
116
- .and_then(|v| v.as_str())
117
- .unwrap_or("(path)");
118
- changes.push(format!("added context_pointer: '{path}'"));
119
- } else {
120
- anyhow::bail!("add_context_pointer must be an object with at least a 'path' field");
121
- }
122
- }
123
-
124
- if changes.is_empty() {
125
- anyhow::bail!(
126
- "No updates specified. Use checklist_index, add_checklist_item, \
127
- add_decision, add_handoff_note, or add_context_pointer."
128
- );
129
- }
130
-
131
- write_active_session_data(&sessions_dir, sid, &data)?;
132
-
133
- let mut msg = format!("Session {} updated:", sid);
134
- for c in &changes {
135
- msg.push_str(&format!("\n - {c}"));
136
- }
137
-
138
- let checked_count = data
139
- .checklist
140
- .iter()
141
- .filter(|item| {
142
- item.get("checked")
143
- .and_then(|v| v.as_bool())
144
- .unwrap_or(false)
145
- })
146
- .count();
147
- msg.push_str(&format!(
148
- "\nChecklist: {}/{} checked",
149
- checked_count,
150
- data.checklist.len()
151
- ));
152
-
153
- Ok(msg)
154
- }
155
-
156
- fn truncate(s: &str, max_chars: usize) -> String {
157
- let mut chars = s.chars();
158
- let truncated: String = chars.by_ref().take(max_chars).collect();
159
- if chars.next().is_some() {
160
- format!("{truncated}...")
161
- } else {
162
- truncated
163
- }
164
- }
165
-
166
- fn write_active_session_data(
167
- sessions_dir: &std::path::Path,
168
- session_id: &str,
169
- data: &SessionData,
170
- ) -> Result<()> {
171
- let suffix = ".active.json";
172
-
173
- for entry in std::fs::read_dir(sessions_dir)? {
174
- let entry = entry?;
175
- let name = entry.file_name().to_string_lossy().to_string();
176
- if !name.ends_with(suffix) {
177
- continue;
178
- }
179
-
180
- let content = std::fs::read_to_string(entry.path())
181
- .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
182
- let existing: SessionData = serde_json::from_str(&content)
183
- .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
184
-
185
- let file_id = existing.id.as_deref().unwrap_or("");
186
- if file_id != session_id {
187
- continue;
188
- }
189
-
190
- let updated = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
191
- crate::storage::atomic_write(entry.path(), updated.as_bytes())
192
- .with_context(|| format!("Failed to write session: {}", entry.path().display()))?;
193
- return Ok(());
194
- }
195
-
196
- anyhow::bail!("Active session '{session_id}' not found on disk")
197
- }
@@ -1,452 +0,0 @@
1
- use std::collections::HashMap;
2
-
3
- use anyhow::{Context, Result};
4
- use chrono::Utc;
5
- use serde_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::*;
11
-
12
- pub fn handle(arguments: &Value) -> Result<String> {
13
- let project_dir = resolve_project_dir(arguments)?;
14
-
15
- let handoff = ensure_handoff_exists(&project_dir)?;
16
- let tasks_dir = handoff.join("tasks");
17
-
18
- let require_estimate_hours = read_config(&handoff.join("config.toml"))
19
- .map(|c| c.settings.require_estimate_hours)
20
- .unwrap_or(true);
21
-
22
- let task_val = arguments
23
- .get("task")
24
- .ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
25
-
26
- let task_id = task_val.get("id").and_then(|v| v.as_str());
27
- let move_to = arguments.get("move_to").and_then(|v| v.as_str());
28
-
29
- if let Some(existing_id) = task_id {
30
- if let Some(new_parent_id) = move_to {
31
- return handle_move(&tasks_dir, existing_id, new_parent_id);
32
- }
33
- let task_exists = find_task_dir_by_id(&tasks_dir, existing_id)?.is_some();
34
- if task_exists {
35
- return handle_update(&tasks_dir, existing_id, task_val, require_estimate_hours);
36
- }
37
- return handle_upsert_create(
38
- &tasks_dir,
39
- existing_id,
40
- task_val,
41
- arguments,
42
- require_estimate_hours,
43
- );
44
- }
45
-
46
- let title = task_val
47
- .get("title")
48
- .and_then(|v| v.as_str())
49
- .ok_or_else(|| anyhow::anyhow!("'task.title' is required for new tasks"))?;
50
-
51
- handle_create(
52
- &tasks_dir,
53
- title,
54
- task_val,
55
- arguments,
56
- require_estimate_hours,
57
- )
58
- }
59
-
60
- fn handle_create(
61
- tasks_dir: &std::path::Path,
62
- title: &str,
63
- task_val: &Value,
64
- arguments: &Value,
65
- require_estimate_hours: bool,
66
- ) -> Result<String> {
67
- let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
68
-
69
- let (new_id, parent_dir) = match parent_id {
70
- Some(pid) => {
71
- let parent_dir = find_task_dir_by_id(tasks_dir, pid)?
72
- .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, pid)))?;
73
- let id = next_child_id(&parent_dir, pid)?;
74
- (id, parent_dir)
75
- }
76
- None => {
77
- let id = next_top_level_id(tasks_dir)?;
78
- (id, tasks_dir.to_path_buf())
79
- }
80
- };
81
-
82
- let slug = title_to_slug(title);
83
- let dir_name = format!("{new_id}-{slug}");
84
- let task_dir = parent_dir.join(&dir_name);
85
-
86
- let now = Utc::now().to_rfc3339();
87
- let status = task_val
88
- .get("status")
89
- .and_then(|v| v.as_str())
90
- .unwrap_or("todo");
91
-
92
- if !is_valid_status(status) {
93
- anyhow::bail!("Invalid status: {status}");
94
- }
95
-
96
- let priority = task_val.get("priority").and_then(|v| v.as_str());
97
- validate_priority(priority)?;
98
-
99
- let dependencies = extract_string_array(task_val, "dependencies");
100
- if !dependencies.is_empty() {
101
- validate_dependencies(tasks_dir, &new_id, &dependencies)?;
102
- }
103
-
104
- let data = TaskData {
105
- id: new_id.clone(),
106
- title: title.to_string(),
107
- notes: task_val
108
- .get("notes")
109
- .and_then(|v| v.as_str())
110
- .map(String::from),
111
- priority: priority.map(String::from),
112
- created_at: Some(now.clone()),
113
- updated_at: Some(now),
114
- completed_at: None,
115
- labels: extract_string_array(task_val, "labels"),
116
- links: extract_string_array(task_val, "links"),
117
- task_links: Vec::new(),
118
- done_criteria: extract_done_criteria(task_val),
119
- schedule: extract_schedule(task_val),
120
- dependencies,
121
- order: task_val
122
- .get("order")
123
- .and_then(|v| v.as_u64())
124
- .map(|v| v as u32),
125
- assignee: task_val
126
- .get("assignee")
127
- .and_then(|v| v.as_str())
128
- .map(String::from),
129
- extra: HashMap::new(),
130
- };
131
-
132
- // A newly created task is always a leaf (no children yet).
133
- validate_estimate_required(
134
- require_estimate_hours,
135
- &new_id,
136
- title,
137
- status,
138
- false,
139
- true,
140
- data.schedule.as_ref(),
141
- )?;
142
-
143
- // Create the directory only once every validation has passed. A rejected
144
- // create must leave nothing behind: an orphan dir would burn the task ID,
145
- // because `next_top_level_id` counts directories, not task files.
146
- std::fs::create_dir_all(&task_dir)
147
- .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
148
-
149
- write_task(&task_dir, status, &data)?;
150
-
151
- Ok(format!("Created task {new_id}: {title} [{status}]"))
152
- }
153
-
154
- fn handle_upsert_create(
155
- tasks_dir: &std::path::Path,
156
- task_id: &str,
157
- task_val: &Value,
158
- arguments: &Value,
159
- require_estimate_hours: bool,
160
- ) -> Result<String> {
161
- let title = task_val
162
- .get("title")
163
- .and_then(|v| v.as_str())
164
- .ok_or_else(|| {
165
- let hint = suggest_task_id(tasks_dir, task_id);
166
- anyhow::anyhow!("{hint}\nProvide 'title' to create a new task with this ID.")
167
- })?;
168
-
169
- let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
170
-
171
- let parent_dir = match parent_id {
172
- Some(pid) => find_task_dir_by_id(tasks_dir, pid)?
173
- .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, pid)))?,
174
- None => tasks_dir.to_path_buf(),
175
- };
176
-
177
- let slug = title_to_slug(title);
178
- let dir_name = format!("{task_id}-{slug}");
179
- let task_dir = parent_dir.join(&dir_name);
180
-
181
- let now = Utc::now().to_rfc3339();
182
- let status = task_val
183
- .get("status")
184
- .and_then(|v| v.as_str())
185
- .unwrap_or("todo");
186
-
187
- if !is_valid_status(status) {
188
- anyhow::bail!("Invalid status: {status}");
189
- }
190
-
191
- let priority = task_val.get("priority").and_then(|v| v.as_str());
192
- validate_priority(priority)?;
193
-
194
- let dependencies = extract_string_array(task_val, "dependencies");
195
- if !dependencies.is_empty() {
196
- validate_dependencies(tasks_dir, task_id, &dependencies)?;
197
- }
198
-
199
- let data = TaskData {
200
- id: task_id.to_string(),
201
- title: title.to_string(),
202
- notes: task_val
203
- .get("notes")
204
- .and_then(|v| v.as_str())
205
- .map(String::from),
206
- priority: priority.map(String::from),
207
- created_at: Some(now.clone()),
208
- updated_at: Some(now),
209
- completed_at: None,
210
- labels: extract_string_array(task_val, "labels"),
211
- links: extract_string_array(task_val, "links"),
212
- task_links: Vec::new(),
213
- done_criteria: extract_done_criteria(task_val),
214
- schedule: extract_schedule(task_val),
215
- dependencies,
216
- order: task_val
217
- .get("order")
218
- .and_then(|v| v.as_u64())
219
- .map(|v| v as u32),
220
- assignee: task_val
221
- .get("assignee")
222
- .and_then(|v| v.as_str())
223
- .map(String::from),
224
- extra: HashMap::new(),
225
- };
226
-
227
- // Upsert-create: a brand-new task is a leaf.
228
- validate_estimate_required(
229
- require_estimate_hours,
230
- task_id,
231
- title,
232
- status,
233
- false,
234
- true,
235
- data.schedule.as_ref(),
236
- )?;
237
-
238
- // Create the directory only once every validation has passed, so a rejected
239
- // upsert-create leaves no orphan dir shadowing the requested ID.
240
- std::fs::create_dir_all(&task_dir)
241
- .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
242
-
243
- write_task(&task_dir, status, &data)?;
244
-
245
- Ok(format!("Created task {task_id}: {title} [{status}]"))
246
- }
247
-
248
- fn handle_update(
249
- tasks_dir: &std::path::Path,
250
- task_id: &str,
251
- task_val: &Value,
252
- require_estimate_hours: bool,
253
- ) -> Result<String> {
254
- let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
255
- .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
256
-
257
- let (mut data, current_status) = read_task(&task_dir)?
258
- .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
259
-
260
- if let Some(title) = task_val.get("title").and_then(|v| v.as_str()) {
261
- data.title = title.to_string();
262
- }
263
- if let Some(notes) = task_val.get("notes").and_then(|v| v.as_str()) {
264
- data.notes = Some(notes.to_string());
265
- } else if let Some(append) = task_val.get("notes_append").and_then(|v| v.as_str()) {
266
- let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S");
267
- let block = format!("--- {timestamp}\n{append}");
268
- match &mut data.notes {
269
- Some(existing) if !existing.is_empty() => {
270
- existing.push_str(&format!("\n\n{block}"));
271
- }
272
- _ => data.notes = Some(block),
273
- }
274
- }
275
- if let Some(priority) = task_val.get("priority").and_then(|v| v.as_str()) {
276
- validate_priority(Some(priority))?;
277
- data.priority = Some(priority.to_string());
278
- }
279
- if task_val.get("labels").is_some() {
280
- data.labels = extract_string_array(task_val, "labels");
281
- }
282
- if task_val.get("links").is_some() {
283
- data.links = extract_string_array(task_val, "links");
284
- }
285
- if task_val.get("done_criteria").is_some() {
286
- data.done_criteria = extract_done_criteria(task_val);
287
- }
288
- if let Some(sched_val) = task_val.get("schedule") {
289
- // Field-level merge (not full replacement) so that fields not present in
290
- // the patch — e.g. actual_hours/remaining_hours accrued by the VSCode timer —
291
- // are preserved. Mirrors bulk_update_tasks. (referral ref-20260623-232823)
292
- let schedule = data.schedule.get_or_insert_with(Default::default);
293
- if let Some(sd) = sched_val.get("start_date").and_then(|v| v.as_str()) {
294
- schedule.start_date = Some(sd.to_string());
295
- }
296
- if let Some(dd) = sched_val.get("due_date").and_then(|v| v.as_str()) {
297
- schedule.due_date = Some(dd.to_string());
298
- }
299
- if let Some(eh) = sched_val.get("estimate_hours").and_then(|v| v.as_f64()) {
300
- schedule.estimate_hours = Some(eh);
301
- }
302
- if let Some(ah) = sched_val.get("actual_hours").and_then(|v| v.as_f64()) {
303
- schedule.actual_hours = Some(ah);
304
- }
305
- if let Some(rh) = sched_val.get("remaining_hours").and_then(|v| v.as_f64()) {
306
- schedule.remaining_hours = Some(rh);
307
- }
308
- if let Some(ms) = sched_val.get("milestone").and_then(|v| v.as_str()) {
309
- schedule.milestone = Some(ms.to_string());
310
- }
311
- if let Some(p) = sched_val.get("pinned").and_then(|v| v.as_bool()) {
312
- schedule.pinned = Some(p);
313
- }
314
- }
315
- if task_val.get("dependencies").is_some() {
316
- let new_deps = extract_string_array(task_val, "dependencies");
317
- if !new_deps.is_empty() {
318
- validate_dependencies(tasks_dir, task_id, &new_deps)?;
319
- }
320
- data.dependencies = new_deps;
321
- }
322
- if let Some(order) = task_val.get("order").and_then(|v| v.as_u64()) {
323
- data.order = Some(order as u32);
324
- }
325
- if task_val.get("assignee").is_some() {
326
- data.assignee = task_val
327
- .get("assignee")
328
- .and_then(|v| v.as_str())
329
- .map(String::from);
330
- }
331
-
332
- let new_status = task_val
333
- .get("status")
334
- .and_then(|v| v.as_str())
335
- .unwrap_or(&current_status);
336
-
337
- if !is_valid_status(new_status) {
338
- anyhow::bail!("Invalid status: {new_status}");
339
- }
340
-
341
- if new_status == "done" && current_status != "done" {
342
- validate_done_transition(&task_dir, &data)?;
343
- data.completed_at = Some(Utc::now().to_rfc3339());
344
- }
345
-
346
- if new_status == "skipped" && current_status != "skipped" {
347
- validate_skipped_transition(&task_dir, &data)?;
348
- }
349
-
350
- // Parent tasks (with children) are exempt; only leaf tasks need an estimate.
351
- let has_children = task_has_children(&task_dir)?;
352
- validate_estimate_required(
353
- require_estimate_hours,
354
- task_id,
355
- &data.title,
356
- new_status,
357
- has_children,
358
- false,
359
- data.schedule.as_ref(),
360
- )?;
361
-
362
- data.updated_at = Some(Utc::now().to_rfc3339());
363
-
364
- if let Some((old_path, _)) = find_task_file(&task_dir)? {
365
- std::fs::remove_file(&old_path)?;
366
- }
367
-
368
- write_task(&task_dir, new_status, &data)?;
369
-
370
- Ok(format!(
371
- "Updated task {task_id}: {} [{new_status}]",
372
- data.title
373
- ))
374
- }
375
-
376
- fn handle_move(tasks_dir: &std::path::Path, task_id: &str, new_parent_id: &str) -> Result<String> {
377
- let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
378
- .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
379
-
380
- let new_parent_dir = find_task_dir_by_id(tasks_dir, new_parent_id)?
381
- .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, new_parent_id)))?;
382
-
383
- let dir_name = task_dir
384
- .file_name()
385
- .ok_or_else(|| anyhow::anyhow!("Invalid task dir"))?;
386
-
387
- let dest = new_parent_dir.join(dir_name);
388
-
389
- std::fs::rename(&task_dir, &dest).with_context(|| {
390
- format!(
391
- "Failed to move {} -> {}",
392
- task_dir.display(),
393
- dest.display()
394
- )
395
- })?;
396
-
397
- Ok(format!("Moved task {task_id} under {new_parent_id}"))
398
- }
399
-
400
- fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
401
- val.get(key)
402
- .and_then(|v| v.as_array())
403
- .map(|arr| {
404
- arr.iter()
405
- .filter_map(|v| v.as_str().map(String::from))
406
- .collect()
407
- })
408
- .unwrap_or_default()
409
- }
410
-
411
- fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
412
- val.get("done_criteria")
413
- .and_then(|v| v.as_array())
414
- .map(|arr| {
415
- arr.iter()
416
- .filter_map(|v| {
417
- let item = v.get("item")?.as_str()?;
418
- let checked = v.get("checked").and_then(|c| c.as_bool()).unwrap_or(false);
419
- Some(DoneCriterion {
420
- item: item.to_string(),
421
- checked,
422
- })
423
- })
424
- .collect()
425
- })
426
- .unwrap_or_default()
427
- }
428
-
429
- fn extract_schedule(val: &Value) -> Option<Schedule> {
430
- let sched = val.get("schedule")?;
431
- if sched.is_null() {
432
- return None;
433
- }
434
- Some(Schedule {
435
- start_date: sched
436
- .get("start_date")
437
- .and_then(|v| v.as_str())
438
- .map(String::from),
439
- due_date: sched
440
- .get("due_date")
441
- .and_then(|v| v.as_str())
442
- .map(String::from),
443
- estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
444
- actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
445
- remaining_hours: sched.get("remaining_hours").and_then(|v| v.as_f64()),
446
- milestone: sched
447
- .get("milestone")
448
- .and_then(|v| v.as_str())
449
- .map(String::from),
450
- pinned: sched.get("pinned").and_then(|v| v.as_bool()),
451
- })
452
- }
package/src/mcp/mod.rs DELETED
@@ -1,6 +0,0 @@
1
- pub mod handlers;
2
- pub mod protocol;
3
- pub mod resources;
4
- pub mod router;
5
- pub mod tools;
6
- pub mod types;
@@ -1,41 +0,0 @@
1
- use super::router::handle_request;
2
- use super::types::{JsonRpcRequest, JsonRpcResponse, INVALID_REQUEST, PARSE_ERROR};
3
-
4
- pub fn process_line(line: &str) -> Option<String> {
5
- let trimmed = line.trim();
6
- if trimmed.is_empty() {
7
- return None;
8
- }
9
-
10
- let response = match serde_json::from_str::<JsonRpcRequest>(trimmed) {
11
- Ok(req) => {
12
- if req.jsonrpc != "2.0" {
13
- JsonRpcResponse::error(req.id, INVALID_REQUEST, "Invalid JSON-RPC version")
14
- } else {
15
- let mut resp = handle_request(&req.method, req.params.as_ref());
16
- if resp.id.is_none() && req.id.is_some() {
17
- resp.id = req.id;
18
- }
19
- if is_notification(&req.method) && resp.result.is_none() && resp.error.is_none() {
20
- return None;
21
- }
22
- resp
23
- }
24
- }
25
- Err(e) => JsonRpcResponse::error(None, PARSE_ERROR, format!("Parse error: {e}")),
26
- };
27
-
28
- match serde_json::to_string(&response) {
29
- Ok(s) => Some(s),
30
- Err(e) => {
31
- let fallback = format!(
32
- r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":-32603,"message":"Serialization error: {e}"}}}}"#
33
- );
34
- Some(fallback)
35
- }
36
- }
37
- }
38
-
39
- fn is_notification(method: &str) -> bool {
40
- method.starts_with("notifications/")
41
- }