handoff-mcp-server 0.26.0 → 0.28.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/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
use std::path::{Path, PathBuf};
|
|
2
|
-
|
|
3
|
-
use anyhow::{Context, 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::expand_tilde;
|
|
10
|
-
use crate::storage::referrals::{is_valid_referral_type, write_referral, ReferralData};
|
|
11
|
-
use crate::storage::tasks::validate_priority;
|
|
12
|
-
|
|
13
|
-
pub fn handle(arguments: &Value) -> Result<String> {
|
|
14
|
-
let source_project_dir = resolve_project_dir(arguments)?;
|
|
15
|
-
|
|
16
|
-
let source_handoff = source_project_dir.join(".handoff");
|
|
17
|
-
if !source_handoff.join("config.toml").exists() {
|
|
18
|
-
anyhow::bail!(
|
|
19
|
-
"Source project is not initialized: {}",
|
|
20
|
-
source_project_dir.display()
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let source_config = read_config(&source_handoff.join("config.toml"))?;
|
|
25
|
-
|
|
26
|
-
let target_dir = resolve_target(arguments, &source_config.dashboard.scan_dirs)?;
|
|
27
|
-
|
|
28
|
-
let target_handoff = target_dir.join(".handoff");
|
|
29
|
-
if !target_handoff.exists() {
|
|
30
|
-
anyhow::bail!(
|
|
31
|
-
"Target project is not initialized (no .handoff/): {}",
|
|
32
|
-
target_dir.display()
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
let summary = arguments
|
|
37
|
-
.get("summary")
|
|
38
|
-
.and_then(|v| v.as_str())
|
|
39
|
-
.ok_or_else(|| anyhow::anyhow!("'summary' is required"))?;
|
|
40
|
-
|
|
41
|
-
let referral_type = arguments
|
|
42
|
-
.get("referral_type")
|
|
43
|
-
.and_then(|v| v.as_str())
|
|
44
|
-
.unwrap_or("request");
|
|
45
|
-
|
|
46
|
-
if !is_valid_referral_type(referral_type) {
|
|
47
|
-
anyhow::bail!(
|
|
48
|
-
"Invalid referral_type: '{referral_type}'. Must be one of: improvement, bug, request, info"
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
let priority = arguments.get("priority").and_then(|v| v.as_str());
|
|
53
|
-
validate_priority(priority)?;
|
|
54
|
-
|
|
55
|
-
let details = arguments
|
|
56
|
-
.get("details")
|
|
57
|
-
.and_then(|v| v.as_str())
|
|
58
|
-
.map(String::from);
|
|
59
|
-
|
|
60
|
-
let tasks: Vec<Value> = arguments
|
|
61
|
-
.get("tasks")
|
|
62
|
-
.and_then(|v| v.as_array())
|
|
63
|
-
.cloned()
|
|
64
|
-
.unwrap_or_default();
|
|
65
|
-
|
|
66
|
-
let context = arguments.get("context").cloned();
|
|
67
|
-
|
|
68
|
-
let now_dt = Utc::now();
|
|
69
|
-
let now = now_dt.to_rfc3339();
|
|
70
|
-
let id = format!("ref-{}", now_dt.format("%Y%m%d-%H%M%S-%f"));
|
|
71
|
-
|
|
72
|
-
let data = ReferralData {
|
|
73
|
-
id: id.clone(),
|
|
74
|
-
source_project: source_config.project.name.clone(),
|
|
75
|
-
source_project_dir: source_project_dir.to_string_lossy().to_string(),
|
|
76
|
-
created_at: now,
|
|
77
|
-
referral_type: referral_type.to_string(),
|
|
78
|
-
summary: summary.to_string(),
|
|
79
|
-
details,
|
|
80
|
-
priority: priority.map(String::from),
|
|
81
|
-
tasks,
|
|
82
|
-
context,
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
let referrals_dir = target_handoff.join("referrals");
|
|
86
|
-
write_referral(&referrals_dir, &data)?;
|
|
87
|
-
|
|
88
|
-
let target_name = if target_handoff.join("config.toml").exists() {
|
|
89
|
-
read_config(&target_handoff.join("config.toml"))
|
|
90
|
-
.map(|c| c.project.name)
|
|
91
|
-
.unwrap_or_else(|_| target_dir.to_string_lossy().to_string())
|
|
92
|
-
} else {
|
|
93
|
-
target_dir.to_string_lossy().to_string()
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
let mut msg = format!(
|
|
97
|
-
"Referral sent: {id}\n From: {}\n To: {target_name}\n Type: {referral_type}\n Summary: {summary}",
|
|
98
|
-
source_config.project.name
|
|
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
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
fn resolve_target(arguments: &Value, scan_dirs: &[String]) -> Result<PathBuf> {
|
|
258
|
-
if let Some(dir) = arguments.get("target_project_dir").and_then(|v| v.as_str()) {
|
|
259
|
-
let path = PathBuf::from(dir);
|
|
260
|
-
return std::fs::canonicalize(&path)
|
|
261
|
-
.with_context(|| format!("Invalid target project path: {}", path.display()));
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if let Some(name) = arguments.get("target_project").and_then(|v| v.as_str()) {
|
|
265
|
-
return resolve_by_name(name, scan_dirs);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
anyhow::bail!("Either 'target_project' or 'target_project_dir' is required")
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
fn resolve_by_name(name: &str, scan_dirs: &[String]) -> Result<PathBuf> {
|
|
272
|
-
for scan_dir in scan_dirs {
|
|
273
|
-
let expanded = expand_tilde(scan_dir);
|
|
274
|
-
let expanded_path = Path::new(&expanded);
|
|
275
|
-
|
|
276
|
-
if !expanded_path.exists() {
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
let entries = match std::fs::read_dir(expanded_path) {
|
|
281
|
-
Ok(e) => e,
|
|
282
|
-
Err(_) => continue,
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
for entry in entries.flatten() {
|
|
286
|
-
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
let config_path = entry.path().join(".handoff/config.toml");
|
|
291
|
-
if !config_path.exists() {
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if let Ok(config) = read_config(&config_path) {
|
|
296
|
-
if config.project.name == name {
|
|
297
|
-
return Ok(entry.path());
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
anyhow::bail!(
|
|
304
|
-
"Target project '{name}' not found in scan_dirs. \
|
|
305
|
-
Use 'target_project_dir' with an absolute path instead."
|
|
306
|
-
)
|
|
307
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
use anyhow::{Context, Result};
|
|
2
|
-
use serde_json::Value;
|
|
3
|
-
|
|
4
|
-
use super::resolve_project_dir;
|
|
5
|
-
use crate::storage::ensure_handoff_exists;
|
|
6
|
-
use crate::storage::referrals::{
|
|
7
|
-
change_referral_status, is_valid_referral_status, read_referral_by_id, read_referral_summaries,
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
pub fn handle_list(arguments: &Value) -> Result<String> {
|
|
11
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
12
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
13
|
-
let referrals_dir = handoff.join("referrals");
|
|
14
|
-
|
|
15
|
-
let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
|
|
16
|
-
|
|
17
|
-
if let Some(filter) = status_filter {
|
|
18
|
-
if !is_valid_referral_status(filter) {
|
|
19
|
-
anyhow::bail!(
|
|
20
|
-
"Invalid status_filter: '{filter}'. Must be one of: open, acknowledged, resolved"
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
let summaries = read_referral_summaries(&referrals_dir, status_filter)?;
|
|
26
|
-
|
|
27
|
-
let result = serde_json::json!({
|
|
28
|
-
"referrals": summaries,
|
|
29
|
-
"total": summaries.len(),
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
serde_json::to_string_pretty(&result).context("Failed to serialize referrals")
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
pub fn handle_get(arguments: &Value) -> Result<String> {
|
|
36
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
37
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
38
|
-
let referrals_dir = handoff.join("referrals");
|
|
39
|
-
|
|
40
|
-
let referral_id = arguments
|
|
41
|
-
.get("referral_id")
|
|
42
|
-
.and_then(|v| v.as_str())
|
|
43
|
-
.ok_or_else(|| anyhow::anyhow!("'referral_id' is required"))?;
|
|
44
|
-
|
|
45
|
-
let (data, status) = read_referral_by_id(&referrals_dir, referral_id)?
|
|
46
|
-
.ok_or_else(|| anyhow::anyhow!("Referral not found: {referral_id}"))?;
|
|
47
|
-
|
|
48
|
-
let mut result = serde_json::to_value(&data).context("Failed to serialize referral")?;
|
|
49
|
-
result["status"] = Value::String(status);
|
|
50
|
-
|
|
51
|
-
serde_json::to_string_pretty(&result).context("Failed to serialize referral")
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
55
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
56
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
57
|
-
let referrals_dir = handoff.join("referrals");
|
|
58
|
-
|
|
59
|
-
let referral_id = arguments
|
|
60
|
-
.get("referral_id")
|
|
61
|
-
.and_then(|v| v.as_str())
|
|
62
|
-
.ok_or_else(|| anyhow::anyhow!("'referral_id' is required"))?;
|
|
63
|
-
|
|
64
|
-
let status = arguments
|
|
65
|
-
.get("status")
|
|
66
|
-
.and_then(|v| v.as_str())
|
|
67
|
-
.ok_or_else(|| anyhow::anyhow!("'status' is required"))?;
|
|
68
|
-
|
|
69
|
-
change_referral_status(&referrals_dir, referral_id, status)?;
|
|
70
|
-
|
|
71
|
-
Ok(format!(
|
|
72
|
-
"Updated referral {referral_id}: status -> {status}"
|
|
73
|
-
))
|
|
74
|
-
}
|