handoff-mcp-server 0.26.0 → 0.27.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,187 +0,0 @@
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 timeline_filter = arguments.get("timeline").and_then(|v| v.as_str());
14
- let limit = arguments
15
- .get("limit")
16
- .and_then(|v| v.as_u64())
17
- .unwrap_or(20) as usize;
18
- let include_children = arguments
19
- .get("include_children")
20
- .and_then(|v| v.as_bool())
21
- .unwrap_or(false);
22
-
23
- if !sessions_dir.exists() {
24
- return serde_json::to_string_pretty(&json!([])).map_err(Into::into);
25
- }
26
-
27
- let mut sessions: Vec<Value> = Vec::new();
28
-
29
- for entry in std::fs::read_dir(&sessions_dir)
30
- .with_context(|| format!("Failed to read sessions dir: {}", sessions_dir.display()))?
31
- {
32
- let entry = entry?;
33
- let name = entry.file_name().to_string_lossy().to_string();
34
- if !name.ends_with(".json") {
35
- continue;
36
- }
37
-
38
- let status = extract_session_status(&name);
39
- if let Some(filter) = status_filter {
40
- if status != filter {
41
- continue;
42
- }
43
- }
44
-
45
- let content = match std::fs::read_to_string(entry.path()) {
46
- Ok(c) => c,
47
- Err(_) => continue,
48
- };
49
- let data: Value = match serde_json::from_str(&content) {
50
- Ok(d) => d,
51
- Err(_) => continue,
52
- };
53
-
54
- let id = data
55
- .get("id")
56
- .and_then(|v| v.as_str())
57
- .map(String::from)
58
- .unwrap_or_else(|| synthesize_id_from_filename(&name));
59
-
60
- let timeline = data.get("timeline").and_then(|v| v.as_str());
61
- if let Some(tl_filter) = timeline_filter {
62
- if timeline.is_none_or(|tl| tl != tl_filter) {
63
- continue;
64
- }
65
- }
66
-
67
- let summary = data.get("summary").and_then(|v| v.as_str()).unwrap_or("");
68
- let label = data.get("label").and_then(|v| v.as_str());
69
- let parent_session_id = data.get("parent_session_id").and_then(|v| v.as_str());
70
- let started_at = data.get("started_at").and_then(|v| v.as_str());
71
- let ended_at = data.get("ended_at").and_then(|v| v.as_str());
72
- let branch = data.get("branch").and_then(|v| v.as_str());
73
- let commit = data.get("commit").and_then(|v| v.as_str());
74
-
75
- let decisions_count = data
76
- .get("decisions")
77
- .and_then(|v| v.as_array())
78
- .map(|a| a.len())
79
- .unwrap_or(0);
80
-
81
- let checklist = data.get("checklist").and_then(|v| v.as_array());
82
- let checklist_count = checklist.map(|a| a.len()).unwrap_or(0);
83
- let checklist_checked = checklist
84
- .map(|a| {
85
- a.iter()
86
- .filter(|item| {
87
- item.get("checked")
88
- .and_then(|v| v.as_bool())
89
- .unwrap_or(false)
90
- })
91
- .count()
92
- })
93
- .unwrap_or(0);
94
-
95
- let mut entry = json!({
96
- "id": id,
97
- "status": status,
98
- "summary": summary,
99
- "started_at": started_at,
100
- "ended_at": ended_at,
101
- "branch": branch,
102
- "commit": commit,
103
- "decisions_count": decisions_count,
104
- "checklist_progress": format!("{}/{}", checklist_checked, checklist_count),
105
- });
106
- if let Some(tl) = timeline {
107
- entry["timeline"] = json!(tl);
108
- }
109
- if let Some(lbl) = label {
110
- entry["label"] = json!(lbl);
111
- }
112
- if let Some(pid) = parent_session_id {
113
- entry["parent_session_id"] = json!(pid);
114
- }
115
- sessions.push(entry);
116
- }
117
-
118
- sessions.sort_by(|a, b| {
119
- let a_time = a.get("ended_at").and_then(|v| v.as_str()).unwrap_or("");
120
- let b_time = b.get("ended_at").and_then(|v| v.as_str()).unwrap_or("");
121
- b_time.cmp(a_time)
122
- });
123
-
124
- let all_sessions_for_children = if include_children {
125
- sessions.clone()
126
- } else {
127
- Vec::new()
128
- };
129
-
130
- sessions.truncate(limit);
131
-
132
- if include_children {
133
- for session in &mut sessions {
134
- let sid = session.get("id").and_then(|v| v.as_str()).unwrap_or("");
135
- if sid.is_empty() {
136
- continue;
137
- }
138
- let children: Vec<Value> = all_sessions_for_children
139
- .iter()
140
- .filter(|s| {
141
- s.get("parent_session_id")
142
- .and_then(|v| v.as_str())
143
- .is_some_and(|pid| pid == sid)
144
- })
145
- .map(|s| {
146
- json!({
147
- "id": s.get("id").and_then(|v| v.as_str()).unwrap_or(""),
148
- "summary": s.get("summary").and_then(|v| v.as_str()).unwrap_or(""),
149
- "status": s.get("status").and_then(|v| v.as_str()).unwrap_or(""),
150
- "label": s.get("label").and_then(|v| v.as_str()),
151
- })
152
- })
153
- .collect();
154
- if !children.is_empty() {
155
- session["children"] = json!(children);
156
- }
157
- }
158
- }
159
-
160
- serde_json::to_string_pretty(&sessions).map_err(Into::into)
161
- }
162
-
163
- fn extract_session_status(filename: &str) -> &str {
164
- let name = filename.strip_suffix(".json").unwrap_or(filename);
165
- if name.ends_with(".open") {
166
- "open"
167
- } else if name.ends_with(".active") {
168
- "active"
169
- } else if name.ends_with(".paused") {
170
- "paused"
171
- } else if name.ends_with(".closed") {
172
- "closed"
173
- } else {
174
- "unknown"
175
- }
176
- }
177
-
178
- fn synthesize_id_from_filename(filename: &str) -> String {
179
- let name = filename.strip_suffix(".json").unwrap_or(filename);
180
- let base = name
181
- .strip_suffix(".open")
182
- .or_else(|| name.strip_suffix(".active"))
183
- .or_else(|| name.strip_suffix(".paused"))
184
- .or_else(|| name.strip_suffix(".closed"))
185
- .unwrap_or(name);
186
- format!("s-{}", &base[..std::cmp::min(base.len(), 20)])
187
- }
@@ -1,308 +0,0 @@
1
- use std::path::{Path, PathBuf};
2
-
3
- use anyhow::{Context, Result};
4
- use serde_json::{json, Value};
5
-
6
- use super::resolve_project_dir;
7
- use crate::storage::config::read_config;
8
- use crate::storage::ensure_handoff_exists;
9
- use crate::storage::tasks::{build_task_index, TaskIndex, TaskSummary};
10
-
11
- /// Maximum depth (relative to the base project dir) scanned for nested
12
- /// `.handoff/` child projects.
13
- const MAX_CHILD_SCAN_DEPTH: usize = 5;
14
-
15
- /// Directory names skipped while scanning for child projects.
16
- const DEFAULT_SCAN_EXCLUDES: &[&str] = &["node_modules", ".git", "target", "dist", ".next"];
17
-
18
- pub fn handle(arguments: &Value) -> Result<String> {
19
- let project_dir = resolve_project_dir(arguments)?;
20
-
21
- let handoff = ensure_handoff_exists(&project_dir)?;
22
- let tasks_dir = handoff.join("tasks");
23
- let config_path = handoff.join("config.toml");
24
-
25
- let done_task_limit = if config_path.exists() {
26
- read_config(&config_path)
27
- .map(|c| c.settings.done_task_limit)
28
- .unwrap_or(10)
29
- } else {
30
- 10
31
- };
32
-
33
- let (tree, summary) = build_task_index(&tasks_dir, done_task_limit)?;
34
-
35
- let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
36
- let assignee_filter = arguments.get("assignee_filter").and_then(|v| v.as_str());
37
- let milestone_filter = arguments.get("milestone_filter").and_then(|v| v.as_str());
38
- let priority_filter = arguments.get("priority_filter").and_then(|v| v.as_str());
39
- let label_filter = arguments.get("label_filter").and_then(|v| v.as_str());
40
-
41
- let filters = Filters {
42
- status: status_filter,
43
- assignee: assignee_filter,
44
- milestone: milestone_filter,
45
- priority: priority_filter,
46
- label: label_filter,
47
- };
48
-
49
- let filtered_tree = if filters.any_active() {
50
- filter_tree(&tree, &filters)
51
- } else {
52
- tree
53
- };
54
-
55
- let include_children = arguments
56
- .get("include_children")
57
- .and_then(|v| v.as_bool())
58
- .unwrap_or(false);
59
-
60
- if !include_children {
61
- let result = serde_json::json!({
62
- "task_tree": filtered_tree,
63
- "task_summary": summary,
64
- });
65
- return serde_json::to_string_pretty(&result).context("Failed to serialize task list");
66
- }
67
-
68
- // Determine own project name for annotation (defaults to config, fallback to "root").
69
- let own_project_name = if config_path.exists() {
70
- read_config(&config_path)
71
- .map(|c| c.project.name)
72
- .unwrap_or_else(|_| "root".to_string())
73
- } else {
74
- "root".to_string()
75
- };
76
- let own_project_dir = project_dir.to_string_lossy().to_string();
77
-
78
- let mut aggregated: Vec<Value> =
79
- annotate_tasks_json(filtered_tree, &own_project_name, &own_project_dir);
80
- let mut total_summary = summary;
81
-
82
- for (child_name, child_dir) in discover_child_projects(&project_dir, MAX_CHILD_SCAN_DEPTH) {
83
- let child_handoff = child_dir.join(".handoff");
84
- let child_tasks_dir = child_handoff.join("tasks");
85
- let child_config_path = child_handoff.join("config.toml");
86
- let child_done_task_limit = read_config(&child_config_path)
87
- .map(|c| c.settings.done_task_limit)
88
- .unwrap_or(10);
89
-
90
- let (child_tree, child_summary) =
91
- match build_task_index(&child_tasks_dir, child_done_task_limit) {
92
- Ok(v) => v,
93
- Err(_) => continue,
94
- };
95
-
96
- let child_filtered = if filters.any_active() {
97
- filter_tree(&child_tree, &filters)
98
- } else {
99
- child_tree
100
- };
101
-
102
- let child_dir_str = child_dir.to_string_lossy().to_string();
103
- aggregated.extend(annotate_tasks_json(
104
- child_filtered,
105
- &child_name,
106
- &child_dir_str,
107
- ));
108
- merge_summary(&mut total_summary, &child_summary);
109
- }
110
-
111
- let result = serde_json::json!({
112
- "task_tree": aggregated,
113
- "task_summary": total_summary,
114
- });
115
-
116
- serde_json::to_string_pretty(&result).context("Failed to serialize task list")
117
- }
118
-
119
- fn merge_summary(base: &mut TaskSummary, other: &TaskSummary) {
120
- base.total += other.total;
121
- for (status, count) in &other.by_status {
122
- *base.by_status.entry(status.clone()).or_insert(0) += count;
123
- }
124
- base.overdue_count += other.overdue_count;
125
- if let Some(other_est) = other.total_estimate_hours {
126
- base.total_estimate_hours = Some(base.total_estimate_hours.unwrap_or(0.0) + other_est);
127
- }
128
- if let Some(other_act) = other.total_actual_hours {
129
- base.total_actual_hours = Some(base.total_actual_hours.unwrap_or(0.0) + other_act);
130
- }
131
- if base.total > 0 {
132
- let done = *base.by_status.get("done").unwrap_or(&0) as f64;
133
- let skipped = *base.by_status.get("skipped").unwrap_or(&0) as f64;
134
- base.completion_rate = Some((done + skipped) / base.total as f64);
135
- }
136
- }
137
-
138
- /// Annotate a task tree with `project_name`/`project_dir` and a composite
139
- /// `task_ref` disambiguator (`{project_name}-{project_dir_hash}:{id}`) for
140
- /// cross-project display purposes.
141
- ///
142
- /// The original `id` field is deliberately left **unmodified**: it is the
143
- /// value accepted by `handoff_get_task`/`handoff_update_task`/etc. (scoped by
144
- /// the sibling `project_dir` field), and it is also what `dependencies`
145
- /// entries reference. Rewriting `id` would make it unusable with every other
146
- /// tool and would desync it from `dependencies`, which still contain raw ids.
147
- ///
148
- /// `project_name` alone is free-form user text with no cross-directory
149
- /// uniqueness guarantee, so two child projects can share the same name. The
150
- /// `task_ref` additionally embeds a short hash of `project_dir` (which is
151
- /// unique per filesystem location) to guarantee it never collides even when
152
- /// `project_name` is duplicated.
153
- fn annotate_tasks_json(tasks: Vec<TaskIndex>, project_name: &str, project_dir: &str) -> Vec<Value> {
154
- let dir_hash = short_hash(project_dir);
155
- tasks
156
- .into_iter()
157
- .map(|t| {
158
- let mut v = serde_json::to_value(&t).unwrap_or_default();
159
- if let Some(obj) = v.as_object_mut() {
160
- obj.insert("project_name".to_string(), json!(project_name));
161
- obj.insert("project_dir".to_string(), json!(project_dir));
162
- if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(String::from) {
163
- obj.insert(
164
- "task_ref".to_string(),
165
- json!(format!("{project_name}-{dir_hash}:{id}")),
166
- );
167
- }
168
- if let Some(children) = obj.remove("children") {
169
- if let Ok(child_tasks) = serde_json::from_value::<Vec<TaskIndex>>(children) {
170
- obj.insert(
171
- "children".to_string(),
172
- json!(annotate_tasks_json(child_tasks, project_name, project_dir)),
173
- );
174
- }
175
- }
176
- }
177
- v
178
- })
179
- .collect()
180
- }
181
-
182
- /// Deterministic short hash (FNV-1a, hex-encoded) used to disambiguate
183
- /// composite task IDs across child projects that share a `project_name`.
184
- /// Not a dependency addition — plain FNV-1a is sufficient since this only
185
- /// needs to be stable and collision-resistant for distinct directory paths,
186
- /// not cryptographically secure.
187
- fn short_hash(input: &str) -> String {
188
- const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
189
- const FNV_PRIME: u64 = 0x100000001b3;
190
- let mut hash = FNV_OFFSET_BASIS;
191
- for byte in input.as_bytes() {
192
- hash ^= u64::from(*byte);
193
- hash = hash.wrapping_mul(FNV_PRIME);
194
- }
195
- format!("{hash:08x}")
196
- }
197
-
198
- fn discover_child_projects(base: &Path, max_depth: usize) -> Vec<(String, PathBuf)> {
199
- let mut results = Vec::new();
200
- scan_children(base, 1, max_depth, &mut results);
201
- results
202
- }
203
-
204
- fn scan_children(dir: &Path, depth: usize, max_depth: usize, results: &mut Vec<(String, PathBuf)>) {
205
- if depth > max_depth {
206
- return;
207
- }
208
- let entries = match std::fs::read_dir(dir) {
209
- Ok(e) => e,
210
- Err(_) => return,
211
- };
212
- for entry in entries.flatten() {
213
- if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
214
- continue;
215
- }
216
- let name = entry.file_name();
217
- let name_str = name.to_string_lossy();
218
- if name_str.starts_with('.') || DEFAULT_SCAN_EXCLUDES.contains(&name_str.as_ref()) {
219
- continue;
220
- }
221
- let path = entry.path();
222
- let config_path = path.join(".handoff").join("config.toml");
223
- if config_path.exists() {
224
- if let Ok(config) = read_config(&config_path) {
225
- results.push((config.project.name.clone(), path.clone()));
226
- }
227
- }
228
- scan_children(&path, depth + 1, max_depth, results);
229
- }
230
- }
231
-
232
- struct Filters<'a> {
233
- status: Option<&'a str>,
234
- assignee: Option<&'a str>,
235
- milestone: Option<&'a str>,
236
- priority: Option<&'a str>,
237
- label: Option<&'a str>,
238
- }
239
-
240
- impl Filters<'_> {
241
- fn any_active(&self) -> bool {
242
- self.status.is_some()
243
- || self.assignee.is_some()
244
- || self.milestone.is_some()
245
- || self.priority.is_some()
246
- || self.label.is_some()
247
- }
248
-
249
- fn matches(&self, node: &TaskIndex, data: Option<&crate::storage::tasks::TaskData>) -> bool {
250
- if let Some(status) = self.status {
251
- if node.status != status {
252
- return false;
253
- }
254
- }
255
- if let Some(assignee) = self.assignee {
256
- if node.assignee.as_deref() != Some(assignee) {
257
- return false;
258
- }
259
- }
260
- if let Some(milestone) = self.milestone {
261
- let ms = node.schedule.as_ref().and_then(|s| s.milestone.as_deref());
262
- if ms != Some(milestone) {
263
- return false;
264
- }
265
- }
266
- if let Some(priority) = self.priority {
267
- if let Some(d) = data {
268
- if d.priority.as_deref() != Some(priority) {
269
- return false;
270
- }
271
- } else {
272
- return false;
273
- }
274
- }
275
- if let Some(label) = self.label {
276
- if let Some(d) = data {
277
- if !d.labels.iter().any(|l| l == label) {
278
- return false;
279
- }
280
- } else {
281
- return false;
282
- }
283
- }
284
- true
285
- }
286
- }
287
-
288
- fn filter_tree(tree: &[TaskIndex], filters: &Filters) -> Vec<TaskIndex> {
289
- tree.iter()
290
- .filter_map(|node| {
291
- let children = filter_tree(&node.children, filters);
292
- if filters.matches(node, None) || !children.is_empty() {
293
- Some(TaskIndex {
294
- id: node.id.clone(),
295
- title: node.title.clone(),
296
- status: node.status.clone(),
297
- schedule: node.schedule.clone(),
298
- dependencies: node.dependencies.clone(),
299
- order: node.order,
300
- assignee: node.assignee.clone(),
301
- children,
302
- })
303
- } else {
304
- None
305
- }
306
- })
307
- .collect()
308
- }