aline-ai 0.1.9__py3-none-any.whl → 0.2.0__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/file_lock.py ADDED
@@ -0,0 +1,120 @@
1
+ """File-based locking mechanism for cross-process synchronization."""
2
+
3
+ import fcntl
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from contextlib import contextmanager
9
+
10
+
11
+ class FileLock:
12
+ """Simple file-based lock using fcntl (Unix/macOS only)."""
13
+
14
+ def __init__(self, lock_file: Path, timeout: float = 10.0):
15
+ """
16
+ Initialize a file lock.
17
+
18
+ Args:
19
+ lock_file: Path to the lock file
20
+ timeout: Maximum time to wait for lock acquisition (seconds)
21
+ """
22
+ self.lock_file = lock_file
23
+ self.timeout = timeout
24
+ self.fd: Optional[int] = None
25
+
26
+ def acquire(self, blocking: bool = True) -> bool:
27
+ """
28
+ Acquire the lock.
29
+
30
+ Args:
31
+ blocking: If True, wait for lock; if False, return immediately
32
+
33
+ Returns:
34
+ True if lock was acquired, False otherwise
35
+ """
36
+ # Create lock file directory if needed
37
+ self.lock_file.parent.mkdir(parents=True, exist_ok=True)
38
+
39
+ # Open lock file
40
+ self.fd = os.open(str(self.lock_file), os.O_CREAT | os.O_RDWR)
41
+
42
+ if blocking:
43
+ # Try to acquire with timeout
44
+ start_time = time.time()
45
+ while True:
46
+ try:
47
+ fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
48
+ return True
49
+ except BlockingIOError:
50
+ if time.time() - start_time > self.timeout:
51
+ os.close(self.fd)
52
+ self.fd = None
53
+ return False
54
+ time.sleep(0.1)
55
+ else:
56
+ # Non-blocking attempt
57
+ try:
58
+ fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
59
+ return True
60
+ except BlockingIOError:
61
+ os.close(self.fd)
62
+ self.fd = None
63
+ return False
64
+
65
+ def release(self):
66
+ """Release the lock."""
67
+ if self.fd is not None:
68
+ try:
69
+ fcntl.flock(self.fd, fcntl.LOCK_UN)
70
+ os.close(self.fd)
71
+ except Exception:
72
+ pass
73
+ finally:
74
+ self.fd = None
75
+
76
+ def __enter__(self):
77
+ """Context manager entry."""
78
+ if not self.acquire():
79
+ raise TimeoutError(f"Could not acquire lock on {self.lock_file} within {self.timeout}s")
80
+ return self
81
+
82
+ def __exit__(self, exc_type, exc_val, exc_tb):
83
+ """Context manager exit."""
84
+ self.release()
85
+ return False
86
+
87
+ def __del__(self):
88
+ """Cleanup on deletion."""
89
+ self.release()
90
+
91
+
92
+ @contextmanager
93
+ def commit_lock(repo_path: Path, timeout: float = 10.0):
94
+ """
95
+ Context manager for acquiring a commit lock.
96
+
97
+ Prevents multiple watchers from committing simultaneously.
98
+
99
+ Usage:
100
+ with commit_lock(repo_path):
101
+ # Perform git commit
102
+ subprocess.run(["git", "commit", ...])
103
+
104
+ Args:
105
+ repo_path: Path to the repository
106
+ timeout: Maximum time to wait for lock (seconds)
107
+
108
+ Yields:
109
+ True if lock was acquired
110
+ """
111
+ lock_file = repo_path / ".realign" / ".commit.lock"
112
+ lock = FileLock(lock_file, timeout=timeout)
113
+
114
+ try:
115
+ if lock.acquire():
116
+ yield True
117
+ else:
118
+ yield False
119
+ finally:
120
+ lock.release()
realign/hooks.py CHANGED
@@ -167,6 +167,41 @@ def find_codex_latest_session(project_path: Path, days_back: int = 7) -> Optiona
167
167
  return matching_sessions[0] if matching_sessions else None
168
168
 
169
169
 
170
+ def find_all_claude_sessions() -> List[Path]:
171
+ """
172
+ Find all active Claude Code sessions from ALL projects.
173
+
174
+ Scans ~/.claude/projects/ and returns the latest session from each project.
175
+
176
+ Returns:
177
+ List of session file paths from all Claude projects
178
+ """
179
+ sessions = []
180
+ claude_base = Path.home() / ".claude" / "projects"
181
+
182
+ if not claude_base.exists():
183
+ logger.debug(f"Claude projects directory not found: {claude_base}")
184
+ return sessions
185
+
186
+ # Iterate through all project directories
187
+ for project_dir in claude_base.iterdir():
188
+ if not project_dir.is_dir():
189
+ continue
190
+
191
+ # Skip system directories
192
+ if project_dir.name.startswith('.'):
193
+ continue
194
+
195
+ # Find the latest session in this project directory
196
+ session = find_latest_session(project_dir)
197
+ if session:
198
+ sessions.append(session)
199
+ logger.debug(f"Found Claude session in {project_dir.name}: {session.name}")
200
+
201
+ logger.info(f"Found {len(sessions)} Claude session(s) across all projects")
202
+ return sessions
203
+
204
+
170
205
  def find_all_active_sessions(
171
206
  config: ReAlignConfig,
172
207
  project_path: Optional[Path] = None
@@ -181,13 +216,15 @@ def find_all_active_sessions(
181
216
 
182
217
  Args:
183
218
  config: Configuration object
184
- project_path: Optional path to the current project (git repo root)
219
+ project_path: Optional path to the current project (git repo root).
220
+ If None, will find sessions from ALL projects (multi-project mode).
185
221
 
186
222
  Returns:
187
223
  List of session file paths (may be empty if no sessions found)
188
224
  """
189
225
  logger.info("Searching for active AI sessions")
190
226
  logger.debug(f"Config: auto_detect_codex={config.auto_detect_codex}, auto_detect_claude={config.auto_detect_claude}")
227
+ logger.debug(f"Project path: {project_path}")
191
228
 
192
229
  sessions = []
193
230
 
@@ -203,15 +240,47 @@ def find_all_active_sessions(
203
240
  logger.warning(f"No session found at explicit path: {history_path}")
204
241
  return sessions
205
242
 
243
+ # Multi-project mode: find sessions from ALL projects
244
+ if project_path is None:
245
+ logger.info("Multi-project mode: scanning all projects")
246
+
247
+ # Find all Claude sessions if enabled
248
+ if config.auto_detect_claude:
249
+ logger.debug("Scanning all Claude projects")
250
+ claude_sessions = find_all_claude_sessions()
251
+ sessions.extend(claude_sessions)
252
+
253
+ # TODO: Add Codex multi-project support if needed
254
+ # For now, Codex sessions are only found when project_path is specified
255
+
256
+ if sessions:
257
+ logger.info(f"Multi-project scan complete: found {len(sessions)} session(s)")
258
+ return sessions
259
+
260
+ # Fallback: try local history path
261
+ logger.debug("No sessions found in multi-project scan, trying fallback path")
262
+ history_path = config.expanded_local_history_path
263
+ session = find_latest_session(history_path)
264
+ if session:
265
+ sessions.append(session)
266
+ logger.info(f"Found session at fallback path: {session}")
267
+ else:
268
+ logger.warning(f"No session found at fallback path: {history_path}")
269
+
270
+ return sessions
271
+
272
+ # Single-project mode: find sessions for specific project
273
+ logger.info(f"Single-project mode for: {project_path}")
274
+
206
275
  # Try Codex auto-detection if enabled
207
- if config.auto_detect_codex and project_path:
276
+ if config.auto_detect_codex:
208
277
  logger.debug("Attempting Codex auto-detection")
209
278
  codex_session = find_codex_latest_session(project_path)
210
279
  if codex_session:
211
280
  sessions.append(codex_session)
212
281
 
213
282
  # Try Claude auto-detection if enabled
214
- if config.auto_detect_claude and project_path:
283
+ if config.auto_detect_claude:
215
284
  logger.debug("Attempting Claude auto-detection")
216
285
  claude_dir = find_claude_sessions_dir(project_path)
217
286
  if claude_dir:
@@ -328,13 +397,48 @@ def simple_summarize(content: str, max_chars: int = 500) -> str:
328
397
  summary = " | ".join(summaries[:3])
329
398
  return summary[:max_chars]
330
399
 
400
+ # Fallback: surface the first few non-empty raw lines to give context
401
+ fallback_lines = []
402
+ for raw_line in lines:
403
+ stripped = raw_line.strip()
404
+ if not stripped:
405
+ continue
406
+ # Skip noisy JSON braces-only lines
407
+ if stripped in ("{", "}", "[", "]"):
408
+ continue
409
+ fallback_lines.append(stripped[:120])
410
+ if len(fallback_lines) == 3:
411
+ break
412
+
413
+ if fallback_lines:
414
+ summary = " | ".join(fallback_lines)
415
+ return summary[:max_chars]
416
+
331
417
  return f"Session updated with {len(lines)} new lines"
332
418
 
333
419
 
334
- def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str = "auto") -> Optional[str]:
420
+ def detect_agent_from_session_path(session_relpath: str) -> str:
421
+ """Infer agent type based on session filename."""
422
+ lower_path = session_relpath.lower()
423
+
424
+ if "codex" in lower_path or "rollout-" in lower_path:
425
+ return "Codex"
426
+ if "claude" in lower_path or "agent-" in lower_path:
427
+ return "Claude"
428
+ if lower_path.endswith(".jsonl"):
429
+ # Default to Unknown to avoid mislabeling generic files
430
+ return "Unknown"
431
+ return "Unknown"
432
+
433
+
434
+ def generate_summary_with_llm(
435
+ content: str,
436
+ max_chars: int = 500,
437
+ provider: str = "auto"
438
+ ) -> Tuple[Optional[str], Optional[str]]:
335
439
  """
336
440
  Generate summary using LLM (Anthropic Claude or OpenAI) for NEW content only.
337
- Returns None if LLM is not available or fails.
441
+ Returns (summary, model_name) tuple, or (None, None) if LLM is unavailable.
338
442
 
339
443
  Args:
340
444
  content: Raw text content of new session additions
@@ -345,7 +449,7 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
345
449
 
346
450
  if not content or not content.strip():
347
451
  logger.debug("No content provided for summarization")
348
- return "No new content in this session"
452
+ return "No new content in this session", None
349
453
 
350
454
  # Truncate content for API (to avoid token limits)
351
455
  # Approximately 4000 chars = ~1000 tokens
@@ -395,13 +499,13 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
395
499
  logger.info(f"Claude API success: {len(summary)} chars in {elapsed:.2f}s")
396
500
  logger.debug(f"Claude response: {summary[:100]}...")
397
501
  print(" ✅ Anthropic (Claude) summary successful", file=sys.stderr)
398
- return summary[:max_chars]
502
+ return summary[:max_chars], "claude-3-5-haiku-20241022"
399
503
 
400
504
  except ImportError:
401
505
  logger.warning("Anthropic package not installed")
402
506
  if provider == "claude":
403
507
  print(" ❌ Anthropic package not installed", file=sys.stderr)
404
- return None
508
+ return None, None
405
509
  else:
406
510
  print(" ❌ Anthropic package not installed, trying OpenAI...", file=sys.stderr)
407
511
  except Exception as e:
@@ -417,7 +521,7 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
417
521
  print(f" ❌ Anthropic quota/credit issue", file=sys.stderr)
418
522
  else:
419
523
  print(f" ❌ Anthropic API error: {e}", file=sys.stderr)
420
- return None
524
+ return None, None
421
525
  else:
422
526
  # Auto mode: try falling back to OpenAI
423
527
  if "authentication" in error_msg.lower() or "invalid" in error_msg.lower():
@@ -433,7 +537,7 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
433
537
  logger.debug("ANTHROPIC_API_KEY not set")
434
538
  if provider == "claude":
435
539
  print(" ❌ ANTHROPIC_API_KEY not set", file=sys.stderr)
436
- return None
540
+ return None, None
437
541
  else:
438
542
  print(" ⓘ ANTHROPIC_API_KEY not set, trying OpenAI...", file=sys.stderr)
439
543
 
@@ -471,12 +575,12 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
471
575
  logger.info(f"OpenAI API success: {len(summary)} chars in {elapsed:.2f}s")
472
576
  logger.debug(f"OpenAI response: {summary[:100]}...")
473
577
  print(" ✅ OpenAI (GPT) summary successful", file=sys.stderr)
474
- return summary[:max_chars]
578
+ return summary[:max_chars], "gpt-3.5-turbo"
475
579
 
476
580
  except ImportError:
477
581
  logger.warning("OpenAI package not installed")
478
582
  print(" ❌ OpenAI package not installed", file=sys.stderr)
479
- return None
583
+ return None, None
480
584
  except Exception as e:
481
585
  error_msg = str(e)
482
586
  logger.error(f"OpenAI API error: {error_msg}", exc_info=True)
@@ -488,17 +592,17 @@ def generate_summary_with_llm(content: str, max_chars: int = 500, provider: str
488
592
  print(f" ❌ OpenAI quota/billing issue", file=sys.stderr)
489
593
  else:
490
594
  print(f" ❌ OpenAI API error: {e}", file=sys.stderr)
491
- return None
595
+ return None, None
492
596
  elif try_openai:
493
597
  logger.debug("OPENAI_API_KEY not set")
494
598
  print(" ❌ OPENAI_API_KEY not set", file=sys.stderr)
495
- return None
599
+ return None, None
496
600
 
497
601
  # No API keys available or provider not configured
498
602
  logger.warning(f"No LLM API keys available (provider: {provider})")
499
603
  if provider == "auto":
500
604
  print(" ❌ No LLM API keys configured", file=sys.stderr)
501
- return None
605
+ return None, None
502
606
 
503
607
 
504
608
  def generate_session_filename(user: str, agent: str = "claude") -> str:
@@ -509,6 +613,63 @@ def generate_session_filename(user: str, agent: str = "claude") -> str:
509
613
  return f"{timestamp}_{user_short}_{agent}_{short_id}.jsonl"
510
614
 
511
615
 
616
+ def extract_codex_rollout_hash(filename: str) -> Optional[str]:
617
+ """
618
+ Extract stable hash from Codex rollout filename.
619
+
620
+ Primary Codex rollout format:
621
+ rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl
622
+ Example: rollout-2025-11-16T18-10-42-019a8ddc-b4b3-7942-9a4f-fac74d1580c9.jsonl
623
+ -> 019a8ddc-b4b3-7942-9a4f-fac74d1580c9
624
+
625
+ Legacy format (still supported):
626
+ rollout-<timestamp>-<hash>.jsonl
627
+ Example: rollout-1763315655-abc123def.jsonl -> abc123def
628
+
629
+ Args:
630
+ filename: Original Codex rollout filename
631
+
632
+ Returns:
633
+ Hash string, or None if parsing fails
634
+ """
635
+ if not filename.startswith("rollout-"):
636
+ return None
637
+
638
+ # Normalize filename (strip extension) and remove prefix
639
+ stem = Path(filename).stem
640
+ if stem.startswith("rollout-"):
641
+ stem = stem[len("rollout-"):]
642
+
643
+ if not stem:
644
+ return None
645
+
646
+ def looks_like_uuid(value: str) -> bool:
647
+ """Return True if value matches canonical UUID format."""
648
+ parts = value.split("-")
649
+ expected_lengths = [8, 4, 4, 4, 12]
650
+ if len(parts) != 5:
651
+ return False
652
+ hex_digits = set("0123456789abcdefABCDEF")
653
+ for part, length in zip(parts, expected_lengths):
654
+ if len(part) != length or not set(part).issubset(hex_digits):
655
+ return False
656
+ return True
657
+
658
+ # Newer Codex exports append a full UUID after the human-readable timestamp.
659
+ uuid_candidate_parts = stem.rsplit("-", 5)
660
+ if len(uuid_candidate_parts) == 6:
661
+ candidate_uuid = "-".join(uuid_candidate_parts[1:])
662
+ if looks_like_uuid(candidate_uuid):
663
+ return candidate_uuid.lower()
664
+
665
+ # Fallback for legacy rollout names: everything after first '-' is the hash.
666
+ legacy_parts = stem.split("-", 1)
667
+ if len(legacy_parts) == 2 and legacy_parts[1]:
668
+ return legacy_parts[1]
669
+
670
+ return None
671
+
672
+
512
673
  def get_git_user() -> str:
513
674
  """Get git user name."""
514
675
  try:
@@ -551,6 +712,8 @@ def copy_session_to_repo(
551
712
  '_' not in stem and
552
713
  len(stem) == 36 # UUID is 36 chars including hyphens
553
714
  )
715
+ # Codex rollout exports always start with rollout-<timestamp>-
716
+ is_codex_rollout = original_filename.startswith("rollout-")
554
717
 
555
718
  # Read session content first to detect agent type
556
719
  try:
@@ -566,6 +729,16 @@ def copy_session_to_repo(
566
729
  user_short = user.split()[0].lower() if user else "unknown"
567
730
  new_filename = f"{user_short}_unknown_{short_id}.jsonl"
568
731
  dest_path = sessions_dir / new_filename
732
+ elif is_codex_rollout:
733
+ # Extract stable hash from rollout filename
734
+ rollout_hash = extract_codex_rollout_hash(original_filename)
735
+ user_short = user.split()[0].lower() if user else "unknown"
736
+ if rollout_hash:
737
+ new_filename = f"{user_short}_codex_{rollout_hash}.jsonl"
738
+ else:
739
+ # Fallback if hash extraction fails
740
+ new_filename = generate_session_filename(user, "codex")
741
+ dest_path = sessions_dir / new_filename
569
742
  else:
570
743
  dest_path = sessions_dir / original_filename
571
744
  temp_path = dest_path.with_suffix(".tmp")
@@ -611,6 +784,18 @@ def copy_session_to_repo(
611
784
  # Format: username_agent_shortid.jsonl (no timestamp for consistency)
612
785
  new_filename = f"{user_short}_{agent_type}_{short_id}.jsonl"
613
786
  dest_path = sessions_dir / new_filename
787
+ elif is_codex_rollout:
788
+ # Extract stable hash from rollout filename
789
+ codex_agent = agent_type if agent_type != "unknown" else "codex"
790
+ rollout_hash = extract_codex_rollout_hash(original_filename)
791
+ user_short = user.split()[0].lower() if user else "unknown"
792
+ if rollout_hash:
793
+ # Format: username_codex_hash.jsonl (stable naming)
794
+ new_filename = f"{user_short}_{codex_agent}_{rollout_hash}.jsonl"
795
+ else:
796
+ # Fallback if hash extraction fails
797
+ new_filename = generate_session_filename(user, codex_agent)
798
+ dest_path = sessions_dir / new_filename
614
799
  else:
615
800
  # Keep original filename (could be timestamp_user_agent_id format or other)
616
801
  dest_path = sessions_dir / original_filename
@@ -678,7 +863,7 @@ def process_sessions(
678
863
  user: User name override (optional)
679
864
 
680
865
  Returns:
681
- Dictionary with keys: summary, session_relpaths, redacted
866
+ Dictionary with keys: summary, session_relpaths, redacted, summary_entries, summary_model
682
867
  """
683
868
  import time
684
869
  start_time = time.time()
@@ -746,7 +931,13 @@ def process_sessions(
746
931
 
747
932
  if not session_relpaths:
748
933
  logger.warning("No session files copied successfully")
749
- return {"summary": "", "session_relpaths": [], "redacted": False}
934
+ return {
935
+ "summary": "",
936
+ "session_relpaths": [],
937
+ "redacted": False,
938
+ "summary_entries": [],
939
+ "summary_model": "",
940
+ }
750
941
 
751
942
  logger.info(f"Copied {len(session_relpaths)} session(s): {session_relpaths}")
752
943
 
@@ -758,6 +949,8 @@ def process_sessions(
758
949
  "summary": "",
759
950
  "session_relpaths": session_relpaths,
760
951
  "redacted": any_redacted,
952
+ "summary_entries": [],
953
+ "summary_model": "",
761
954
  }
762
955
 
763
956
  # For prepare-commit-msg mode, we need to stage files first to get accurate diff
@@ -776,7 +969,9 @@ def process_sessions(
776
969
 
777
970
  # For prepare-commit-msg mode, generate summary from all sessions
778
971
  logger.info("Generating summaries for sessions")
779
- all_summaries = []
972
+ summary_entries: List[Dict[str, str]] = []
973
+ legacy_summary_chunks: List[str] = []
974
+ summary_model_label: Optional[str] = None
780
975
 
781
976
  for session_relpath in session_relpaths:
782
977
  # Extract NEW content using git diff
@@ -787,37 +982,50 @@ def process_sessions(
787
982
  continue
788
983
 
789
984
  # Generate summary for NEW content only
790
- summary = None
985
+ summary_text: Optional[str] = None
986
+ is_llm_summary = False
987
+ llm_model_name: Optional[str] = None
791
988
  if config.use_LLM:
792
989
  print(f"🤖 Attempting to generate LLM summary (provider: {config.llm_provider})...", file=sys.stderr)
793
- summary = generate_summary_with_llm(new_content, config.summary_max_chars, config.llm_provider)
990
+ summary_text, llm_model_name = generate_summary_with_llm(
991
+ new_content,
992
+ config.summary_max_chars,
993
+ config.llm_provider
994
+ )
794
995
 
795
- if summary:
996
+ if summary_text:
796
997
  print("✅ LLM summary generated successfully", file=sys.stderr)
998
+ is_llm_summary = True
999
+ if summary_model_label is None:
1000
+ summary_model_label = llm_model_name or config.llm_provider
797
1001
  else:
798
1002
  print("⚠️ LLM summary failed - falling back to local summarization", file=sys.stderr)
799
1003
  print(" Check your API keys: ANTHROPIC_API_KEY or OPENAI_API_KEY", file=sys.stderr)
800
1004
 
801
- if not summary:
1005
+ if not summary_text:
802
1006
  # Fallback to simple summarize
803
1007
  logger.info("Using local summarization (no LLM)")
804
1008
  print("📝 Using local summarization (no LLM)", file=sys.stderr)
805
- summary = simple_summarize(new_content, config.summary_max_chars)
1009
+ summary_text = simple_summarize(new_content, config.summary_max_chars)
806
1010
 
807
1011
  # Identify agent type from filename
808
- agent_name = "Unknown"
809
- if "rollout-" in session_relpath:
810
- agent_name = "Codex"
811
- elif "agent-" in session_relpath or ".jsonl" in session_relpath:
812
- agent_name = "Claude"
1012
+ agent_name = detect_agent_from_session_path(session_relpath)
813
1013
 
814
- logger.debug(f"Summary for {session_relpath} ({agent_name}): {summary[:100]}...")
815
- all_summaries.append(f"[{agent_name}] {summary}")
1014
+ summary_text = summary_text.strip()
1015
+ logger.debug(f"Summary for {session_relpath} ({agent_name}): {summary_text[:100]}...")
1016
+ summary_entries.append({
1017
+ "agent": agent_name,
1018
+ "text": summary_text,
1019
+ "source": "llm" if is_llm_summary else "local",
1020
+ })
1021
+ legacy_summary_chunks.append(f"[{agent_name}] {summary_text}")
816
1022
 
817
1023
  # Combine all summaries
818
- if all_summaries:
819
- combined_summary = " | ".join(all_summaries)
820
- logger.info(f"Generated {len(all_summaries)} summary(ies)")
1024
+ if summary_entries:
1025
+ if summary_model_label is None:
1026
+ summary_model_label = "Local summarizer"
1027
+ combined_summary = " | ".join(legacy_summary_chunks)
1028
+ logger.info(f"Generated {len(summary_entries)} summary(ies)")
821
1029
  else:
822
1030
  combined_summary = "No new content in sessions"
823
1031
  logger.info("No summaries generated (no new content)")
@@ -829,6 +1037,8 @@ def process_sessions(
829
1037
  "summary": combined_summary,
830
1038
  "session_relpaths": session_relpaths,
831
1039
  "redacted": any_redacted,
1040
+ "summary_entries": summary_entries,
1041
+ "summary_model": summary_model_label or "",
832
1042
  }
833
1043
 
834
1044
 
@@ -875,22 +1085,37 @@ def prepare_commit_msg_hook():
875
1085
  Generates session summary and appends to commit message.
876
1086
  """
877
1087
  # Get commit message file path from command line arguments
878
- if len(sys.argv) < 2:
879
- print("Error: Commit message file path not provided", file=sys.stderr)
880
- sys.exit(1)
881
-
882
- msg_file = sys.argv[1]
1088
+ # When called via __main__ with --prepare-commit-msg flag, the file is at index 2
1089
+ # When called directly as a hook entry point, the file is at index 1
1090
+ if sys.argv[1] == "--prepare-commit-msg":
1091
+ # Called via: python -m realign.hooks --prepare-commit-msg <msg-file> <source>
1092
+ if len(sys.argv) < 3:
1093
+ print("Error: Commit message file path not provided", file=sys.stderr)
1094
+ sys.exit(1)
1095
+ msg_file = sys.argv[2]
1096
+ else:
1097
+ # Called via: realign-hook-prepare-commit-msg <msg-file> <source>
1098
+ msg_file = sys.argv[1]
883
1099
 
884
1100
  # Process sessions and generate summary
885
1101
  result = process_sessions(pre_commit_mode=False)
886
1102
 
887
1103
  # Append summary to commit message
888
- if result["summary"] and result["session_relpaths"]:
1104
+ summary_entries = result.get("summary_entries") or []
1105
+ if summary_entries:
889
1106
  try:
890
1107
  with open(msg_file, "a", encoding="utf-8") as f:
891
- f.write(f"\n\nAgent-Summary: {result['summary']}\n")
892
- f.write(f"Agent-Session-Paths: {', '.join(result['session_relpaths'])}\n")
893
- if result["redacted"]:
1108
+ summary_model = result.get("summary_model") or "Local summarizer"
1109
+ f.write("\n\n")
1110
+ f.write(f"--- LLM-Summary ({summary_model}) ---\n")
1111
+ for entry in summary_entries:
1112
+ agent_label = entry.get("agent", "Agent")
1113
+ text = (entry.get("text") or "").strip()
1114
+ if not text:
1115
+ continue
1116
+ f.write(f"* [{agent_label}] {text}\n")
1117
+ f.write("\n")
1118
+ if result.get("redacted"):
894
1119
  f.write("Agent-Redacted: true\n")
895
1120
  except Exception as e:
896
1121
  print(f"Warning: Could not append to commit message: {e}", file=sys.stderr)