handoff-mcp-server 0.13.0 → 0.15.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.
- package/Cargo.lock +25 -229
- package/Cargo.toml +2 -2
- package/README.md +116 -18
- package/package.json +1 -1
- package/scripts/handoff-memory-hook.py +6 -6
- package/skills/handoff/SKILL.md +17 -0
- package/src/cli.rs +551 -0
- package/src/lib.rs +2 -0
- package/src/main.rs +57 -0
- package/src/mcp/handlers/bulk_update.rs +1 -1
- package/src/mcp/handlers/check_criterion.rs +5 -6
- package/src/mcp/handlers/config.rs +38 -0
- package/src/mcp/handlers/get_task.rs +3 -6
- package/src/mcp/handlers/log_time.rs +3 -6
- package/src/mcp/handlers/mod.rs +8 -4
- package/src/mcp/handlers/timer.rs +529 -0
- package/src/mcp/handlers/update_task.rs +10 -21
- package/src/mcp/tools.rs +41 -4
- package/src/setup.rs +424 -0
- package/src/storage/config.rs +25 -0
- package/src/storage/tasks.rs +95 -4
package/src/storage/tasks.rs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
use std::cmp::Reverse;
|
|
1
2
|
use std::collections::{HashMap, HashSet};
|
|
2
3
|
use std::path::{Path, PathBuf};
|
|
3
4
|
|
|
@@ -341,27 +342,117 @@ pub fn find_task_dir_by_id(tasks_dir: &Path, task_id: &str) -> Result<Option<Pat
|
|
|
341
342
|
find_task_dir_recursive(tasks_dir, task_id)
|
|
342
343
|
}
|
|
343
344
|
|
|
345
|
+
fn dir_name_could_match(dir_name: &str, task_id: &str) -> bool {
|
|
346
|
+
dir_name == task_id
|
|
347
|
+
|| (dir_name.starts_with(task_id) && dir_name.as_bytes().get(task_id.len()) == Some(&b'-'))
|
|
348
|
+
}
|
|
349
|
+
|
|
344
350
|
fn find_task_dir_recursive(dir: &Path, task_id: &str) -> Result<Option<PathBuf>> {
|
|
345
351
|
if !dir.exists() {
|
|
346
352
|
return Ok(None);
|
|
347
353
|
}
|
|
354
|
+
let mut candidates = Vec::new();
|
|
355
|
+
let mut other_subdirs = Vec::new();
|
|
348
356
|
for entry in std::fs::read_dir(dir)? {
|
|
349
357
|
let entry = entry?;
|
|
350
358
|
if !entry.file_type()?.is_dir() {
|
|
351
359
|
continue;
|
|
352
360
|
}
|
|
353
361
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
362
|
+
if dir_name_could_match(&name, task_id) {
|
|
363
|
+
candidates.push(entry.path());
|
|
364
|
+
} else {
|
|
365
|
+
other_subdirs.push(entry.path());
|
|
357
366
|
}
|
|
358
|
-
|
|
367
|
+
}
|
|
368
|
+
// Verify candidates by reading the JSON id field.
|
|
369
|
+
for candidate in &candidates {
|
|
370
|
+
if let Some((data, _)) = read_task(candidate)? {
|
|
371
|
+
if data.id == task_id {
|
|
372
|
+
return Ok(Some(candidate.clone()));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Recurse into all subdirectories (candidates that didn't match + others).
|
|
377
|
+
for subdir in candidates.into_iter().chain(other_subdirs) {
|
|
378
|
+
if let Some(found) = find_task_dir_recursive(&subdir, task_id)? {
|
|
359
379
|
return Ok(Some(found));
|
|
360
380
|
}
|
|
361
381
|
}
|
|
362
382
|
Ok(None)
|
|
363
383
|
}
|
|
364
384
|
|
|
385
|
+
fn collect_all_ids_recursive(dir: &Path, ids: &mut Vec<String>) -> Result<()> {
|
|
386
|
+
if !dir.exists() {
|
|
387
|
+
return Ok(());
|
|
388
|
+
}
|
|
389
|
+
for entry in std::fs::read_dir(dir)? {
|
|
390
|
+
let entry = entry?;
|
|
391
|
+
if !entry.file_type()?.is_dir() {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if let Some((data, _)) = read_task(&entry.path())? {
|
|
395
|
+
ids.push(data.id);
|
|
396
|
+
}
|
|
397
|
+
collect_all_ids_recursive(&entry.path(), ids)?;
|
|
398
|
+
}
|
|
399
|
+
Ok(())
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
pub fn suggest_task_id(tasks_dir: &Path, requested_id: &str) -> String {
|
|
403
|
+
let mut all_ids = Vec::new();
|
|
404
|
+
let _ = collect_all_ids_recursive(tasks_dir, &mut all_ids);
|
|
405
|
+
if all_ids.is_empty() {
|
|
406
|
+
return format!("Task not found: '{requested_id}'. No tasks exist yet.");
|
|
407
|
+
}
|
|
408
|
+
let mut scored: Vec<(&str, usize)> = all_ids
|
|
409
|
+
.iter()
|
|
410
|
+
.filter_map(|id| {
|
|
411
|
+
let score = fuzzy_score(requested_id, id);
|
|
412
|
+
if score > 0 {
|
|
413
|
+
Some((id.as_str(), score))
|
|
414
|
+
} else {
|
|
415
|
+
None
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
.collect();
|
|
419
|
+
scored.sort_by_key(|&(_, s)| Reverse(s));
|
|
420
|
+
scored.truncate(5);
|
|
421
|
+
|
|
422
|
+
if scored.is_empty() {
|
|
423
|
+
return format!(
|
|
424
|
+
"Task not found: '{requested_id}'. Use handoff_list_tasks to see available task IDs."
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
let suggestions: Vec<String> = scored.iter().map(|(id, _)| format!(" - {id}")).collect();
|
|
428
|
+
format!(
|
|
429
|
+
"Task not found: '{requested_id}'. Did you mean one of these?\n{}\n\
|
|
430
|
+
Use handoff_list_tasks to see all task IDs.",
|
|
431
|
+
suggestions.join("\n")
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
fn fuzzy_score(query: &str, candidate: &str) -> usize {
|
|
436
|
+
let q = query.to_lowercase();
|
|
437
|
+
let c = candidate.to_lowercase();
|
|
438
|
+
if c == q {
|
|
439
|
+
return 100;
|
|
440
|
+
}
|
|
441
|
+
if c.starts_with(&q) || q.starts_with(&c) {
|
|
442
|
+
return 80;
|
|
443
|
+
}
|
|
444
|
+
if c.contains(&q) || q.contains(&c) {
|
|
445
|
+
return 60;
|
|
446
|
+
}
|
|
447
|
+
let q_parts: Vec<&str> = q.split(['-', '.', '_']).collect();
|
|
448
|
+
let c_parts: Vec<&str> = c.split(['-', '.', '_']).collect();
|
|
449
|
+
let matching = q_parts.iter().filter(|p| c_parts.contains(p)).count();
|
|
450
|
+
if matching > 0 {
|
|
451
|
+
return 20 * matching;
|
|
452
|
+
}
|
|
453
|
+
0
|
|
454
|
+
}
|
|
455
|
+
|
|
365
456
|
pub fn build_task_index(
|
|
366
457
|
tasks_dir: &Path,
|
|
367
458
|
done_task_limit: u32,
|