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.
@@ -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
+ }
@@ -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
- close_active_sessions, close_session_by_id, enforce_history_limit, generate_session_id,
13
- pause_active_sessions, pause_session_by_id, read_active_sessions, write_session_with_status,
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 total_closed = if let Some(id) = close_id {
86
- let closed = close_session_by_id(&sessions_dir, id)?;
87
- if closed.is_some() {
88
- 1
89
- } else {
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 data = SessionData {
82
+ let handoff_updates = SessionData {
114
83
  version: 2,
115
- id: Some(session_id.clone()),
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 = write_session_with_status(&sessions_dir, &data, session_status)?;
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(&data, &project_dir) {
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").is_some() {
218
- data.schedule = extract_schedule(task_val);
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. 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\
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\n\n\
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
  };