handoff-mcp-server 0.16.0 → 0.17.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.
@@ -10,21 +10,28 @@ pub fn handle(arguments: &Value) -> Result<String> {
10
10
  let handoff = ensure_handoff_exists(&project_dir)?;
11
11
  let sessions_dir = handoff.join("sessions");
12
12
 
13
+ let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
14
+
13
15
  let active = read_active_sessions(&sessions_dir)?;
14
16
  if active.is_empty() {
15
17
  anyhow::bail!(
16
18
  "No active session. Call save_context with session_status='active' to create one first."
17
19
  );
18
20
  }
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
21
 
27
- let session = &active[0];
22
+ let session = if let Some(tid) = target_session_id {
23
+ active
24
+ .iter()
25
+ .find(|s| {
26
+ s.id.as_deref()
27
+ .is_some_and(|id| id == tid || id.starts_with(tid) || tid.starts_with(id))
28
+ })
29
+ .ok_or_else(|| anyhow::anyhow!("session_id '{tid}' not found among active sessions"))?
30
+ } else if active.len() == 1 {
31
+ &active[0]
32
+ } else {
33
+ active.last().unwrap()
34
+ };
28
35
  let sid = session.id.as_deref().unwrap_or("");
29
36
 
30
37
  let checklist_index = arguments
@@ -247,6 +247,15 @@ fn handle_update(
247
247
  }
248
248
  if let Some(notes) = task_val.get("notes").and_then(|v| v.as_str()) {
249
249
  data.notes = Some(notes.to_string());
250
+ } else if let Some(append) = task_val.get("notes_append").and_then(|v| v.as_str()) {
251
+ let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S");
252
+ let block = format!("--- {timestamp}\n{append}");
253
+ match &mut data.notes {
254
+ Some(existing) if !existing.is_empty() => {
255
+ existing.push_str(&format!("\n\n{block}"));
256
+ }
257
+ _ => data.notes = Some(block),
258
+ }
250
259
  }
251
260
  if let Some(priority) = task_val.get("priority").and_then(|v| v.as_str()) {
252
261
  validate_priority(Some(priority))?;
package/src/mcp/tools.rs CHANGED
@@ -168,6 +168,23 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
168
168
  "environment": {
169
169
  "type": "object",
170
170
  "description": "Free-form environment state"
171
+ },
172
+ "session_id": {
173
+ "type": "string",
174
+ "description": "Target active session ID. When multiple active sessions exist, specifies which to update/close. If omitted, uses the latest active session. Lower priority than close_session_id / pause_session_id."
175
+ },
176
+ "timeline": {
177
+ "type": "string",
178
+ "description": "Session timeline/group label (e.g. 'feature-x', 'hotfix-y')."
179
+ },
180
+ "label": {
181
+ "type": "string",
182
+ "description": "Short human-readable session label for switching UI (e.g. 'WT2作業', 'API設計')."
183
+ },
184
+ "related_task_ids": {
185
+ "type": "array",
186
+ "description": "Task IDs this session is primarily working on.",
187
+ "items": { "type": "string" }
171
188
  }
172
189
  },
173
190
  "required": ["summary"]
@@ -272,6 +289,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
272
289
  "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"]
273
290
  },
274
291
  "notes": { "type": "string" },
292
+ "notes_append": { "type": "string", "description": "Append text to existing notes with a timestamp heading. If both notes and notes_append are provided, notes (replace) takes precedence." },
275
293
  "priority": {
276
294
  "type": "string",
277
295
  "enum": ["low", "medium", "high"]
@@ -695,6 +713,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
695
713
  "type": "string",
696
714
  "description": "Project directory path. Defaults to current working directory."
697
715
  },
716
+ "session_id": {
717
+ "type": "string",
718
+ "description": "Target active session ID. When multiple active sessions exist, specifies which to update. If omitted and multiple exist, uses the latest."
719
+ },
698
720
  "checklist_index": {
699
721
  "type": "integer",
700
722
  "description": "0-based index of a checklist item to toggle."
@@ -798,9 +820,17 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
798
820
  "enum": ["open", "active", "paused", "closed"],
799
821
  "description": "Filter sessions by status."
800
822
  },
823
+ "timeline": {
824
+ "type": "string",
825
+ "description": "Filter sessions by timeline label."
826
+ },
801
827
  "limit": {
802
828
  "type": "integer",
803
829
  "description": "Max sessions to return (default 20)."
830
+ },
831
+ "include_children": {
832
+ "type": "boolean",
833
+ "description": "If true, include a 'children' array on each session showing its forked child sessions."
804
834
  }
805
835
  }
806
836
  }),
@@ -838,6 +868,8 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
838
868
  "status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"] },
839
869
  "priority": { "type": "string", "enum": ["low", "medium", "high"] },
840
870
  "assignee": { "type": "string" },
871
+ "notes": { "type": "string", "description": "Replace task notes." },
872
+ "notes_append": { "type": "string", "description": "Append text to existing notes with a timestamp heading. If both notes and notes_append are provided, notes (replace) takes precedence." },
841
873
  "schedule": {
842
874
  "type": "object",
843
875
  "properties": {
@@ -1082,6 +1114,74 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1082
1114
  }
1083
1115
  }),
1084
1116
  },
1117
+ // ---- Session fork/merge tools ----
1118
+ ToolDefinition {
1119
+ name: "handoff_fork_session".to_string(),
1120
+ description: "Fork a new session from an existing one. Inherits decisions, context_pointers, references, and handoff_notes by default. The forked session becomes active with parent_session_id set.".to_string(),
1121
+ input_schema: json!({
1122
+ "type": "object",
1123
+ "properties": {
1124
+ "project_dir": {
1125
+ "type": "string",
1126
+ "description": "Project directory path. Defaults to current working directory."
1127
+ },
1128
+ "source_session_id": {
1129
+ "type": "string",
1130
+ "description": "Session ID to fork from (active, paused, or closed)."
1131
+ },
1132
+ "summary": {
1133
+ "type": "string",
1134
+ "description": "Summary for the new forked session."
1135
+ },
1136
+ "label": {
1137
+ "type": "string",
1138
+ "description": "Short human-readable label for the forked session."
1139
+ },
1140
+ "timeline": {
1141
+ "type": "string",
1142
+ "description": "Timeline label. Defaults to the source session's timeline."
1143
+ },
1144
+ "inherit": {
1145
+ "type": "array",
1146
+ "description": "Fields to inherit from the source. Default: [\"decisions\", \"context_pointers\", \"references\", \"handoff_notes\", \"environment\"]. Available: decisions, context_pointers, references, handoff_notes, environment, blockers, checklist.",
1147
+ "items": { "type": "string" }
1148
+ },
1149
+ "related_task_ids": {
1150
+ "type": "array",
1151
+ "description": "Task IDs the forked session will work on.",
1152
+ "items": { "type": "string" }
1153
+ }
1154
+ },
1155
+ "required": ["source_session_id", "summary"]
1156
+ }),
1157
+ },
1158
+ ToolDefinition {
1159
+ name: "handoff_merge_sessions".to_string(),
1160
+ description: "Merge multiple sessions into one. Combines decisions, notes, references, and context_pointers. Detects duplicate decisions as conflicts. Source sessions (except the target) are closed by default.".to_string(),
1161
+ input_schema: json!({
1162
+ "type": "object",
1163
+ "properties": {
1164
+ "project_dir": {
1165
+ "type": "string",
1166
+ "description": "Project directory path. Defaults to current working directory."
1167
+ },
1168
+ "source_session_ids": {
1169
+ "type": "array",
1170
+ "description": "Session IDs to merge (must include at least 2).",
1171
+ "items": { "type": "string" }
1172
+ },
1173
+ "target_session_id": {
1174
+ "type": "string",
1175
+ "description": "Which source session becomes the merge target (must be one of source_session_ids)."
1176
+ },
1177
+ "close_sources": {
1178
+ "type": "boolean",
1179
+ "description": "Close non-target source sessions after merge. Default: true."
1180
+ }
1181
+ },
1182
+ "required": ["source_session_ids", "target_session_id"]
1183
+ }),
1184
+ },
1085
1185
  // ---- Timer coordination tools ----
1086
1186
  ToolDefinition {
1087
1187
  name: "handoff_timer_start".to_string(),
package/src/setup.rs CHANGED
@@ -56,7 +56,7 @@ fn build_hooks_config() -> BTreeMap<&'static str, Value> {
56
56
  "UserPromptSubmit",
57
57
  serde_json::json!([{
58
58
  "hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
59
- "project_dir": "${cwd}",
59
+ "project_dir": "${CLAUDE_PROJECT_DIR}",
60
60
  "session_id": "${session_id}",
61
61
  "text": "${prompt}"
62
62
  }))]
@@ -68,7 +68,7 @@ fn build_hooks_config() -> BTreeMap<&'static str, Value> {
68
68
  serde_json::json!([{
69
69
  "matcher": "Edit|Write|MultiEdit",
70
70
  "hooks": [mcp_tool_hook(HOOK_TOOL_QUERY, serde_json::json!({
71
- "project_dir": "${cwd}",
71
+ "project_dir": "${CLAUDE_PROJECT_DIR}",
72
72
  "session_id": "${session_id}",
73
73
  "tool_name": "${tool_name}",
74
74
  "text": "${tool_input.file_path}",
@@ -81,7 +81,7 @@ fn build_hooks_config() -> BTreeMap<&'static str, Value> {
81
81
  "SessionStart",
82
82
  serde_json::json!([{
83
83
  "hooks": [mcp_tool_hook(HOOK_TOOL_CLEANUP, serde_json::json!({
84
- "project_dir": "${cwd}"
84
+ "project_dir": "${CLAUDE_PROJECT_DIR}"
85
85
  }))]
86
86
  }]),
87
87
  );
@@ -90,6 +90,9 @@ pub struct SettingsConfig {
90
90
  /// Idle timeout in minutes for MCP fallback timer. Default 10.
91
91
  #[serde(default = "default_timer_idle_timeout_minutes")]
92
92
  pub timer_idle_timeout_minutes: u64,
93
+ /// Allow multiple active sessions simultaneously. Default false (single-active).
94
+ #[serde(default)]
95
+ pub multi_session: bool,
93
96
  #[serde(default)]
94
97
  pub custom_fields: HashMap<String, toml::Value>,
95
98
  }
@@ -295,6 +298,7 @@ impl Default for SettingsConfig {
295
298
  timer_provider: default_timer_provider(),
296
299
  timer_authority_ttl_secs: default_timer_authority_ttl_secs(),
297
300
  timer_idle_timeout_minutes: default_timer_idle_timeout_minutes(),
301
+ multi_session: false,
298
302
  custom_fields: HashMap::new(),
299
303
  }
300
304
  }
@@ -311,6 +315,10 @@ impl Default for DashboardConfig {
311
315
 
312
316
  impl Config {
313
317
  pub fn new(name: &str, description: &str) -> Self {
318
+ let settings = SettingsConfig {
319
+ multi_session: true,
320
+ ..SettingsConfig::default()
321
+ };
314
322
  Self {
315
323
  project: ProjectConfig {
316
324
  name: name.to_string(),
@@ -320,7 +328,7 @@ impl Config {
320
328
  Some(description.to_string())
321
329
  },
322
330
  },
323
- settings: SettingsConfig::default(),
331
+ settings,
324
332
  dashboard: DashboardConfig::default(),
325
333
  started_at: None,
326
334
  schedule_mode: None,
@@ -32,6 +32,14 @@ pub struct SessionData {
32
32
  pub context_pointers: Vec<Value>,
33
33
  #[serde(default, skip_serializing_if = "Option::is_none")]
34
34
  pub environment: Option<Value>,
35
+ #[serde(default, skip_serializing_if = "Option::is_none")]
36
+ pub timeline: Option<String>,
37
+ #[serde(default, skip_serializing_if = "Option::is_none")]
38
+ pub label: Option<String>,
39
+ #[serde(default, skip_serializing_if = "Option::is_none")]
40
+ pub parent_session_id: Option<String>,
41
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
42
+ pub related_task_ids: Vec<String>,
35
43
  }
36
44
 
37
45
  pub fn generate_session_id() -> String {
@@ -79,19 +87,34 @@ fn summary_to_slug(summary: &str) -> String {
79
87
  }
80
88
  }
81
89
 
82
- fn compact_timestamp(data: &SessionData) -> String {
83
- let timestamp = data.ended_at.as_deref().unwrap_or("00000000-000000");
84
- let ts_compact = timestamp
85
- .replace(['-', ':'], "")
86
- .replace('T', "-")
87
- .replace('Z', "");
88
- if ts_compact.len() >= 15 {
89
- ts_compact[..15].to_string()
90
+ fn extract_timestamp_from_id(id: &str) -> Option<String> {
91
+ // "s-20260704-003557-792065" "20260704-003557"
92
+ let rest = id.strip_prefix("s-")?;
93
+ if rest.len() >= 15 {
94
+ Some(rest[..15].to_string())
90
95
  } else {
91
- ts_compact
96
+ None
92
97
  }
93
98
  }
94
99
 
100
+ fn compact_timestamp(data: &SessionData) -> String {
101
+ data.ended_at
102
+ .as_deref()
103
+ .map(|ts| {
104
+ let compact = ts
105
+ .replace(['-', ':'], "")
106
+ .replace('T', "-")
107
+ .replace('Z', "");
108
+ if compact.len() >= 15 {
109
+ compact[..15].to_string()
110
+ } else {
111
+ compact
112
+ }
113
+ })
114
+ .or_else(|| data.id.as_deref().and_then(extract_timestamp_from_id))
115
+ .unwrap_or_else(|| "00000000-000000".to_string())
116
+ }
117
+
95
118
  fn synthesize_id_from_filename(filename: &str) -> String {
96
119
  // Old format: YYYYMMDD-HHMMSS-slug.status.json
97
120
  // Extract timestamp part and create s-YYYYMMDD-HHMMSS-000000
@@ -169,13 +192,12 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
169
192
  read_sessions_by_status(sessions_dir, "active")
170
193
  }
171
194
 
172
- /// Append `decision` (a decision object: {decision, reason?, confidence?}) to the
173
- /// `decisions` list of every active session file. Returns the number of sessions
174
- /// updated. Used by tools like auto_schedule to record applied changes so the
175
- /// audit trail lives with the session, not just the tool response.
195
+ /// Append `decision` to active session(s). When `target_session_id` is Some,
196
+ /// only the matching session is updated; otherwise all active sessions are updated.
176
197
  pub fn append_decision_to_active_sessions(
177
198
  sessions_dir: &Path,
178
199
  decision: serde_json::Value,
200
+ target_session_id: Option<&str>,
179
201
  ) -> Result<usize> {
180
202
  let suffix = ".active.json";
181
203
  if !sessions_dir.exists() {
@@ -194,6 +216,14 @@ pub fn append_decision_to_active_sessions(
194
216
  .with_context(|| format!("Failed to read session: {}", path.display()))?;
195
217
  let mut data: SessionData = serde_json::from_str(&content)
196
218
  .with_context(|| format!("Failed to parse session: {}", path.display()))?;
219
+
220
+ if let Some(tid) = target_session_id {
221
+ let file_id = data.id.as_deref().unwrap_or("");
222
+ if !ids_match(file_id, tid) {
223
+ continue;
224
+ }
225
+ }
226
+
197
227
  data.decisions.push(decision.clone());
198
228
  let updated = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
199
229
  crate::storage::atomic_write(&path, updated.as_bytes())
@@ -369,6 +399,15 @@ fn apply_session_updates(data: &mut SessionData, updates: &SessionData) {
369
399
  if updates.environment.is_some() {
370
400
  data.environment = updates.environment.clone();
371
401
  }
402
+ if updates.timeline.is_some() {
403
+ data.timeline = updates.timeline.clone();
404
+ }
405
+ if updates.label.is_some() {
406
+ data.label = updates.label.clone();
407
+ }
408
+ if !updates.related_task_ids.is_empty() {
409
+ data.related_task_ids = updates.related_task_ids.clone();
410
+ }
372
411
  }
373
412
 
374
413
  fn find_and_update_active_session(
@@ -419,7 +458,7 @@ fn find_and_update_active_session(
419
458
  );
420
459
  }
421
460
 
422
- if let Some((path, name, _)) = matches.into_iter().next() {
461
+ if let Some((path, _, _)) = matches.into_iter().next() {
423
462
  let content = std::fs::read_to_string(&path)
424
463
  .with_context(|| format!("Failed to read session: {}", path.display()))?;
425
464
  let mut data: SessionData = serde_json::from_str(&content)
@@ -433,8 +472,9 @@ fn find_and_update_active_session(
433
472
  .with_context(|| format!("Failed to write session: {}", path.display()))?;
434
473
 
435
474
  if let Some(target_status) = transition_to {
436
- let target_suffix = format!(".{target_status}.json");
437
- let new_name = name.replace(suffix, &target_suffix);
475
+ let ts_part = compact_timestamp(&data);
476
+ let base = generate_session_filename(&data.summary, &ts_part);
477
+ let new_name = format!("{base}.{target_status}.json");
438
478
  let new_path = sessions_dir.join(&new_name);
439
479
  std::fs::rename(&path, &new_path).with_context(|| {
440
480
  format!(
@@ -467,6 +507,277 @@ pub fn update_active_session(
467
507
  find_and_update_active_session(sessions_dir, session_id, updates, None)
468
508
  }
469
509
 
510
+ pub fn read_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<SessionData>> {
511
+ if !sessions_dir.exists() {
512
+ return Ok(None);
513
+ }
514
+
515
+ let mut matches: Vec<(SessionData, String)> = Vec::new();
516
+
517
+ for entry in std::fs::read_dir(sessions_dir)? {
518
+ let entry = entry?;
519
+ let name = entry.file_name().to_string_lossy().to_string();
520
+ if !name.ends_with(".json") {
521
+ continue;
522
+ }
523
+ let content = std::fs::read_to_string(entry.path())
524
+ .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
525
+ let data: SessionData = serde_json::from_str(&content)
526
+ .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
527
+
528
+ let file_id = data.id.as_deref().unwrap_or("");
529
+ let resolved_id = if file_id.is_empty() {
530
+ synthesize_id_from_filename(&name)
531
+ } else {
532
+ file_id.to_string()
533
+ };
534
+
535
+ if ids_match(&resolved_id, session_id) {
536
+ matches.push((data, resolved_id));
537
+ }
538
+ }
539
+
540
+ if matches.len() > 1 {
541
+ let candidates: Vec<&str> = matches.iter().map(|(_, id)| id.as_str()).collect();
542
+ anyhow::bail!(
543
+ "Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
544
+ session_id,
545
+ matches.len(),
546
+ candidates.join(", ")
547
+ );
548
+ }
549
+
550
+ Ok(matches.into_iter().next().map(|(data, _)| data))
551
+ }
552
+
553
+ pub fn fork_session(
554
+ sessions_dir: &Path,
555
+ source: &SessionData,
556
+ summary: &str,
557
+ label: Option<&str>,
558
+ timeline: Option<&str>,
559
+ related_task_ids: Vec<String>,
560
+ inherit: &[&str],
561
+ ) -> Result<SessionData> {
562
+ let source_id = source
563
+ .id
564
+ .as_deref()
565
+ .ok_or_else(|| anyhow::anyhow!("Source session has no ID"))?;
566
+
567
+ let mut forked = SessionData {
568
+ version: source.version,
569
+ id: Some(generate_session_id()),
570
+ ended_at: None,
571
+ summary: summary.to_string(),
572
+ branch: None,
573
+ commit: None,
574
+ dirty_files: Vec::new(),
575
+ decisions: Vec::new(),
576
+ blockers: Vec::new(),
577
+ checklist: Vec::new(),
578
+ handoff_notes: Vec::new(),
579
+ references: Vec::new(),
580
+ context_pointers: Vec::new(),
581
+ environment: None,
582
+ timeline: timeline
583
+ .map(String::from)
584
+ .or_else(|| source.timeline.clone()),
585
+ label: label.map(String::from),
586
+ parent_session_id: Some(source_id.to_string()),
587
+ related_task_ids,
588
+ };
589
+
590
+ for field in inherit {
591
+ match *field {
592
+ "decisions" => forked.decisions = source.decisions.clone(),
593
+ "context_pointers" => forked.context_pointers = source.context_pointers.clone(),
594
+ "references" => forked.references = source.references.clone(),
595
+ "handoff_notes" => forked.handoff_notes = source.handoff_notes.clone(),
596
+ "environment" => forked.environment = source.environment.clone(),
597
+ "blockers" => forked.blockers = source.blockers.clone(),
598
+ "checklist" => forked.checklist = source.checklist.clone(),
599
+ _ => {}
600
+ }
601
+ }
602
+
603
+ write_session_with_status(sessions_dir, &forked, "active")?;
604
+ Ok(forked)
605
+ }
606
+
607
+ pub fn merge_sessions(
608
+ sessions_dir: &Path,
609
+ source_ids: &[&str],
610
+ target_id: &str,
611
+ close_sources: bool,
612
+ ) -> Result<MergeResult> {
613
+ let mut sources: Vec<SessionData> = Vec::new();
614
+ for sid in source_ids {
615
+ let session = read_session_by_id(sessions_dir, sid)?
616
+ .ok_or_else(|| anyhow::anyhow!("Source session not found: {sid}"))?;
617
+ sources.push(session);
618
+ }
619
+
620
+ let mut target = read_session_by_id(sessions_dir, target_id)?
621
+ .ok_or_else(|| anyhow::anyhow!("Target session not found: {target_id}"))?;
622
+
623
+ let target_actual_id = target.id.clone().unwrap_or_default();
624
+
625
+ let mut merged_decisions = 0usize;
626
+ let mut merged_notes = 0usize;
627
+ let mut merged_references = 0usize;
628
+ let mut merged_context_pointers = 0usize;
629
+ let mut conflicts = Vec::new();
630
+ let mut closed_sessions = Vec::new();
631
+
632
+ for source in &sources {
633
+ let source_actual_id = source.id.as_deref().unwrap_or("");
634
+ if source_actual_id == target_actual_id {
635
+ continue;
636
+ }
637
+
638
+ for d in &source.decisions {
639
+ let decision_text = d.get("decision").and_then(|v| v.as_str()).unwrap_or("");
640
+ let already_exists = target.decisions.iter().any(|td| {
641
+ td.get("decision").and_then(|v| v.as_str()).unwrap_or("") == decision_text
642
+ });
643
+ if already_exists {
644
+ conflicts.push(MergeConflict {
645
+ conflict_type: "decision_conflict".to_string(),
646
+ description: format!("Duplicate decision: {decision_text}"),
647
+ session_a: target_actual_id.clone(),
648
+ session_b: source_actual_id.to_string(),
649
+ });
650
+ } else {
651
+ target.decisions.push(d.clone());
652
+ merged_decisions += 1;
653
+ }
654
+ }
655
+
656
+ for n in &source.handoff_notes {
657
+ target.handoff_notes.push(n.clone());
658
+ merged_notes += 1;
659
+ }
660
+
661
+ for r in &source.references {
662
+ let label = r.get("label").and_then(|v| v.as_str()).unwrap_or("");
663
+ let already_exists = target
664
+ .references
665
+ .iter()
666
+ .any(|tr| tr.get("label").and_then(|v| v.as_str()).unwrap_or("") == label);
667
+ if !already_exists {
668
+ target.references.push(r.clone());
669
+ merged_references += 1;
670
+ }
671
+ }
672
+
673
+ for cp in &source.context_pointers {
674
+ let path = cp.get("path").and_then(|v| v.as_str()).unwrap_or("");
675
+ let already_exists = target
676
+ .context_pointers
677
+ .iter()
678
+ .any(|tcp| tcp.get("path").and_then(|v| v.as_str()).unwrap_or("") == path);
679
+ if !already_exists {
680
+ target.context_pointers.push(cp.clone());
681
+ merged_context_pointers += 1;
682
+ }
683
+ }
684
+
685
+ for task_id in &source.related_task_ids {
686
+ if !target.related_task_ids.contains(task_id) {
687
+ target.related_task_ids.push(task_id.clone());
688
+ }
689
+ }
690
+
691
+ if close_sources {
692
+ if let Some(path) = close_session_by_id(sessions_dir, source_actual_id)? {
693
+ let _ = path;
694
+ closed_sessions.push(source_actual_id.to_string());
695
+ }
696
+ }
697
+ }
698
+
699
+ update_session_in_place(sessions_dir, &target_actual_id, &target)?;
700
+
701
+ Ok(MergeResult {
702
+ merged_session_id: target_actual_id,
703
+ merged_decisions,
704
+ merged_notes,
705
+ merged_references,
706
+ merged_context_pointers,
707
+ conflicts,
708
+ closed_sessions,
709
+ })
710
+ }
711
+
712
+ fn update_session_in_place(
713
+ sessions_dir: &Path,
714
+ session_id: &str,
715
+ updated: &SessionData,
716
+ ) -> Result<()> {
717
+ if !sessions_dir.exists() {
718
+ anyhow::bail!("Sessions directory not found");
719
+ }
720
+
721
+ let mut matches: Vec<(PathBuf, String)> = Vec::new();
722
+
723
+ for entry in std::fs::read_dir(sessions_dir)? {
724
+ let entry = entry?;
725
+ let name = entry.file_name().to_string_lossy().to_string();
726
+ if !name.ends_with(".json") {
727
+ continue;
728
+ }
729
+ let content = std::fs::read_to_string(entry.path())?;
730
+ let data: SessionData = serde_json::from_str(&content)?;
731
+ let file_id = data.id.as_deref().unwrap_or("");
732
+ let resolved_id = if file_id.is_empty() {
733
+ synthesize_id_from_filename(&name)
734
+ } else {
735
+ file_id.to_string()
736
+ };
737
+ if ids_match(&resolved_id, session_id) {
738
+ matches.push((entry.path(), resolved_id));
739
+ }
740
+ }
741
+
742
+ if matches.len() > 1 {
743
+ let candidates: Vec<&str> = matches.iter().map(|(_, id)| id.as_str()).collect();
744
+ anyhow::bail!(
745
+ "Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
746
+ session_id,
747
+ matches.len(),
748
+ candidates.join(", ")
749
+ );
750
+ }
751
+
752
+ if let Some((path, _)) = matches.into_iter().next() {
753
+ let serialized =
754
+ serde_json::to_string_pretty(updated).context("Failed to serialize session")?;
755
+ crate::storage::atomic_write(path, serialized.as_bytes())?;
756
+ return Ok(());
757
+ }
758
+
759
+ anyhow::bail!("Session not found for in-place update: {session_id}")
760
+ }
761
+
762
+ #[derive(Debug)]
763
+ pub struct MergeConflict {
764
+ pub conflict_type: String,
765
+ pub description: String,
766
+ pub session_a: String,
767
+ pub session_b: String,
768
+ }
769
+
770
+ #[derive(Debug)]
771
+ pub struct MergeResult {
772
+ pub merged_session_id: String,
773
+ pub merged_decisions: usize,
774
+ pub merged_notes: usize,
775
+ pub merged_references: usize,
776
+ pub merged_context_pointers: usize,
777
+ pub conflicts: Vec<MergeConflict>,
778
+ pub closed_sessions: Vec<String>,
779
+ }
780
+
470
781
  pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
471
782
  if !sessions_dir.exists() {
472
783
  return Ok(0);