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,354 +0,0 @@
1
- use std::path::Path;
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::config::read_config;
9
- use crate::storage::ensure_handoff_exists;
10
- use crate::storage::git::capture_git_state;
11
- use crate::storage::sessions::{
12
- close_session_by_id, enforce_history_limit, generate_session_id, pause_active_sessions,
13
- pause_session_by_id, read_active_sessions, update_active_session,
14
- update_and_close_active_session, write_session_with_status, SessionData,
15
- };
16
-
17
- pub fn handle(arguments: &Value) -> Result<String> {
18
- let project_dir = resolve_project_dir(arguments)?;
19
-
20
- let handoff = ensure_handoff_exists(&project_dir)?;
21
- let sessions_dir = handoff.join("sessions");
22
- let config_path = handoff.join("config.toml");
23
-
24
- let close_id = arguments.get("close_session_id").and_then(|v| v.as_str());
25
- let pause_id = arguments.get("pause_session_id").and_then(|v| v.as_str());
26
- let pause_all = arguments
27
- .get("pause_active")
28
- .and_then(|v| v.as_bool())
29
- .unwrap_or(false);
30
- let is_pause_only = arguments
31
- .get("pause_only")
32
- .and_then(|v| v.as_bool())
33
- .unwrap_or(false);
34
-
35
- let summary_opt = arguments.get("summary").and_then(|v| v.as_str());
36
-
37
- if !is_pause_only && summary_opt.is_none() {
38
- anyhow::bail!("'summary' is required (unless pause_only=true)");
39
- }
40
-
41
- let summary = summary_opt.unwrap_or("");
42
-
43
- let mut total_paused = 0usize;
44
- if let Some(id) = pause_id {
45
- if pause_session_by_id(&sessions_dir, id)?.is_some() {
46
- total_paused = 1;
47
- }
48
- } else if pause_all {
49
- total_paused = pause_active_sessions(&sessions_dir)?.len();
50
- }
51
-
52
- if is_pause_only {
53
- let mut msg = String::new();
54
- if total_paused > 0 {
55
- msg.push_str(&format!("Paused {} session(s)", total_paused));
56
- }
57
- if let Some(id) = pause_id {
58
- if total_paused == 0 {
59
- msg.push_str(&format!(
60
- "Warning: pause_session_id '{id}' not found among active/open sessions"
61
- ));
62
- }
63
- }
64
- if !pause_all && pause_id.is_none() {
65
- msg.push_str("Warning: pause_only=true requires pause_session_id or pause_active=true");
66
- }
67
- if msg.is_empty() {
68
- msg.push_str("No sessions were paused (none active)");
69
- }
70
- return Ok(msg);
71
- }
72
-
73
- let session_status = arguments
74
- .get("session_status")
75
- .and_then(|v| v.as_str())
76
- .unwrap_or("closed");
77
- let keep_active = session_status == "active";
78
-
79
- let git_state = capture_git_state(&project_dir)?;
80
- let now = Utc::now().to_rfc3339();
81
-
82
- let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
83
-
84
- let handoff_updates = SessionData {
85
- version: 2,
86
- id: None,
87
- ended_at: Some(now),
88
- summary: summary.to_string(),
89
- branch: Some(git_state.branch),
90
- commit: Some(git_state.commit),
91
- dirty_files: git_state.dirty_files,
92
- decisions: extract_array(arguments, "decisions"),
93
- blockers: extract_string_array(arguments, "blockers"),
94
- checklist: extract_array(arguments, "checklist"),
95
- handoff_notes: extract_array(arguments, "handoff_notes"),
96
- references: extract_array(arguments, "references"),
97
- context_pointers: extract_array(arguments, "context_pointers"),
98
- environment: arguments.get("environment").cloned(),
99
- timeline: arguments
100
- .get("timeline")
101
- .and_then(|v| v.as_str())
102
- .map(String::from),
103
- label: arguments
104
- .get("label")
105
- .and_then(|v| v.as_str())
106
- .map(String::from),
107
- parent_session_id: None,
108
- related_task_ids: extract_string_array(arguments, "related_task_ids"),
109
- };
110
-
111
- let (total_closed, path, session_id) = if let Some(id) = close_id {
112
- let closed = close_session_by_id(&sessions_dir, id)?;
113
- (if closed.is_some() { 1 } else { 0 }, None, None)
114
- } else if pause_id.is_some() || pause_all {
115
- (0, None, None)
116
- } else {
117
- let active = read_active_sessions(&sessions_dir)?;
118
- let target = if let Some(tid) = target_session_id {
119
- active.iter().find(|s| {
120
- s.id.as_deref()
121
- .is_some_and(|id| id == tid || id.starts_with(tid) || tid.starts_with(id))
122
- })
123
- } else if active.len() == 1 {
124
- active.first()
125
- } else if active.len() > 1 {
126
- active.iter().last()
127
- } else {
128
- None
129
- };
130
- if let Some(active_session) = target {
131
- let sid = active_session.id.clone().unwrap_or_default();
132
- if keep_active {
133
- let updated_path =
134
- update_active_session(&sessions_dir, &sid, &handoff_updates, Some(arguments))?;
135
- (0, updated_path, Some(sid))
136
- } else {
137
- let closed_path = update_and_close_active_session(
138
- &sessions_dir,
139
- &sid,
140
- &handoff_updates,
141
- Some(arguments),
142
- )?;
143
- (
144
- if closed_path.is_some() { 1 } else { 0 },
145
- closed_path,
146
- Some(sid),
147
- )
148
- }
149
- } else if target_session_id.is_some() {
150
- anyhow::bail!(
151
- "session_id '{}' not found among active sessions",
152
- target_session_id.unwrap_or("")
153
- );
154
- } else {
155
- let new_id = generate_session_id();
156
- let mut data = handoff_updates.clone();
157
- data.id = Some(new_id.clone());
158
- let target_status = if keep_active { "active" } else { "closed" };
159
- let p = write_session_with_status(&sessions_dir, &data, target_status)?;
160
- (0, Some(p), Some(new_id))
161
- }
162
- };
163
-
164
- let history_limit = if config_path.exists() {
165
- read_config(&config_path)
166
- .map(|c| c.settings.history_limit)
167
- .unwrap_or(20)
168
- } else {
169
- 20
170
- };
171
- let removed = enforce_history_limit(&sessions_dir, history_limit)?;
172
-
173
- let file_display = path
174
- .as_ref()
175
- .and_then(|p| p.file_name())
176
- .map(|n| n.to_string_lossy().to_string())
177
- .unwrap_or_else(|| "(none)".to_string());
178
- let sid_display = session_id.as_deref().unwrap_or("(no active session)");
179
- let mut msg = format!(
180
- "Session saved: {}\nSession ID: {}\nFile: {}",
181
- summary, sid_display, file_display
182
- );
183
-
184
- if keep_active {
185
- msg.push_str("\nSession kept active (session_status: active)");
186
- }
187
-
188
- if total_paused > 0 {
189
- msg.push_str(&format!("\nPaused {} session(s)", total_paused));
190
- }
191
- if let Some(id) = pause_id {
192
- if total_paused == 0 {
193
- msg.push_str(&format!(
194
- "\nWarning: pause_session_id '{id}' not found among active/open sessions"
195
- ));
196
- }
197
- }
198
- if total_closed > 0 {
199
- msg.push_str(&format!("\nClosed {} previous session(s)", total_closed));
200
- }
201
- if let Some(id) = close_id {
202
- if total_closed == 0 {
203
- msg.push_str(&format!(
204
- "\nWarning: close_session_id '{id}' not found among active/open/paused sessions"
205
- ));
206
- }
207
- }
208
- if removed > 0 {
209
- msg.push_str(&format!(
210
- "\nRemoved {removed} old session(s) (history_limit: {history_limit})"
211
- ));
212
- }
213
-
214
- for w in collect_save_warnings(&handoff_updates, &project_dir) {
215
- msg.push_str(&format!("\n{w}"));
216
- }
217
-
218
- Ok(msg)
219
- }
220
-
221
- fn collect_save_warnings(data: &SessionData, project_dir: &Path) -> Vec<String> {
222
- let mut warnings = Vec::new();
223
-
224
- if data.checklist.is_empty() {
225
- warnings.push(
226
- "Warning: No checklist items. Consider adding verification items for the next session."
227
- .to_string(),
228
- );
229
- } else {
230
- let unchecked: Vec<&str> = data
231
- .checklist
232
- .iter()
233
- .filter_map(|item| {
234
- let checked = item
235
- .get("checked")
236
- .and_then(|v| v.as_bool())
237
- .unwrap_or(false);
238
- if !checked {
239
- item.get("item").and_then(|v| v.as_str())
240
- } else {
241
- None
242
- }
243
- })
244
- .collect();
245
-
246
- if !unchecked.is_empty() {
247
- warnings.push(format!(
248
- "Warning: {} unchecked checklist item(s) \u{2014} {}",
249
- unchecked.len(),
250
- unchecked.join(", ")
251
- ));
252
- }
253
- }
254
-
255
- let has_suggestion = data.handoff_notes.iter().any(|note| {
256
- note.get("category")
257
- .and_then(|v| v.as_str())
258
- .is_some_and(|c| c == "suggestion")
259
- });
260
- if !has_suggestion {
261
- warnings.push(
262
- "Warning: No 'suggestion' handoff_notes \u{2014} the next session won't know what to \
263
- do first. Add at least one note with category 'suggestion' describing the recommended \
264
- next action."
265
- .to_string(),
266
- );
267
- }
268
-
269
- if data.context_pointers.is_empty() {
270
- warnings.push(
271
- "Warning: No context_pointers. The next session won't know which files to read first."
272
- .to_string(),
273
- );
274
- }
275
-
276
- if data.decisions.is_empty() {
277
- warnings.push(
278
- "Warning: No decisions recorded. Consider documenting key decisions made during this session."
279
- .to_string(),
280
- );
281
- }
282
-
283
- if data.references.is_empty() {
284
- warnings.push(
285
- "Warning: No references. Consider adding links to relevant docs, issues, or MRs."
286
- .to_string(),
287
- );
288
- } else {
289
- for r in &data.references {
290
- let uri = r.get("uri").and_then(|v| v.as_str()).unwrap_or("");
291
- if uri.is_empty() {
292
- continue;
293
- }
294
- if uri.starts_with("http://") || uri.starts_with("https://") {
295
- continue;
296
- }
297
- if uri.starts_with("ref-") {
298
- continue;
299
- }
300
- let resolved = if Path::new(uri).is_absolute() {
301
- std::path::PathBuf::from(uri)
302
- } else {
303
- project_dir.join(uri)
304
- };
305
- let check_path = resolved
306
- .to_string_lossy()
307
- .split('#')
308
- .next()
309
- .unwrap_or("")
310
- .to_string();
311
- if !Path::new(&check_path).exists() {
312
- let label = r.get("label").and_then(|v| v.as_str()).unwrap_or("");
313
- warnings.push(format!(
314
- "Warning: reference '{label}' path does not exist: {uri}"
315
- ));
316
- }
317
- }
318
- }
319
-
320
- for cp in &data.context_pointers {
321
- let p = cp.get("path").and_then(|v| v.as_str()).unwrap_or("");
322
- if p.is_empty() {
323
- continue;
324
- }
325
- let resolved = if Path::new(p).is_absolute() {
326
- std::path::PathBuf::from(p)
327
- } else {
328
- project_dir.join(p)
329
- };
330
- if !resolved.exists() {
331
- warnings.push(format!("Warning: context_pointer path does not exist: {p}"));
332
- }
333
- }
334
-
335
- warnings
336
- }
337
-
338
- fn extract_array(val: &Value, key: &str) -> Vec<Value> {
339
- val.get(key)
340
- .and_then(|v| v.as_array())
341
- .cloned()
342
- .unwrap_or_default()
343
- }
344
-
345
- fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
346
- val.get(key)
347
- .and_then(|v| v.as_array())
348
- .map(|arr| {
349
- arr.iter()
350
- .filter_map(|v| v.as_str().map(String::from))
351
- .collect()
352
- })
353
- .unwrap_or_default()
354
- }