aline-ai 0.1.10__py3-none-any.whl → 0.2.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.
realign/mcp_watcher.py CHANGED
@@ -1,4 +1,7 @@
1
- """Session file watcher for MCP auto-commit per user request completion."""
1
+ """Session file watcher for MCP auto-commit per user request completion.
2
+
3
+ Supports both Claude Code and Codex session formats with unified interface.
4
+ """
2
5
 
3
6
  import asyncio
4
7
  import json
@@ -6,49 +9,25 @@ import subprocess
6
9
  import sys
7
10
  import time
8
11
  from pathlib import Path
9
- from typing import Optional, Dict
12
+ from typing import Optional, Dict, Literal
10
13
  from datetime import datetime
11
14
 
12
15
  from .config import ReAlignConfig
13
16
  from .hooks import find_all_active_sessions
14
17
 
15
18
 
16
- # File-based logger for debugging when stderr is not visible
17
- _log_file = None
18
-
19
- def _log(msg: str):
20
- """Log to both stderr and a debug file."""
21
- global _log_file
22
- timestamp = datetime.now().strftime("%H:%M:%S")
23
- full_msg = f"[{timestamp}] {msg}"
24
- print(f"[MCP Watcher] {msg}", file=sys.stderr)
25
-
26
- # Also write to file for debugging
27
- if _log_file is None:
28
- log_path = Path.home() / ".aline_watcher.log"
29
- try:
30
- _log_file = open(log_path, "a", buffering=1) # Line buffered
31
- _log_file.write(f"\n{'='*60}\n")
32
- _log_file.write(f"[{timestamp}] MCP Watcher Started\n")
33
- _log_file.write(f"{'='*60}\n")
34
- except Exception:
35
- _log_file = False # Mark as failed so we don't retry
36
-
37
- if _log_file and _log_file is not False:
38
- try:
39
- _log_file.write(full_msg + "\n")
40
- _log_file.flush()
41
- except Exception:
42
- pass
19
+ # Session type detection
20
+ SessionType = Literal["claude", "codex", "unknown"]
43
21
 
44
22
 
45
23
  class DialogueWatcher:
46
24
  """Watch session files and auto-commit immediately after each user request completes."""
47
25
 
48
- def __init__(self, repo_path: Path):
49
- self.repo_path = repo_path # Default repo path (may be overridden per-session)
26
+ def __init__(self):
27
+ """Initialize watcher without fixed repo_path - will extract dynamically from sessions."""
50
28
  self.config = ReAlignConfig.load()
51
- self.last_commit_time: Dict[str, float] = {} # Track commit time per project
29
+ self.project_path = self._detect_project_path()
30
+ self.last_commit_times: Dict[str, float] = {} # Track last commit time per project
52
31
  self.last_session_sizes: Dict[str, int] = {} # Track file sizes
53
32
  self.last_stop_reason_counts: Dict[str, int] = {} # Track stop_reason counts per session
54
33
  self.min_commit_interval = 5.0 # Minimum 5 seconds between commits (cooldown)
@@ -59,38 +38,30 @@ class DialogueWatcher:
59
38
  async def start(self):
60
39
  """Start watching session files."""
61
40
  if not self.config.mcp_auto_commit:
62
- _log("Auto-commit disabled in config")
41
+ print("[MCP Watcher] Auto-commit disabled in config", file=sys.stderr)
63
42
  return
64
43
 
65
44
  self.running = True
66
- _log("Started watching for dialogue completion")
67
- _log(f"Mode: Per-request (triggers at end of each Claude response)")
68
- _log(f"Debounce: {self.debounce_delay}s, Cooldown: {self.min_commit_interval}s")
69
- _log(f"Home directory: {Path.home()}")
70
- _log(f"Current working directory: {Path.cwd()}")
45
+ print("[MCP Watcher] Started watching for dialogue completion", file=sys.stderr)
46
+ print(f"[MCP Watcher] Mode: Per-request (triggers at end of each AI response)", file=sys.stderr)
47
+ print(f"[MCP Watcher] Supports: Claude Code & Codex (auto-detected)", file=sys.stderr)
48
+ print(f"[MCP Watcher] Debounce: {self.debounce_delay}s, Cooldown: {self.min_commit_interval}s", file=sys.stderr)
49
+ if self.project_path:
50
+ print(f"[MCP Watcher] Monitoring project: {self.project_path}", file=sys.stderr)
51
+ else:
52
+ print("[MCP Watcher] Project path unknown, falling back to multi-project scan", file=sys.stderr)
71
53
 
72
54
  # Initialize baseline sizes and stop_reason counts
73
55
  self.last_session_sizes = self._get_session_sizes()
74
56
  self.last_stop_reason_counts = self._get_stop_reason_counts()
75
57
 
76
- # Log initial session files being monitored
77
- if self.last_session_sizes:
78
- _log(f"Monitoring {len(self.last_session_sizes)} session file(s):")
79
- for session_path in list(self.last_session_sizes.keys())[:5]: # Show first 5
80
- _log(f" - {session_path}")
81
- if len(self.last_session_sizes) > 5:
82
- _log(f" ... and {len(self.last_session_sizes) - 5} more")
83
- else:
84
- claude_projects = Path.home() / ".claude" / "projects"
85
- _log(f"WARNING: No session files found in {claude_projects}")
86
-
87
58
  # Poll for file changes more frequently
88
59
  while self.running:
89
60
  try:
90
61
  await self.check_for_changes()
91
62
  await asyncio.sleep(0.5) # Check every 0.5 seconds for responsiveness
92
63
  except Exception as e:
93
- _log(f"Error: {e}")
64
+ print(f"[MCP Watcher] Error: {e}", file=sys.stderr)
94
65
  await asyncio.sleep(1.0)
95
66
 
96
67
  async def stop(self):
@@ -98,42 +69,178 @@ class DialogueWatcher:
98
69
  self.running = False
99
70
  if self.pending_commit_task:
100
71
  self.pending_commit_task.cancel()
101
- _log("Stopped")
72
+ print("[MCP Watcher] Stopped", file=sys.stderr)
102
73
 
103
74
  def _get_session_sizes(self) -> Dict[str, int]:
104
- """Get current sizes of all active session files (from all Claude projects)."""
75
+ """Get current sizes of all active session files."""
105
76
  sizes = {}
106
77
  try:
107
- # Watch ALL Claude session files, not just one project
108
- claude_projects = Path.home() / ".claude" / "projects"
109
- if claude_projects.exists():
110
- for session_file in claude_projects.glob("*/*.jsonl"):
111
- if session_file.exists():
112
- sizes[str(session_file)] = session_file.stat().st_size
78
+ session_files = find_all_active_sessions(
79
+ self.config,
80
+ project_path=self.project_path if self.project_path and self.project_path.exists() else None,
81
+ )
82
+ for session_file in session_files:
83
+ if session_file.exists():
84
+ sizes[str(session_file)] = session_file.stat().st_size
113
85
  except Exception as e:
114
- _log(f"Error getting session sizes: {e}")
86
+ print(f"[MCP Watcher] Error getting session sizes: {e}", file=sys.stderr)
115
87
  return sizes
116
88
 
117
89
  def _get_stop_reason_counts(self) -> Dict[str, int]:
118
- """Get current count of stop_reason entries in all active session files."""
90
+ """Get current count of turn completion markers in all active session files."""
119
91
  counts = {}
120
92
  try:
121
- # Watch ALL Claude session files
122
- claude_projects = Path.home() / ".claude" / "projects"
123
- if claude_projects.exists():
124
- for session_file in claude_projects.glob("*/*.jsonl"):
125
- if session_file.exists():
126
- counts[str(session_file)] = self._count_stop_reasons(session_file)
93
+ session_files = find_all_active_sessions(
94
+ self.config,
95
+ project_path=self.project_path if self.project_path and self.project_path.exists() else None,
96
+ )
97
+ for session_file in session_files:
98
+ if session_file.exists():
99
+ counts[str(session_file)] = self._count_complete_turns(session_file)
127
100
  except Exception as e:
128
- _log(f"Error getting stop_reason counts: {e}")
101
+ print(f"[MCP Watcher] Error getting turn counts: {e}", file=sys.stderr)
129
102
  return counts
130
103
 
131
- def _count_stop_reasons(self, session_file: Path) -> int:
104
+ def _extract_project_path(self, session_file: Path) -> Optional[Path]:
105
+ """
106
+ Extract project path (cwd) from session file.
107
+
108
+ Supports:
109
+ 1. Codex: Read cwd from session_meta payload
110
+ 2. Claude Code: Read cwd from message objects OR infer from project directory name
111
+
112
+ Args:
113
+ session_file: Path to session file
114
+
115
+ Returns:
116
+ Path to project directory, or None if cannot determine
117
+ """
118
+ try:
119
+ # Method 1: Try to read cwd from session content
120
+ with open(session_file, 'r', encoding='utf-8') as f:
121
+ # Check first 10 lines for cwd field
122
+ for i, line in enumerate(f):
123
+ if i >= 10:
124
+ break
125
+
126
+ line = line.strip()
127
+ if not line:
128
+ continue
129
+
130
+ try:
131
+ data = json.loads(line)
132
+
133
+ # Codex format: session_meta.payload.cwd
134
+ if data.get('type') == 'session_meta':
135
+ cwd = data.get('payload', {}).get('cwd')
136
+ if cwd:
137
+ project_path = Path(cwd)
138
+ if project_path.exists():
139
+ return project_path
140
+
141
+ # Claude Code format: message object has cwd field
142
+ if 'cwd' in data:
143
+ cwd = data['cwd']
144
+ if cwd:
145
+ project_path = Path(cwd)
146
+ if project_path.exists():
147
+ return project_path
148
+
149
+ except json.JSONDecodeError:
150
+ continue
151
+
152
+ # Method 2: For Claude Code, infer from project directory name
153
+ # ~/.claude/projects/-Users-huminhao-Projects-ReAlign/xxx.jsonl
154
+ if '.claude/projects/' in str(session_file):
155
+ project_dir_name = session_file.parent.name
156
+ if project_dir_name.startswith('-'):
157
+ # Decode: -Users-huminhao-Projects-ReAlign -> /Users/huminhao/Projects/ReAlign
158
+ project_path_str = '/' + project_dir_name[1:].replace('-', '/')
159
+ project_path = Path(project_path_str)
160
+ if project_path.exists() and (project_path / '.git').exists():
161
+ return project_path
162
+
163
+ print(f"[MCP Watcher] Could not extract project path from {session_file.name}", file=sys.stderr)
164
+ return None
165
+
166
+ except Exception as e:
167
+ print(f"[MCP Watcher] Error extracting project path from {session_file}: {e}", file=sys.stderr)
168
+ return None
169
+
170
+ def _detect_session_type(self, session_file: Path) -> SessionType:
171
+ """
172
+ Detect the type of session file by examining its structure.
173
+
174
+ Returns:
175
+ "claude" for Claude Code sessions
176
+ "codex" for Codex/GPT sessions
177
+ "unknown" if cannot determine
178
+ """
179
+ try:
180
+ with open(session_file, 'r', encoding='utf-8') as f:
181
+ # Check first 20 lines for format indicators
182
+ for i, line in enumerate(f):
183
+ if i >= 20:
184
+ break
185
+
186
+ line = line.strip()
187
+ if not line:
188
+ continue
189
+
190
+ try:
191
+ data = json.loads(line)
192
+
193
+ # Claude Code format: {type: "assistant", message: {...}}
194
+ if data.get("type") in ("assistant", "user") and "message" in data:
195
+ return "claude"
196
+
197
+ # Codex format: {type: "session_meta", payload: {originator: "codex_*"}}
198
+ if data.get("type") == "session_meta":
199
+ payload = data.get("payload", {})
200
+ if "codex" in payload.get("originator", "").lower():
201
+ return "codex"
202
+
203
+ # Codex format: {type: "response_item", payload: {...}}
204
+ if data.get("type") == "response_item":
205
+ payload = data.get("payload", {})
206
+ # Claude has "message" wrapper, Codex doesn't
207
+ if "message" not in data and "role" in payload:
208
+ return "codex"
209
+
210
+ except json.JSONDecodeError:
211
+ continue
212
+
213
+ return "unknown"
214
+
215
+ except Exception as e:
216
+ print(f"[MCP Watcher] Error detecting session type for {session_file.name}: {e}", file=sys.stderr)
217
+ return "unknown"
218
+
219
+ def _count_complete_turns(self, session_file: Path) -> int:
220
+ """
221
+ Unified interface to count complete dialogue turns for any session type.
222
+
223
+ Automatically detects session format and uses appropriate counting method.
224
+
225
+ Returns:
226
+ Number of complete dialogue turns (user request + assistant response)
227
+ """
228
+ session_type = self._detect_session_type(session_file)
229
+
230
+ if session_type == "claude":
231
+ return self._count_claude_turns(session_file)
232
+ elif session_type == "codex":
233
+ return self._count_codex_turns(session_file)
234
+ else:
235
+ print(f"[MCP Watcher] Unknown session type for {session_file.name}, skipping", file=sys.stderr)
236
+ return 0
237
+
238
+ def _count_claude_turns(self, session_file: Path) -> int:
132
239
  """
133
- Count the number of unique stop_reason='end_turn' entries in a session file.
240
+ Count complete dialogue turns for Claude Code sessions.
134
241
 
135
- Deduplicates by message ID since Claude Code writes the same message multiple times
136
- (first with thinking, then with full content).
242
+ Uses stop_reason='end_turn' as the marker, with message ID deduplication
243
+ to handle Claude Code's incremental writes (thinking first, then full content).
137
244
  """
138
245
  unique_message_ids = set()
139
246
  try:
@@ -157,26 +264,50 @@ class DialogueWatcher:
157
264
  except json.JSONDecodeError:
158
265
  continue
159
266
  except Exception as e:
160
- _log(f"Error counting stop_reasons in {session_file}: {e}")
267
+ print(f"[MCP Watcher] Error counting Claude turns in {session_file}: {e}", file=sys.stderr)
161
268
  return len(unique_message_ids)
162
269
 
270
+ def _count_codex_turns(self, session_file: Path) -> int:
271
+ """
272
+ Count complete dialogue turns for Codex sessions.
273
+
274
+ Uses 'token_count' event_msg as the marker for dialogue turn completion.
275
+ Each token_count event appears after an assistant response finishes.
276
+
277
+ Note: Codex doesn't have the duplicate write issue that Claude Code has,
278
+ so no deduplication is needed.
279
+ """
280
+ count = 0
281
+ try:
282
+ with open(session_file, 'r', encoding='utf-8') as f:
283
+ for line in f:
284
+ line = line.strip()
285
+ if not line:
286
+ continue
287
+ try:
288
+ data = json.loads(line)
289
+ # Look for token_count events which mark turn completion
290
+ if data.get("type") == "event_msg":
291
+ payload = data.get("payload", {})
292
+ if payload.get("type") == "token_count":
293
+ count += 1
294
+ except json.JSONDecodeError:
295
+ continue
296
+ except Exception as e:
297
+ print(f"[MCP Watcher] Error counting Codex turns in {session_file}: {e}", file=sys.stderr)
298
+ return count
299
+
163
300
  async def check_for_changes(self):
164
301
  """Check if any session file has been modified."""
165
302
  try:
166
303
  current_sizes = self._get_session_sizes()
167
304
 
168
- # Debug: log if no sessions found (only once)
169
- if not current_sizes and not hasattr(self, '_no_session_warned'):
170
- print(f"[MCP Watcher] Warning: No active sessions found for repo: {self.repo_path}", file=sys.stderr)
171
- self._no_session_warned = True
172
-
173
305
  # Detect changed files
174
306
  changed_files = []
175
307
  for path, size in current_sizes.items():
176
308
  old_size = self.last_session_sizes.get(path, 0)
177
309
  if size > old_size:
178
310
  changed_files.append(Path(path))
179
- _log(f"Session file changed: {Path(path).name} ({old_size} -> {size} bytes)")
180
311
 
181
312
  if changed_files:
182
313
  # File changed - cancel any pending commit and schedule a new one
@@ -192,7 +323,7 @@ class DialogueWatcher:
192
323
  self.last_session_sizes = current_sizes
193
324
 
194
325
  except Exception as e:
195
- _log(f"Error checking for changes: {e}")
326
+ print(f"[MCP Watcher] Error checking for changes: {e}", file=sys.stderr)
196
327
 
197
328
  async def _debounced_commit(self, changed_files: list):
198
329
  """Wait for debounce period, then check if dialogue is complete and commit."""
@@ -201,51 +332,65 @@ class DialogueWatcher:
201
332
  await asyncio.sleep(self.debounce_delay)
202
333
 
203
334
  # Check if any of the changed files contains a complete dialogue turn
335
+ # And collect the session file that triggered the commit
336
+ session_to_commit = None
204
337
  for session_file in changed_files:
205
338
  if await self._check_if_turn_complete(session_file):
206
- _log(f"Complete turn detected in {session_file.name}")
207
-
208
- # Extract project path from session file's parent directory
209
- project_path = self._get_project_path_from_session(session_file)
210
- if project_path:
211
- # Check cooldown for this specific project
212
- current_time = time.time()
213
- last_time = self.last_commit_time.get(str(project_path), 0)
214
- if current_time - last_time < self.min_commit_interval:
215
- _log(f"Skipping commit for {project_path} (cooldown)")
216
- continue
217
-
218
- await self._do_commit(project_path)
219
- else:
220
- _log(f"WARNING: Could not extract project path from {session_file}, skipping commit")
339
+ session_to_commit = session_file
340
+ print(f"[MCP Watcher] Complete turn detected in {session_file.name}", file=sys.stderr)
341
+ break
342
+
343
+ if session_to_commit:
344
+ # Extract project path from the session file
345
+ project_path = self._extract_project_path(session_to_commit)
346
+ if not project_path:
347
+ print(f"[MCP Watcher] Could not determine project path for {session_to_commit.name}, skipping commit", file=sys.stderr)
348
+ return
349
+
350
+ # Check cooldown period for this specific project
351
+ current_time = time.time()
352
+ project_key = str(project_path)
353
+ last_commit_time = self.last_commit_times.get(project_key)
354
+ if last_commit_time:
355
+ time_since_last = current_time - last_commit_time
356
+ if time_since_last < self.min_commit_interval:
357
+ print(f"[MCP Watcher] Skipping commit for {project_path.name} (cooldown: {time_since_last:.1f}s < {self.min_commit_interval}s)", file=sys.stderr)
358
+ return
359
+
360
+ # Perform commit for this project
361
+ await self._do_commit(project_path, session_to_commit)
221
362
 
222
363
  except asyncio.CancelledError:
223
364
  # Task was cancelled because a newer change was detected
224
365
  pass
225
366
  except Exception as e:
226
- _log(f"Error in debounced commit: {e}")
367
+ print(f"[MCP Watcher] Error in debounced commit: {e}", file=sys.stderr)
227
368
 
228
369
  async def _check_if_turn_complete(self, session_file: Path) -> bool:
229
370
  """
230
- Check if the session file has at least 1 new stop_reason='end_turn' entry since last check.
371
+ Check if the session file has at least 1 new complete dialogue turn since last check.
231
372
 
232
- Each complete dialogue round in Claude Code has:
233
- 1. User message (no stop_reason field)
234
- 2. Assistant response with stop_reason: "end_turn"
373
+ Supports both Claude Code and Codex formats:
374
+ - Claude Code: Uses stop_reason='end_turn' (deduplicated by message ID)
375
+ - Codex: Uses token_count events (no deduplication needed)
235
376
 
236
- We deduplicate by message ID to handle incremental writes of the same message.
237
- Therefore, each request-response cycle produces 1 unique end_turn entry.
377
+ Each complete dialogue round consists of:
378
+ 1. User message/request
379
+ 2. Assistant response
380
+ 3. Turn completion marker (format-specific)
238
381
  """
239
382
  try:
240
383
  session_path = str(session_file)
241
- current_count = self._count_stop_reasons(session_file)
384
+ session_type = self._detect_session_type(session_file)
385
+
386
+ current_count = self._count_complete_turns(session_file)
242
387
  last_count = self.last_stop_reason_counts.get(session_path, 0)
243
388
 
244
- new_stop_reasons = current_count - last_count
389
+ new_turns = current_count - last_count
245
390
 
246
- # Commit after each complete assistant response (1 new end_turn)
247
- if new_stop_reasons >= 1:
248
- _log(f"Detected {new_stop_reasons} new end_turn entry(ies) in {session_file.name}")
391
+ # Commit after each complete assistant response (1 new turn)
392
+ if new_turns >= 1:
393
+ print(f"[MCP Watcher] Detected {new_turns} new turn(s) in {session_file.name} ({session_type})", file=sys.stderr)
249
394
  # Update baseline immediately to avoid double-counting
250
395
  self.last_stop_reason_counts[session_path] = current_count
251
396
  return True
@@ -253,216 +398,174 @@ class DialogueWatcher:
253
398
  return False
254
399
 
255
400
  except Exception as e:
256
- _log(f"Error checking turn completion: {e}")
401
+ print(f"[MCP Watcher] Error checking turn completion: {e}", file=sys.stderr)
257
402
  return False
258
403
 
259
- def _get_project_path_from_session(self, session_file: Path) -> Optional[Path]:
404
+ async def _do_commit(self, project_path: Path, session_file: Path):
260
405
  """
261
- Extract the actual project path from a Claude session file's location.
406
+ Perform the actual commit for a specific project.
262
407
 
263
- Claude Code stores sessions in: ~/.claude/projects/-Users-username-path/session.jsonl
264
- The directory name encodes the project path with dashes replacing both slashes and underscores.
265
- This means we need to intelligently reconstruct the path by testing which separators work.
408
+ Args:
409
+ project_path: Path to the project directory
410
+ session_file: Session file that triggered the commit
266
411
  """
267
- try:
268
- project_dir_name = session_file.parent.name
269
- _log(f"Extracting project path from: {project_dir_name}")
270
-
271
- if project_dir_name.startswith('-'):
272
- # Split into segments
273
- segments = project_dir_name[1:].split('-')
274
- _log(f"Path segments: {segments}")
275
-
276
- # Try to reconstruct the path by checking which combinations exist
277
- candidate_path = self._reconstruct_path_from_segments(segments)
278
-
279
- if candidate_path and candidate_path.exists():
280
- _log(f"Project path exists: {candidate_path}")
281
- return candidate_path
282
- else:
283
- # Fallback: try simple replacement (for backward compatibility)
284
- simple_path = Path('/' + project_dir_name[1:].replace('-', '/'))
285
- if simple_path.exists():
286
- _log(f"Project path exists (simple method): {simple_path}")
287
- return simple_path
288
- _log(f"WARNING: Could not find valid project path for: {project_dir_name}")
289
- else:
290
- _log(f"WARNING: Directory name doesn't start with '-': {project_dir_name}")
291
- except Exception as e:
292
- _log(f"Error extracting project path: {e}")
293
- return None
294
-
295
- def _reconstruct_path_from_segments(self, segments: list) -> Optional[Path]:
296
- """
297
- Reconstruct a file path from encoded segments.
298
-
299
- Claude Code encodes paths by replacing both '/' and '_' with '-'.
300
- This method tries to find the correct path by testing which separators
301
- produce valid paths.
302
-
303
- Strategy:
304
- 1. Start from root '/'
305
- 2. Build path incrementally, checking if each partial path exists
306
- 3. When a segment doesn't match, try combining with next segment using '_' or '-'
307
- """
308
- if not segments:
309
- return None
310
-
311
- current_path = Path('/')
312
- i = 0
313
-
314
- while i < len(segments):
315
- segment = segments[i]
316
-
317
- # Try direct match first (segment is a directory/file name)
318
- test_path = current_path / segment
319
- if test_path.exists():
320
- current_path = test_path
321
- i += 1
322
- continue
323
-
324
- # If direct match fails, try combining with next segments using '_' or '-'
325
- found_match = False
326
- for lookahead in range(1, min(10, len(segments) - i)): # Try up to 10 segments ahead
327
- # Try combining segments with underscores
328
- combined_underscore = '_'.join(segments[i:i+lookahead+1])
329
- test_path_underscore = current_path / combined_underscore
330
-
331
- # Try combining segments with hyphens
332
- combined_hyphen = '-'.join(segments[i:i+lookahead+1])
333
- test_path_hyphen = current_path / combined_hyphen
334
-
335
- # Try mixed combinations for longer paths
336
- if test_path_underscore.exists():
337
- current_path = test_path_underscore
338
- i += lookahead + 1
339
- found_match = True
340
- _log(f"Found match with underscores: {combined_underscore}")
341
- break
342
- elif test_path_hyphen.exists():
343
- current_path = test_path_hyphen
344
- i += lookahead + 1
345
- found_match = True
346
- _log(f"Found match with hyphens: {combined_hyphen}")
347
- break
348
-
349
- if not found_match:
350
- # If no match found, this might be the final segment (file or non-existent dir)
351
- # Just append remaining segments with '/' and check
352
- remaining = '/'.join(segments[i:])
353
- final_path = current_path / remaining
354
- if final_path.exists():
355
- return final_path
356
-
357
- # Try treating remaining as underscore-joined
358
- remaining_underscore = '_'.join(segments[i:])
359
- final_path_underscore = current_path / remaining_underscore
360
- if final_path_underscore.exists():
361
- return final_path_underscore
362
-
363
- # Nothing worked, return what we have
364
- _log(f"Could not match segment '{segment}' at path {current_path}")
365
- return None
366
-
367
- return current_path if current_path != Path('/') else None
368
-
369
- async def _do_commit(self, project_path: Path):
370
- """Perform the actual commit for a specific project."""
371
412
  try:
372
413
  # Generate commit message
373
414
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
374
415
  message = f"chore: Auto-commit MCP session ({timestamp})"
375
416
 
376
- _log(f"Attempting commit in {project_path} with message: {message}")
377
-
378
- # Use realign commit command with the specific project path
417
+ # Use realign commit command
379
418
  result = await asyncio.get_event_loop().run_in_executor(
380
419
  None,
381
420
  self._run_realign_commit,
382
- message,
383
- project_path
421
+ project_path,
422
+ message
384
423
  )
385
424
 
386
425
  if result:
387
- _log(f"✓ Committed in {project_path}: {message}")
388
- self.last_commit_time[str(project_path)] = time.time()
426
+ print(f"[MCP Watcher] ✓ Committed to {project_path.name}: {message}", file=sys.stderr)
427
+ # Update last commit time for this project
428
+ self.last_commit_times[str(project_path)] = time.time()
389
429
  # Baseline counts already updated in _check_if_turn_complete()
390
430
 
391
431
  except Exception as e:
392
- _log(f"Error during commit: {e}")
432
+ print(f"[MCP Watcher] Error during commit for {project_path}: {e}", file=sys.stderr)
393
433
 
394
- def _run_realign_commit(self, message: str, project_path: Path) -> bool:
434
+ def _run_realign_commit(self, project_path: Path, message: str) -> bool:
395
435
  """
396
- Run aline commit command using Python functions directly.
436
+ Run realign commit command with file locking to prevent race conditions.
437
+
438
+ Args:
439
+ project_path: Path to the project directory
440
+ message: Commit message
397
441
 
398
442
  The command will:
399
- - Auto-initialize git and Aline if needed
443
+ - Acquire a file lock to prevent concurrent commits from multiple watchers
444
+ - Auto-initialize git and ReAlign if needed
400
445
  - Check for session changes (modified within last 5 minutes)
401
446
  - Create empty commit if only sessions changed
402
447
  - Return True if commit was created, False otherwise
403
448
  """
449
+ from .file_lock import commit_lock
450
+
404
451
  try:
405
- from .commands.init import init_repository
406
- from .commands.commit import smart_commit
452
+ # Acquire commit lock to prevent concurrent commits from multiple MCP servers
453
+ with commit_lock(project_path, timeout=5.0) as locked:
454
+ if not locked:
455
+ print(f"[MCP Watcher] Another watcher is committing to {project_path.name}, skipping", file=sys.stderr)
456
+ return False
457
+
458
+ return self._do_commit_locked(project_path, message)
459
+
460
+ except TimeoutError:
461
+ print("[MCP Watcher] Could not acquire commit lock (timeout)", file=sys.stderr)
462
+ return False
463
+ except Exception as e:
464
+ print(f"[MCP Watcher] Commit error: {e}", file=sys.stderr)
465
+ return False
407
466
 
408
- # Check if Aline is initialized
467
+ def _do_commit_locked(self, project_path: Path, message: str) -> bool:
468
+ """
469
+ Perform the actual commit operation (must be called with lock held).
470
+
471
+ Args:
472
+ project_path: Path to the project directory
473
+ message: Commit message
474
+
475
+ Returns:
476
+ True if commit was created, False otherwise
477
+ """
478
+ try:
479
+ # Check if ReAlign is initialized
409
480
  realign_dir = project_path / ".realign"
410
481
 
411
482
  if not realign_dir.exists():
412
- _log(f"Aline not initialized in {project_path}, initializing...")
413
-
414
- # Auto-initialize Aline (which also inits git if needed)
415
- init_result = init_repository(
416
- repo_path=str(project_path),
417
- auto_init_git=True,
418
- skip_commit=False,
483
+ print(f"[MCP Watcher] ReAlign not initialized in {project_path.name}, initializing...", file=sys.stderr)
484
+
485
+ # Auto-initialize ReAlign (which also inits git if needed)
486
+ init_result = subprocess.run(
487
+ ["python3", "-m", "realign.cli", "init", "--yes"],
488
+ cwd=project_path,
489
+ capture_output=True,
490
+ text=True,
491
+ timeout=30,
419
492
  )
420
493
 
421
- if not init_result.get("success"):
422
- _log(f"Failed to initialize Aline: {init_result.get('message', 'Unknown error')}")
494
+ if init_result.returncode != 0:
495
+ print(f"[MCP Watcher] Failed to initialize ReAlign in {project_path.name}: {init_result.stderr}", file=sys.stderr)
423
496
  return False
424
497
 
425
- _log("✓ Aline initialized successfully")
498
+ print(f"[MCP Watcher] ReAlign initialized successfully in {project_path.name}", file=sys.stderr)
426
499
 
427
- # Now run the commit with stage_all=True
428
- result = smart_commit(
500
+ # Use direct function call instead of subprocess
501
+ # This allows environment variables (like OPENAI_API_KEY) to be inherited
502
+ from realign.commands.commit import commit_internal
503
+
504
+ success, commit_hash, status_msg = commit_internal(
505
+ repo_root=project_path,
429
506
  message=message,
430
- repo_path=str(project_path),
431
- stage_all=True,
507
+ all_files=True,
432
508
  amend=False,
433
509
  no_edit=False,
434
510
  )
435
511
 
436
- # Check if commit was successful
437
- if result.get("success"):
512
+ if success:
513
+ print(f"[MCP Watcher] ✓ {status_msg}", file=sys.stderr)
438
514
  return True
439
- elif result.get("no_changes"):
515
+ elif "No changes detected" in status_msg:
440
516
  # No changes - this is expected, not an error
441
517
  return False
442
518
  else:
443
519
  # Log the error for debugging
444
- error_msg = result.get("message", "Unknown error")
445
- _log(f"Commit failed: {error_msg}")
520
+ print(f"[MCP Watcher] Commit failed for {project_path.name}: {status_msg}", file=sys.stderr)
446
521
  return False
447
522
 
448
523
  except Exception as e:
449
- _log(f"Commit error: {e}")
524
+ print(f"[MCP Watcher] Commit error for {project_path.name}: {e}", file=sys.stderr)
450
525
  return False
451
526
 
527
+ def _detect_project_path(self) -> Optional[Path]:
528
+ """
529
+ Attempt to detect the git repository root for the current process.
530
+ Returns None if not inside a git repo.
531
+ """
532
+ try:
533
+ result = subprocess.run(
534
+ ["git", "rev-parse", "--show-toplevel"],
535
+ capture_output=True,
536
+ text=True,
537
+ check=True,
538
+ )
539
+ repo_path = Path(result.stdout.strip())
540
+ if repo_path.exists():
541
+ return repo_path
542
+ except subprocess.CalledProcessError:
543
+ current_dir = Path.cwd()
544
+ if (current_dir / ".git").exists():
545
+ return current_dir
546
+ except Exception as e:
547
+ print(f"[MCP Watcher] Could not detect project path: {e}", file=sys.stderr)
548
+ return None
549
+
452
550
 
453
551
  # Global watcher instance
454
552
  _watcher: Optional[DialogueWatcher] = None
455
553
 
456
554
 
457
- async def start_watcher(repo_path: Path):
458
- """Start the global session watcher for auto-commit on user request completion."""
555
+ async def start_watcher():
556
+ """
557
+ Start the global session watcher for auto-commit on user request completion.
558
+
559
+ No longer requires a repo_path - the watcher will dynamically extract project paths
560
+ from session files and commit to the appropriate repositories.
561
+ """
459
562
  global _watcher
460
563
 
461
564
  if _watcher and _watcher.running:
462
565
  print("[MCP Watcher] Already running", file=sys.stderr)
463
566
  return
464
567
 
465
- _watcher = DialogueWatcher(repo_path)
568
+ _watcher = DialogueWatcher()
466
569
  asyncio.create_task(_watcher.start())
467
570
 
468
571