handoff-mcp-server 0.4.0 → 0.6.1

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/src/mcp/tools.rs CHANGED
@@ -35,6 +35,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
35
35
  "project_dir": {
36
36
  "type": "string",
37
37
  "description": "Project directory path. Defaults to current working directory."
38
+ },
39
+ "session_id": {
40
+ "type": "string",
41
+ "description": "Session ID to activate and load. If omitted, activates all open sessions and returns the latest."
38
42
  }
39
43
  }
40
44
  }),
@@ -53,16 +57,21 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
53
57
  "type": "string",
54
58
  "description": "One-line summary of this session"
55
59
  },
60
+ "close_session_id": {
61
+ "type": "string",
62
+ "description": "Session ID to close. If omitted, all active and open sessions are closed (default behavior)."
63
+ },
56
64
  "decisions": {
57
65
  "type": "array",
58
66
  "description": "Decisions made during this session",
59
67
  "items": {
60
68
  "type": "object",
61
69
  "properties": {
62
- "decision": { "type": "string" },
63
- "reason": { "type": "string" },
70
+ "decision": { "type": "string", "description": "What was decided" },
71
+ "reason": { "type": "string", "description": "Why this decision was made" },
64
72
  "confidence": {
65
73
  "type": "string",
74
+ "description": "confirmed = verified by testing/evidence; estimated = reasoned but not verified; unverified = hypothesis needing validation",
66
75
  "enum": ["confirmed", "estimated", "unverified"]
67
76
  }
68
77
  },
@@ -71,17 +80,20 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
71
80
  },
72
81
  "blockers": {
73
82
  "type": "array",
83
+ "description": "Issues preventing progress. The next session should address these before starting new work.",
74
84
  "items": { "type": "string" }
75
85
  },
76
86
  "checklist": {
77
87
  "type": "array",
88
+ "description": "Verification items for the next session or user. Mark completed items as checked:true before saving.",
78
89
  "items": {
79
90
  "type": "object",
80
91
  "properties": {
81
- "item": { "type": "string" },
82
- "checked": { "type": "boolean" },
92
+ "item": { "type": "string", "description": "What to verify or confirm" },
93
+ "checked": { "type": "boolean", "description": "true if already verified, false if pending" },
83
94
  "owner": {
84
95
  "type": "string",
96
+ "description": "user = requires human action; ai = the next AI session should handle this",
85
97
  "enum": ["user", "ai"]
86
98
  }
87
99
  },
@@ -90,12 +102,14 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
90
102
  },
91
103
  "handoff_notes": {
92
104
  "type": "array",
105
+ "description": "Notes for the next session. Include at least one 'suggestion' with a concrete next action.",
93
106
  "items": {
94
107
  "type": "object",
95
108
  "properties": {
96
- "note": { "type": "string" },
109
+ "note": { "type": "string", "description": "The note content. For suggestions: state what is ALREADY DONE, then describe the concrete next action." },
97
110
  "category": {
98
111
  "type": "string",
112
+ "description": "caution = risks/rules the next session must respect; context = background info for decisions; suggestion = concrete next action the next session should execute first (at least one required)",
99
113
  "enum": ["caution", "context", "suggestion"]
100
114
  }
101
115
  },
@@ -104,28 +118,31 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
104
118
  },
105
119
  "references": {
106
120
  "type": "array",
121
+ "description": "Links to related docs, issues, MRs, or external resources for reference (not active work files — use context_pointers for those).",
107
122
  "items": {
108
123
  "type": "object",
109
124
  "properties": {
110
- "label": { "type": "string" },
111
- "uri": { "type": "string" },
125
+ "label": { "type": "string", "description": "Human-readable label for this reference" },
126
+ "uri": { "type": "string", "description": "Path, URL, or identifier" },
112
127
  "type": {
113
128
  "type": "string",
129
+ "description": "file = project file; issue = issue tracker; mr = merge/pull request; wiki = wiki page; doc = design document; url = external URL",
114
130
  "enum": ["file", "issue", "mr", "wiki", "doc", "url"]
115
131
  },
116
- "notes": { "type": "string" }
132
+ "notes": { "type": "string", "description": "Additional context (e.g. 'see section 3 for root cause analysis')" }
117
133
  },
118
134
  "required": ["label", "uri"]
119
135
  }
120
136
  },
121
137
  "context_pointers": {
122
138
  "type": "array",
139
+ "description": "Files the next session should open first to resume work. Point to files that NEED WORK, not completed files. For completed files, use a 'context' handoff_note instead.",
123
140
  "items": {
124
141
  "type": "object",
125
142
  "properties": {
126
- "path": { "type": "string" },
127
- "reason": { "type": "string" },
128
- "lines": { "type": "string" }
143
+ "path": { "type": "string", "description": "File path relative to project root" },
144
+ "reason": { "type": "string", "description": "Why the next session should read this (e.g. 'resume implementation here', 'needs review')" },
145
+ "lines": { "type": "string", "description": "Line range to focus on (e.g. '42-78')" }
129
146
  },
130
147
  "required": ["path"]
131
148
  }
@@ -366,13 +383,15 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
366
383
  "summary": { "type": "string", "description": "One-line summary (required)" },
367
384
  "decisions": {
368
385
  "type": "array",
386
+ "description": "Decisions made during this session",
369
387
  "items": {
370
388
  "type": "object",
371
389
  "properties": {
372
- "decision": { "type": "string" },
373
- "reason": { "type": "string" },
390
+ "decision": { "type": "string", "description": "What was decided" },
391
+ "reason": { "type": "string", "description": "Why this decision was made" },
374
392
  "confidence": {
375
393
  "type": "string",
394
+ "description": "confirmed = verified; estimated = reasoned but not verified; unverified = hypothesis",
376
395
  "enum": ["confirmed", "estimated", "unverified"]
377
396
  }
378
397
  },
@@ -381,52 +400,57 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
381
400
  },
382
401
  "blockers": {
383
402
  "type": "array",
403
+ "description": "Issues preventing progress",
384
404
  "items": { "type": "string" }
385
405
  },
386
406
  "checklist": {
387
407
  "type": "array",
408
+ "description": "Verification items for the next session or user",
388
409
  "items": {
389
410
  "type": "object",
390
411
  "properties": {
391
- "item": { "type": "string" },
392
- "checked": { "type": "boolean" },
393
- "owner": { "type": "string", "enum": ["user", "ai"] }
412
+ "item": { "type": "string", "description": "What to verify" },
413
+ "checked": { "type": "boolean", "description": "true if verified, false if pending" },
414
+ "owner": { "type": "string", "description": "user = human action; ai = next AI session", "enum": ["user", "ai"] }
394
415
  },
395
416
  "required": ["item"]
396
417
  }
397
418
  },
398
419
  "handoff_notes": {
399
420
  "type": "array",
421
+ "description": "Notes for the next session. Include at least one 'suggestion' with a concrete next action.",
400
422
  "items": {
401
423
  "type": "object",
402
424
  "properties": {
403
- "note": { "type": "string" },
404
- "category": { "type": "string", "enum": ["caution", "context", "suggestion"] }
425
+ "note": { "type": "string", "description": "The note content. For suggestions: state what is done, then the next action." },
426
+ "category": { "type": "string", "description": "caution = risks/rules; context = background; suggestion = concrete next action (at least one required)", "enum": ["caution", "context", "suggestion"] }
405
427
  },
406
428
  "required": ["note"]
407
429
  }
408
430
  },
409
431
  "references": {
410
432
  "type": "array",
433
+ "description": "Links to related docs, issues, MRs (not active work files)",
411
434
  "items": {
412
435
  "type": "object",
413
436
  "properties": {
414
- "label": { "type": "string" },
415
- "uri": { "type": "string" },
416
- "type": { "type": "string", "enum": ["file", "issue", "mr", "wiki", "doc", "url"] },
417
- "notes": { "type": "string" }
437
+ "label": { "type": "string", "description": "Human-readable label" },
438
+ "uri": { "type": "string", "description": "Path, URL, or identifier" },
439
+ "type": { "type": "string", "description": "file/issue/mr/wiki/doc/url", "enum": ["file", "issue", "mr", "wiki", "doc", "url"] },
440
+ "notes": { "type": "string", "description": "Additional context" }
418
441
  },
419
442
  "required": ["label", "uri"]
420
443
  }
421
444
  },
422
445
  "context_pointers": {
423
446
  "type": "array",
447
+ "description": "Files the next session should open first to resume work (not completed files)",
424
448
  "items": {
425
449
  "type": "object",
426
450
  "properties": {
427
- "path": { "type": "string" },
428
- "reason": { "type": "string" },
429
- "lines": { "type": "string" }
451
+ "path": { "type": "string", "description": "File path relative to project root" },
452
+ "reason": { "type": "string", "description": "Why to read this file" },
453
+ "lines": { "type": "string", "description": "Line range (e.g. '42-78')" }
430
454
  },
431
455
  "required": ["path"]
432
456
  }
@@ -7,6 +7,8 @@ use serde_json::Value;
7
7
  #[derive(Debug, Clone, Serialize, Deserialize)]
8
8
  pub struct SessionData {
9
9
  pub version: u32,
10
+ #[serde(default, skip_serializing_if = "Option::is_none")]
11
+ pub id: Option<String>,
10
12
  #[serde(skip_serializing_if = "Option::is_none")]
11
13
  pub ended_at: Option<String>,
12
14
  pub summary: String,
@@ -32,6 +34,11 @@ pub struct SessionData {
32
34
  pub environment: Option<Value>,
33
35
  }
34
36
 
37
+ pub fn generate_session_id() -> String {
38
+ let now = chrono::Utc::now();
39
+ format!("s-{}", now.format("%Y%m%d-%H%M%S-%6f"))
40
+ }
41
+
35
42
  pub fn generate_session_filename(summary: &str, timestamp: &str) -> String {
36
43
  let slug = summary_to_slug(summary);
37
44
  format!("{timestamp}-{slug}")
@@ -72,53 +79,57 @@ fn summary_to_slug(summary: &str) -> String {
72
79
  }
73
80
  }
74
81
 
75
- pub fn write_active_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
82
+ fn compact_timestamp(data: &SessionData) -> String {
76
83
  let timestamp = data.ended_at.as_deref().unwrap_or("00000000-000000");
77
84
  let ts_compact = timestamp
78
85
  .replace(['-', ':'], "")
79
86
  .replace('T', "-")
80
87
  .replace('Z', "");
81
- let ts_part = if ts_compact.len() >= 15 {
82
- &ts_compact[..15]
88
+ if ts_compact.len() >= 15 {
89
+ ts_compact[..15].to_string()
83
90
  } else {
84
- &ts_compact
85
- };
86
-
87
- let base = generate_session_filename(&data.summary, ts_part);
88
- let filename = format!("{base}.active.json");
89
- let path = sessions_dir.join(&filename);
90
-
91
- let content = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
92
- std::fs::write(&path, content)
93
- .with_context(|| format!("Failed to write session: {}", path.display()))?;
94
-
95
- Ok(path)
91
+ ts_compact
92
+ }
96
93
  }
97
94
 
98
- pub fn close_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
99
- let mut closed = Vec::new();
95
+ fn synthesize_id_from_filename(filename: &str) -> String {
96
+ // Old format: YYYYMMDD-HHMMSS-slug.status.json
97
+ // Extract timestamp part and create s-YYYYMMDD-HHMMSS-000000
98
+ let base = filename
99
+ .rsplit_once('.')
100
+ .and_then(|(rest, _)| rest.rsplit_once('.'))
101
+ .map(|(rest, _)| rest)
102
+ .unwrap_or(filename);
100
103
 
101
- if !sessions_dir.exists() {
102
- return Ok(closed);
104
+ if base.len() >= 15 {
105
+ let ts = &base[..15]; // YYYYMMDD-HHMMSS
106
+ format!("s-{ts}-000000")
107
+ } else {
108
+ format!("s-{base}-000000")
103
109
  }
110
+ }
104
111
 
105
- for entry in std::fs::read_dir(sessions_dir)? {
106
- let entry = entry?;
107
- let name = entry.file_name().to_string_lossy().to_string();
108
- if name.ends_with(".active.json") {
109
- let new_name = name.replace(".active.json", ".closed.json");
110
- let new_path = sessions_dir.join(&new_name);
111
- std::fs::rename(entry.path(), &new_path)
112
- .with_context(|| format!("Failed to close session: {}", entry.path().display()))?;
113
- closed.push(new_path);
114
- }
112
+ pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
113
+ let mut data = data.clone();
114
+ if data.id.is_none() {
115
+ data.id = Some(generate_session_id());
115
116
  }
116
117
 
117
- Ok(closed)
118
+ let ts_part = compact_timestamp(&data);
119
+ let base = generate_session_filename(&data.summary, &ts_part);
120
+ let filename = format!("{base}.open.json");
121
+ let path = sessions_dir.join(&filename);
122
+
123
+ let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
124
+ std::fs::write(&path, content)
125
+ .with_context(|| format!("Failed to write session: {}", path.display()))?;
126
+
127
+ Ok(path)
118
128
  }
119
129
 
120
- pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
130
+ pub fn read_sessions_by_status(sessions_dir: &Path, status: &str) -> Result<Vec<SessionData>> {
121
131
  let mut sessions = Vec::new();
132
+ let suffix = format!(".{status}.json");
122
133
 
123
134
  if !sessions_dir.exists() {
124
135
  return Ok(sessions);
@@ -131,11 +142,14 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
131
142
 
132
143
  for entry in entries {
133
144
  let name = entry.file_name().to_string_lossy().to_string();
134
- if name.ends_with(".active.json") {
145
+ if name.ends_with(&suffix) {
135
146
  let content = std::fs::read_to_string(entry.path())
136
147
  .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
137
- let data: SessionData = serde_json::from_str(&content)
148
+ let mut data: SessionData = serde_json::from_str(&content)
138
149
  .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
150
+ if data.id.is_none() {
151
+ data.id = Some(synthesize_id_from_filename(&name));
152
+ }
139
153
  sessions.push(data);
140
154
  }
141
155
  }
@@ -143,6 +157,114 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
143
157
  Ok(sessions)
144
158
  }
145
159
 
160
+ pub fn read_open_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
161
+ read_sessions_by_status(sessions_dir, "open")
162
+ }
163
+
164
+ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
165
+ read_sessions_by_status(sessions_dir, "active")
166
+ }
167
+
168
+ fn transition_sessions(sessions_dir: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
169
+ let mut transitioned = Vec::new();
170
+ let from_suffix = format!(".{from}.json");
171
+ let to_suffix = format!(".{to}.json");
172
+
173
+ if !sessions_dir.exists() {
174
+ return Ok(transitioned);
175
+ }
176
+
177
+ for entry in std::fs::read_dir(sessions_dir)? {
178
+ let entry = entry?;
179
+ let name = entry.file_name().to_string_lossy().to_string();
180
+ if name.ends_with(&from_suffix) {
181
+ let new_name = name.replace(&from_suffix, &to_suffix);
182
+ let new_path = sessions_dir.join(&new_name);
183
+ std::fs::rename(entry.path(), &new_path).with_context(|| {
184
+ format!(
185
+ "Failed to transition session {from}->{to}: {}",
186
+ entry.path().display()
187
+ )
188
+ })?;
189
+ transitioned.push(new_path);
190
+ }
191
+ }
192
+
193
+ Ok(transitioned)
194
+ }
195
+
196
+ fn transition_session_by_id(
197
+ sessions_dir: &Path,
198
+ session_id: &str,
199
+ from: &str,
200
+ to: &str,
201
+ ) -> Result<Option<PathBuf>> {
202
+ let from_suffix = format!(".{from}.json");
203
+ let to_suffix = format!(".{to}.json");
204
+
205
+ if !sessions_dir.exists() {
206
+ return Ok(None);
207
+ }
208
+
209
+ for entry in std::fs::read_dir(sessions_dir)? {
210
+ let entry = entry?;
211
+ let name = entry.file_name().to_string_lossy().to_string();
212
+ if !name.ends_with(&from_suffix) {
213
+ continue;
214
+ }
215
+
216
+ let content = std::fs::read_to_string(entry.path())
217
+ .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
218
+ let data: SessionData = serde_json::from_str(&content)
219
+ .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
220
+
221
+ let file_id = data.id.as_deref().unwrap_or("").to_string();
222
+ let synthesized = if file_id.is_empty() {
223
+ synthesize_id_from_filename(&name)
224
+ } else {
225
+ file_id
226
+ };
227
+
228
+ if synthesized == session_id {
229
+ let new_name = name.replace(&from_suffix, &to_suffix);
230
+ let new_path = sessions_dir.join(&new_name);
231
+ std::fs::rename(entry.path(), &new_path).with_context(|| {
232
+ format!(
233
+ "Failed to transition session {from}->{to}: {}",
234
+ entry.path().display()
235
+ )
236
+ })?;
237
+ return Ok(Some(new_path));
238
+ }
239
+ }
240
+
241
+ Ok(None)
242
+ }
243
+
244
+ pub fn activate_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
245
+ transition_sessions(sessions_dir, "open", "active")
246
+ }
247
+
248
+ pub fn activate_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
249
+ transition_session_by_id(sessions_dir, session_id, "open", "active")
250
+ }
251
+
252
+ pub fn close_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
253
+ transition_sessions(sessions_dir, "active", "closed")
254
+ }
255
+
256
+ pub fn close_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
257
+ transition_sessions(sessions_dir, "open", "closed")
258
+ }
259
+
260
+ pub fn close_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
261
+ // Try active first, then open
262
+ if let Some(path) = transition_session_by_id(sessions_dir, session_id, "active", "closed")? {
263
+ return Ok(Some(path));
264
+ }
265
+ transition_session_by_id(sessions_dir, session_id, "open", "closed")
266
+ }
267
+
146
268
  pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
147
269
  if !sessions_dir.exists() {
148
270
  return Ok(0);
@@ -172,3 +294,9 @@ pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
172
294
 
173
295
  Ok(removed)
174
296
  }
297
+
298
+ // Backward-compatible aliases for tests and migration
299
+ #[doc(hidden)]
300
+ pub fn write_active_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
301
+ write_open_session(sessions_dir, data)
302
+ }