handoff-mcp-server 0.3.0 → 0.6.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/dashboard.rs +4 -2
- package/src/mcp/handlers/get_task.rs +3 -0
- package/src/mcp/handlers/import_context.rs +39 -5
- package/src/mcp/handlers/list_tasks.rs +3 -0
- package/src/mcp/handlers/load_context.rs +50 -21
- package/src/mcp/handlers/save_context.rs +106 -9
- package/src/mcp/handlers/update_task.rs +47 -0
- package/src/mcp/resources.rs +3 -2
- package/src/mcp/router.rs +1 -1
- package/src/mcp/tools.rs +69 -25
- package/src/storage/sessions.rs +161 -33
- package/src/storage/tasks.rs +162 -1
- package/tests/storage_sessions.rs +189 -25
- package/tests/storage_tasks.rs +3 -0
- package/tests/tool_import_context.rs +1 -1
- package/tests/tool_sessions.rs +638 -5
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.6.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>",
|
|
@@ -6,7 +6,7 @@ use serde_json::Value;
|
|
|
6
6
|
use crate::storage::config::read_config;
|
|
7
7
|
use crate::storage::expand_tilde;
|
|
8
8
|
use crate::storage::referrals::read_referral_summaries;
|
|
9
|
-
use crate::storage::sessions::read_active_sessions;
|
|
9
|
+
use crate::storage::sessions::{read_active_sessions, read_open_sessions};
|
|
10
10
|
use crate::storage::tasks::build_task_index;
|
|
11
11
|
|
|
12
12
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
@@ -70,7 +70,9 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
|
|
|
70
70
|
let handoff_dir = project_path.join(".handoff");
|
|
71
71
|
let config = read_config(&handoff_dir.join("config.toml"))?;
|
|
72
72
|
|
|
73
|
-
let
|
|
73
|
+
let sessions_dir = handoff_dir.join("sessions");
|
|
74
|
+
let mut sessions = read_open_sessions(&sessions_dir)?;
|
|
75
|
+
sessions.extend(read_active_sessions(&sessions_dir)?);
|
|
74
76
|
|
|
75
77
|
let (_, summary) =
|
|
76
78
|
build_task_index(&handoff_dir.join("tasks"), config.settings.done_task_limit)?;
|
|
@@ -34,6 +34,9 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
34
34
|
"labels": data.labels,
|
|
35
35
|
"links": data.links,
|
|
36
36
|
"done_criteria": data.done_criteria,
|
|
37
|
+
"schedule": data.schedule,
|
|
38
|
+
"dependencies": data.dependencies,
|
|
39
|
+
"order": data.order,
|
|
37
40
|
});
|
|
38
41
|
|
|
39
42
|
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
@@ -7,7 +7,8 @@ use crate::storage::config::read_config;
|
|
|
7
7
|
use crate::storage::ensure_handoff_exists;
|
|
8
8
|
use crate::storage::git::capture_git_state;
|
|
9
9
|
use crate::storage::sessions::{
|
|
10
|
-
close_active_sessions,
|
|
10
|
+
close_active_sessions, close_open_sessions, enforce_history_limit, write_open_session,
|
|
11
|
+
SessionData,
|
|
11
12
|
};
|
|
12
13
|
use crate::storage::tasks::*;
|
|
13
14
|
|
|
@@ -58,7 +59,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
58
59
|
anyhow::anyhow!("'session.summary' is required when session is provided")
|
|
59
60
|
})?;
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
close_active_sessions(&sessions_dir)?;
|
|
63
|
+
let closed = close_open_sessions(&sessions_dir)?;
|
|
62
64
|
let git_state = capture_git_state(&project_dir)?;
|
|
63
65
|
let now = Utc::now().to_rfc3339();
|
|
64
66
|
|
|
@@ -89,6 +91,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
89
91
|
|
|
90
92
|
let data = SessionData {
|
|
91
93
|
version: 2,
|
|
94
|
+
id: None,
|
|
92
95
|
ended_at: Some(now),
|
|
93
96
|
summary: summary.to_string(),
|
|
94
97
|
branch: Some(git_state.branch),
|
|
@@ -103,7 +106,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
103
106
|
environment: Some(environment),
|
|
104
107
|
};
|
|
105
108
|
|
|
106
|
-
|
|
109
|
+
write_open_session(&sessions_dir, &data)?;
|
|
107
110
|
|
|
108
111
|
let history_limit = if config_path.exists() {
|
|
109
112
|
read_config(&config_path)
|
|
@@ -118,12 +121,14 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
118
121
|
session_saved = true;
|
|
119
122
|
} else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
|
|
120
123
|
if !raw_notes.is_empty() {
|
|
121
|
-
|
|
124
|
+
close_active_sessions(&sessions_dir)?;
|
|
125
|
+
let closed = close_open_sessions(&sessions_dir)?;
|
|
122
126
|
let git_state = capture_git_state(&project_dir)?;
|
|
123
127
|
let now = Utc::now().to_rfc3339();
|
|
124
128
|
|
|
125
129
|
let data = SessionData {
|
|
126
130
|
version: 2,
|
|
131
|
+
id: None,
|
|
127
132
|
ended_at: Some(now),
|
|
128
133
|
summary: format!("[import] {source_description}"),
|
|
129
134
|
branch: Some(git_state.branch),
|
|
@@ -146,7 +151,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
146
151
|
})),
|
|
147
152
|
};
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
write_open_session(&sessions_dir, &data)?;
|
|
150
155
|
|
|
151
156
|
let history_limit = if config_path.exists() {
|
|
152
157
|
read_config(&config_path)
|
|
@@ -235,6 +240,12 @@ fn create_task_recursive(
|
|
|
235
240
|
labels: extract_string_array_from(task_val, "labels"),
|
|
236
241
|
links: extract_string_array_from(task_val, "links"),
|
|
237
242
|
done_criteria: extract_done_criteria(task_val),
|
|
243
|
+
schedule: extract_schedule(task_val),
|
|
244
|
+
dependencies: extract_string_array_from(task_val, "dependencies"),
|
|
245
|
+
order: task_val
|
|
246
|
+
.get("order")
|
|
247
|
+
.and_then(|v| v.as_u64())
|
|
248
|
+
.map(|v| v as u32),
|
|
238
249
|
};
|
|
239
250
|
|
|
240
251
|
write_task(&task_dir, status, &data)?;
|
|
@@ -308,3 +319,26 @@ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
|
|
|
308
319
|
})
|
|
309
320
|
.unwrap_or_default()
|
|
310
321
|
}
|
|
322
|
+
|
|
323
|
+
fn extract_schedule(val: &Value) -> Option<Schedule> {
|
|
324
|
+
let sched = val.get("schedule")?;
|
|
325
|
+
if sched.is_null() {
|
|
326
|
+
return None;
|
|
327
|
+
}
|
|
328
|
+
Some(Schedule {
|
|
329
|
+
start_date: sched
|
|
330
|
+
.get("start_date")
|
|
331
|
+
.and_then(|v| v.as_str())
|
|
332
|
+
.map(String::from),
|
|
333
|
+
due_date: sched
|
|
334
|
+
.get("due_date")
|
|
335
|
+
.and_then(|v| v.as_str())
|
|
336
|
+
.map(String::from),
|
|
337
|
+
estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
|
|
338
|
+
actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
|
|
339
|
+
milestone: sched
|
|
340
|
+
.get("milestone")
|
|
341
|
+
.and_then(|v| v.as_str())
|
|
342
|
+
.map(String::from),
|
|
343
|
+
})
|
|
344
|
+
}
|
|
@@ -4,7 +4,9 @@ use serde_json::Value;
|
|
|
4
4
|
use super::resolve_project_dir;
|
|
5
5
|
use crate::storage::config::read_config;
|
|
6
6
|
use crate::storage::referrals::read_referral_summaries;
|
|
7
|
-
use crate::storage::sessions::
|
|
7
|
+
use crate::storage::sessions::{
|
|
8
|
+
activate_open_sessions, activate_session_by_id, read_open_sessions,
|
|
9
|
+
};
|
|
8
10
|
use crate::storage::tasks::build_task_index;
|
|
9
11
|
use crate::storage::{ensure_handoff_exists, handoff_dir};
|
|
10
12
|
|
|
@@ -34,23 +36,21 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
34
36
|
anyhow::bail!("config.toml not found");
|
|
35
37
|
};
|
|
36
38
|
|
|
37
|
-
let
|
|
39
|
+
let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
|
|
38
40
|
|
|
39
|
-
let
|
|
41
|
+
let sessions = read_open_sessions(&sessions_dir)?;
|
|
40
42
|
|
|
41
|
-
let
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
let selected_session = if let Some(sid) = target_session_id {
|
|
44
|
+
activate_session_by_id(&sessions_dir, sid)?;
|
|
45
|
+
sessions
|
|
46
|
+
.into_iter()
|
|
47
|
+
.find(|s| s.id.as_deref().is_some_and(|id| id == sid))
|
|
48
|
+
} else {
|
|
49
|
+
activate_open_sessions(&sessions_dir)?;
|
|
50
|
+
sessions.into_iter().last()
|
|
51
|
+
};
|
|
49
52
|
|
|
50
|
-
let
|
|
51
|
-
.iter()
|
|
52
|
-
.map(|s| serde_json::to_value(s).unwrap_or_default())
|
|
53
|
-
.collect();
|
|
53
|
+
let (task_tree, task_summary) = build_task_index(&tasks_dir, config.settings.done_task_limit)?;
|
|
54
54
|
|
|
55
55
|
let mut result = serde_json::json!({
|
|
56
56
|
"project": config.project.name,
|
|
@@ -58,12 +58,26 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
58
58
|
"task_summary": task_summary,
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
if
|
|
62
|
-
|
|
61
|
+
if selected_session.is_none() {
|
|
62
|
+
if let Some(sid) = target_session_id {
|
|
63
|
+
result["warning"] =
|
|
64
|
+
serde_json::json!(format!("session_id '{sid}' not found among open sessions"));
|
|
65
|
+
}
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
if
|
|
66
|
-
|
|
68
|
+
if let Some(ref session) = selected_session {
|
|
69
|
+
result["last_session"] = serde_json::json!({
|
|
70
|
+
"ended_at": session.ended_at,
|
|
71
|
+
"summary": session.summary,
|
|
72
|
+
"branch": session.branch,
|
|
73
|
+
"commit": session.commit,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if let Some(ref id) = session.id {
|
|
77
|
+
result["session_id"] = serde_json::json!(id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let session_val = serde_json::to_value(session).unwrap_or_default();
|
|
67
81
|
|
|
68
82
|
for key in [
|
|
69
83
|
"decisions",
|
|
@@ -73,20 +87,35 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
73
87
|
"references",
|
|
74
88
|
"context_pointers",
|
|
75
89
|
] {
|
|
76
|
-
if let Some(val) =
|
|
90
|
+
if let Some(val) = session_val.get(key) {
|
|
77
91
|
if val.as_array().is_some_and(|a| !a.is_empty()) {
|
|
78
92
|
result[key] = val.clone();
|
|
79
93
|
}
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
if let Some(env) =
|
|
97
|
+
if let Some(env) = session_val.get("environment") {
|
|
84
98
|
if !env.is_null() {
|
|
85
99
|
result["environment"] = env.clone();
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
}
|
|
89
103
|
|
|
104
|
+
if let Some(notes) = result.get("handoff_notes").and_then(|v| v.as_array()) {
|
|
105
|
+
let suggestions: Vec<&str> = notes
|
|
106
|
+
.iter()
|
|
107
|
+
.filter(|n| {
|
|
108
|
+
n.get("category")
|
|
109
|
+
.and_then(|c| c.as_str())
|
|
110
|
+
.is_some_and(|c| c == "suggestion")
|
|
111
|
+
})
|
|
112
|
+
.filter_map(|n| n.get("note").and_then(|v| v.as_str()))
|
|
113
|
+
.collect();
|
|
114
|
+
if !suggestions.is_empty() {
|
|
115
|
+
result["next_actions"] = serde_json::json!(suggestions);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
if !config.settings.context_files.is_empty() {
|
|
91
120
|
result["suggested_reads"] = serde_json::to_value(&config.settings.context_files)?;
|
|
92
121
|
}
|
|
@@ -7,7 +7,8 @@ use crate::storage::config::read_config;
|
|
|
7
7
|
use crate::storage::ensure_handoff_exists;
|
|
8
8
|
use crate::storage::git::capture_git_state;
|
|
9
9
|
use crate::storage::sessions::{
|
|
10
|
-
close_active_sessions,
|
|
10
|
+
close_active_sessions, close_open_sessions, close_session_by_id, enforce_history_limit,
|
|
11
|
+
generate_session_id, write_open_session, SessionData,
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
@@ -22,13 +23,28 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
22
23
|
.and_then(|v| v.as_str())
|
|
23
24
|
.ok_or_else(|| anyhow::anyhow!("'summary' is required"))?;
|
|
24
25
|
|
|
25
|
-
let
|
|
26
|
+
let close_id = arguments.get("close_session_id").and_then(|v| v.as_str());
|
|
27
|
+
|
|
28
|
+
let total_closed = if let Some(id) = close_id {
|
|
29
|
+
let closed = close_session_by_id(&sessions_dir, id)?;
|
|
30
|
+
if closed.is_some() {
|
|
31
|
+
1
|
|
32
|
+
} else {
|
|
33
|
+
0
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
let closed_active = close_active_sessions(&sessions_dir)?;
|
|
37
|
+
let closed_open = close_open_sessions(&sessions_dir)?;
|
|
38
|
+
closed_active.len() + closed_open.len()
|
|
39
|
+
};
|
|
26
40
|
|
|
27
41
|
let git_state = capture_git_state(&project_dir)?;
|
|
28
42
|
let now = Utc::now().to_rfc3339();
|
|
43
|
+
let session_id = generate_session_id();
|
|
29
44
|
|
|
30
45
|
let data = SessionData {
|
|
31
46
|
version: 2,
|
|
47
|
+
id: Some(session_id.clone()),
|
|
32
48
|
ended_at: Some(now),
|
|
33
49
|
summary: summary.to_string(),
|
|
34
50
|
branch: Some(git_state.branch),
|
|
@@ -43,7 +59,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
43
59
|
environment: arguments.get("environment").cloned(),
|
|
44
60
|
};
|
|
45
61
|
|
|
46
|
-
let path =
|
|
62
|
+
let path = write_open_session(&sessions_dir, &data)?;
|
|
47
63
|
|
|
48
64
|
let history_limit = if config_path.exists() {
|
|
49
65
|
read_config(&config_path)
|
|
@@ -55,18 +71,23 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
55
71
|
let removed = enforce_history_limit(&sessions_dir, history_limit)?;
|
|
56
72
|
|
|
57
73
|
let mut msg = format!(
|
|
58
|
-
"Session saved: {}\nFile: {}",
|
|
74
|
+
"Session saved: {}\nSession ID: {}\nFile: {}",
|
|
59
75
|
summary,
|
|
76
|
+
session_id,
|
|
60
77
|
path.file_name()
|
|
61
78
|
.map(|n| n.to_string_lossy().to_string())
|
|
62
79
|
.unwrap_or_default()
|
|
63
80
|
);
|
|
64
81
|
|
|
65
|
-
if
|
|
66
|
-
msg.push_str(&format!(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
if total_closed > 0 {
|
|
83
|
+
msg.push_str(&format!("\nClosed {} previous session(s)", total_closed));
|
|
84
|
+
}
|
|
85
|
+
if let Some(id) = close_id {
|
|
86
|
+
if total_closed == 0 {
|
|
87
|
+
msg.push_str(&format!(
|
|
88
|
+
"\nWarning: close_session_id '{id}' not found among active/open sessions"
|
|
89
|
+
));
|
|
90
|
+
}
|
|
70
91
|
}
|
|
71
92
|
if removed > 0 {
|
|
72
93
|
msg.push_str(&format!(
|
|
@@ -74,9 +95,85 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
74
95
|
));
|
|
75
96
|
}
|
|
76
97
|
|
|
98
|
+
for w in collect_save_warnings(&data) {
|
|
99
|
+
msg.push_str(&format!("\n{w}"));
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
Ok(msg)
|
|
78
103
|
}
|
|
79
104
|
|
|
105
|
+
fn collect_save_warnings(data: &SessionData) -> Vec<String> {
|
|
106
|
+
let mut warnings = Vec::new();
|
|
107
|
+
|
|
108
|
+
if data.checklist.is_empty() {
|
|
109
|
+
warnings.push(
|
|
110
|
+
"Warning: No checklist items. Consider adding verification items for the next session."
|
|
111
|
+
.to_string(),
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
let unchecked: Vec<&str> = data
|
|
115
|
+
.checklist
|
|
116
|
+
.iter()
|
|
117
|
+
.filter_map(|item| {
|
|
118
|
+
let checked = item
|
|
119
|
+
.get("checked")
|
|
120
|
+
.and_then(|v| v.as_bool())
|
|
121
|
+
.unwrap_or(false);
|
|
122
|
+
if !checked {
|
|
123
|
+
item.get("item").and_then(|v| v.as_str())
|
|
124
|
+
} else {
|
|
125
|
+
None
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.collect();
|
|
129
|
+
|
|
130
|
+
if !unchecked.is_empty() {
|
|
131
|
+
warnings.push(format!(
|
|
132
|
+
"Warning: {} unchecked checklist item(s) \u{2014} {}",
|
|
133
|
+
unchecked.len(),
|
|
134
|
+
unchecked.join(", ")
|
|
135
|
+
));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let has_suggestion = data.handoff_notes.iter().any(|note| {
|
|
140
|
+
note.get("category")
|
|
141
|
+
.and_then(|v| v.as_str())
|
|
142
|
+
.is_some_and(|c| c == "suggestion")
|
|
143
|
+
});
|
|
144
|
+
if !has_suggestion {
|
|
145
|
+
warnings.push(
|
|
146
|
+
"Warning: No 'suggestion' handoff_notes \u{2014} the next session won't know what to \
|
|
147
|
+
do first. Add at least one note with category 'suggestion' describing the recommended \
|
|
148
|
+
next action."
|
|
149
|
+
.to_string(),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if data.context_pointers.is_empty() {
|
|
154
|
+
warnings.push(
|
|
155
|
+
"Warning: No context_pointers. The next session won't know which files to read first."
|
|
156
|
+
.to_string(),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if data.decisions.is_empty() {
|
|
161
|
+
warnings.push(
|
|
162
|
+
"Warning: No decisions recorded. Consider documenting key decisions made during this session."
|
|
163
|
+
.to_string(),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if data.references.is_empty() {
|
|
168
|
+
warnings.push(
|
|
169
|
+
"Warning: No references. Consider adding links to relevant docs, issues, or MRs."
|
|
170
|
+
.to_string(),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
warnings
|
|
175
|
+
}
|
|
176
|
+
|
|
80
177
|
fn extract_array(val: &Value, key: &str) -> Vec<Value> {
|
|
81
178
|
val.get(key)
|
|
82
179
|
.and_then(|v| v.as_array())
|
|
@@ -74,6 +74,11 @@ fn handle_create(
|
|
|
74
74
|
let priority = task_val.get("priority").and_then(|v| v.as_str());
|
|
75
75
|
validate_priority(priority)?;
|
|
76
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
|
+
|
|
77
82
|
let data = TaskData {
|
|
78
83
|
id: new_id.clone(),
|
|
79
84
|
title: title.to_string(),
|
|
@@ -88,6 +93,12 @@ fn handle_create(
|
|
|
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)?;
|
|
@@ -121,6 +132,19 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
|
|
|
121
132
|
if task_val.get("done_criteria").is_some() {
|
|
122
133
|
data.done_criteria = extract_done_criteria(task_val);
|
|
123
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
|
+
}
|
|
124
148
|
|
|
125
149
|
let new_status = task_val
|
|
126
150
|
.get("status")
|
|
@@ -206,3 +230,26 @@ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
|
|
|
206
230
|
})
|
|
207
231
|
.unwrap_or_default()
|
|
208
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
|
+
}
|
package/src/mcp/resources.rs
CHANGED
|
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
|
|
|
2
2
|
use serde_json::{json, Value};
|
|
3
3
|
|
|
4
4
|
use crate::storage::config::read_config;
|
|
5
|
-
use crate::storage::sessions::read_active_sessions;
|
|
5
|
+
use crate::storage::sessions::{read_active_sessions, read_open_sessions};
|
|
6
6
|
use crate::storage::{ensure_handoff_exists, handoff_dir};
|
|
7
7
|
|
|
8
8
|
pub fn handle_resource_read(uri: &str) -> Result<Value> {
|
|
@@ -24,7 +24,8 @@ pub fn handle_resource_read(uri: &str) -> Result<Value> {
|
|
|
24
24
|
|
|
25
25
|
fn read_sessions_resource(handoff: &std::path::Path) -> Result<Value> {
|
|
26
26
|
let sessions_dir = handoff.join("sessions");
|
|
27
|
-
let sessions =
|
|
27
|
+
let mut sessions = read_open_sessions(&sessions_dir)?;
|
|
28
|
+
sessions.extend(read_active_sessions(&sessions_dir)?);
|
|
28
29
|
|
|
29
30
|
let contents: Vec<Value> = sessions
|
|
30
31
|
.iter()
|
package/src/mcp/router.rs
CHANGED
|
@@ -48,7 +48,7 @@ fn handle_initialize() -> JsonRpcResponse {
|
|
|
48
48
|
## Session Start\n\
|
|
49
49
|
1. Call handoff_load_context (no args needed — uses cwd)\n\
|
|
50
50
|
2. If it returns \"not initialized\", call handoff_init with the project name\n\
|
|
51
|
-
3.
|
|
51
|
+
3. Check the `next_actions` array first — these are the previous session's recommended next steps. Do not re-verify work the previous session already completed\n\n\
|
|
52
52
|
## Session End\n\
|
|
53
53
|
1. Call handoff_save_context with:\n\
|
|
54
54
|
- summary: one-line description of what was accomplished\n\
|