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/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
  }
@@ -242,6 +259,26 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
242
259
  },
243
260
  "required": ["item"]
244
261
  }
262
+ },
263
+ "schedule": {
264
+ "type": "object",
265
+ "description": "Schedule and effort tracking.",
266
+ "properties": {
267
+ "start_date": { "type": "string", "description": "YYYY-MM-DD" },
268
+ "due_date": { "type": "string", "description": "YYYY-MM-DD" },
269
+ "estimate_hours": { "type": "number" },
270
+ "actual_hours": { "type": "number" },
271
+ "milestone": { "type": "string" }
272
+ }
273
+ },
274
+ "dependencies": {
275
+ "type": "array",
276
+ "description": "Task IDs this task depends on. Circular dependencies are rejected.",
277
+ "items": { "type": "string" }
278
+ },
279
+ "order": {
280
+ "type": "integer",
281
+ "description": "Display order among siblings. 0-based, lower = higher priority."
245
282
  }
246
283
  },
247
284
  },
@@ -346,13 +383,15 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
346
383
  "summary": { "type": "string", "description": "One-line summary (required)" },
347
384
  "decisions": {
348
385
  "type": "array",
386
+ "description": "Decisions made during this session",
349
387
  "items": {
350
388
  "type": "object",
351
389
  "properties": {
352
- "decision": { "type": "string" },
353
- "reason": { "type": "string" },
390
+ "decision": { "type": "string", "description": "What was decided" },
391
+ "reason": { "type": "string", "description": "Why this decision was made" },
354
392
  "confidence": {
355
393
  "type": "string",
394
+ "description": "confirmed = verified; estimated = reasoned but not verified; unverified = hypothesis",
356
395
  "enum": ["confirmed", "estimated", "unverified"]
357
396
  }
358
397
  },
@@ -361,52 +400,57 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
361
400
  },
362
401
  "blockers": {
363
402
  "type": "array",
403
+ "description": "Issues preventing progress",
364
404
  "items": { "type": "string" }
365
405
  },
366
406
  "checklist": {
367
407
  "type": "array",
408
+ "description": "Verification items for the next session or user",
368
409
  "items": {
369
410
  "type": "object",
370
411
  "properties": {
371
- "item": { "type": "string" },
372
- "checked": { "type": "boolean" },
373
- "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"] }
374
415
  },
375
416
  "required": ["item"]
376
417
  }
377
418
  },
378
419
  "handoff_notes": {
379
420
  "type": "array",
421
+ "description": "Notes for the next session. Include at least one 'suggestion' with a concrete next action.",
380
422
  "items": {
381
423
  "type": "object",
382
424
  "properties": {
383
- "note": { "type": "string" },
384
- "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"] }
385
427
  },
386
428
  "required": ["note"]
387
429
  }
388
430
  },
389
431
  "references": {
390
432
  "type": "array",
433
+ "description": "Links to related docs, issues, MRs (not active work files)",
391
434
  "items": {
392
435
  "type": "object",
393
436
  "properties": {
394
- "label": { "type": "string" },
395
- "uri": { "type": "string" },
396
- "type": { "type": "string", "enum": ["file", "issue", "mr", "wiki", "doc", "url"] },
397
- "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" }
398
441
  },
399
442
  "required": ["label", "uri"]
400
443
  }
401
444
  },
402
445
  "context_pointers": {
403
446
  "type": "array",
447
+ "description": "Files the next session should open first to resume work (not completed files)",
404
448
  "items": {
405
449
  "type": "object",
406
450
  "properties": {
407
- "path": { "type": "string" },
408
- "reason": { "type": "string" },
409
- "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')" }
410
454
  },
411
455
  "required": ["path"]
412
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
+ }