handoff-mcp-server 0.10.0 → 0.12.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 +2 -1
- package/Cargo.toml +2 -1
- package/README.md +112 -32
- package/package.json +2 -1
- package/skills/handoff/SKILL.md +230 -0
- package/skills/handoff-import/SKILL.md +238 -0
- package/skills/handoff-load/SKILL.md +37 -0
- package/skills/handoff-refer/SKILL.md +71 -0
- package/src/mcp/handlers/assignees.rs +254 -0
- package/src/mcp/handlers/auto_schedule.rs +498 -0
- package/src/mcp/handlers/bulk_update.rs +115 -0
- package/src/mcp/handlers/calendar.rs +196 -0
- package/src/mcp/handlers/capacity.rs +331 -0
- package/src/mcp/handlers/config.rs +229 -67
- package/src/mcp/handlers/config_crud.rs +183 -0
- package/src/mcp/handlers/get_session.rs +48 -0
- package/src/mcp/handlers/get_task.rs +1 -0
- package/src/mcp/handlers/import_context.rs +7 -0
- package/src/mcp/handlers/list_sessions.rs +129 -0
- package/src/mcp/handlers/list_tasks.rs +76 -10
- package/src/mcp/handlers/load_context.rs +16 -0
- package/src/mcp/handlers/log_time.rs +70 -0
- package/src/mcp/handlers/metrics.rs +196 -0
- package/src/mcp/handlers/milestones.rs +102 -0
- package/src/mcp/handlers/mod.rs +30 -0
- package/src/mcp/handlers/referrals.rs +20 -1
- package/src/mcp/handlers/update_session.rs +1 -1
- package/src/mcp/handlers/update_task.rs +99 -6
- package/src/mcp/tools.rs +357 -3
- package/src/storage/config.rs +162 -1
- package/src/storage/mod.rs +42 -0
- package/src/storage/referrals.rs +39 -1
- package/src/storage/sessions.rs +95 -22
- package/src/storage/tasks.rs +122 -2
package/src/storage/tasks.rs
CHANGED
|
@@ -4,6 +4,11 @@ use std::path::{Path, PathBuf};
|
|
|
4
4
|
use anyhow::{Context, Result};
|
|
5
5
|
use chrono::Utc;
|
|
6
6
|
use serde::{Deserialize, Serialize};
|
|
7
|
+
use serde_json::Value;
|
|
8
|
+
|
|
9
|
+
fn is_empty_map(m: &HashMap<String, Value>) -> bool {
|
|
10
|
+
m.is_empty()
|
|
11
|
+
}
|
|
7
12
|
|
|
8
13
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
9
14
|
pub struct TaskData {
|
|
@@ -31,9 +36,13 @@ pub struct TaskData {
|
|
|
31
36
|
pub dependencies: Vec<String>,
|
|
32
37
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
33
38
|
pub order: Option<u32>,
|
|
39
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
40
|
+
pub assignee: Option<String>,
|
|
41
|
+
#[serde(flatten, default, skip_serializing_if = "is_empty_map")]
|
|
42
|
+
pub extra: HashMap<String, Value>,
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
45
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
37
46
|
pub struct Schedule {
|
|
38
47
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
39
48
|
pub start_date: Option<String>,
|
|
@@ -44,7 +53,11 @@ pub struct Schedule {
|
|
|
44
53
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
45
54
|
pub actual_hours: Option<f64>,
|
|
46
55
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
56
|
+
pub remaining_hours: Option<f64>,
|
|
57
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
47
58
|
pub milestone: Option<String>,
|
|
59
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
60
|
+
pub pinned: Option<bool>,
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -65,6 +78,8 @@ pub struct TaskIndex {
|
|
|
65
78
|
pub dependencies: Vec<String>,
|
|
66
79
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
67
80
|
pub order: Option<u32>,
|
|
81
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
82
|
+
pub assignee: Option<String>,
|
|
68
83
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
69
84
|
pub children: Vec<TaskIndex>,
|
|
70
85
|
}
|
|
@@ -190,11 +205,60 @@ pub fn read_task(task_dir: &Path) -> Result<Option<(TaskData, String)>> {
|
|
|
190
205
|
pub fn write_task(task_dir: &Path, status: &str, data: &TaskData) -> Result<()> {
|
|
191
206
|
let file_path = task_dir.join(format!("_task.{status}.json"));
|
|
192
207
|
let content = serde_json::to_string_pretty(data).context("Failed to serialize task")?;
|
|
193
|
-
|
|
208
|
+
crate::storage::atomic_write(&file_path, content.as_bytes())
|
|
194
209
|
.with_context(|| format!("Failed to write task: {}", file_path.display()))?;
|
|
195
210
|
Ok(())
|
|
196
211
|
}
|
|
197
212
|
|
|
213
|
+
/// Read-modify-write a task with optimistic concurrency control.
|
|
214
|
+
///
|
|
215
|
+
/// Reads the current task, runs `mutate` on a copy, then re-reads just before
|
|
216
|
+
/// writing: if the file's `updated_at` changed since the snapshot (another
|
|
217
|
+
/// writer — e.g. the VSCode extension — won the race), the whole cycle retries
|
|
218
|
+
/// up to `MAX_RETRIES` times. This matches the VSCode side's `updated_at`
|
|
219
|
+
/// protocol (wiki/95-concurrency-safety.md) and prevents lost updates that
|
|
220
|
+
/// atomic_write alone cannot (atomic_write stops *torn* reads, not *lost*
|
|
221
|
+
/// updates).
|
|
222
|
+
///
|
|
223
|
+
/// `mutate` receives the current `TaskData` and the resolved status, and returns
|
|
224
|
+
/// the new status the task should have after the change (usually unchanged).
|
|
225
|
+
pub fn read_modify_write_task<F>(task_dir: &Path, mut mutate: F) -> Result<()>
|
|
226
|
+
where
|
|
227
|
+
F: FnMut(&mut TaskData, &str) -> Result<String>,
|
|
228
|
+
{
|
|
229
|
+
const MAX_RETRIES: usize = 5;
|
|
230
|
+
|
|
231
|
+
for attempt in 0..=MAX_RETRIES {
|
|
232
|
+
let (mut data, status) = read_task(task_dir)?
|
|
233
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
234
|
+
let snapshot_updated_at = data.updated_at.clone();
|
|
235
|
+
|
|
236
|
+
let new_status = mutate(&mut data, &status)?;
|
|
237
|
+
|
|
238
|
+
// Re-read to detect a concurrent writer before committing.
|
|
239
|
+
let current_updated_at = read_task(task_dir)?.and_then(|(d, _)| d.updated_at);
|
|
240
|
+
if current_updated_at != snapshot_updated_at {
|
|
241
|
+
// Someone else wrote between our read and write. Retry from scratch.
|
|
242
|
+
if attempt == MAX_RETRIES {
|
|
243
|
+
anyhow::bail!(
|
|
244
|
+
"Concurrent modification of task in {} after {} retries; aborting to avoid \
|
|
245
|
+
overwriting another writer's changes.",
|
|
246
|
+
task_dir.display(),
|
|
247
|
+
MAX_RETRIES
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if new_status != status {
|
|
254
|
+
change_status(task_dir, &new_status)?;
|
|
255
|
+
}
|
|
256
|
+
write_task(task_dir, &new_status, &data)?;
|
|
257
|
+
return Ok(());
|
|
258
|
+
}
|
|
259
|
+
unreachable!("loop returns or bails within MAX_RETRIES iterations")
|
|
260
|
+
}
|
|
261
|
+
|
|
198
262
|
pub fn change_status(task_dir: &Path, new_status: &str) -> Result<()> {
|
|
199
263
|
if !is_valid_status(new_status) {
|
|
200
264
|
anyhow::bail!("Invalid status: {new_status}");
|
|
@@ -423,6 +487,7 @@ fn build_index_recursive(
|
|
|
423
487
|
schedule: data.schedule,
|
|
424
488
|
dependencies: data.dependencies,
|
|
425
489
|
order: data.order,
|
|
490
|
+
assignee: data.assignee,
|
|
426
491
|
children,
|
|
427
492
|
});
|
|
428
493
|
}
|
|
@@ -525,6 +590,61 @@ pub fn validate_skipped_transition(task_dir: &Path, data: &TaskData) -> Result<(
|
|
|
525
590
|
Ok(())
|
|
526
591
|
}
|
|
527
592
|
|
|
593
|
+
/// Returns true if the task directory contains at least one child task
|
|
594
|
+
/// (a non-`_`/`.`-prefixed subdirectory holding a task file).
|
|
595
|
+
pub fn task_has_children(task_dir: &Path) -> Result<bool> {
|
|
596
|
+
if !task_dir.exists() {
|
|
597
|
+
return Ok(false);
|
|
598
|
+
}
|
|
599
|
+
for entry in std::fs::read_dir(task_dir)? {
|
|
600
|
+
let entry = entry?;
|
|
601
|
+
if !entry.file_type()?.is_dir() {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
605
|
+
if name.starts_with('_') || name.starts_with('.') {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if find_task_file(&entry.path())?.is_some() {
|
|
609
|
+
return Ok(true);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
Ok(false)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/// Whether a task in the given status requires an effort estimate.
|
|
616
|
+
/// Parent tasks (with children) and blocked/skipped tasks are exempt;
|
|
617
|
+
/// this only covers the status dimension.
|
|
618
|
+
pub fn status_requires_estimate(status: &str) -> bool {
|
|
619
|
+
matches!(status, "todo" | "in_progress" | "review" | "done")
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/// Validate that a leaf task carries an `estimate_hours` when the project
|
|
623
|
+
/// requires it. `has_children` lets the caller skip parent tasks.
|
|
624
|
+
/// Returns an error guiding the caller to add an estimate.
|
|
625
|
+
pub fn validate_estimate_required(
|
|
626
|
+
require_estimate_hours: bool,
|
|
627
|
+
status: &str,
|
|
628
|
+
has_children: bool,
|
|
629
|
+
schedule: Option<&Schedule>,
|
|
630
|
+
) -> Result<()> {
|
|
631
|
+
if !require_estimate_hours || has_children || !status_requires_estimate(status) {
|
|
632
|
+
return Ok(());
|
|
633
|
+
}
|
|
634
|
+
let has_estimate = schedule
|
|
635
|
+
.and_then(|s| s.estimate_hours)
|
|
636
|
+
.is_some_and(|h| h > 0.0);
|
|
637
|
+
if !has_estimate {
|
|
638
|
+
anyhow::bail!(
|
|
639
|
+
"Task requires an effort estimate: set schedule.estimate_hours (hours, > 0) \
|
|
640
|
+
when creating or updating a leaf task in status '{status}'. \
|
|
641
|
+
Estimate the human-effort hours for this task. \
|
|
642
|
+
To disable this requirement project-wide, set settings.require_estimate_hours = false."
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
Ok(())
|
|
646
|
+
}
|
|
647
|
+
|
|
528
648
|
fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
|
|
529
649
|
if !task_dir.exists() {
|
|
530
650
|
return Ok(());
|