handoff-mcp-server 0.1.0 → 0.2.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 CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.1.0"
147
+ version = "0.2.0"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -55,9 +55,12 @@ Add to your Claude Code MCP configuration:
55
55
 
56
56
  ```json
57
57
  {
58
- "handoff": {
59
- "command": "handoff-mcp",
60
- "args": []
58
+ "mcpServers": {
59
+ "handoff": {
60
+ "type": "stdio",
61
+ "command": "handoff-mcp",
62
+ "args": []
63
+ }
61
64
  }
62
65
  }
63
66
  ```
@@ -66,9 +69,12 @@ Add to your Claude Code MCP configuration:
66
69
 
67
70
  ```json
68
71
  {
69
- "handoff": {
70
- "command": "handoff-mcp",
71
- "args": []
72
+ "mcpServers": {
73
+ "handoff": {
74
+ "type": "stdio",
75
+ "command": "handoff-mcp",
76
+ "args": []
77
+ }
72
78
  }
73
79
  }
74
80
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.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>",
@@ -0,0 +1,310 @@
1
+ use anyhow::{Context, Result};
2
+ use chrono::Utc;
3
+ use serde_json::Value;
4
+
5
+ use super::resolve_project_dir;
6
+ use crate::storage::config::read_config;
7
+ use crate::storage::ensure_handoff_exists;
8
+ use crate::storage::git::capture_git_state;
9
+ use crate::storage::sessions::{
10
+ close_active_sessions, enforce_history_limit, write_active_session, SessionData,
11
+ };
12
+ use crate::storage::tasks::*;
13
+
14
+ pub fn handle(arguments: &Value) -> Result<String> {
15
+ let project_dir = resolve_project_dir(arguments)?;
16
+ let handoff = ensure_handoff_exists(&project_dir)?;
17
+ let tasks_dir = handoff.join("tasks");
18
+ let sessions_dir = handoff.join("sessions");
19
+ let config_path = handoff.join("config.toml");
20
+
21
+ let source = arguments
22
+ .get("source")
23
+ .ok_or_else(|| anyhow::anyhow!("'source' parameter is required"))?;
24
+ let source_description = source
25
+ .get("description")
26
+ .and_then(|v| v.as_str())
27
+ .ok_or_else(|| anyhow::anyhow!("'source.description' is required"))?;
28
+ let source_format = source
29
+ .get("format")
30
+ .and_then(|v| v.as_str())
31
+ .unwrap_or("other");
32
+
33
+ let mut tasks_created: u32 = 0;
34
+ let mut top_level_count: u32 = 0;
35
+ let mut nested_count: u32 = 0;
36
+
37
+ if let Some(tasks) = arguments.get("tasks").and_then(|v| v.as_array()) {
38
+ for task_val in tasks {
39
+ let title = task_val
40
+ .get("title")
41
+ .and_then(|v| v.as_str())
42
+ .ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))?;
43
+
44
+ let task_id = next_top_level_id(&tasks_dir)?;
45
+ let count = create_task_recursive(&tasks_dir, &task_id, None, title, task_val)?;
46
+ tasks_created += count;
47
+ top_level_count += 1;
48
+ nested_count += count - 1;
49
+ }
50
+ }
51
+
52
+ let mut session_saved = false;
53
+ if let Some(session) = arguments.get("session") {
54
+ let summary = session
55
+ .get("summary")
56
+ .and_then(|v| v.as_str())
57
+ .ok_or_else(|| {
58
+ anyhow::anyhow!("'session.summary' is required when session is provided")
59
+ })?;
60
+
61
+ let closed = close_active_sessions(&sessions_dir)?;
62
+ let git_state = capture_git_state(&project_dir)?;
63
+ let now = Utc::now().to_rfc3339();
64
+
65
+ let mut handoff_notes = extract_array(session, "handoff_notes");
66
+
67
+ if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
68
+ if !raw_notes.is_empty() {
69
+ handoff_notes.push(serde_json::json!({
70
+ "note": raw_notes,
71
+ "category": "context"
72
+ }));
73
+ }
74
+ }
75
+
76
+ let mut environment = session
77
+ .get("environment")
78
+ .cloned()
79
+ .unwrap_or(serde_json::json!({}));
80
+ if let Some(env_obj) = environment.as_object_mut() {
81
+ env_obj.insert(
82
+ "import_source".to_string(),
83
+ serde_json::json!({
84
+ "description": source_description,
85
+ "format": source_format
86
+ }),
87
+ );
88
+ }
89
+
90
+ let data = SessionData {
91
+ version: 2,
92
+ ended_at: Some(now),
93
+ summary: summary.to_string(),
94
+ branch: Some(git_state.branch),
95
+ commit: Some(git_state.commit),
96
+ dirty_files: git_state.dirty_files,
97
+ decisions: extract_array(session, "decisions"),
98
+ blockers: extract_string_array(session, "blockers"),
99
+ checklist: extract_array(session, "checklist"),
100
+ handoff_notes,
101
+ references: extract_array(session, "references"),
102
+ context_pointers: extract_array(session, "context_pointers"),
103
+ environment: Some(environment),
104
+ };
105
+
106
+ write_active_session(&sessions_dir, &data)?;
107
+
108
+ let history_limit = if config_path.exists() {
109
+ read_config(&config_path)
110
+ .map(|c| c.settings.history_limit)
111
+ .unwrap_or(20)
112
+ } else {
113
+ 20
114
+ };
115
+ enforce_history_limit(&sessions_dir, history_limit)?;
116
+
117
+ let _ = closed;
118
+ session_saved = true;
119
+ } else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
120
+ if !raw_notes.is_empty() {
121
+ let closed = close_active_sessions(&sessions_dir)?;
122
+ let git_state = capture_git_state(&project_dir)?;
123
+ let now = Utc::now().to_rfc3339();
124
+
125
+ let data = SessionData {
126
+ version: 2,
127
+ ended_at: Some(now),
128
+ summary: format!("[import] {source_description}"),
129
+ branch: Some(git_state.branch),
130
+ commit: Some(git_state.commit),
131
+ dirty_files: git_state.dirty_files,
132
+ decisions: Vec::new(),
133
+ blockers: Vec::new(),
134
+ checklist: Vec::new(),
135
+ handoff_notes: vec![serde_json::json!({
136
+ "note": raw_notes,
137
+ "category": "context"
138
+ })],
139
+ references: Vec::new(),
140
+ context_pointers: Vec::new(),
141
+ environment: Some(serde_json::json!({
142
+ "import_source": {
143
+ "description": source_description,
144
+ "format": source_format
145
+ }
146
+ })),
147
+ };
148
+
149
+ write_active_session(&sessions_dir, &data)?;
150
+
151
+ let history_limit = if config_path.exists() {
152
+ read_config(&config_path)
153
+ .map(|c| c.settings.history_limit)
154
+ .unwrap_or(20)
155
+ } else {
156
+ 20
157
+ };
158
+ enforce_history_limit(&sessions_dir, history_limit)?;
159
+
160
+ let _ = closed;
161
+ session_saved = true;
162
+ }
163
+ }
164
+
165
+ let mut msg = format!("Import complete:\n Source: {source_description}");
166
+
167
+ if tasks_created > 0 {
168
+ msg.push_str(&format!(
169
+ "\n Tasks created: {tasks_created} ({top_level_count} top-level, {nested_count} nested)"
170
+ ));
171
+ } else {
172
+ msg.push_str("\n Tasks created: 0");
173
+ }
174
+
175
+ if session_saved {
176
+ msg.push_str("\n Session saved: yes");
177
+ }
178
+
179
+ if arguments
180
+ .get("raw_notes")
181
+ .and_then(|v| v.as_str())
182
+ .is_some_and(|s| !s.is_empty())
183
+ {
184
+ msg.push_str("\n Raw notes: saved as handoff_note (context)");
185
+ }
186
+
187
+ Ok(msg)
188
+ }
189
+
190
+ fn create_task_recursive(
191
+ tasks_dir: &std::path::Path,
192
+ task_id: &str,
193
+ parent_dir: Option<&std::path::Path>,
194
+ title: &str,
195
+ task_val: &Value,
196
+ ) -> Result<u32> {
197
+ let slug = title_to_slug(title);
198
+ let dir_name = format!("{task_id}-{slug}");
199
+ let base_dir = parent_dir.unwrap_or(tasks_dir);
200
+ let task_dir = base_dir.join(&dir_name);
201
+
202
+ std::fs::create_dir_all(&task_dir)
203
+ .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
204
+
205
+ let now = Utc::now().to_rfc3339();
206
+ let status = task_val
207
+ .get("status")
208
+ .and_then(|v| v.as_str())
209
+ .unwrap_or("todo");
210
+
211
+ if !is_valid_status(status) {
212
+ anyhow::bail!("Invalid status: {status}");
213
+ }
214
+
215
+ let completed_at = if is_terminal_status(status) {
216
+ Some(now.clone())
217
+ } else {
218
+ None
219
+ };
220
+
221
+ let data = TaskData {
222
+ id: task_id.to_string(),
223
+ title: title.to_string(),
224
+ notes: task_val
225
+ .get("notes")
226
+ .and_then(|v| v.as_str())
227
+ .map(String::from),
228
+ priority: task_val
229
+ .get("priority")
230
+ .and_then(|v| v.as_str())
231
+ .map(String::from),
232
+ created_at: Some(now.clone()),
233
+ updated_at: Some(now),
234
+ completed_at,
235
+ labels: extract_string_array_from(task_val, "labels"),
236
+ links: extract_string_array_from(task_val, "links"),
237
+ done_criteria: extract_done_criteria(task_val),
238
+ };
239
+
240
+ write_task(&task_dir, status, &data)?;
241
+
242
+ let mut count: u32 = 1;
243
+
244
+ if let Some(children) = task_val.get("children").and_then(|v| v.as_array()) {
245
+ for (i, child_val) in children.iter().enumerate() {
246
+ let child_title = child_val
247
+ .get("title")
248
+ .and_then(|v| v.as_str())
249
+ .ok_or_else(|| anyhow::anyhow!("Each child task requires a 'title'"))?;
250
+
251
+ let child_id = format!("{task_id}.{}", i + 1);
252
+ count += create_task_recursive(
253
+ &task_dir,
254
+ &child_id,
255
+ Some(&task_dir),
256
+ child_title,
257
+ child_val,
258
+ )?;
259
+ }
260
+ }
261
+
262
+ Ok(count)
263
+ }
264
+
265
+ fn extract_array(val: &Value, key: &str) -> Vec<Value> {
266
+ val.get(key)
267
+ .and_then(|v| v.as_array())
268
+ .cloned()
269
+ .unwrap_or_default()
270
+ }
271
+
272
+ fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
273
+ val.get(key)
274
+ .and_then(|v| v.as_array())
275
+ .map(|arr| {
276
+ arr.iter()
277
+ .filter_map(|v| v.as_str().map(String::from))
278
+ .collect()
279
+ })
280
+ .unwrap_or_default()
281
+ }
282
+
283
+ fn extract_string_array_from(val: &Value, key: &str) -> Vec<String> {
284
+ val.get(key)
285
+ .and_then(|v| v.as_array())
286
+ .map(|arr| {
287
+ arr.iter()
288
+ .filter_map(|v| v.as_str().map(String::from))
289
+ .collect()
290
+ })
291
+ .unwrap_or_default()
292
+ }
293
+
294
+ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
295
+ val.get("done_criteria")
296
+ .and_then(|v| v.as_array())
297
+ .map(|arr| {
298
+ arr.iter()
299
+ .filter_map(|v| {
300
+ let item = v.get("item")?.as_str()?;
301
+ let checked = v.get("checked").and_then(|c| c.as_bool()).unwrap_or(false);
302
+ Some(DoneCriterion {
303
+ item: item.to_string(),
304
+ checked,
305
+ })
306
+ })
307
+ .collect()
308
+ })
309
+ .unwrap_or_default()
310
+ }
@@ -1,5 +1,6 @@
1
1
  pub mod config;
2
2
  pub mod dashboard;
3
+ pub mod import_context;
3
4
  pub mod init;
4
5
  pub mod list_tasks;
5
6
  pub mod load_context;
@@ -31,6 +32,7 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
31
32
  "handoff_dashboard" => dashboard::handle(arguments),
32
33
  "handoff_get_config" => config::handle_get(arguments),
33
34
  "handoff_update_config" => config::handle_update(arguments),
35
+ "handoff_import_context" => import_context::handle(arguments),
34
36
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
35
37
  };
36
38
 
package/src/mcp/tools.rs CHANGED
@@ -263,6 +263,170 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
263
263
  }
264
264
  }),
265
265
  },
266
+ ToolDefinition {
267
+ name: "handoff_import_context".to_string(),
268
+ description: "Import existing handoff documents into .handoff/ management. AI reads the source material, structures it, and submits everything in one call. Supports nested task hierarchies via children field.".to_string(),
269
+ input_schema: json!({
270
+ "type": "object",
271
+ "properties": {
272
+ "project_dir": {
273
+ "type": "string",
274
+ "description": "Project directory path. Defaults to current working directory."
275
+ },
276
+ "source": {
277
+ "type": "object",
278
+ "description": "Metadata about the original document being imported",
279
+ "properties": {
280
+ "description": {
281
+ "type": "string",
282
+ "description": "What is being imported (e.g. 'tmp/260601-sprint-handoff.md からの移行')"
283
+ },
284
+ "format": {
285
+ "type": "string",
286
+ "enum": ["markdown", "json", "text", "other"],
287
+ "description": "Format of the source material. Defaults to 'other'."
288
+ }
289
+ },
290
+ "required": ["description"]
291
+ },
292
+ "tasks": {
293
+ "type": "array",
294
+ "description": "Tasks to import. Supports nested hierarchies via children field.",
295
+ "items": {
296
+ "$ref": "#/$defs/importTask"
297
+ }
298
+ },
299
+ "session": {
300
+ "type": "object",
301
+ "description": "Session context to save. Same fields as handoff_save_context.",
302
+ "properties": {
303
+ "summary": { "type": "string", "description": "One-line summary (required)" },
304
+ "decisions": {
305
+ "type": "array",
306
+ "items": {
307
+ "type": "object",
308
+ "properties": {
309
+ "decision": { "type": "string" },
310
+ "reason": { "type": "string" },
311
+ "confidence": {
312
+ "type": "string",
313
+ "enum": ["confirmed", "estimated", "unverified"]
314
+ }
315
+ },
316
+ "required": ["decision"]
317
+ }
318
+ },
319
+ "blockers": {
320
+ "type": "array",
321
+ "items": { "type": "string" }
322
+ },
323
+ "checklist": {
324
+ "type": "array",
325
+ "items": {
326
+ "type": "object",
327
+ "properties": {
328
+ "item": { "type": "string" },
329
+ "checked": { "type": "boolean" },
330
+ "owner": { "type": "string", "enum": ["user", "ai"] }
331
+ },
332
+ "required": ["item"]
333
+ }
334
+ },
335
+ "handoff_notes": {
336
+ "type": "array",
337
+ "items": {
338
+ "type": "object",
339
+ "properties": {
340
+ "note": { "type": "string" },
341
+ "category": { "type": "string", "enum": ["caution", "context", "suggestion"] }
342
+ },
343
+ "required": ["note"]
344
+ }
345
+ },
346
+ "references": {
347
+ "type": "array",
348
+ "items": {
349
+ "type": "object",
350
+ "properties": {
351
+ "label": { "type": "string" },
352
+ "uri": { "type": "string" },
353
+ "type": { "type": "string", "enum": ["file", "issue", "mr", "wiki", "doc", "url"] },
354
+ "notes": { "type": "string" }
355
+ },
356
+ "required": ["label", "uri"]
357
+ }
358
+ },
359
+ "context_pointers": {
360
+ "type": "array",
361
+ "items": {
362
+ "type": "object",
363
+ "properties": {
364
+ "path": { "type": "string" },
365
+ "reason": { "type": "string" },
366
+ "lines": { "type": "string" }
367
+ },
368
+ "required": ["path"]
369
+ }
370
+ },
371
+ "environment": {
372
+ "type": "object",
373
+ "description": "Free-form environment state"
374
+ }
375
+ },
376
+ "required": ["summary"]
377
+ },
378
+ "raw_notes": {
379
+ "type": "string",
380
+ "description": "Free-form text that couldn't be structured. Saved as a handoff_note with category 'context'."
381
+ }
382
+ },
383
+ "required": ["source"],
384
+ "$defs": {
385
+ "importTask": {
386
+ "type": "object",
387
+ "properties": {
388
+ "title": { "type": "string" },
389
+ "status": {
390
+ "type": "string",
391
+ "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"]
392
+ },
393
+ "notes": { "type": "string" },
394
+ "priority": {
395
+ "type": "string",
396
+ "enum": ["low", "medium", "high"]
397
+ },
398
+ "labels": {
399
+ "type": "array",
400
+ "items": { "type": "string" }
401
+ },
402
+ "links": {
403
+ "type": "array",
404
+ "items": { "type": "string" }
405
+ },
406
+ "done_criteria": {
407
+ "type": "array",
408
+ "items": {
409
+ "type": "object",
410
+ "properties": {
411
+ "item": { "type": "string" },
412
+ "checked": { "type": "boolean" }
413
+ },
414
+ "required": ["item"]
415
+ }
416
+ },
417
+ "children": {
418
+ "type": "array",
419
+ "description": "Nested child tasks. Recursively supports the same structure.",
420
+ "items": {
421
+ "$ref": "#/$defs/importTask"
422
+ }
423
+ }
424
+ },
425
+ "required": ["title"]
426
+ }
427
+ }
428
+ }),
429
+ },
266
430
  ]
267
431
  }
268
432
 
@@ -39,10 +39,17 @@ pub fn generate_session_filename(summary: &str, timestamp: &str) -> String {
39
39
 
40
40
  fn summary_to_slug(summary: &str) -> String {
41
41
  let slug: String = summary
42
- .to_lowercase()
43
42
  .chars()
44
43
  .take(40)
45
- .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
44
+ .map(|c| {
45
+ if c.is_ascii_alphanumeric() {
46
+ c.to_ascii_lowercase()
47
+ } else if !c.is_ascii() {
48
+ c
49
+ } else {
50
+ '-'
51
+ }
52
+ })
46
53
  .collect();
47
54
  let slug = slug.trim_matches('-').to_string();
48
55
  let mut result = String::new();
@@ -66,9 +66,16 @@ pub fn is_terminal_status(status: &str) -> bool {
66
66
 
67
67
  pub fn title_to_slug(title: &str) -> String {
68
68
  let slug: String = title
69
- .to_lowercase()
70
69
  .chars()
71
- .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
70
+ .map(|c| {
71
+ if c.is_ascii_alphanumeric() {
72
+ c.to_ascii_lowercase()
73
+ } else if !c.is_ascii() {
74
+ c
75
+ } else {
76
+ '-'
77
+ }
78
+ })
72
79
  .collect();
73
80
  let slug = slug.trim_matches('-').to_string();
74
81
  let mut result = String::new();
@@ -0,0 +1,377 @@
1
+ use serde_json::{json, Value};
2
+ use tempfile::TempDir;
3
+
4
+ fn send(input: &str) -> Option<Value> {
5
+ let result = handoff_mcp::mcp::protocol::process_line(input)?;
6
+ Some(serde_json::from_str(&result).expect("response should be valid JSON"))
7
+ }
8
+
9
+ fn setup_project() -> TempDir {
10
+ let dir = tempfile::tempdir().expect("failed to create temp dir");
11
+
12
+ std::process::Command::new("git")
13
+ .args(["init"])
14
+ .current_dir(dir.path())
15
+ .output()
16
+ .unwrap();
17
+ std::process::Command::new("git")
18
+ .args(["commit", "--allow-empty", "-m", "init"])
19
+ .current_dir(dir.path())
20
+ .output()
21
+ .unwrap();
22
+
23
+ let req = json!({
24
+ "jsonrpc": "2.0", "id": 0,
25
+ "method": "tools/call",
26
+ "params": {
27
+ "name": "handoff_init",
28
+ "arguments": {
29
+ "project_dir": dir.path().to_string_lossy(),
30
+ "project_name": "test"
31
+ }
32
+ }
33
+ });
34
+ send(&req.to_string()).unwrap();
35
+ dir
36
+ }
37
+
38
+ fn call_tool(name: &str, arguments: Value) -> Value {
39
+ let req = json!({
40
+ "jsonrpc": "2.0", "id": 1,
41
+ "method": "tools/call",
42
+ "params": { "name": name, "arguments": arguments }
43
+ });
44
+ send(&req.to_string()).unwrap()
45
+ }
46
+
47
+ fn get_text(resp: &Value) -> String {
48
+ resp["result"]["content"][0]["text"]
49
+ .as_str()
50
+ .unwrap_or("")
51
+ .to_string()
52
+ }
53
+
54
+ #[test]
55
+ fn import_source_only() {
56
+ let dir = setup_project();
57
+ let resp = call_tool(
58
+ "handoff_import_context",
59
+ json!({
60
+ "project_dir": dir.path().to_string_lossy(),
61
+ "source": {
62
+ "description": "test import",
63
+ "format": "markdown"
64
+ }
65
+ }),
66
+ );
67
+ let text = get_text(&resp);
68
+ assert!(text.contains("Import complete"));
69
+ assert!(text.contains("Tasks created: 0"));
70
+ }
71
+
72
+ #[test]
73
+ fn import_flat_tasks() {
74
+ let dir = setup_project();
75
+ let resp = call_tool(
76
+ "handoff_import_context",
77
+ json!({
78
+ "project_dir": dir.path().to_string_lossy(),
79
+ "source": { "description": "flat task import" },
80
+ "tasks": [
81
+ { "title": "Task A", "status": "todo" },
82
+ { "title": "Task B", "status": "in_progress", "priority": "high" }
83
+ ]
84
+ }),
85
+ );
86
+ let text = get_text(&resp);
87
+ assert!(text.contains("Tasks created: 2"));
88
+ assert!(text.contains("2 top-level"));
89
+ assert!(text.contains("0 nested"));
90
+
91
+ let list_resp = call_tool(
92
+ "handoff_list_tasks",
93
+ json!({ "project_dir": dir.path().to_string_lossy() }),
94
+ );
95
+ let list_text = get_text(&list_resp);
96
+ assert!(list_text.contains("Task A"));
97
+ assert!(list_text.contains("Task B"));
98
+ }
99
+
100
+ #[test]
101
+ fn import_nested_tasks() {
102
+ let dir = setup_project();
103
+ let resp = call_tool(
104
+ "handoff_import_context",
105
+ json!({
106
+ "project_dir": dir.path().to_string_lossy(),
107
+ "source": { "description": "nested import" },
108
+ "tasks": [
109
+ {
110
+ "title": "Parent",
111
+ "status": "in_progress",
112
+ "children": [
113
+ { "title": "Child 1", "status": "done" },
114
+ {
115
+ "title": "Child 2",
116
+ "status": "todo",
117
+ "children": [
118
+ { "title": "Grandchild", "status": "todo" }
119
+ ]
120
+ }
121
+ ]
122
+ }
123
+ ]
124
+ }),
125
+ );
126
+ let text = get_text(&resp);
127
+ assert!(text.contains("Tasks created: 4"));
128
+ assert!(text.contains("1 top-level"));
129
+ assert!(text.contains("3 nested"));
130
+
131
+ let list_resp = call_tool(
132
+ "handoff_list_tasks",
133
+ json!({ "project_dir": dir.path().to_string_lossy() }),
134
+ );
135
+ let list_text = get_text(&list_resp);
136
+ assert!(list_text.contains("Parent"));
137
+ assert!(list_text.contains("Child 1"));
138
+ assert!(list_text.contains("Child 2"));
139
+ assert!(list_text.contains("Grandchild"));
140
+ }
141
+
142
+ #[test]
143
+ fn import_with_session() {
144
+ let dir = setup_project();
145
+ let resp = call_tool(
146
+ "handoff_import_context",
147
+ json!({
148
+ "project_dir": dir.path().to_string_lossy(),
149
+ "source": { "description": "session import" },
150
+ "session": {
151
+ "summary": "[import] test session",
152
+ "decisions": [
153
+ { "decision": "Use OAuth2", "reason": "mobile support", "confidence": "confirmed" }
154
+ ],
155
+ "blockers": ["waiting on API key"],
156
+ "handoff_notes": [
157
+ { "note": "check auth flow", "category": "caution" }
158
+ ]
159
+ }
160
+ }),
161
+ );
162
+ let text = get_text(&resp);
163
+ assert!(text.contains("Session saved: yes"));
164
+
165
+ let load_resp = call_tool(
166
+ "handoff_load_context",
167
+ json!({ "project_dir": dir.path().to_string_lossy() }),
168
+ );
169
+ let load_text = get_text(&load_resp);
170
+ assert!(load_text.contains("[import] test session"));
171
+ assert!(load_text.contains("Use OAuth2"));
172
+ assert!(load_text.contains("waiting on API key"));
173
+ }
174
+
175
+ #[test]
176
+ fn import_with_raw_notes() {
177
+ let dir = setup_project();
178
+ let resp = call_tool(
179
+ "handoff_import_context",
180
+ json!({
181
+ "project_dir": dir.path().to_string_lossy(),
182
+ "source": { "description": "raw notes import" },
183
+ "session": {
184
+ "summary": "[import] with raw notes"
185
+ },
186
+ "raw_notes": "Deploy on Fridays is forbidden\nSSL cert expires 7/1"
187
+ }),
188
+ );
189
+ let text = get_text(&resp);
190
+ assert!(text.contains("Raw notes: saved as handoff_note"));
191
+
192
+ let load_resp = call_tool(
193
+ "handoff_load_context",
194
+ json!({ "project_dir": dir.path().to_string_lossy() }),
195
+ );
196
+ let load_text = get_text(&load_resp);
197
+ assert!(load_text.contains("Deploy on Fridays is forbidden"));
198
+ }
199
+
200
+ #[test]
201
+ fn import_raw_notes_without_session_creates_auto_session() {
202
+ let dir = setup_project();
203
+ let resp = call_tool(
204
+ "handoff_import_context",
205
+ json!({
206
+ "project_dir": dir.path().to_string_lossy(),
207
+ "source": { "description": "auto session test" },
208
+ "raw_notes": "Some unstructured notes"
209
+ }),
210
+ );
211
+ let text = get_text(&resp);
212
+ assert!(text.contains("Session saved: yes"));
213
+ assert!(text.contains("Raw notes: saved as handoff_note"));
214
+
215
+ let load_resp = call_tool(
216
+ "handoff_load_context",
217
+ json!({ "project_dir": dir.path().to_string_lossy() }),
218
+ );
219
+ let load_text = get_text(&load_resp);
220
+ assert!(load_text.contains("[import] auto session test"));
221
+ assert!(load_text.contains("Some unstructured notes"));
222
+ }
223
+
224
+ #[test]
225
+ fn import_full_scenario() {
226
+ let dir = setup_project();
227
+ let resp = call_tool(
228
+ "handoff_import_context",
229
+ json!({
230
+ "project_dir": dir.path().to_string_lossy(),
231
+ "source": {
232
+ "description": "tmp/260601-sprint-handoff.md",
233
+ "format": "markdown"
234
+ },
235
+ "tasks": [
236
+ {
237
+ "title": "Auth renewal",
238
+ "status": "in_progress",
239
+ "priority": "high",
240
+ "labels": ["auth"],
241
+ "children": [
242
+ { "title": "Session migration", "status": "done" },
243
+ { "title": "PKCE implementation", "status": "in_progress" }
244
+ ]
245
+ },
246
+ {
247
+ "title": "CI speedup",
248
+ "status": "todo",
249
+ "priority": "medium",
250
+ "done_criteria": [
251
+ { "item": "Build time under 5min", "checked": false }
252
+ ]
253
+ }
254
+ ],
255
+ "session": {
256
+ "summary": "[import] Sprint handoff migration",
257
+ "decisions": [
258
+ { "decision": "OAuth2 + PKCE", "confidence": "confirmed" }
259
+ ],
260
+ "blockers": ["DB migration window TBD"],
261
+ "references": [
262
+ { "label": "Original doc", "uri": "tmp/260601-sprint-handoff.md", "type": "doc" }
263
+ ],
264
+ "context_pointers": [
265
+ { "path": "src/auth/oauth.rs", "reason": "PKCE core" }
266
+ ]
267
+ },
268
+ "raw_notes": "Deploy on Fridays is forbidden"
269
+ }),
270
+ );
271
+ let text = get_text(&resp);
272
+ assert!(text.contains("Tasks created: 4"));
273
+ assert!(text.contains("2 top-level"));
274
+ assert!(text.contains("2 nested"));
275
+ assert!(text.contains("Session saved: yes"));
276
+ assert!(text.contains("Raw notes: saved as handoff_note"));
277
+ }
278
+
279
+ #[test]
280
+ fn import_without_source_fails() {
281
+ let dir = setup_project();
282
+ let resp = call_tool(
283
+ "handoff_import_context",
284
+ json!({
285
+ "project_dir": dir.path().to_string_lossy(),
286
+ "tasks": [{ "title": "Something" }]
287
+ }),
288
+ );
289
+ let text = get_text(&resp);
290
+ assert!(text.contains("Error"));
291
+ assert!(text.contains("source"));
292
+ }
293
+
294
+ #[test]
295
+ fn import_task_without_title_fails() {
296
+ let dir = setup_project();
297
+ let resp = call_tool(
298
+ "handoff_import_context",
299
+ json!({
300
+ "project_dir": dir.path().to_string_lossy(),
301
+ "source": { "description": "bad task" },
302
+ "tasks": [{ "status": "todo" }]
303
+ }),
304
+ );
305
+ let text = get_text(&resp);
306
+ assert!(text.contains("Error"));
307
+ assert!(text.contains("title"));
308
+ }
309
+
310
+ #[test]
311
+ fn import_preserves_existing_tasks() {
312
+ let dir = setup_project();
313
+
314
+ call_tool(
315
+ "handoff_update_task",
316
+ json!({
317
+ "project_dir": dir.path().to_string_lossy(),
318
+ "task": { "title": "Pre-existing task", "status": "in_progress" }
319
+ }),
320
+ );
321
+
322
+ call_tool(
323
+ "handoff_import_context",
324
+ json!({
325
+ "project_dir": dir.path().to_string_lossy(),
326
+ "source": { "description": "import after existing" },
327
+ "tasks": [
328
+ { "title": "Imported task", "status": "todo" }
329
+ ]
330
+ }),
331
+ );
332
+
333
+ let list_resp = call_tool(
334
+ "handoff_list_tasks",
335
+ json!({ "project_dir": dir.path().to_string_lossy() }),
336
+ );
337
+ let list_text = get_text(&list_resp);
338
+ assert!(list_text.contains("Pre-existing task"));
339
+ assert!(list_text.contains("Imported task"));
340
+ }
341
+
342
+ #[test]
343
+ fn import_source_recorded_in_environment() {
344
+ let dir = setup_project();
345
+ call_tool(
346
+ "handoff_import_context",
347
+ json!({
348
+ "project_dir": dir.path().to_string_lossy(),
349
+ "source": {
350
+ "description": "my-handoff.md",
351
+ "format": "markdown"
352
+ },
353
+ "session": {
354
+ "summary": "[import] env test"
355
+ }
356
+ }),
357
+ );
358
+
359
+ let sessions_dir = dir.path().join(".handoff/sessions");
360
+ let entries: Vec<_> = std::fs::read_dir(&sessions_dir)
361
+ .unwrap()
362
+ .filter_map(|e| e.ok())
363
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
364
+ .collect();
365
+ assert_eq!(entries.len(), 1);
366
+
367
+ let content = std::fs::read_to_string(entries[0].path()).unwrap();
368
+ let session: serde_json::Value = serde_json::from_str(&content).unwrap();
369
+ assert_eq!(
370
+ session["environment"]["import_source"]["description"],
371
+ "my-handoff.md"
372
+ );
373
+ assert_eq!(
374
+ session["environment"]["import_source"]["format"],
375
+ "markdown"
376
+ );
377
+ }