handoff-mcp-server 0.6.0 → 0.6.1

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 CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.6.0"
147
+ version = "0.6.1"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.6.0"
3
+ version = "0.6.1"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
Binary file
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -93,10 +93,165 @@ pub fn handle(arguments: &Value) -> Result<String> {
93
93
  target_dir.to_string_lossy().to_string()
94
94
  };
95
95
 
96
- Ok(format!(
96
+ let mut msg = format!(
97
97
  "Referral sent: {id}\n From: {}\n To: {target_name}\n Type: {referral_type}\n Summary: {summary}",
98
98
  source_config.project.name
99
- ))
99
+ );
100
+
101
+ for w in collect_refer_warnings(&data, &source_project_dir, &target_dir) {
102
+ msg.push_str(&format!("\n{w}"));
103
+ }
104
+
105
+ Ok(msg)
106
+ }
107
+
108
+ fn collect_refer_warnings(
109
+ data: &ReferralData,
110
+ source_dir: &Path,
111
+ target_dir: &Path,
112
+ ) -> Vec<String> {
113
+ let mut warnings = Vec::new();
114
+
115
+ if data.details.is_none() {
116
+ warnings.push(
117
+ "Warning: No details. The target project won't know what specifically to do. \
118
+ Add a 'details' field describing the change, its impact, and what needs updating."
119
+ .to_string(),
120
+ );
121
+ }
122
+
123
+ if data.tasks.is_empty() {
124
+ warnings.push(
125
+ "Warning: No tasks. Consider adding suggested tasks with done_criteria \
126
+ so the target project has actionable items to work from."
127
+ .to_string(),
128
+ );
129
+ }
130
+
131
+ if let Some(ref ctx) = data.context {
132
+ let refs = collect_refs_from_value(ctx);
133
+ if refs.is_empty() {
134
+ warnings.push(
135
+ "Warning: context has no spec/doc references. Add 'spec_docs' with wiki paths, \
136
+ MR URLs, or file paths so the target can find the authoritative specification."
137
+ .to_string(),
138
+ );
139
+ } else {
140
+ for r in &refs {
141
+ if r.starts_with("http://") || r.starts_with("https://") {
142
+ continue;
143
+ }
144
+ let clean = r.split(" — ").next().unwrap_or(r).trim();
145
+ let clean = clean.split('#').next().unwrap_or(clean).trim();
146
+ let p = Path::new(clean);
147
+ if p.is_absolute() {
148
+ if !p.exists() {
149
+ warnings.push(format!(
150
+ "Warning: spec reference path does not exist: {clean}"
151
+ ));
152
+ }
153
+ } else {
154
+ let in_source = source_dir.join(clean);
155
+ let in_target = target_dir.join(clean);
156
+ if !in_source.exists() && !in_target.exists() {
157
+ warnings.push(format!(
158
+ "Warning: spec reference path does not exist \
159
+ in source or target project: {clean}"
160
+ ));
161
+ } else if !in_target.exists() {
162
+ warnings.push(format!(
163
+ "Warning: spec reference '{clean}' exists in source project \
164
+ but not in target project. Use an absolute path or ensure \
165
+ the target has this file."
166
+ ));
167
+ }
168
+ }
169
+ }
170
+ }
171
+ } else {
172
+ warnings.push(
173
+ "Warning: No context. Add a 'context' field with 'spec_docs' referencing the \
174
+ authoritative specification (wiki paths, MR URLs, source file paths)."
175
+ .to_string(),
176
+ );
177
+ }
178
+
179
+ if data.priority.is_none() {
180
+ warnings.push(
181
+ "Warning: No priority. Set 'priority' (low/medium/high) so the target project \
182
+ can triage this referral appropriately."
183
+ .to_string(),
184
+ );
185
+ }
186
+
187
+ for (i, task) in data.tasks.iter().enumerate() {
188
+ let has_criteria = task
189
+ .get("done_criteria")
190
+ .and_then(|v| v.as_array())
191
+ .is_some_and(|a| !a.is_empty());
192
+ if !has_criteria {
193
+ let title = task
194
+ .get("title")
195
+ .and_then(|v| v.as_str())
196
+ .unwrap_or("(untitled)");
197
+ warnings.push(format!(
198
+ "Warning: Task #{} '{}' has no done_criteria. \
199
+ Add criteria so the target knows when the task is complete.",
200
+ i + 1,
201
+ title
202
+ ));
203
+ }
204
+ }
205
+
206
+ warnings
207
+ }
208
+
209
+ fn is_ref_string(s: &str) -> bool {
210
+ s.starts_with("http://")
211
+ || s.starts_with("https://")
212
+ || s.starts_with('/')
213
+ || s.starts_with("wiki/")
214
+ || s.starts_with("docs/")
215
+ || s.starts_with("src/")
216
+ || s.ends_with(".md")
217
+ || s.ends_with(".rs")
218
+ || s.ends_with(".ts")
219
+ || s.ends_with(".toml")
220
+ }
221
+
222
+ fn collect_refs_from_value(val: &Value) -> Vec<String> {
223
+ let mut refs = Vec::new();
224
+ match val {
225
+ Value::String(s) => {
226
+ if is_ref_string(s) {
227
+ refs.push(s.clone());
228
+ }
229
+ }
230
+ Value::Array(arr) => {
231
+ for item in arr {
232
+ refs.extend(collect_refs_from_value(item));
233
+ }
234
+ }
235
+ Value::Object(map) => {
236
+ for (key, v) in map {
237
+ if key == "spec_docs"
238
+ || key == "source_wiki"
239
+ || key == "source_data_model"
240
+ || key.contains("spec")
241
+ || key.contains("doc")
242
+ || key.contains("wiki")
243
+ {
244
+ refs.extend(collect_refs_from_value(v));
245
+ } else if let Value::String(s) = v {
246
+ if is_ref_string(s) {
247
+ refs.push(s.clone());
248
+ }
249
+ }
250
+ }
251
+ }
252
+ _ => {}
253
+ }
254
+ refs
100
255
  }
101
256
 
102
257
  fn resolve_target(arguments: &Value, scan_dirs: &[String]) -> Result<PathBuf> {
@@ -1,3 +1,5 @@
1
+ use std::path::Path;
2
+
1
3
  use anyhow::Result;
2
4
  use chrono::Utc;
3
5
  use serde_json::Value;
@@ -95,14 +97,14 @@ pub fn handle(arguments: &Value) -> Result<String> {
95
97
  ));
96
98
  }
97
99
 
98
- for w in collect_save_warnings(&data) {
100
+ for w in collect_save_warnings(&data, &project_dir) {
99
101
  msg.push_str(&format!("\n{w}"));
100
102
  }
101
103
 
102
104
  Ok(msg)
103
105
  }
104
106
 
105
- fn collect_save_warnings(data: &SessionData) -> Vec<String> {
107
+ fn collect_save_warnings(data: &SessionData, project_dir: &Path) -> Vec<String> {
106
108
  let mut warnings = Vec::new();
107
109
 
108
110
  if data.checklist.is_empty() {
@@ -169,6 +171,51 @@ fn collect_save_warnings(data: &SessionData) -> Vec<String> {
169
171
  "Warning: No references. Consider adding links to relevant docs, issues, or MRs."
170
172
  .to_string(),
171
173
  );
174
+ } else {
175
+ for r in &data.references {
176
+ let uri = r.get("uri").and_then(|v| v.as_str()).unwrap_or("");
177
+ if uri.is_empty() {
178
+ continue;
179
+ }
180
+ if uri.starts_with("http://") || uri.starts_with("https://") {
181
+ continue;
182
+ }
183
+ if uri.starts_with("ref-") {
184
+ continue;
185
+ }
186
+ let resolved = if Path::new(uri).is_absolute() {
187
+ std::path::PathBuf::from(uri)
188
+ } else {
189
+ project_dir.join(uri)
190
+ };
191
+ let check_path = resolved
192
+ .to_string_lossy()
193
+ .split('#')
194
+ .next()
195
+ .unwrap_or("")
196
+ .to_string();
197
+ if !Path::new(&check_path).exists() {
198
+ let label = r.get("label").and_then(|v| v.as_str()).unwrap_or("");
199
+ warnings.push(format!(
200
+ "Warning: reference '{label}' path does not exist: {uri}"
201
+ ));
202
+ }
203
+ }
204
+ }
205
+
206
+ for cp in &data.context_pointers {
207
+ let p = cp.get("path").and_then(|v| v.as_str()).unwrap_or("");
208
+ if p.is_empty() {
209
+ continue;
210
+ }
211
+ let resolved = if Path::new(p).is_absolute() {
212
+ std::path::PathBuf::from(p)
213
+ } else {
214
+ project_dir.join(p)
215
+ };
216
+ if !resolved.exists() {
217
+ warnings.push(format!("Warning: context_pointer path does not exist: {p}"));
218
+ }
172
219
  }
173
220
 
174
221
  warnings
@@ -440,3 +440,165 @@ fn no_referrals_dir_is_graceful() {
440
440
  let parsed: Value = serde_json::from_str(&text).unwrap();
441
441
  assert_eq!(parsed["total"], 0);
442
442
  }
443
+
444
+ #[test]
445
+ fn refer_warns_on_missing_details() {
446
+ let (_base, proj_a, proj_b) = setup_two_projects();
447
+
448
+ let resp = call_tool(
449
+ "handoff_refer",
450
+ json!({
451
+ "project_dir": proj_a.to_string_lossy(),
452
+ "target_project_dir": proj_b.to_string_lossy(),
453
+ "summary": "Minimal referral"
454
+ }),
455
+ );
456
+
457
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
458
+ let text = get_text(&resp);
459
+ assert!(text.contains("Referral sent"));
460
+ let warning_count = text.matches("Warning").count();
461
+ assert!(
462
+ warning_count >= 3,
463
+ "should have at least 3 warnings (details, tasks, context), got {warning_count}: {text}"
464
+ );
465
+ assert!(
466
+ text.contains("details"),
467
+ "should warn about missing details: {text}"
468
+ );
469
+ assert!(
470
+ text.contains("tasks"),
471
+ "should warn about missing tasks: {text}"
472
+ );
473
+ assert!(
474
+ text.contains("context"),
475
+ "should warn about missing context: {text}"
476
+ );
477
+ }
478
+
479
+ #[test]
480
+ fn refer_warns_on_tasks_without_done_criteria() {
481
+ let (_base, proj_a, proj_b) = setup_two_projects();
482
+
483
+ let resp = call_tool(
484
+ "handoff_refer",
485
+ json!({
486
+ "project_dir": proj_a.to_string_lossy(),
487
+ "target_project_dir": proj_b.to_string_lossy(),
488
+ "summary": "Referral with bare tasks",
489
+ "details": "Some details here",
490
+ "priority": "high",
491
+ "context": { "branch": "main" },
492
+ "tasks": [
493
+ { "title": "Task without criteria" },
494
+ { "title": "Task with criteria", "done_criteria": [{"item": "check", "checked": false}] }
495
+ ]
496
+ }),
497
+ );
498
+
499
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
500
+ let text = get_text(&resp);
501
+ assert!(
502
+ text.contains("Task #1 'Task without criteria' has no done_criteria"),
503
+ "should warn about task without criteria: {text}"
504
+ );
505
+ assert!(
506
+ !text.contains("Task #2"),
507
+ "should NOT warn about task with criteria: {text}"
508
+ );
509
+ }
510
+
511
+ #[test]
512
+ fn refer_no_warnings_when_complete() {
513
+ let (_base, proj_a, proj_b) = setup_two_projects();
514
+
515
+ let resp = call_tool(
516
+ "handoff_refer",
517
+ json!({
518
+ "project_dir": proj_a.to_string_lossy(),
519
+ "target_project_dir": proj_b.to_string_lossy(),
520
+ "summary": "Complete referral",
521
+ "referral_type": "request",
522
+ "priority": "high",
523
+ "details": "Full description of what needs to happen",
524
+ "context": {
525
+ "branch": "feat/x",
526
+ "commit": "abc123",
527
+ "spec_docs": [
528
+ format!("{}/.handoff/config.toml", proj_a.to_string_lossy()),
529
+ "https://gitlab.example.com/project/-/merge_requests/1"
530
+ ]
531
+ },
532
+ "tasks": [
533
+ {
534
+ "title": "Do the thing",
535
+ "done_criteria": [{"item": "Thing is done", "checked": false}]
536
+ }
537
+ ]
538
+ }),
539
+ );
540
+
541
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
542
+ let text = get_text(&resp);
543
+ assert!(text.contains("Referral sent"));
544
+ assert!(
545
+ !text.contains("Warning"),
546
+ "should have no warnings when fully specified: {text}"
547
+ );
548
+ }
549
+
550
+ #[test]
551
+ fn refer_warns_on_context_without_spec_docs() {
552
+ let (_base, proj_a, proj_b) = setup_two_projects();
553
+
554
+ let resp = call_tool(
555
+ "handoff_refer",
556
+ json!({
557
+ "project_dir": proj_a.to_string_lossy(),
558
+ "target_project_dir": proj_b.to_string_lossy(),
559
+ "summary": "Has context but no spec refs",
560
+ "details": "Description",
561
+ "priority": "medium",
562
+ "context": { "branch": "main", "commit": "abc123" },
563
+ "tasks": [
564
+ { "title": "Task", "done_criteria": [{"item": "check", "checked": false}] }
565
+ ]
566
+ }),
567
+ );
568
+
569
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
570
+ let text = get_text(&resp);
571
+ assert!(
572
+ text.contains("spec/doc references"),
573
+ "should warn about missing spec references in context: {text}"
574
+ );
575
+ }
576
+
577
+ #[test]
578
+ fn refer_warns_on_nonexistent_spec_path() {
579
+ let (_base, proj_a, proj_b) = setup_two_projects();
580
+
581
+ let resp = call_tool(
582
+ "handoff_refer",
583
+ json!({
584
+ "project_dir": proj_a.to_string_lossy(),
585
+ "target_project_dir": proj_b.to_string_lossy(),
586
+ "summary": "Spec path does not exist",
587
+ "details": "Description",
588
+ "priority": "medium",
589
+ "context": {
590
+ "spec_docs": ["/nonexistent/path/to/spec.md"]
591
+ },
592
+ "tasks": [
593
+ { "title": "Task", "done_criteria": [{"item": "check", "checked": false}] }
594
+ ]
595
+ }),
596
+ );
597
+
598
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
599
+ let text = get_text(&resp);
600
+ assert!(
601
+ text.contains("does not exist"),
602
+ "should warn about nonexistent spec path: {text}"
603
+ );
604
+ }
@@ -583,13 +583,13 @@ fn save_context_no_warnings_when_valid() {
583
583
  { "note": "Next: implement feature Y", "category": "suggestion" }
584
584
  ],
585
585
  "context_pointers": [
586
- { "path": "src/main.rs", "reason": "Entry point" }
586
+ { "path": ".handoff/config.toml", "reason": "Entry point" }
587
587
  ],
588
588
  "decisions": [
589
589
  { "decision": "Use approach A", "confidence": "confirmed" }
590
590
  ],
591
591
  "references": [
592
- { "label": "Design doc", "uri": "docs/design.md", "type": "doc" }
592
+ { "label": "Config", "uri": ".handoff/config.toml", "type": "doc" }
593
593
  ]
594
594
  }),
595
595
  );