handoff-mcp-server 0.2.0 → 0.4.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 +1 -1
- package/package.json +1 -1
- package/src/mcp/handlers/check_criterion.rs +68 -0
- package/src/mcp/handlers/dashboard.rs +7 -9
- package/src/mcp/handlers/get_task.rs +43 -0
- package/src/mcp/handlers/import_context.rs +33 -4
- package/src/mcp/handlers/list_tasks.rs +3 -0
- package/src/mcp/handlers/load_context.rs +7 -0
- package/src/mcp/handlers/mod.rs +9 -0
- package/src/mcp/handlers/refer.rs +152 -0
- package/src/mcp/handlers/referrals.rs +55 -0
- package/src/mcp/handlers/update_task.rs +57 -9
- package/src/mcp/tools.rs +174 -3
- package/src/storage/mod.rs +10 -0
- package/src/storage/referrals.rs +210 -0
- package/src/storage/tasks.rs +180 -1
- package/tests/mcp_protocol.rs +4 -0
- package/tests/storage_tasks.rs +37 -0
- package/tests/tool_import_context.rs +66 -0
- package/tests/tool_referrals.rs +442 -0
- package/tests/tool_tasks.rs +357 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "MCP server that gives AI coding agents persistent memory across sessions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use chrono::Utc;
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use super::resolve_project_dir;
|
|
6
|
+
use crate::storage::ensure_handoff_exists;
|
|
7
|
+
use crate::storage::tasks::{find_task_dir_by_id, find_task_file, read_task, write_task};
|
|
8
|
+
|
|
9
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
10
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
11
|
+
|
|
12
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
13
|
+
let tasks_dir = handoff.join("tasks");
|
|
14
|
+
|
|
15
|
+
let task_id = arguments
|
|
16
|
+
.get("task_id")
|
|
17
|
+
.and_then(|v| v.as_str())
|
|
18
|
+
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
19
|
+
|
|
20
|
+
let criterion_index = arguments
|
|
21
|
+
.get("criterion_index")
|
|
22
|
+
.and_then(|v| v.as_u64())
|
|
23
|
+
.ok_or_else(|| anyhow::anyhow!("'criterion_index' parameter is required"))?
|
|
24
|
+
as usize;
|
|
25
|
+
|
|
26
|
+
let checked = arguments
|
|
27
|
+
.get("checked")
|
|
28
|
+
.and_then(|v| v.as_bool())
|
|
29
|
+
.ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
|
|
30
|
+
|
|
31
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
32
|
+
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
33
|
+
|
|
34
|
+
let (mut data, status) = read_task(&task_dir)?
|
|
35
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
36
|
+
|
|
37
|
+
if criterion_index >= data.done_criteria.len() {
|
|
38
|
+
anyhow::bail!(
|
|
39
|
+
"criterion_index {criterion_index} is out of range (task has {} criteria)",
|
|
40
|
+
data.done_criteria.len()
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
data.done_criteria[criterion_index].checked = checked;
|
|
45
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
46
|
+
|
|
47
|
+
if let Some((old_path, _)) = find_task_file(&task_dir)? {
|
|
48
|
+
std::fs::remove_file(&old_path)?;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
write_task(&task_dir, &status, &data)?;
|
|
52
|
+
|
|
53
|
+
let checked_count = data.done_criteria.iter().filter(|c| c.checked).count();
|
|
54
|
+
let total = data.done_criteria.len();
|
|
55
|
+
|
|
56
|
+
let result = serde_json::json!({
|
|
57
|
+
"task_id": data.id,
|
|
58
|
+
"criterion_index": criterion_index,
|
|
59
|
+
"item": data.done_criteria[criterion_index].item,
|
|
60
|
+
"checked": checked,
|
|
61
|
+
"done_criteria_summary": {
|
|
62
|
+
"total": total,
|
|
63
|
+
"checked": checked_count,
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
68
|
+
}
|
|
@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
|
|
|
4
4
|
use serde_json::Value;
|
|
5
5
|
|
|
6
6
|
use crate::storage::config::read_config;
|
|
7
|
+
use crate::storage::expand_tilde;
|
|
8
|
+
use crate::storage::referrals::read_referral_summaries;
|
|
7
9
|
use crate::storage::sessions::read_active_sessions;
|
|
8
10
|
use crate::storage::tasks::build_task_index;
|
|
9
11
|
|
|
@@ -88,6 +90,10 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
|
|
|
88
90
|
.flat_map(|s| s.blockers.iter().cloned())
|
|
89
91
|
.collect();
|
|
90
92
|
|
|
93
|
+
let unread_referrals = read_referral_summaries(&handoff_dir.join("referrals"), Some("open"))
|
|
94
|
+
.map(|r| r.len() as u32)
|
|
95
|
+
.unwrap_or(0);
|
|
96
|
+
|
|
91
97
|
Ok(serde_json::json!({
|
|
92
98
|
"name": config.project.name,
|
|
93
99
|
"path": project_path.to_string_lossy(),
|
|
@@ -96,14 +102,6 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
|
|
|
96
102
|
"active_tasks": active_tasks,
|
|
97
103
|
"blocked_tasks": blocked_tasks,
|
|
98
104
|
"blockers": blockers,
|
|
105
|
+
"unread_referrals": unread_referrals,
|
|
99
106
|
}))
|
|
100
107
|
}
|
|
101
|
-
|
|
102
|
-
fn expand_tilde(path: &str) -> String {
|
|
103
|
-
if let Some(rest) = path.strip_prefix("~/") {
|
|
104
|
-
if let Ok(home) = std::env::var("HOME") {
|
|
105
|
-
return format!("{home}/{rest}");
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
path.to_string()
|
|
109
|
-
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::ensure_handoff_exists;
|
|
6
|
+
use crate::storage::tasks::{find_task_dir_by_id, read_task};
|
|
7
|
+
|
|
8
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
9
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
10
|
+
|
|
11
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
12
|
+
let tasks_dir = handoff.join("tasks");
|
|
13
|
+
|
|
14
|
+
let task_id = arguments
|
|
15
|
+
.get("task_id")
|
|
16
|
+
.and_then(|v| v.as_str())
|
|
17
|
+
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
18
|
+
|
|
19
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
20
|
+
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
21
|
+
|
|
22
|
+
let (data, status) = read_task(&task_dir)?
|
|
23
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
24
|
+
|
|
25
|
+
let result = serde_json::json!({
|
|
26
|
+
"id": data.id,
|
|
27
|
+
"title": data.title,
|
|
28
|
+
"status": status,
|
|
29
|
+
"notes": data.notes,
|
|
30
|
+
"priority": data.priority,
|
|
31
|
+
"created_at": data.created_at,
|
|
32
|
+
"updated_at": data.updated_at,
|
|
33
|
+
"completed_at": data.completed_at,
|
|
34
|
+
"labels": data.labels,
|
|
35
|
+
"links": data.links,
|
|
36
|
+
"done_criteria": data.done_criteria,
|
|
37
|
+
"schedule": data.schedule,
|
|
38
|
+
"dependencies": data.dependencies,
|
|
39
|
+
"order": data.order,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
43
|
+
}
|
|
@@ -212,6 +212,9 @@ fn create_task_recursive(
|
|
|
212
212
|
anyhow::bail!("Invalid status: {status}");
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
let priority = task_val.get("priority").and_then(|v| v.as_str());
|
|
216
|
+
validate_priority(priority)?;
|
|
217
|
+
|
|
215
218
|
let completed_at = if is_terminal_status(status) {
|
|
216
219
|
Some(now.clone())
|
|
217
220
|
} else {
|
|
@@ -225,16 +228,19 @@ fn create_task_recursive(
|
|
|
225
228
|
.get("notes")
|
|
226
229
|
.and_then(|v| v.as_str())
|
|
227
230
|
.map(String::from),
|
|
228
|
-
priority:
|
|
229
|
-
.get("priority")
|
|
230
|
-
.and_then(|v| v.as_str())
|
|
231
|
-
.map(String::from),
|
|
231
|
+
priority: priority.map(String::from),
|
|
232
232
|
created_at: Some(now.clone()),
|
|
233
233
|
updated_at: Some(now),
|
|
234
234
|
completed_at,
|
|
235
235
|
labels: extract_string_array_from(task_val, "labels"),
|
|
236
236
|
links: extract_string_array_from(task_val, "links"),
|
|
237
237
|
done_criteria: extract_done_criteria(task_val),
|
|
238
|
+
schedule: extract_schedule(task_val),
|
|
239
|
+
dependencies: extract_string_array_from(task_val, "dependencies"),
|
|
240
|
+
order: task_val
|
|
241
|
+
.get("order")
|
|
242
|
+
.and_then(|v| v.as_u64())
|
|
243
|
+
.map(|v| v as u32),
|
|
238
244
|
};
|
|
239
245
|
|
|
240
246
|
write_task(&task_dir, status, &data)?;
|
|
@@ -308,3 +314,26 @@ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
|
|
|
308
314
|
})
|
|
309
315
|
.unwrap_or_default()
|
|
310
316
|
}
|
|
317
|
+
|
|
318
|
+
fn extract_schedule(val: &Value) -> Option<Schedule> {
|
|
319
|
+
let sched = val.get("schedule")?;
|
|
320
|
+
if sched.is_null() {
|
|
321
|
+
return None;
|
|
322
|
+
}
|
|
323
|
+
Some(Schedule {
|
|
324
|
+
start_date: sched
|
|
325
|
+
.get("start_date")
|
|
326
|
+
.and_then(|v| v.as_str())
|
|
327
|
+
.map(String::from),
|
|
328
|
+
due_date: sched
|
|
329
|
+
.get("due_date")
|
|
330
|
+
.and_then(|v| v.as_str())
|
|
331
|
+
.map(String::from),
|
|
332
|
+
estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
|
|
333
|
+
actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
|
|
334
|
+
milestone: sched
|
|
335
|
+
.get("milestone")
|
|
336
|
+
.and_then(|v| v.as_str())
|
|
337
|
+
.map(String::from),
|
|
338
|
+
})
|
|
339
|
+
}
|
|
@@ -3,6 +3,7 @@ use serde_json::Value;
|
|
|
3
3
|
|
|
4
4
|
use super::resolve_project_dir;
|
|
5
5
|
use crate::storage::config::read_config;
|
|
6
|
+
use crate::storage::referrals::read_referral_summaries;
|
|
6
7
|
use crate::storage::sessions::read_active_sessions;
|
|
7
8
|
use crate::storage::tasks::build_task_index;
|
|
8
9
|
use crate::storage::{ensure_handoff_exists, handoff_dir};
|
|
@@ -90,5 +91,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
90
91
|
result["suggested_reads"] = serde_json::to_value(&config.settings.context_files)?;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
let referrals_dir = handoff.join("referrals");
|
|
95
|
+
let open_referrals = read_referral_summaries(&referrals_dir, Some("open"))?;
|
|
96
|
+
if !open_referrals.is_empty() {
|
|
97
|
+
result["referrals"] = serde_json::to_value(&open_referrals)?;
|
|
98
|
+
}
|
|
99
|
+
|
|
93
100
|
serde_json::to_string_pretty(&result).context("Failed to serialize context")
|
|
94
101
|
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
pub mod check_criterion;
|
|
1
2
|
pub mod config;
|
|
2
3
|
pub mod dashboard;
|
|
4
|
+
pub mod get_task;
|
|
3
5
|
pub mod import_context;
|
|
4
6
|
pub mod init;
|
|
5
7
|
pub mod list_tasks;
|
|
6
8
|
pub mod load_context;
|
|
9
|
+
pub mod refer;
|
|
10
|
+
pub mod referrals;
|
|
7
11
|
pub mod save_context;
|
|
8
12
|
pub mod update_task;
|
|
9
13
|
|
|
@@ -32,7 +36,12 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
32
36
|
"handoff_dashboard" => dashboard::handle(arguments),
|
|
33
37
|
"handoff_get_config" => config::handle_get(arguments),
|
|
34
38
|
"handoff_update_config" => config::handle_update(arguments),
|
|
39
|
+
"handoff_get_task" => get_task::handle(arguments),
|
|
40
|
+
"handoff_check_criterion" => check_criterion::handle(arguments),
|
|
35
41
|
"handoff_import_context" => import_context::handle(arguments),
|
|
42
|
+
"handoff_refer" => refer::handle(arguments),
|
|
43
|
+
"handoff_list_referrals" => referrals::handle_list(arguments),
|
|
44
|
+
"handoff_update_referral" => referrals::handle_update(arguments),
|
|
36
45
|
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
37
46
|
};
|
|
38
47
|
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
Ok(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
|
+
|
|
102
|
+
fn resolve_target(arguments: &Value, scan_dirs: &[String]) -> Result<PathBuf> {
|
|
103
|
+
if let Some(dir) = arguments.get("target_project_dir").and_then(|v| v.as_str()) {
|
|
104
|
+
let path = PathBuf::from(dir);
|
|
105
|
+
return std::fs::canonicalize(&path)
|
|
106
|
+
.with_context(|| format!("Invalid target project path: {}", path.display()));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if let Some(name) = arguments.get("target_project").and_then(|v| v.as_str()) {
|
|
110
|
+
return resolve_by_name(name, scan_dirs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
anyhow::bail!("Either 'target_project' or 'target_project_dir' is required")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn resolve_by_name(name: &str, scan_dirs: &[String]) -> Result<PathBuf> {
|
|
117
|
+
for scan_dir in scan_dirs {
|
|
118
|
+
let expanded = expand_tilde(scan_dir);
|
|
119
|
+
let expanded_path = Path::new(&expanded);
|
|
120
|
+
|
|
121
|
+
if !expanded_path.exists() {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let entries = match std::fs::read_dir(expanded_path) {
|
|
126
|
+
Ok(e) => e,
|
|
127
|
+
Err(_) => continue,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for entry in entries.flatten() {
|
|
131
|
+
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let config_path = entry.path().join(".handoff/config.toml");
|
|
136
|
+
if !config_path.exists() {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if let Ok(config) = read_config(&config_path) {
|
|
141
|
+
if config.project.name == name {
|
|
142
|
+
return Ok(entry.path());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
anyhow::bail!(
|
|
149
|
+
"Target project '{name}' not found in scan_dirs. \
|
|
150
|
+
Use 'target_project_dir' with an absolute path instead."
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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_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_update(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 status = arguments
|
|
46
|
+
.get("status")
|
|
47
|
+
.and_then(|v| v.as_str())
|
|
48
|
+
.ok_or_else(|| anyhow::anyhow!("'status' is required"))?;
|
|
49
|
+
|
|
50
|
+
change_referral_status(&referrals_dir, referral_id, status)?;
|
|
51
|
+
|
|
52
|
+
Ok(format!(
|
|
53
|
+
"Updated referral {referral_id}: status -> {status}"
|
|
54
|
+
))
|
|
55
|
+
}
|
|
@@ -16,11 +16,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
16
16
|
.get("task")
|
|
17
17
|
.ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
|
|
18
18
|
|
|
19
|
-
let title = task_val
|
|
20
|
-
.get("title")
|
|
21
|
-
.and_then(|v| v.as_str())
|
|
22
|
-
.ok_or_else(|| anyhow::anyhow!("'task.title' is required"))?;
|
|
23
|
-
|
|
24
19
|
let task_id = task_val.get("id").and_then(|v| v.as_str());
|
|
25
20
|
let move_to = arguments.get("move_to").and_then(|v| v.as_str());
|
|
26
21
|
|
|
@@ -31,6 +26,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
31
26
|
return handle_update(&tasks_dir, existing_id, task_val);
|
|
32
27
|
}
|
|
33
28
|
|
|
29
|
+
let title = task_val
|
|
30
|
+
.get("title")
|
|
31
|
+
.and_then(|v| v.as_str())
|
|
32
|
+
.ok_or_else(|| anyhow::anyhow!("'task.title' is required for new tasks"))?;
|
|
33
|
+
|
|
34
34
|
handle_create(&tasks_dir, title, task_val, arguments)
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -71,6 +71,14 @@ fn handle_create(
|
|
|
71
71
|
anyhow::bail!("Invalid status: {status}");
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
let priority = task_val.get("priority").and_then(|v| v.as_str());
|
|
75
|
+
validate_priority(priority)?;
|
|
76
|
+
|
|
77
|
+
let dependencies = extract_string_array(task_val, "dependencies");
|
|
78
|
+
if !dependencies.is_empty() {
|
|
79
|
+
validate_dependencies(tasks_dir, &new_id, &dependencies)?;
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
let data = TaskData {
|
|
75
83
|
id: new_id.clone(),
|
|
76
84
|
title: title.to_string(),
|
|
@@ -78,16 +86,19 @@ fn handle_create(
|
|
|
78
86
|
.get("notes")
|
|
79
87
|
.and_then(|v| v.as_str())
|
|
80
88
|
.map(String::from),
|
|
81
|
-
priority:
|
|
82
|
-
.get("priority")
|
|
83
|
-
.and_then(|v| v.as_str())
|
|
84
|
-
.map(String::from),
|
|
89
|
+
priority: priority.map(String::from),
|
|
85
90
|
created_at: Some(now.clone()),
|
|
86
91
|
updated_at: Some(now),
|
|
87
92
|
completed_at: None,
|
|
88
93
|
labels: extract_string_array(task_val, "labels"),
|
|
89
94
|
links: extract_string_array(task_val, "links"),
|
|
90
95
|
done_criteria: extract_done_criteria(task_val),
|
|
96
|
+
schedule: extract_schedule(task_val),
|
|
97
|
+
dependencies,
|
|
98
|
+
order: task_val
|
|
99
|
+
.get("order")
|
|
100
|
+
.and_then(|v| v.as_u64())
|
|
101
|
+
.map(|v| v as u32),
|
|
91
102
|
};
|
|
92
103
|
|
|
93
104
|
write_task(&task_dir, status, &data)?;
|
|
@@ -109,6 +120,7 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
|
|
|
109
120
|
data.notes = Some(notes.to_string());
|
|
110
121
|
}
|
|
111
122
|
if let Some(priority) = task_val.get("priority").and_then(|v| v.as_str()) {
|
|
123
|
+
validate_priority(Some(priority))?;
|
|
112
124
|
data.priority = Some(priority.to_string());
|
|
113
125
|
}
|
|
114
126
|
if task_val.get("labels").is_some() {
|
|
@@ -120,6 +132,19 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
|
|
|
120
132
|
if task_val.get("done_criteria").is_some() {
|
|
121
133
|
data.done_criteria = extract_done_criteria(task_val);
|
|
122
134
|
}
|
|
135
|
+
if task_val.get("schedule").is_some() {
|
|
136
|
+
data.schedule = extract_schedule(task_val);
|
|
137
|
+
}
|
|
138
|
+
if task_val.get("dependencies").is_some() {
|
|
139
|
+
let new_deps = extract_string_array(task_val, "dependencies");
|
|
140
|
+
if !new_deps.is_empty() {
|
|
141
|
+
validate_dependencies(tasks_dir, task_id, &new_deps)?;
|
|
142
|
+
}
|
|
143
|
+
data.dependencies = new_deps;
|
|
144
|
+
}
|
|
145
|
+
if let Some(order) = task_val.get("order").and_then(|v| v.as_u64()) {
|
|
146
|
+
data.order = Some(order as u32);
|
|
147
|
+
}
|
|
123
148
|
|
|
124
149
|
let new_status = task_val
|
|
125
150
|
.get("status")
|
|
@@ -205,3 +230,26 @@ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
|
|
|
205
230
|
})
|
|
206
231
|
.unwrap_or_default()
|
|
207
232
|
}
|
|
233
|
+
|
|
234
|
+
fn extract_schedule(val: &Value) -> Option<Schedule> {
|
|
235
|
+
let sched = val.get("schedule")?;
|
|
236
|
+
if sched.is_null() {
|
|
237
|
+
return None;
|
|
238
|
+
}
|
|
239
|
+
Some(Schedule {
|
|
240
|
+
start_date: sched
|
|
241
|
+
.get("start_date")
|
|
242
|
+
.and_then(|v| v.as_str())
|
|
243
|
+
.map(String::from),
|
|
244
|
+
due_date: sched
|
|
245
|
+
.get("due_date")
|
|
246
|
+
.and_then(|v| v.as_str())
|
|
247
|
+
.map(String::from),
|
|
248
|
+
estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
|
|
249
|
+
actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
|
|
250
|
+
milestone: sched
|
|
251
|
+
.get("milestone")
|
|
252
|
+
.and_then(|v| v.as_str())
|
|
253
|
+
.map(String::from),
|
|
254
|
+
})
|
|
255
|
+
}
|