handoff-mcp-server 0.8.1 → 0.11.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 +126 -43
- package/package.json +2 -1
- package/skills/handoff/SKILL.md +223 -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 +316 -0
- package/src/mcp/handlers/config.rs +212 -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 +126 -15
- package/src/mcp/handlers/log_time.rs +70 -0
- package/src/mcp/handlers/metrics.rs +185 -0
- package/src/mcp/handlers/milestones.rs +102 -0
- package/src/mcp/handlers/mod.rs +31 -0
- package/src/mcp/handlers/save_context.rs +61 -48
- package/src/mcp/handlers/update_session.rs +188 -0
- package/src/mcp/handlers/update_task.rs +46 -2
- package/src/mcp/router.rs +10 -6
- package/src/mcp/tools.rs +401 -6
- package/src/storage/config.rs +145 -1
- package/src/storage/mod.rs +42 -0
- package/src/storage/referrals.rs +1 -1
- package/src/storage/sessions.rs +186 -13
- package/src/storage/tasks.rs +67 -2
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//! Milestone CRUD over the `[milestones.<name>]` config.toml section.
|
|
2
|
+
//! Mirrors the VSCode extension's addMilestone/updateMilestone/removeMilestone.
|
|
3
|
+
|
|
4
|
+
use anyhow::Result;
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
|
+
use toml_edit::{Item, Table};
|
|
7
|
+
|
|
8
|
+
use super::config_crud::{
|
|
9
|
+
config_path, ensure_subtable, ensure_table, load_doc, require_str, save_doc, set_opt_str,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/// handoff_list_milestones — return every `[milestones.*]` entry.
|
|
13
|
+
pub fn handle_list(arguments: &Value) -> Result<String> {
|
|
14
|
+
let path = config_path(arguments)?;
|
|
15
|
+
let doc = load_doc(&path)?;
|
|
16
|
+
|
|
17
|
+
let mut out = serde_json::Map::new();
|
|
18
|
+
if let Some(table) = doc.get("milestones").and_then(|v| v.as_table()) {
|
|
19
|
+
for (name, item) in table.iter() {
|
|
20
|
+
let sub = match item.as_table() {
|
|
21
|
+
Some(t) => t,
|
|
22
|
+
None => continue,
|
|
23
|
+
};
|
|
24
|
+
out.insert(
|
|
25
|
+
name.to_string(),
|
|
26
|
+
json!({
|
|
27
|
+
"date": sub.get("date").and_then(|v| v.as_str()),
|
|
28
|
+
"color": sub.get("color").and_then(|v| v.as_str()),
|
|
29
|
+
"description": sub.get("description").and_then(|v| v.as_str()),
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
serde_json::to_string_pretty(&json!({ "milestones": out })).map_err(Into::into)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// handoff_add_milestone — create a `[milestones.<name>]` entry. Fails if it exists.
|
|
38
|
+
pub fn handle_add(arguments: &Value) -> Result<String> {
|
|
39
|
+
let path = config_path(arguments)?;
|
|
40
|
+
let name = require_str(arguments, "name")?;
|
|
41
|
+
let mut doc = load_doc(&path)?;
|
|
42
|
+
|
|
43
|
+
let table = ensure_table(&mut doc, "milestones")?;
|
|
44
|
+
table.set_implicit(true);
|
|
45
|
+
if table.contains_key(name) {
|
|
46
|
+
anyhow::bail!(
|
|
47
|
+
"Milestone '{name}' already exists. Use handoff_update_milestone to modify it."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
let mut sub = Table::new();
|
|
51
|
+
apply_fields(&mut sub, arguments);
|
|
52
|
+
doc["milestones"][name] = Item::Table(sub);
|
|
53
|
+
|
|
54
|
+
save_doc(&path, &doc)?;
|
|
55
|
+
Ok(format!("Added milestone '{name}'"))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// handoff_update_milestone — patch an existing `[milestones.<name>]` entry.
|
|
59
|
+
pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
60
|
+
let path = config_path(arguments)?;
|
|
61
|
+
let name = require_str(arguments, "name")?;
|
|
62
|
+
let mut doc = load_doc(&path)?;
|
|
63
|
+
|
|
64
|
+
let exists = doc
|
|
65
|
+
.get("milestones")
|
|
66
|
+
.and_then(|v| v.as_table())
|
|
67
|
+
.map(|t| t.contains_key(name))
|
|
68
|
+
.unwrap_or(false);
|
|
69
|
+
if !exists {
|
|
70
|
+
anyhow::bail!("Milestone '{name}' not found. Use handoff_add_milestone to create it.");
|
|
71
|
+
}
|
|
72
|
+
let sub = ensure_subtable(&mut doc, "milestones", name)?;
|
|
73
|
+
apply_fields(sub, arguments);
|
|
74
|
+
|
|
75
|
+
save_doc(&path, &doc)?;
|
|
76
|
+
Ok(format!("Updated milestone '{name}'"))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// handoff_remove_milestone — delete a `[milestones.<name>]` entry.
|
|
80
|
+
pub fn handle_remove(arguments: &Value) -> Result<String> {
|
|
81
|
+
let path = config_path(arguments)?;
|
|
82
|
+
let name = require_str(arguments, "name")?;
|
|
83
|
+
let mut doc = load_doc(&path)?;
|
|
84
|
+
|
|
85
|
+
let removed = doc
|
|
86
|
+
.get_mut("milestones")
|
|
87
|
+
.and_then(|v| v.as_table_mut())
|
|
88
|
+
.map(|t| t.remove(name).is_some())
|
|
89
|
+
.unwrap_or(false);
|
|
90
|
+
if !removed {
|
|
91
|
+
anyhow::bail!("Milestone '{name}' not found.");
|
|
92
|
+
}
|
|
93
|
+
save_doc(&path, &doc)?;
|
|
94
|
+
Ok(format!("Removed milestone '{name}'"))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn apply_fields(table: &mut Table, arguments: &Value) {
|
|
98
|
+
table.set_implicit(false);
|
|
99
|
+
set_opt_str(table, "date", arguments.get("date"));
|
|
100
|
+
set_opt_str(table, "color", arguments.get("color"));
|
|
101
|
+
set_opt_str(table, "description", arguments.get("description"));
|
|
102
|
+
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
|
+
pub mod assignees;
|
|
2
|
+
pub mod auto_schedule;
|
|
3
|
+
pub mod bulk_update;
|
|
4
|
+
pub mod calendar;
|
|
5
|
+
pub mod capacity;
|
|
1
6
|
pub mod check_criterion;
|
|
2
7
|
pub mod config;
|
|
8
|
+
pub mod config_crud;
|
|
3
9
|
pub mod dashboard;
|
|
10
|
+
pub mod get_session;
|
|
4
11
|
pub mod get_task;
|
|
5
12
|
pub mod import_context;
|
|
6
13
|
pub mod init;
|
|
14
|
+
pub mod list_sessions;
|
|
7
15
|
pub mod list_tasks;
|
|
8
16
|
pub mod load_context;
|
|
17
|
+
pub mod log_time;
|
|
18
|
+
pub mod metrics;
|
|
19
|
+
pub mod milestones;
|
|
9
20
|
pub mod refer;
|
|
10
21
|
pub mod referrals;
|
|
11
22
|
pub mod save_context;
|
|
23
|
+
pub mod update_session;
|
|
12
24
|
pub mod update_task;
|
|
13
25
|
|
|
14
26
|
use std::path::PathBuf;
|
|
@@ -42,6 +54,25 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
42
54
|
"handoff_refer" => refer::handle(arguments),
|
|
43
55
|
"handoff_list_referrals" => referrals::handle_list(arguments),
|
|
44
56
|
"handoff_update_referral" => referrals::handle_update(arguments),
|
|
57
|
+
"handoff_update_session" => update_session::handle(arguments),
|
|
58
|
+
"handoff_log_time" => log_time::handle(arguments),
|
|
59
|
+
"handoff_get_metrics" => metrics::handle(arguments),
|
|
60
|
+
"handoff_list_sessions" => list_sessions::handle(arguments),
|
|
61
|
+
"handoff_list_assignees" => assignees::handle(arguments),
|
|
62
|
+
"handoff_bulk_update_tasks" => bulk_update::handle(arguments),
|
|
63
|
+
"handoff_get_session" => get_session::handle(arguments),
|
|
64
|
+
"handoff_get_capacity" => capacity::handle(arguments),
|
|
65
|
+
"handoff_auto_schedule" => auto_schedule::handle(arguments),
|
|
66
|
+
"handoff_add_assignee" => assignees::handle_add(arguments),
|
|
67
|
+
"handoff_update_assignee" => assignees::handle_update(arguments),
|
|
68
|
+
"handoff_remove_assignee" => assignees::handle_remove(arguments),
|
|
69
|
+
"handoff_list_milestones" => milestones::handle_list(arguments),
|
|
70
|
+
"handoff_add_milestone" => milestones::handle_add(arguments),
|
|
71
|
+
"handoff_update_milestone" => milestones::handle_update(arguments),
|
|
72
|
+
"handoff_remove_milestone" => milestones::handle_remove(arguments),
|
|
73
|
+
"handoff_update_calendar" => calendar::handle_update_calendar(arguments),
|
|
74
|
+
"handoff_update_labels" => calendar::handle_update_labels(arguments),
|
|
75
|
+
"handoff_start_project" => calendar::handle_start_project(arguments),
|
|
45
76
|
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
46
77
|
};
|
|
47
78
|
|
|
@@ -9,9 +9,9 @@ use crate::storage::config::read_config;
|
|
|
9
9
|
use crate::storage::ensure_handoff_exists;
|
|
10
10
|
use crate::storage::git::capture_git_state;
|
|
11
11
|
use crate::storage::sessions::{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
SessionData,
|
|
12
|
+
close_session_by_id, enforce_history_limit, generate_session_id, pause_active_sessions,
|
|
13
|
+
pause_session_by_id, read_active_sessions, update_active_session,
|
|
14
|
+
update_and_close_active_session, write_session_with_status, SessionData,
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
@@ -27,11 +27,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
27
27
|
.get("pause_active")
|
|
28
28
|
.and_then(|v| v.as_bool())
|
|
29
29
|
.unwrap_or(false);
|
|
30
|
-
let session_status = arguments
|
|
31
|
-
.get("session_status")
|
|
32
|
-
.and_then(|v| v.as_str())
|
|
33
|
-
.unwrap_or("open");
|
|
34
|
-
|
|
35
30
|
let is_pause_only = arguments
|
|
36
31
|
.get("pause_only")
|
|
37
32
|
.and_then(|v| v.as_bool())
|
|
@@ -45,13 +40,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
45
40
|
|
|
46
41
|
let summary = summary_opt.unwrap_or("");
|
|
47
42
|
|
|
48
|
-
if session_status != "open" && session_status != "active" {
|
|
49
|
-
anyhow::bail!(
|
|
50
|
-
"Invalid session_status '{}': must be 'open' or 'active'",
|
|
51
|
-
session_status
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
43
|
let mut total_paused = 0usize;
|
|
56
44
|
if let Some(id) = pause_id {
|
|
57
45
|
if pause_session_by_id(&sessions_dir, id)?.is_some() {
|
|
@@ -82,37 +70,18 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
82
70
|
return Ok(msg);
|
|
83
71
|
}
|
|
84
72
|
|
|
85
|
-
let
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
0
|
|
91
|
-
}
|
|
92
|
-
} else if pause_id.is_some() || pause_all {
|
|
93
|
-
0
|
|
94
|
-
} else {
|
|
95
|
-
let active = read_active_sessions(&sessions_dir)?;
|
|
96
|
-
if active.len() > 1 {
|
|
97
|
-
let active_ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
|
|
98
|
-
anyhow::bail!(
|
|
99
|
-
"Multiple active sessions found ({}).\n\
|
|
100
|
-
Use close_session_id or pause_session_id to specify which to \
|
|
101
|
-
close/pause before saving a new session.",
|
|
102
|
-
active_ids.join(", ")
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
let closed_active = close_active_sessions(&sessions_dir)?;
|
|
106
|
-
closed_active.len()
|
|
107
|
-
};
|
|
73
|
+
let session_status = arguments
|
|
74
|
+
.get("session_status")
|
|
75
|
+
.and_then(|v| v.as_str())
|
|
76
|
+
.unwrap_or("closed");
|
|
77
|
+
let keep_active = session_status == "active";
|
|
108
78
|
|
|
109
79
|
let git_state = capture_git_state(&project_dir)?;
|
|
110
80
|
let now = Utc::now().to_rfc3339();
|
|
111
|
-
let session_id = generate_session_id();
|
|
112
81
|
|
|
113
|
-
let
|
|
82
|
+
let handoff_updates = SessionData {
|
|
114
83
|
version: 2,
|
|
115
|
-
id:
|
|
84
|
+
id: None,
|
|
116
85
|
ended_at: Some(now),
|
|
117
86
|
summary: summary.to_string(),
|
|
118
87
|
branch: Some(git_state.branch),
|
|
@@ -127,7 +96,45 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
127
96
|
environment: arguments.get("environment").cloned(),
|
|
128
97
|
};
|
|
129
98
|
|
|
130
|
-
let path =
|
|
99
|
+
let (total_closed, path, session_id) = if let Some(id) = close_id {
|
|
100
|
+
let closed = close_session_by_id(&sessions_dir, id)?;
|
|
101
|
+
(if closed.is_some() { 1 } else { 0 }, None, None)
|
|
102
|
+
} else if pause_id.is_some() || pause_all {
|
|
103
|
+
(0, None, None)
|
|
104
|
+
} else {
|
|
105
|
+
let active = read_active_sessions(&sessions_dir)?;
|
|
106
|
+
if active.len() > 1 {
|
|
107
|
+
let active_ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
|
|
108
|
+
anyhow::bail!(
|
|
109
|
+
"Multiple active sessions found ({}).\n\
|
|
110
|
+
Use close_session_id or pause_session_id to specify which to \
|
|
111
|
+
close/pause before saving context.",
|
|
112
|
+
active_ids.join(", ")
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if let Some(active_session) = active.first() {
|
|
116
|
+
let sid = active_session.id.clone().unwrap_or_default();
|
|
117
|
+
if keep_active {
|
|
118
|
+
let updated_path = update_active_session(&sessions_dir, &sid, &handoff_updates)?;
|
|
119
|
+
(0, updated_path, Some(sid))
|
|
120
|
+
} else {
|
|
121
|
+
let closed_path =
|
|
122
|
+
update_and_close_active_session(&sessions_dir, &sid, &handoff_updates)?;
|
|
123
|
+
(
|
|
124
|
+
if closed_path.is_some() { 1 } else { 0 },
|
|
125
|
+
closed_path,
|
|
126
|
+
Some(sid),
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
let new_id = generate_session_id();
|
|
131
|
+
let mut data = handoff_updates.clone();
|
|
132
|
+
data.id = Some(new_id.clone());
|
|
133
|
+
let target_status = if keep_active { "active" } else { "closed" };
|
|
134
|
+
let p = write_session_with_status(&sessions_dir, &data, target_status)?;
|
|
135
|
+
(0, Some(p), Some(new_id))
|
|
136
|
+
}
|
|
137
|
+
};
|
|
131
138
|
|
|
132
139
|
let history_limit = if config_path.exists() {
|
|
133
140
|
read_config(&config_path)
|
|
@@ -138,15 +145,21 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
138
145
|
};
|
|
139
146
|
let removed = enforce_history_limit(&sessions_dir, history_limit)?;
|
|
140
147
|
|
|
148
|
+
let file_display = path
|
|
149
|
+
.as_ref()
|
|
150
|
+
.and_then(|p| p.file_name())
|
|
151
|
+
.map(|n| n.to_string_lossy().to_string())
|
|
152
|
+
.unwrap_or_else(|| "(none)".to_string());
|
|
153
|
+
let sid_display = session_id.as_deref().unwrap_or("(no active session)");
|
|
141
154
|
let mut msg = format!(
|
|
142
155
|
"Session saved: {}\nSession ID: {}\nFile: {}",
|
|
143
|
-
summary,
|
|
144
|
-
session_id,
|
|
145
|
-
path.file_name()
|
|
146
|
-
.map(|n| n.to_string_lossy().to_string())
|
|
147
|
-
.unwrap_or_default()
|
|
156
|
+
summary, sid_display, file_display
|
|
148
157
|
);
|
|
149
158
|
|
|
159
|
+
if keep_active {
|
|
160
|
+
msg.push_str("\nSession kept active (session_status: active)");
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
if total_paused > 0 {
|
|
151
164
|
msg.push_str(&format!("\nPaused {} session(s)", total_paused));
|
|
152
165
|
}
|
|
@@ -173,7 +186,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
173
186
|
));
|
|
174
187
|
}
|
|
175
188
|
|
|
176
|
-
for w in collect_save_warnings(&
|
|
189
|
+
for w in collect_save_warnings(&handoff_updates, &project_dir) {
|
|
177
190
|
msg.push_str(&format!("\n{w}"));
|
|
178
191
|
}
|
|
179
192
|
|
|
@@ -0,0 +1,188 @@
|
|
|
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::sessions::{read_active_sessions, SessionData};
|
|
7
|
+
|
|
8
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
9
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
10
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
11
|
+
let sessions_dir = handoff.join("sessions");
|
|
12
|
+
|
|
13
|
+
let active = read_active_sessions(&sessions_dir)?;
|
|
14
|
+
if active.is_empty() {
|
|
15
|
+
anyhow::bail!(
|
|
16
|
+
"No active session. Call save_context with session_status='active' to create one first."
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
if active.len() > 1 {
|
|
20
|
+
let ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
|
|
21
|
+
anyhow::bail!(
|
|
22
|
+
"Multiple active sessions ({}). Use save_context to resolve.",
|
|
23
|
+
ids.join(", ")
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let session = &active[0];
|
|
28
|
+
let sid = session.id.as_deref().unwrap_or("");
|
|
29
|
+
|
|
30
|
+
let checklist_index = arguments
|
|
31
|
+
.get("checklist_index")
|
|
32
|
+
.and_then(|v| v.as_u64())
|
|
33
|
+
.map(|v| v as usize);
|
|
34
|
+
let checklist_checked = arguments.get("checklist_checked").and_then(|v| v.as_bool());
|
|
35
|
+
let add_checklist_item = arguments.get("add_checklist_item").and_then(|v| v.as_str());
|
|
36
|
+
let add_decision = arguments.get("add_decision");
|
|
37
|
+
let add_handoff_note = arguments.get("add_handoff_note");
|
|
38
|
+
let add_context_pointer = arguments.get("add_context_pointer");
|
|
39
|
+
|
|
40
|
+
let mut data = session.clone();
|
|
41
|
+
let mut changes = Vec::new();
|
|
42
|
+
|
|
43
|
+
if let Some(idx) = checklist_index {
|
|
44
|
+
let checked = checklist_checked.unwrap_or(true);
|
|
45
|
+
if idx >= data.checklist.len() {
|
|
46
|
+
anyhow::bail!(
|
|
47
|
+
"checklist_index {idx} out of range (session has {} items)",
|
|
48
|
+
data.checklist.len()
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if let Some(item) = data.checklist.get_mut(idx) {
|
|
52
|
+
item["checked"] = serde_json::json!(checked);
|
|
53
|
+
let item_text = item
|
|
54
|
+
.get("item")
|
|
55
|
+
.and_then(|v| v.as_str())
|
|
56
|
+
.unwrap_or("(unknown)");
|
|
57
|
+
changes.push(format!(
|
|
58
|
+
"checklist[{idx}] '{}' → {}",
|
|
59
|
+
item_text,
|
|
60
|
+
if checked { "checked" } else { "unchecked" }
|
|
61
|
+
));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if let Some(item_text) = add_checklist_item {
|
|
66
|
+
let owner = arguments
|
|
67
|
+
.get("checklist_owner")
|
|
68
|
+
.and_then(|v| v.as_str())
|
|
69
|
+
.unwrap_or("ai");
|
|
70
|
+
data.checklist.push(serde_json::json!({
|
|
71
|
+
"item": item_text,
|
|
72
|
+
"checked": false,
|
|
73
|
+
"owner": owner
|
|
74
|
+
}));
|
|
75
|
+
changes.push(format!("added checklist item: '{item_text}'"));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if let Some(decision) = add_decision {
|
|
79
|
+
if decision.is_object() {
|
|
80
|
+
data.decisions.push(decision.clone());
|
|
81
|
+
let desc = decision
|
|
82
|
+
.get("decision")
|
|
83
|
+
.and_then(|v| v.as_str())
|
|
84
|
+
.unwrap_or("(decision)");
|
|
85
|
+
changes.push(format!("added decision: '{desc}'"));
|
|
86
|
+
} else {
|
|
87
|
+
anyhow::bail!("add_decision must be an object with at least a 'decision' field");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if let Some(note) = add_handoff_note {
|
|
92
|
+
if note.is_object() {
|
|
93
|
+
data.handoff_notes.push(note.clone());
|
|
94
|
+
let text = note
|
|
95
|
+
.get("note")
|
|
96
|
+
.and_then(|v| v.as_str())
|
|
97
|
+
.unwrap_or("(note)");
|
|
98
|
+
changes.push(format!("added handoff_note: '{}'", truncate(text, 60)));
|
|
99
|
+
} else {
|
|
100
|
+
anyhow::bail!("add_handoff_note must be an object with at least a 'note' field");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if let Some(pointer) = add_context_pointer {
|
|
105
|
+
if pointer.is_object() {
|
|
106
|
+
data.context_pointers.push(pointer.clone());
|
|
107
|
+
let path = pointer
|
|
108
|
+
.get("path")
|
|
109
|
+
.and_then(|v| v.as_str())
|
|
110
|
+
.unwrap_or("(path)");
|
|
111
|
+
changes.push(format!("added context_pointer: '{path}'"));
|
|
112
|
+
} else {
|
|
113
|
+
anyhow::bail!("add_context_pointer must be an object with at least a 'path' field");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if changes.is_empty() {
|
|
118
|
+
anyhow::bail!(
|
|
119
|
+
"No updates specified. Use checklist_index, add_checklist_item, \
|
|
120
|
+
add_decision, add_handoff_note, or add_context_pointer."
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
write_active_session_data(&sessions_dir, sid, &data)?;
|
|
125
|
+
|
|
126
|
+
let mut msg = format!("Session {} updated:", sid);
|
|
127
|
+
for c in &changes {
|
|
128
|
+
msg.push_str(&format!("\n - {c}"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let checked_count = data
|
|
132
|
+
.checklist
|
|
133
|
+
.iter()
|
|
134
|
+
.filter(|item| {
|
|
135
|
+
item.get("checked")
|
|
136
|
+
.and_then(|v| v.as_bool())
|
|
137
|
+
.unwrap_or(false)
|
|
138
|
+
})
|
|
139
|
+
.count();
|
|
140
|
+
msg.push_str(&format!(
|
|
141
|
+
"\nChecklist: {}/{} checked",
|
|
142
|
+
checked_count,
|
|
143
|
+
data.checklist.len()
|
|
144
|
+
));
|
|
145
|
+
|
|
146
|
+
Ok(msg)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fn truncate(s: &str, max: usize) -> String {
|
|
150
|
+
if s.len() <= max {
|
|
151
|
+
s.to_string()
|
|
152
|
+
} else {
|
|
153
|
+
format!("{}...", &s[..max])
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn write_active_session_data(
|
|
158
|
+
sessions_dir: &std::path::Path,
|
|
159
|
+
session_id: &str,
|
|
160
|
+
data: &SessionData,
|
|
161
|
+
) -> Result<()> {
|
|
162
|
+
let suffix = ".active.json";
|
|
163
|
+
|
|
164
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
165
|
+
let entry = entry?;
|
|
166
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
167
|
+
if !name.ends_with(suffix) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let content = std::fs::read_to_string(entry.path())
|
|
172
|
+
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
173
|
+
let existing: SessionData = serde_json::from_str(&content)
|
|
174
|
+
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
175
|
+
|
|
176
|
+
let file_id = existing.id.as_deref().unwrap_or("");
|
|
177
|
+
if file_id != session_id {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let updated = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
|
|
182
|
+
crate::storage::atomic_write(entry.path(), updated.as_bytes())
|
|
183
|
+
.with_context(|| format!("Failed to write session: {}", entry.path().display()))?;
|
|
184
|
+
return Ok(());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
anyhow::bail!("Active session '{session_id}' not found on disk")
|
|
188
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
|
|
1
3
|
use anyhow::{Context, Result};
|
|
2
4
|
use chrono::Utc;
|
|
3
5
|
use serde_json::Value;
|
|
@@ -103,6 +105,11 @@ fn handle_create(
|
|
|
103
105
|
.get("order")
|
|
104
106
|
.and_then(|v| v.as_u64())
|
|
105
107
|
.map(|v| v as u32),
|
|
108
|
+
assignee: task_val
|
|
109
|
+
.get("assignee")
|
|
110
|
+
.and_then(|v| v.as_str())
|
|
111
|
+
.map(String::from),
|
|
112
|
+
extra: HashMap::new(),
|
|
106
113
|
};
|
|
107
114
|
|
|
108
115
|
write_task(&task_dir, status, &data)?;
|
|
@@ -181,6 +188,11 @@ fn handle_upsert_create(
|
|
|
181
188
|
.get("order")
|
|
182
189
|
.and_then(|v| v.as_u64())
|
|
183
190
|
.map(|v| v as u32),
|
|
191
|
+
assignee: task_val
|
|
192
|
+
.get("assignee")
|
|
193
|
+
.and_then(|v| v.as_str())
|
|
194
|
+
.map(String::from),
|
|
195
|
+
extra: HashMap::new(),
|
|
184
196
|
};
|
|
185
197
|
|
|
186
198
|
write_task(&task_dir, status, &data)?;
|
|
@@ -214,8 +226,32 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
|
|
|
214
226
|
if task_val.get("done_criteria").is_some() {
|
|
215
227
|
data.done_criteria = extract_done_criteria(task_val);
|
|
216
228
|
}
|
|
217
|
-
if task_val.get("schedule")
|
|
218
|
-
|
|
229
|
+
if let Some(sched_val) = task_val.get("schedule") {
|
|
230
|
+
// Field-level merge (not full replacement) so that fields not present in
|
|
231
|
+
// the patch — e.g. actual_hours/remaining_hours accrued by the VSCode timer —
|
|
232
|
+
// are preserved. Mirrors bulk_update_tasks. (referral ref-20260623-232823)
|
|
233
|
+
let schedule = data.schedule.get_or_insert_with(Default::default);
|
|
234
|
+
if let Some(sd) = sched_val.get("start_date").and_then(|v| v.as_str()) {
|
|
235
|
+
schedule.start_date = Some(sd.to_string());
|
|
236
|
+
}
|
|
237
|
+
if let Some(dd) = sched_val.get("due_date").and_then(|v| v.as_str()) {
|
|
238
|
+
schedule.due_date = Some(dd.to_string());
|
|
239
|
+
}
|
|
240
|
+
if let Some(eh) = sched_val.get("estimate_hours").and_then(|v| v.as_f64()) {
|
|
241
|
+
schedule.estimate_hours = Some(eh);
|
|
242
|
+
}
|
|
243
|
+
if let Some(ah) = sched_val.get("actual_hours").and_then(|v| v.as_f64()) {
|
|
244
|
+
schedule.actual_hours = Some(ah);
|
|
245
|
+
}
|
|
246
|
+
if let Some(rh) = sched_val.get("remaining_hours").and_then(|v| v.as_f64()) {
|
|
247
|
+
schedule.remaining_hours = Some(rh);
|
|
248
|
+
}
|
|
249
|
+
if let Some(ms) = sched_val.get("milestone").and_then(|v| v.as_str()) {
|
|
250
|
+
schedule.milestone = Some(ms.to_string());
|
|
251
|
+
}
|
|
252
|
+
if let Some(p) = sched_val.get("pinned").and_then(|v| v.as_bool()) {
|
|
253
|
+
schedule.pinned = Some(p);
|
|
254
|
+
}
|
|
219
255
|
}
|
|
220
256
|
if task_val.get("dependencies").is_some() {
|
|
221
257
|
let new_deps = extract_string_array(task_val, "dependencies");
|
|
@@ -227,6 +263,12 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
|
|
|
227
263
|
if let Some(order) = task_val.get("order").and_then(|v| v.as_u64()) {
|
|
228
264
|
data.order = Some(order as u32);
|
|
229
265
|
}
|
|
266
|
+
if task_val.get("assignee").is_some() {
|
|
267
|
+
data.assignee = task_val
|
|
268
|
+
.get("assignee")
|
|
269
|
+
.and_then(|v| v.as_str())
|
|
270
|
+
.map(String::from);
|
|
271
|
+
}
|
|
230
272
|
|
|
231
273
|
let new_status = task_val
|
|
232
274
|
.get("status")
|
|
@@ -335,9 +377,11 @@ fn extract_schedule(val: &Value) -> Option<Schedule> {
|
|
|
335
377
|
.map(String::from),
|
|
336
378
|
estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
|
|
337
379
|
actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
|
|
380
|
+
remaining_hours: sched.get("remaining_hours").and_then(|v| v.as_f64()),
|
|
338
381
|
milestone: sched
|
|
339
382
|
.get("milestone")
|
|
340
383
|
.and_then(|v| v.as_str())
|
|
341
384
|
.map(String::from),
|
|
385
|
+
pinned: sched.get("pinned").and_then(|v| v.as_bool()),
|
|
342
386
|
})
|
|
343
387
|
}
|
package/src/mcp/router.rs
CHANGED
|
@@ -48,18 +48,22 @@ 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. If session_guidance is present, call handoff_save_context with session_status='active' to establish a persistent session before starting work\n\
|
|
52
|
+
4. 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\
|
|
53
|
+
## During Work — Progressive Updates\n\
|
|
54
|
+
- Use handoff_update_task to create/update tasks as work progresses\n\
|
|
55
|
+
- Mark tasks in_progress when starting, done when complete\n\
|
|
56
|
+
- Use handoff_check_criterion to check off task done_criteria as each item is verified — do not wait until the task is fully done\n\
|
|
57
|
+
- Use handoff_update_session to progressively update the active session: toggle checklist items, append decisions, notes, or context pointers\n\
|
|
58
|
+
- When work reaches a point requiring user confirmation, set the task status to review\n\
|
|
59
|
+
- Record decisions as they are made, not just at session end\n\n\
|
|
52
60
|
## Session End\n\
|
|
53
61
|
1. Call handoff_save_context with:\n\
|
|
54
62
|
- summary: one-line description of what was accomplished\n\
|
|
55
63
|
- decisions: key decisions made (with reason and confidence)\n\
|
|
56
64
|
- blockers: anything preventing progress\n\
|
|
57
65
|
- handoff_notes: caution/context/suggestion for the next session\n\
|
|
58
|
-
- context_pointers: files and line ranges the next session should look at
|
|
59
|
-
## During Work\n\
|
|
60
|
-
- Use handoff_update_task to create/update tasks as work progresses\n\
|
|
61
|
-
- Mark tasks in_progress when starting, done when complete\n\
|
|
62
|
-
- Record decisions as they are made, not just at session end"
|
|
66
|
+
- context_pointers: files and line ranges the next session should look at"
|
|
63
67
|
.to_string(),
|
|
64
68
|
),
|
|
65
69
|
};
|