claude-mpm 4.11.0__py3-none-any.whl → 4.11.1__py3-none-any.whl

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.
@@ -1,523 +0,0 @@
1
- """
2
- Session Resume Manager - Loads and analyzes paused session state.
3
-
4
- This module provides functionality to resume a paused Claude MPM session by:
5
- - Loading the most recent (or specified) paused session
6
- - Checking for changes since the pause
7
- - Detecting potential conflicts
8
- - Generating context summary for seamless resumption
9
-
10
- The resume manager ensures users have full awareness of what changed during the pause.
11
- """
12
-
13
- import subprocess
14
- from datetime import datetime
15
- from pathlib import Path
16
- from typing import Any, Dict, List, Optional
17
-
18
- from rich.console import Console
19
- from rich.panel import Panel
20
- from rich.table import Table
21
-
22
- from claude_mpm.core.logging_utils import get_logger
23
- from claude_mpm.storage.state_storage import StateStorage
24
-
25
- logger = get_logger(__name__)
26
- console = Console()
27
-
28
-
29
- class SessionResumeManager:
30
- """Manages resuming paused sessions with change detection."""
31
-
32
- def __init__(self, project_path: Path):
33
- """Initialize Session Resume Manager.
34
-
35
- Args:
36
- project_path: Path to the project directory
37
- """
38
- self.project_path = project_path
39
- self.session_dir = project_path / ".claude-mpm" / "sessions" / "pause"
40
- self.storage = StateStorage()
41
-
42
- def resume_session(self, session_id: Optional[str] = None) -> Dict[str, Any]:
43
- """Resume a paused session.
44
-
45
- Args:
46
- session_id: Optional specific session ID to resume (defaults to most recent)
47
-
48
- Returns:
49
- Dict containing resume context and warnings
50
- """
51
- try:
52
- # Load session state
53
- if session_id:
54
- session_state = self._load_session_by_id(session_id)
55
- else:
56
- session_state = self._load_latest_session()
57
-
58
- if not session_state:
59
- return {
60
- "status": "error",
61
- "message": (
62
- "No paused sessions found"
63
- if not session_id
64
- else f"Session {session_id} not found"
65
- ),
66
- }
67
-
68
- # Analyze changes since pause
69
- changes = self._analyze_changes_since_pause(session_state)
70
-
71
- # Generate resume context
72
- resume_context = self._generate_resume_context(session_state, changes)
73
-
74
- # Display resume information
75
- self._display_resume_info(session_state, changes)
76
-
77
- return {
78
- "status": "success",
79
- "session_state": session_state,
80
- "changes": changes,
81
- "resume_context": resume_context,
82
- }
83
-
84
- except Exception as e:
85
- logger.error(f"Failed to resume session: {e}")
86
- return {"status": "error", "message": str(e)}
87
-
88
- def _load_session_by_id(self, session_id: str) -> Optional[Dict[str, Any]]:
89
- """Load a specific session by ID.
90
-
91
- Args:
92
- session_id: The session identifier
93
-
94
- Returns:
95
- Session state dict or None if not found
96
- """
97
- session_file = self.session_dir / f"{session_id}.json"
98
- if not session_file.exists():
99
- logger.warning(f"Session file not found: {session_file}")
100
- return None
101
-
102
- return self.storage.read_json(session_file)
103
-
104
- def _load_latest_session(self) -> Optional[Dict[str, Any]]:
105
- """Load the most recent paused session.
106
-
107
- Returns:
108
- Session state dict or None if no sessions found
109
- """
110
- if not self.session_dir.exists():
111
- return None
112
-
113
- session_files = sorted(self.session_dir.glob("session-*.json"))
114
- if not session_files:
115
- return None
116
-
117
- # Load the most recent session
118
- latest_file = session_files[-1]
119
- return self.storage.read_json(latest_file)
120
-
121
- def _analyze_changes_since_pause(
122
- self, session_state: Dict[str, Any]
123
- ) -> Dict[str, Any]:
124
- """Analyze what changed since session was paused.
125
-
126
- Args:
127
- session_state: The paused session state
128
-
129
- Returns:
130
- Dict containing change analysis
131
- """
132
- changes: Dict[str, Any] = {
133
- "branch_changed": False,
134
- "new_commits": [],
135
- "new_commits_count": 0,
136
- "working_directory_changes": {
137
- "new_modified": [],
138
- "new_untracked": [],
139
- "resolved_files": [],
140
- },
141
- "warnings": [],
142
- }
143
-
144
- try:
145
- git_context = session_state.get("git_context", {})
146
-
147
- if not git_context.get("is_git_repo"):
148
- return changes
149
-
150
- # Check if branch changed
151
- paused_branch = git_context.get("branch")
152
- current_branch = self._get_current_branch()
153
-
154
- if paused_branch and current_branch and paused_branch != current_branch:
155
- changes["branch_changed"] = True
156
- changes["warnings"].append(
157
- f"Branch changed from '{paused_branch}' to '{current_branch}'"
158
- )
159
-
160
- # Check for new commits
161
- paused_commits = git_context.get("recent_commits", [])
162
- if paused_commits:
163
- latest_paused_sha = paused_commits[0]["sha"]
164
- new_commits = self._get_commits_since(latest_paused_sha)
165
- changes["new_commits"] = new_commits
166
- changes["new_commits_count"] = len(new_commits)
167
-
168
- if new_commits:
169
- changes["warnings"].append(
170
- f"{len(new_commits)} new commit(s) since pause"
171
- )
172
-
173
- # Check working directory changes
174
- paused_status = git_context.get("status", {})
175
- current_status = self._get_current_status()
176
-
177
- paused_modified = set(paused_status.get("modified_files", []))
178
- current_modified = set(current_status.get("modified_files", []))
179
- paused_untracked = set(paused_status.get("untracked_files", []))
180
- current_untracked = set(current_status.get("untracked_files", []))
181
-
182
- # Files that are newly modified
183
- new_modified = list(current_modified - paused_modified)
184
- # Files that are newly untracked
185
- new_untracked = list(current_untracked - paused_untracked)
186
- # Files that were modified but are now clean
187
- resolved_files = list(paused_modified - current_modified)
188
-
189
- changes["working_directory_changes"] = {
190
- "new_modified": new_modified,
191
- "new_untracked": new_untracked,
192
- "resolved_files": resolved_files,
193
- }
194
-
195
- if new_modified:
196
- changes["warnings"].append(
197
- f"{len(new_modified)} file(s) modified since pause"
198
- )
199
- if new_untracked:
200
- changes["warnings"].append(
201
- f"{len(new_untracked)} new untracked file(s)"
202
- )
203
-
204
- except Exception as e:
205
- logger.warning(f"Could not analyze changes: {e}")
206
- changes["warnings"].append(f"Could not analyze changes: {e}")
207
-
208
- return changes
209
-
210
- def _get_current_branch(self) -> Optional[str]:
211
- """Get current git branch.
212
-
213
- Returns:
214
- Branch name or None
215
- """
216
- try:
217
- result = subprocess.run(
218
- ["git", "branch", "--show-current"],
219
- cwd=str(self.project_path),
220
- capture_output=True,
221
- text=True,
222
- check=True,
223
- )
224
- return result.stdout.strip()
225
- except Exception:
226
- return None
227
-
228
- def _get_commits_since(self, since_sha: str) -> List[Dict[str, str]]:
229
- """Get commits since a specific SHA.
230
-
231
- Args:
232
- since_sha: The SHA to get commits after
233
-
234
- Returns:
235
- List of commit dicts
236
- """
237
- try:
238
- result = subprocess.run(
239
- ["git", "log", f"{since_sha}..HEAD", "--format=%h|%an|%ai|%s"],
240
- cwd=str(self.project_path),
241
- capture_output=True,
242
- text=True,
243
- check=True,
244
- )
245
-
246
- commits = []
247
- for line in result.stdout.strip().split("\n"):
248
- if not line:
249
- continue
250
- parts = line.split("|", 3)
251
- if len(parts) == 4:
252
- sha, author, timestamp, message = parts
253
- commits.append(
254
- {
255
- "sha": sha,
256
- "author": author,
257
- "timestamp": timestamp,
258
- "message": message,
259
- }
260
- )
261
-
262
- return commits
263
-
264
- except Exception as e:
265
- logger.warning(f"Could not get commits: {e}")
266
- return []
267
-
268
- def _get_current_status(self) -> Dict[str, Any]:
269
- """Get current git status.
270
-
271
- Returns:
272
- Dict with status information
273
- """
274
- status = {"clean": True, "modified_files": [], "untracked_files": []}
275
-
276
- try:
277
- result = subprocess.run(
278
- ["git", "status", "--porcelain"],
279
- cwd=str(self.project_path),
280
- capture_output=True,
281
- text=True,
282
- check=True,
283
- )
284
-
285
- modified_files = []
286
- untracked_files = []
287
-
288
- for line in result.stdout.strip().split("\n"):
289
- if not line:
290
- continue
291
- status_code = line[:2]
292
- file_path = line[3:]
293
-
294
- if status_code.startswith("??"):
295
- untracked_files.append(file_path)
296
- else:
297
- modified_files.append(file_path)
298
-
299
- status = {
300
- "clean": len(modified_files) == 0 and len(untracked_files) == 0,
301
- "modified_files": modified_files,
302
- "untracked_files": untracked_files,
303
- }
304
-
305
- except Exception as e:
306
- logger.warning(f"Could not get status: {e}")
307
-
308
- return status
309
-
310
- def _generate_resume_context(
311
- self, session_state: Dict[str, Any], changes: Dict[str, Any]
312
- ) -> str:
313
- """Generate formatted context summary for resuming.
314
-
315
- Args:
316
- session_state: The paused session state
317
- changes: Analysis of changes since pause
318
-
319
- Returns:
320
- Formatted context string
321
- """
322
- lines = []
323
- lines.append("=" * 80)
324
- lines.append("SESSION RESUME CONTEXT")
325
- lines.append("=" * 80)
326
- lines.append("")
327
-
328
- # Session info
329
- session_id = session_state.get("session_id", "unknown")
330
- paused_at = session_state.get("paused_at", "unknown")
331
- lines.append(f"Session ID: {session_id}")
332
- lines.append(f"Paused at: {paused_at}")
333
- lines.append("")
334
-
335
- # Previous context
336
- conversation = session_state.get("conversation", {})
337
- lines.append("PREVIOUS CONTEXT:")
338
- lines.append(f" Summary: {conversation.get('summary', 'Not provided')}")
339
- lines.append("")
340
-
341
- # Accomplishments
342
- accomplishments = conversation.get("accomplishments", [])
343
- if accomplishments:
344
- lines.append("ACCOMPLISHMENTS:")
345
- for item in accomplishments:
346
- lines.append(f" - {item}")
347
- lines.append("")
348
-
349
- # Next steps
350
- next_steps = conversation.get("next_steps", [])
351
- if next_steps:
352
- lines.append("PLANNED NEXT STEPS:")
353
- for item in next_steps:
354
- lines.append(f" - {item}")
355
- lines.append("")
356
-
357
- # Changes since pause
358
- if changes.get("warnings"):
359
- lines.append("CHANGES SINCE PAUSE:")
360
- for warning in changes["warnings"]:
361
- lines.append(f" ⚠️ {warning}")
362
- lines.append("")
363
-
364
- # New commits details
365
- if changes.get("new_commits"):
366
- lines.append("NEW COMMITS:")
367
- for commit in changes["new_commits"][:5]:
368
- lines.append(
369
- f" - {commit['sha']}: {commit['message']} ({commit['author']})"
370
- )
371
- if len(changes["new_commits"]) > 5:
372
- lines.append(f" ... and {len(changes['new_commits']) - 5} more")
373
- lines.append("")
374
-
375
- # Working directory changes
376
- wd_changes = changes.get("working_directory_changes", {})
377
- if wd_changes.get("new_modified"):
378
- lines.append("NEWLY MODIFIED FILES:")
379
- for file_path in wd_changes["new_modified"][:10]:
380
- lines.append(f" - {file_path}")
381
- if len(wd_changes["new_modified"]) > 10:
382
- lines.append(f" ... and {len(wd_changes['new_modified']) - 10} more")
383
- lines.append("")
384
-
385
- # Todos
386
- todos = session_state.get("todos", {})
387
- active_todos = todos.get("active", [])
388
- if active_todos:
389
- lines.append("ACTIVE TODO ITEMS:")
390
- for todo in active_todos:
391
- status = todo.get("status", "unknown")
392
- content = todo.get("content", "unknown")
393
- lines.append(f" [{status}] {content}")
394
- lines.append("")
395
-
396
- lines.append("=" * 80)
397
- lines.append("Ready to resume work!")
398
- lines.append("=" * 80)
399
-
400
- return "\n".join(lines)
401
-
402
- def _display_resume_info(
403
- self, session_state: Dict[str, Any], changes: Dict[str, Any]
404
- ) -> None:
405
- """Display resume information to console.
406
-
407
- Args:
408
- session_state: The paused session state
409
- changes: Analysis of changes since pause
410
- """
411
- console.print()
412
-
413
- # Session header
414
- session_id = session_state.get("session_id", "unknown")
415
- paused_at = session_state.get("paused_at", "unknown")
416
-
417
- # Parse timestamp for better display
418
- try:
419
- dt = datetime.fromisoformat(paused_at.replace("Z", "+00:00"))
420
- paused_display = dt.strftime("%Y-%m-%d %H:%M:%S UTC")
421
- except Exception:
422
- paused_display = paused_at
423
-
424
- console.print(
425
- Panel(
426
- f"[bold cyan]Resuming Session[/bold cyan]\n\n"
427
- f"[yellow]Session ID:[/yellow] {session_id}\n"
428
- f"[yellow]Paused at:[/yellow] {paused_display}",
429
- title="🟢 Session Resume",
430
- border_style="cyan",
431
- )
432
- )
433
-
434
- # Previous context
435
- conversation = session_state.get("conversation", {})
436
- console.print("\n[bold]Previous Context:[/bold]")
437
- console.print(f" {conversation.get('summary', 'Not provided')}")
438
-
439
- # Accomplishments
440
- accomplishments = conversation.get("accomplishments", [])
441
- if accomplishments:
442
- console.print("\n[bold green]Accomplishments:[/bold green]")
443
- for item in accomplishments:
444
- console.print(f" ✓ {item}")
445
-
446
- # Next steps
447
- next_steps = conversation.get("next_steps", [])
448
- if next_steps:
449
- console.print("\n[bold yellow]Planned Next Steps:[/bold yellow]")
450
- for idx, item in enumerate(next_steps, 1):
451
- console.print(f" {idx}. {item}")
452
-
453
- # Changes/Warnings
454
- warnings = changes.get("warnings", [])
455
- if warnings:
456
- console.print("\n[bold red]⚠️ Changes Since Pause:[/bold red]")
457
- for warning in warnings:
458
- console.print(f" • {warning}")
459
-
460
- # New commits table
461
- new_commits = changes.get("new_commits", [])
462
- if new_commits:
463
- console.print("\n[bold]New Commits:[/bold]")
464
- table = Table(show_header=True, header_style="bold magenta")
465
- table.add_column("SHA", style="yellow", width=8)
466
- table.add_column("Author", style="green", width=20)
467
- table.add_column("Message", style="white")
468
-
469
- for commit in new_commits[:5]:
470
- msg = commit["message"]
471
- if len(msg) > 60:
472
- msg = msg[:57] + "..."
473
- table.add_row(commit["sha"], commit["author"], msg)
474
-
475
- console.print(table)
476
- if len(new_commits) > 5:
477
- console.print(
478
- f" [dim]... and {len(new_commits) - 5} more commits[/dim]"
479
- )
480
-
481
- # Active todos
482
- todos = session_state.get("todos", {})
483
- active_todos = todos.get("active", [])
484
- if active_todos:
485
- console.print("\n[bold]Active Todo Items:[/bold]")
486
- for todo in active_todos:
487
- status = todo.get("status", "unknown")
488
- content = todo.get("content", "unknown")
489
- status_icon = {
490
- "pending": "⏸️",
491
- "in_progress": "🔄",
492
- "completed": "✅",
493
- }.get(status, "❓")
494
- console.print(f" {status_icon} [{status}] {content}")
495
-
496
- console.print()
497
-
498
- def list_available_sessions(self) -> List[Dict[str, Any]]:
499
- """List all available paused sessions.
500
-
501
- Returns:
502
- List of session information dicts
503
- """
504
- if not self.session_dir.exists():
505
- return []
506
-
507
- sessions = []
508
- for session_file in sorted(self.session_dir.glob("session-*.json")):
509
- try:
510
- state = self.storage.read_json(session_file)
511
- if state:
512
- sessions.append(
513
- {
514
- "session_id": state.get("session_id"),
515
- "paused_at": state.get("paused_at"),
516
- "summary": state.get("conversation", {}).get("summary"),
517
- "file_path": str(session_file),
518
- }
519
- )
520
- except Exception as e:
521
- logger.warning(f"Could not read session file {session_file}: {e}")
522
-
523
- return sessions