handoff-mcp-server 0.20.0 → 0.22.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 +52 -11
- package/Cargo.toml +3 -2
- package/README.md +31 -2
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +24 -0
- package/skills/handoff-docs/SKILL.md +233 -0
- package/skills/handoff-load/SKILL.md +10 -2
- package/skills/handoff-memory/SKILL.md +20 -0
- package/src/context/injection.rs +239 -0
- package/src/context/mod.rs +129 -0
- package/src/lib.rs +1 -0
- package/src/mcp/handlers/docs.rs +1492 -0
- package/src/mcp/handlers/docs_query.rs +1335 -0
- package/src/mcp/handlers/get_task.rs +9 -0
- package/src/mcp/handlers/import_context.rs +1 -0
- package/src/mcp/handlers/memory.rs +33 -53
- package/src/mcp/handlers/mod.rs +15 -0
- package/src/mcp/handlers/update_task.rs +2 -0
- package/src/mcp/tools.rs +195 -0
- package/src/storage/docs/mod.rs +478 -0
- package/src/storage/docs/model.rs +537 -0
- package/src/storage/docs/reassemble.rs +167 -0
- package/src/storage/docs/split.rs +377 -0
- package/src/storage/mod.rs +1 -0
- package/src/storage/tasks.rs +131 -0
package/src/storage/tasks.rs
CHANGED
|
@@ -29,6 +29,12 @@ pub struct TaskData {
|
|
|
29
29
|
pub labels: Vec<String>,
|
|
30
30
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
31
31
|
pub links: Vec<String>,
|
|
32
|
+
/// Typed document/task/URL links (wiki/130-document-management.md §9.1).
|
|
33
|
+
/// Additive alongside the legacy `links: Vec<String>` field, which is kept
|
|
34
|
+
/// as-is for backward compatibility. Use the `links()` accessor to read a
|
|
35
|
+
/// normalized, deduplicated view of both fields combined.
|
|
36
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
37
|
+
pub task_links: Vec<TaskLink>,
|
|
32
38
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
33
39
|
pub done_criteria: Vec<DoneCriterion>,
|
|
34
40
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
@@ -68,6 +74,50 @@ pub struct DoneCriterion {
|
|
|
68
74
|
pub checked: bool,
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
/// A typed link from a task to a document, URL, file, or another task
|
|
78
|
+
/// (wiki/130-document-management.md §9.1). `link_type` distinguishes the
|
|
79
|
+
/// target kind: `"doc"` (document management fragment), `"url"`, `"file"`,
|
|
80
|
+
/// or `"task"`.
|
|
81
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
82
|
+
pub struct TaskLink {
|
|
83
|
+
pub target: String,
|
|
84
|
+
pub link_type: String,
|
|
85
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
86
|
+
pub label: Option<String>,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
impl TaskData {
|
|
90
|
+
/// Normalized, deduplicated view of every link on this task: the legacy
|
|
91
|
+
/// `links: Vec<String>` field (each entry becomes `TaskLink { link_type:
|
|
92
|
+
/// "file", label: None }`) merged with `task_links`. Dedupes by
|
|
93
|
+
/// `(target, link_type)`, keeping the first occurrence — `task_links`
|
|
94
|
+
/// entries are checked first so a richer (labeled) `task_links` entry
|
|
95
|
+
/// wins over an equivalent bare legacy `links` entry.
|
|
96
|
+
pub fn links(&self) -> Vec<TaskLink> {
|
|
97
|
+
let mut seen: HashSet<(String, String)> = HashSet::new();
|
|
98
|
+
let mut result = Vec::with_capacity(self.task_links.len() + self.links.len());
|
|
99
|
+
|
|
100
|
+
for link in &self.task_links {
|
|
101
|
+
let key = (link.target.clone(), link.link_type.clone());
|
|
102
|
+
if seen.insert(key) {
|
|
103
|
+
result.push(link.clone());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for target in &self.links {
|
|
107
|
+
let key = (target.clone(), "file".to_string());
|
|
108
|
+
if seen.insert(key) {
|
|
109
|
+
result.push(TaskLink {
|
|
110
|
+
target: target.clone(),
|
|
111
|
+
link_type: "file".to_string(),
|
|
112
|
+
label: None,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
result
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
71
121
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
72
122
|
pub struct TaskIndex {
|
|
73
123
|
pub id: String,
|
|
@@ -821,3 +871,84 @@ fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
|
|
|
821
871
|
}
|
|
822
872
|
Ok(())
|
|
823
873
|
}
|
|
874
|
+
|
|
875
|
+
/// Result of [`sync_doc_task_links`]: reports which of the requested task ids
|
|
876
|
+
/// could not be resolved to an existing task, so the caller (e.g. `doc_save`)
|
|
877
|
+
/// can surface a warning to the user instead of the sync silently doing
|
|
878
|
+
/// nothing for a mistyped id.
|
|
879
|
+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
880
|
+
pub struct SyncReport {
|
|
881
|
+
/// Task ids passed in `link_task_ids` or `unlink_task_ids` that did not
|
|
882
|
+
/// resolve to an existing task directory. Order-preserved, may contain
|
|
883
|
+
/// ids from either list combined.
|
|
884
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
885
|
+
pub unresolved: Vec<String>,
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/// Synchronize the task side of a task<->document bidirectional link
|
|
889
|
+
/// (wiki/130-document-management.md §9.2). Intended to be called by the
|
|
890
|
+
/// `handoff_doc_save` handler (t96) after it has written its own doc-side
|
|
891
|
+
/// `task_ids` field, so the two stay in agreement.
|
|
892
|
+
///
|
|
893
|
+
/// For every task id in `link_task_ids`, push-if-absent a
|
|
894
|
+
/// `TaskLink { target: doc_id, link_type: "doc", label: Some(doc_title) }`
|
|
895
|
+
/// into that task's `task_links` (deduped by `(target, link_type)`).
|
|
896
|
+
/// For every task id in `unlink_task_ids`, remove any `task_links` entry
|
|
897
|
+
/// with `target == doc_id && link_type == "doc"`.
|
|
898
|
+
///
|
|
899
|
+
/// A task id that does not resolve to an existing task directory is not
|
|
900
|
+
/// treated as a hard error: the caller (doc_save) may be syncing a batch of
|
|
901
|
+
/// task_ids where one was mistyped, and a document write should not be
|
|
902
|
+
/// rolled back over an unrelated task lookup failure. Instead it is
|
|
903
|
+
/// collected into the returned [`SyncReport::unresolved`] list so the caller
|
|
904
|
+
/// can report it back to the user (partial success + report, not a silent
|
|
905
|
+
/// skip).
|
|
906
|
+
pub fn sync_doc_task_links(
|
|
907
|
+
tasks_dir: &Path,
|
|
908
|
+
doc_id: &str,
|
|
909
|
+
doc_title: &str,
|
|
910
|
+
link_task_ids: &[String],
|
|
911
|
+
unlink_task_ids: &[String],
|
|
912
|
+
) -> Result<SyncReport> {
|
|
913
|
+
let mut unresolved = Vec::new();
|
|
914
|
+
|
|
915
|
+
for task_id in link_task_ids {
|
|
916
|
+
let Some(task_dir) = find_task_dir_by_id(tasks_dir, task_id)? else {
|
|
917
|
+
unresolved.push(task_id.clone());
|
|
918
|
+
continue;
|
|
919
|
+
};
|
|
920
|
+
read_modify_write_task(&task_dir, |data, status| {
|
|
921
|
+
let already_linked = data
|
|
922
|
+
.task_links
|
|
923
|
+
.iter()
|
|
924
|
+
.any(|l| l.target == doc_id && l.link_type == "doc");
|
|
925
|
+
if !already_linked {
|
|
926
|
+
data.task_links.push(TaskLink {
|
|
927
|
+
target: doc_id.to_string(),
|
|
928
|
+
link_type: "doc".to_string(),
|
|
929
|
+
label: Some(doc_title.to_string()),
|
|
930
|
+
});
|
|
931
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
932
|
+
}
|
|
933
|
+
Ok(status.to_string())
|
|
934
|
+
})?;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
for task_id in unlink_task_ids {
|
|
938
|
+
let Some(task_dir) = find_task_dir_by_id(tasks_dir, task_id)? else {
|
|
939
|
+
unresolved.push(task_id.clone());
|
|
940
|
+
continue;
|
|
941
|
+
};
|
|
942
|
+
read_modify_write_task(&task_dir, |data, status| {
|
|
943
|
+
let before = data.task_links.len();
|
|
944
|
+
data.task_links
|
|
945
|
+
.retain(|l| !(l.target == doc_id && l.link_type == "doc"));
|
|
946
|
+
if data.task_links.len() != before {
|
|
947
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
948
|
+
}
|
|
949
|
+
Ok(status.to_string())
|
|
950
|
+
})?;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
Ok(SyncReport { unresolved })
|
|
954
|
+
}
|