handoff-mcp-server 0.22.0 → 0.23.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 +1 -1
- package/Cargo.toml +2 -2
- package/README.md +4 -4
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +11 -9
- package/skills/handoff-docs/SKILL.md +55 -1
- package/src/mcp/handlers/bulk_update.rs +2 -3
- package/src/mcp/handlers/docs.rs +449 -0
- package/src/mcp/handlers/mod.rs +4 -0
- package/src/mcp/handlers/task_checklist.rs +495 -0
- package/src/mcp/tools.rs +49 -8
- package/src/storage/docs/mod.rs +119 -0
- package/src/storage/tasks.rs +7 -4
package/src/storage/docs/mod.rs
CHANGED
|
@@ -207,6 +207,34 @@ pub fn find_doc_by_id(handoff_dir: &Path, doc_id: &str) -> Result<Option<DocMeta
|
|
|
207
207
|
Ok(docs.into_iter().find(|d| d.id == doc_id))
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/// Resolve every `link_type == "doc"` entry in `task_links` to its
|
|
211
|
+
/// [`DocMetadata`], in one pass over `docs/` (via [`read_all_docs`]) rather
|
|
212
|
+
/// than one [`find_doc_by_id`] scan per link. `task_links[].target` holds the
|
|
213
|
+
/// document's stable `id` (see `crate::storage::tasks::sync_doc_task_links`),
|
|
214
|
+
/// so lookup is by `id`, not `slug`. Links whose target doesn't resolve to an
|
|
215
|
+
/// existing document (stale/dangling link) are silently skipped — callers
|
|
216
|
+
/// that need to detect that should compare the input link count against the
|
|
217
|
+
/// output length themselves.
|
|
218
|
+
pub fn batch_resolve_docs(
|
|
219
|
+
handoff_dir: &Path,
|
|
220
|
+
task_links: &[crate::storage::tasks::TaskLink],
|
|
221
|
+
) -> Result<Vec<DocMetadata>> {
|
|
222
|
+
let doc_ids: Vec<&str> = task_links
|
|
223
|
+
.iter()
|
|
224
|
+
.filter(|l| l.link_type == "doc")
|
|
225
|
+
.map(|l| l.target.as_str())
|
|
226
|
+
.collect();
|
|
227
|
+
if doc_ids.is_empty() {
|
|
228
|
+
return Ok(Vec::new());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let all_docs = read_all_docs(handoff_dir)?;
|
|
232
|
+
Ok(doc_ids
|
|
233
|
+
.iter()
|
|
234
|
+
.filter_map(|id| all_docs.iter().find(|d| &d.id == id).cloned())
|
|
235
|
+
.collect())
|
|
236
|
+
}
|
|
237
|
+
|
|
210
238
|
/// Delete a document's metadata file by exact slug. Returns `Ok(false)` when
|
|
211
239
|
/// the file does not exist. Does not touch the document's body file —
|
|
212
240
|
/// callers that want a full delete should also call [`delete_doc_body`].
|
|
@@ -475,4 +503,95 @@ mod tests {
|
|
|
475
503
|
write_doc_body(&h, "my-slug", "body").unwrap();
|
|
476
504
|
assert!(docs_dir(&h).exists());
|
|
477
505
|
}
|
|
506
|
+
|
|
507
|
+
#[test]
|
|
508
|
+
fn batch_resolve_docs_resolves_doc_links_by_id() {
|
|
509
|
+
use crate::storage::tasks::TaskLink;
|
|
510
|
+
|
|
511
|
+
let tmp = TempDir::new().unwrap();
|
|
512
|
+
let h = handoff(&tmp);
|
|
513
|
+
write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
|
|
514
|
+
write_doc(&h, &sample_doc("doc-2", "doc-two")).unwrap();
|
|
515
|
+
|
|
516
|
+
let links = vec![
|
|
517
|
+
TaskLink {
|
|
518
|
+
target: "doc-1".to_string(),
|
|
519
|
+
link_type: "doc".to_string(),
|
|
520
|
+
label: None,
|
|
521
|
+
},
|
|
522
|
+
TaskLink {
|
|
523
|
+
target: "doc-2".to_string(),
|
|
524
|
+
link_type: "doc".to_string(),
|
|
525
|
+
label: None,
|
|
526
|
+
},
|
|
527
|
+
];
|
|
528
|
+
|
|
529
|
+
let resolved = batch_resolve_docs(&h, &links).unwrap();
|
|
530
|
+
assert_eq!(resolved.len(), 2);
|
|
531
|
+
let ids: Vec<&str> = resolved.iter().map(|d| d.id.as_str()).collect();
|
|
532
|
+
assert!(ids.contains(&"doc-1"));
|
|
533
|
+
assert!(ids.contains(&"doc-2"));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#[test]
|
|
537
|
+
fn batch_resolve_docs_ignores_non_doc_link_types() {
|
|
538
|
+
use crate::storage::tasks::TaskLink;
|
|
539
|
+
|
|
540
|
+
let tmp = TempDir::new().unwrap();
|
|
541
|
+
let h = handoff(&tmp);
|
|
542
|
+
write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
|
|
543
|
+
|
|
544
|
+
let links = vec![
|
|
545
|
+
TaskLink {
|
|
546
|
+
target: "doc-1".to_string(),
|
|
547
|
+
link_type: "doc".to_string(),
|
|
548
|
+
label: None,
|
|
549
|
+
},
|
|
550
|
+
TaskLink {
|
|
551
|
+
target: "https://example.com".to_string(),
|
|
552
|
+
link_type: "url".to_string(),
|
|
553
|
+
label: None,
|
|
554
|
+
},
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
let resolved = batch_resolve_docs(&h, &links).unwrap();
|
|
558
|
+
assert_eq!(resolved.len(), 1);
|
|
559
|
+
assert_eq!(resolved[0].id, "doc-1");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
#[test]
|
|
563
|
+
fn batch_resolve_docs_skips_dangling_links() {
|
|
564
|
+
use crate::storage::tasks::TaskLink;
|
|
565
|
+
|
|
566
|
+
let tmp = TempDir::new().unwrap();
|
|
567
|
+
let h = handoff(&tmp);
|
|
568
|
+
write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
|
|
569
|
+
|
|
570
|
+
let links = vec![
|
|
571
|
+
TaskLink {
|
|
572
|
+
target: "doc-1".to_string(),
|
|
573
|
+
link_type: "doc".to_string(),
|
|
574
|
+
label: None,
|
|
575
|
+
},
|
|
576
|
+
TaskLink {
|
|
577
|
+
target: "doc-missing".to_string(),
|
|
578
|
+
link_type: "doc".to_string(),
|
|
579
|
+
label: None,
|
|
580
|
+
},
|
|
581
|
+
];
|
|
582
|
+
|
|
583
|
+
let resolved = batch_resolve_docs(&h, &links).unwrap();
|
|
584
|
+
assert_eq!(resolved.len(), 1);
|
|
585
|
+
assert_eq!(resolved[0].id, "doc-1");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
#[test]
|
|
589
|
+
fn batch_resolve_docs_empty_links_is_empty_without_reading_docs_dir() {
|
|
590
|
+
let tmp = TempDir::new().unwrap();
|
|
591
|
+
let h = tmp.path().join(".handoff");
|
|
592
|
+
std::fs::create_dir_all(&h).unwrap();
|
|
593
|
+
// docs/ dir does not exist at all — must not error.
|
|
594
|
+
assert!(!docs_dir(&h).exists());
|
|
595
|
+
assert!(batch_resolve_docs(&h, &[]).unwrap().is_empty());
|
|
596
|
+
}
|
|
478
597
|
}
|
package/src/storage/tasks.rs
CHANGED
|
@@ -786,10 +786,13 @@ pub fn task_has_children(task_dir: &Path) -> Result<bool> {
|
|
|
786
786
|
}
|
|
787
787
|
|
|
788
788
|
/// Whether a task in the given status requires an effort estimate.
|
|
789
|
-
/// Parent tasks (with children) and blocked/skipped tasks are exempt;
|
|
790
|
-
/// this only covers the status dimension.
|
|
789
|
+
/// Parent tasks (with children) and blocked/skipped/todo tasks are exempt;
|
|
790
|
+
/// this only covers the status dimension. `todo` is exempt so that parent
|
|
791
|
+
/// tasks can be created before their children exist (the children turn the
|
|
792
|
+
/// parent from a leaf into a parent, removing the estimate requirement).
|
|
793
|
+
/// The estimate is enforced when the task moves to `in_progress` or later.
|
|
791
794
|
pub fn status_requires_estimate(status: &str) -> bool {
|
|
792
|
-
matches!(status, "
|
|
795
|
+
matches!(status, "in_progress" | "review" | "done")
|
|
793
796
|
}
|
|
794
797
|
|
|
795
798
|
/// Validate that a leaf task carries an `estimate_hours` when the project
|
|
@@ -839,7 +842,7 @@ pub fn validate_estimate_required(
|
|
|
839
842
|
Resend with, for example:\n \
|
|
840
843
|
{example}\n\
|
|
841
844
|
Exempt from this rule: parent tasks (any task with children), and \
|
|
842
|
-
tasks in status 'blocked' or 'skipped'.\n\
|
|
845
|
+
tasks in status 'todo', 'blocked', or 'skipped'.\n\
|
|
843
846
|
To disable this requirement project-wide, set \
|
|
844
847
|
settings.require_estimate_hours = false."
|
|
845
848
|
);
|