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.
- claude_mpm/VERSION +1 -1
- claude_mpm/cli/commands/mpm_init.py +216 -162
- claude_mpm/cli/commands/mpm_init_handler.py +14 -25
- claude_mpm/cli/parsers/mpm_init_parser.py +32 -31
- claude_mpm/commands/mpm-init.md +129 -78
- claude_mpm/utils/git_analyzer.py +305 -0
- {claude_mpm-4.11.0.dist-info → claude_mpm-4.11.1.dist-info}/METADATA +1 -1
- {claude_mpm-4.11.0.dist-info → claude_mpm-4.11.1.dist-info}/RECORD +12 -13
- claude_mpm/cli/commands/session_pause_manager.py +0 -389
- claude_mpm/cli/commands/session_resume_manager.py +0 -523
- {claude_mpm-4.11.0.dist-info → claude_mpm-4.11.1.dist-info}/WHEEL +0 -0
- {claude_mpm-4.11.0.dist-info → claude_mpm-4.11.1.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.11.0.dist-info → claude_mpm-4.11.1.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.11.0.dist-info → claude_mpm-4.11.1.dist-info}/top_level.txt +0 -0
@@ -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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|