aline-ai 0.2.4__py3-none-any.whl → 0.2.6__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aline-ai
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Shared AI memory; everyone knows everything in teams
5
5
  Author: Sharemind
6
6
  License: MIT
@@ -1,27 +1,28 @@
1
- aline_ai-0.2.4.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=DWRnxKpLy7YmDatlK_Q7QaCcwo6p-FhjIz_gNyJ0o4o,68
1
+ aline_ai-0.2.6.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
+ realign/__init__.py,sha256=kYl1kB-KlgNv4t7nc-BFE8PSabtXYIzbte8yU4ZaTvY,68
3
3
  realign/claude_detector.py,sha256=NLxI0zJWcqNxNha9jAy9AslTMwHKakCc9yPGdkrbiFE,3028
4
- realign/cli.py,sha256=OC5KWJ__7_MXbFRlacVxE-rkylBcDIYxdhVbfwsDbY0,1936
4
+ realign/cli.py,sha256=WSdT9zpHLswBeDPjb5M_U2cvhIm7yrgnXPDyb7vD-jg,2798
5
5
  realign/codex_detector.py,sha256=RI3JbZgebrhoqpRfTBMfclYCAISN7hZAHVW3bgftJpU,4428
6
6
  realign/config.py,sha256=jarinbr0mA6e5DmgY19b_VpMnxk6SOYTwyvB9luq0ww,7207
7
7
  realign/file_lock.py,sha256=-9c3tMdMj_ZxmasK5y6hV9Gfo6KDsSO3Q7PXiTBhsu4,3369
8
- realign/hooks.py,sha256=GzkEbW35-ifwAilYbVxDpNURZ_7XrF68DTqDSQ8v7fE,50670
8
+ realign/hooks.py,sha256=ZckwB_W7nx5WO_JfyqXP78peL-HDiefoZWE43yBhRtI,63854
9
9
  realign/logging_config.py,sha256=KvkKktF-bkUu031y9vgUoHpsbnOw7ud25jhpzliNZwA,4929
10
- realign/mcp_server.py,sha256=dntFatMpozI80K5hHrIiQ9sviC6ARKTP89goULhi1T4,16477
11
- realign/mcp_watcher.py,sha256=ffYOXDLuf9T6Kab3CdGNAOY3DBlAbjZrVrSjM5RdYGU,26828
10
+ realign/mcp_server.py,sha256=Q82nuEm6LF17eKUZfHHt6exQjzOWbbBmGXLYwNIGnoo,21002
11
+ realign/mcp_watcher.py,sha256=XmpaX5X8Dm91RCZKC5PoGPNaF1ssUcpRKOe4zWPJ-0A,26751
12
12
  realign/redactor.py,sha256=FizaGSdW-QTBAQl4h-gtmMpx2mFrfd2a5DoPEPyLfRg,9989
13
- realign/commands/__init__.py,sha256=m4bAl1uQoPN6QQV7JUCD0zaLBMXFAXP0Kd4-Vpq9i8g,199
14
- realign/commands/auto_commit.py,sha256=jgjAYZHqN34NmQkncZg3Vtwsl3MyAlsvucxEBwUj7ko,7450
13
+ realign/commands/__init__.py,sha256=caHulsUeguKyy2ZIIa9hVwzGwNHfIbeHwZIC67C8gnI,213
14
+ realign/commands/auto_commit.py,sha256=3QMlUerXlIJgiwN1BKH6U--qwEqZupzoLPgyAivLr1A,7899
15
15
  realign/commands/commit.py,sha256=mlwrv5nfTRY17WlcAdiJKKGh5uM7dGvT7sMxhdbsfkw,12605
16
16
  realign/commands/config.py,sha256=iiu7usqw00djKZja5bx0iDH8DB0vU2maUPMkXLdgXwI,6609
17
17
  realign/commands/hide.py,sha256=i_XLsmsB4duLKNIA-eRAUvniS4N6GdQUyfN92CGndEA,34138
18
18
  realign/commands/init.py,sha256=vOXSAveWgP8TWp0rgqZO8zNRKZMPiaxWsg4PeDZ9RVs,13586
19
+ realign/commands/push.py,sha256=77GXeKggzzc-100JN1PSgDFcDhSaB4kv2jN24h5YM6I,10249
19
20
  realign/commands/review.py,sha256=3TY6F87RGNEbutxvUr_konr24-gCyj5fmD9usd3n4-U,15570
20
21
  realign/commands/search.py,sha256=xTWuX0lpjQPX8cen0ewl-BNF0FeWgjMwN06bdeesED8,18770
21
22
  realign/commands/session_utils.py,sha256=L1DwZIGCOBirp6tkAswACJEeDa6i9aAAfsialAs4rRY,864
22
23
  realign/commands/show.py,sha256=A9LvhOBcY6_HoI76irPB2rBOSgdftBuX2uZiO8IwNoU,16338
23
- aline_ai-0.2.4.dist-info/METADATA,sha256=Hgg_pnm9t39cmHHrFRMllNsfASfBgHWbdEc8AtY8GZ0,1437
24
- aline_ai-0.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
25
- aline_ai-0.2.4.dist-info/entry_points.txt,sha256=h-NocHDzSueXfsepHTIdRPNQzhNZQPAztJfldd-mQTE,202
26
- aline_ai-0.2.4.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
27
- aline_ai-0.2.4.dist-info/RECORD,,
24
+ aline_ai-0.2.6.dist-info/METADATA,sha256=UqZkpvO5NlU8KpL2KN7DezSmaWMTiS_Lrbcs-_xHmtg,1437
25
+ aline_ai-0.2.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
+ aline_ai-0.2.6.dist-info/entry_points.txt,sha256=h-NocHDzSueXfsepHTIdRPNQzhNZQPAztJfldd-mQTE,202
27
+ aline_ai-0.2.6.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
28
+ aline_ai-0.2.6.dist-info/RECORD,,
realign/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Aline - AI Agent Chat Session Tracker."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.6"
realign/cli.py CHANGED
@@ -7,7 +7,7 @@ from typing import Optional
7
7
  from rich.console import Console
8
8
  from rich.syntax import Syntax
9
9
 
10
- from .commands import init, search, show, config, commit, auto_commit, review, hide
10
+ from .commands import init, search, show, config, commit, auto_commit, review, hide, push
11
11
 
12
12
  app = typer.Typer(
13
13
  name="realign",
@@ -48,6 +48,29 @@ def hide_cli(
48
48
  raise typer.Exit(code=exit_code)
49
49
 
50
50
 
51
+ @app.command(name="push")
52
+ def push_cli(
53
+ remote: str = typer.Option("origin", "--remote", "-r", help="Git remote name"),
54
+ branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Branch to push (default: current branch)"),
55
+ force: bool = typer.Option(False, "--force", "-f", help="Skip all confirmation prompts"),
56
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be pushed without actually pushing"),
57
+ ):
58
+ """Interactive push workflow with review and hide options.
59
+
60
+ This command provides an interactive workflow:
61
+ 1. Review unpushed commits
62
+ 2. Optionally hide sensitive commits
63
+ 3. Final confirmation before pushing
64
+ """
65
+ exit_code = push.push_command(
66
+ remote=remote,
67
+ branch=branch,
68
+ force=force,
69
+ dry_run=dry_run
70
+ )
71
+ raise typer.Exit(code=exit_code)
72
+
73
+
51
74
  @app.command()
52
75
  def version():
53
76
  """Show ReAlign version."""
@@ -1,5 +1,5 @@
1
1
  """ReAlign commands module."""
2
2
 
3
- from . import init, search, show, config, commit, auto_commit, review, hide
3
+ from . import init, search, show, config, commit, auto_commit, review, hide, push
4
4
 
5
- __all__ = ["init", "search", "show", "config", "commit", "auto_commit", "review", "hide"]
5
+ __all__ = ["init", "search", "show", "config", "commit", "auto_commit", "review", "hide", "push"]
@@ -85,10 +85,16 @@ def get_session_mtimes(repo_root: Path) -> dict:
85
85
 
86
86
 
87
87
  def generate_commit_message() -> str:
88
- """Generate an automatic commit message."""
89
- from datetime import datetime
90
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
91
- return f"chore: Auto-commit MCP session ({timestamp})"
88
+ """
89
+ Generate an automatic commit message.
90
+
91
+ DEPRECATED: This function is no longer used for auto-commits.
92
+ The commit message is now generated by the prepare-commit-msg hook
93
+ with LLM-based summaries and keywords.
94
+
95
+ Returns empty string to let the hook generate the message.
96
+ """
97
+ return ""
92
98
 
93
99
 
94
100
  def auto_commit_once(repo_root: Path, message: Optional[str] = None, silent: bool = False) -> bool:
@@ -97,12 +103,13 @@ def auto_commit_once(repo_root: Path, message: Optional[str] = None, silent: boo
97
103
 
98
104
  Args:
99
105
  repo_root: Path to the repository root
100
- message: Custom commit message (auto-generated if not provided)
106
+ message: Custom commit message (if provided, will be used as-is and aline summary appended)
101
107
  silent: If True, suppress console output (for watcher mode)
102
108
 
103
109
  Returns:
104
110
  True if commit was successful, False otherwise
105
111
  """
112
+ # If user provided a custom message, use it; otherwise let the hook generate it
106
113
  commit_message = message or generate_commit_message()
107
114
 
108
115
  try:
@@ -124,7 +131,11 @@ def auto_commit_once(repo_root: Path, message: Optional[str] = None, silent: boo
124
131
 
125
132
  if result.returncode == 0:
126
133
  if not silent:
127
- console.print(f"[green]✓[/green] Auto-committed: {commit_message}")
134
+ # If message was provided by user, show it; otherwise indicate auto-commit
135
+ if message:
136
+ console.print(f"[green]✓[/green] Auto-committed: {message}")
137
+ else:
138
+ console.print("[green]✓[/green] Auto-committed successfully")
128
139
  return True
129
140
  else:
130
141
  # Check if it's just "no changes" error
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Push command - Interactive workflow for reviewing and pushing commits.
4
+
5
+ This command provides an interactive workflow:
6
+ 1. Run aline review to show unpushed commits
7
+ 2. Ask user if they want to hide any commits
8
+ 3. If yes, allow interactive hiding
9
+ 4. Ask for final confirmation before pushing
10
+ """
11
+
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from .review import review_command, get_unpushed_commits
18
+ from .hide import hide_command, parse_commit_indices
19
+ from ..logging_config import setup_logger
20
+
21
+ logger = setup_logger('realign.commands.push', 'push.log')
22
+
23
+
24
+ def prompt_yes_no(question: str, default: bool = False) -> bool:
25
+ """
26
+ Prompt user for yes/no confirmation.
27
+
28
+ Args:
29
+ question: Question to ask
30
+ default: Default answer if user just presses Enter
31
+
32
+ Returns:
33
+ True for yes, False for no
34
+ """
35
+ default_str = "Y/n" if default else "y/N"
36
+ prompt = f"{question} [{default_str}] "
37
+
38
+ while True:
39
+ response = input(prompt).strip().lower()
40
+
41
+ if not response:
42
+ return default
43
+
44
+ if response in ('y', 'yes'):
45
+ return True
46
+ elif response in ('n', 'no'):
47
+ return False
48
+ else:
49
+ print("Please answer 'y' or 'n'")
50
+
51
+
52
+ def push_command(
53
+ repo_root: Optional[Path] = None,
54
+ remote: str = "origin",
55
+ branch: Optional[str] = None,
56
+ force: bool = False,
57
+ dry_run: bool = False
58
+ ) -> int:
59
+ """
60
+ Interactive push workflow with review and hide options.
61
+
62
+ Workflow:
63
+ 1. Review unpushed commits
64
+ 2. Ask if user wants to hide any commits
65
+ 3. Interactive hiding loop (if requested)
66
+ 4. Final confirmation
67
+ 5. Execute git push
68
+
69
+ Args:
70
+ repo_root: Path to repository root (auto-detected if None)
71
+ remote: Git remote name (default: origin)
72
+ branch: Branch to push (default: current branch)
73
+ force: Skip all confirmation prompts
74
+ dry_run: Don't actually push, just show what would happen
75
+
76
+ Returns:
77
+ 0 on success, 1 on error, 2 if cancelled by user
78
+ """
79
+ logger.info("======== Push command started ========")
80
+
81
+ # Auto-detect repo root if not provided
82
+ if repo_root is None:
83
+ try:
84
+ result = subprocess.run(
85
+ ["git", "rev-parse", "--show-toplevel"],
86
+ capture_output=True,
87
+ text=True,
88
+ check=True
89
+ )
90
+ repo_root = Path(result.stdout.strip())
91
+ logger.debug(f"Detected repo root: {repo_root}")
92
+ except subprocess.CalledProcessError:
93
+ print("Error: Not in a git repository", file=sys.stderr)
94
+ logger.error("Not in a git repository")
95
+ return 1
96
+
97
+ # Detect current branch if not provided
98
+ if branch is None:
99
+ try:
100
+ result = subprocess.run(
101
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
102
+ cwd=repo_root,
103
+ capture_output=True,
104
+ text=True,
105
+ check=True
106
+ )
107
+ branch = result.stdout.strip()
108
+ logger.debug(f"Detected current branch: {branch}")
109
+ except subprocess.CalledProcessError:
110
+ print("Error: Failed to detect current branch", file=sys.stderr)
111
+ logger.error("Failed to detect current branch")
112
+ return 1
113
+
114
+ # Step 1: Review unpushed commits
115
+ print("\n" + "=" * 60)
116
+ print("STEP 1: Review unpushed commits")
117
+ print("=" * 60 + "\n")
118
+
119
+ try:
120
+ commits = get_unpushed_commits(repo_root)
121
+ except Exception as e:
122
+ print(f"Error: Failed to get unpushed commits: {e}", file=sys.stderr)
123
+ logger.error(f"Failed to get unpushed commits: {e}", exc_info=True)
124
+ return 1
125
+
126
+ if not commits:
127
+ print("✓ No unpushed commits found. Nothing to push.\n")
128
+ logger.info("No unpushed commits found")
129
+ return 0
130
+
131
+ # Run review command to display commits
132
+ exit_code = review_command(repo_root=repo_root, verbose=False, detect_secrets=False)
133
+ if exit_code != 0:
134
+ logger.error("Review command failed")
135
+ return 1
136
+
137
+ # Step 2: Ask if user wants to hide any commits
138
+ if not force:
139
+ print("\n" + "=" * 60)
140
+ print("STEP 2: Hide sensitive commits (optional)")
141
+ print("=" * 60 + "\n")
142
+
143
+ want_to_hide = prompt_yes_no(
144
+ "Do you want to hide any commits before pushing?",
145
+ default=False
146
+ )
147
+
148
+ if want_to_hide:
149
+ # Interactive hiding loop
150
+ while True:
151
+ print("\nEnter commit indices to hide (e.g., '1', '1,3,5', '2-4', or 'done'):")
152
+ print(" Type 'done' when finished hiding commits")
153
+ print(" Type 'review' to see the current list of commits")
154
+ print(" Type 'cancel' to abort the push operation")
155
+
156
+ user_input = input("\n> ").strip()
157
+
158
+ if not user_input:
159
+ continue
160
+
161
+ if user_input.lower() == 'done':
162
+ break
163
+ elif user_input.lower() == 'cancel':
164
+ print("\n❌ Push cancelled by user.\n")
165
+ logger.info("Push cancelled by user during hide phase")
166
+ return 2
167
+ elif user_input.lower() == 'review':
168
+ print("\n" + "-" * 60)
169
+ review_command(repo_root=repo_root, verbose=False, detect_secrets=False)
170
+ print("-" * 60)
171
+ continue
172
+
173
+ # Try to parse and hide the commits
174
+ try:
175
+ # Validate indices first
176
+ indices_list = parse_commit_indices(user_input)
177
+
178
+ # Refresh commit list
179
+ commits = get_unpushed_commits(repo_root)
180
+ if not commits:
181
+ print("\n✓ No more unpushed commits to hide.\n")
182
+ break
183
+
184
+ max_index = len(commits)
185
+ invalid_indices = [i for i in indices_list if i < 1 or i > max_index]
186
+ if invalid_indices:
187
+ print(f"❌ Invalid indices (out of range 1-{max_index}): {invalid_indices}")
188
+ continue
189
+
190
+ # Execute hide command
191
+ print()
192
+ exit_code = hide_command(
193
+ indices=user_input,
194
+ repo_root=repo_root,
195
+ force=False # Always ask for confirmation
196
+ )
197
+
198
+ if exit_code == 0:
199
+ print("\n✅ Commits hidden successfully.\n")
200
+ logger.info(f"Successfully hidden commits: {user_input}")
201
+
202
+ # Refresh and show updated commit list
203
+ commits = get_unpushed_commits(repo_root)
204
+ if not commits:
205
+ print("✓ No more unpushed commits.\n")
206
+ logger.info("No commits left after hiding")
207
+ return 0
208
+ else:
209
+ print("\n📋 Updated commit list:\n")
210
+ review_command(repo_root=repo_root, verbose=False, detect_secrets=False)
211
+ else:
212
+ print(f"\n❌ Failed to hide commits.\n")
213
+ logger.error(f"Hide command failed for indices: {user_input}")
214
+
215
+ # Ask if user wants to continue
216
+ if not prompt_yes_no("Continue with push workflow?", default=True):
217
+ print("\n❌ Push cancelled.\n")
218
+ return 2
219
+
220
+ except ValueError as e:
221
+ print(f"❌ Invalid input: {e}")
222
+ print(" Examples: '1', '1,3,5', '2-4'")
223
+ continue
224
+ except Exception as e:
225
+ print(f"❌ Error during hide: {e}", file=sys.stderr)
226
+ logger.error(f"Error during hide: {e}", exc_info=True)
227
+
228
+ # Ask if user wants to continue
229
+ if not prompt_yes_no("Continue with push workflow?", default=True):
230
+ print("\n❌ Push cancelled.\n")
231
+ return 2
232
+
233
+ # Step 3: Final confirmation
234
+ if not force:
235
+ print("\n" + "=" * 60)
236
+ print("STEP 3: Final confirmation")
237
+ print("=" * 60 + "\n")
238
+
239
+ # Refresh commit list one more time
240
+ try:
241
+ commits = get_unpushed_commits(repo_root)
242
+ except Exception as e:
243
+ print(f"Error: Failed to refresh commits: {e}", file=sys.stderr)
244
+ return 1
245
+
246
+ if not commits:
247
+ print("✓ No unpushed commits. Nothing to push.\n")
248
+ return 0
249
+
250
+ print(f"Ready to push {len(commits)} commit(s) to {remote}/{branch}\n")
251
+
252
+ if not prompt_yes_no("Proceed with git push?", default=True):
253
+ print("\n❌ Push cancelled by user.\n")
254
+ logger.info("Push cancelled by user at final confirmation")
255
+ return 2
256
+
257
+ # Step 4: Execute git push
258
+ print("\n" + "=" * 60)
259
+ print("STEP 4: Pushing to remote")
260
+ print("=" * 60 + "\n")
261
+
262
+ if dry_run:
263
+ print(f"🔍 DRY RUN: Would execute: git push {remote} {branch}\n")
264
+ logger.info(f"Dry run: would push to {remote}/{branch}")
265
+ return 0
266
+
267
+ try:
268
+ print(f"🚀 Pushing to {remote}/{branch}...")
269
+
270
+ result = subprocess.run(
271
+ ["git", "push", remote, branch],
272
+ cwd=repo_root,
273
+ capture_output=True,
274
+ text=True
275
+ )
276
+
277
+ if result.returncode == 0:
278
+ print("\n✅ Successfully pushed to remote!\n")
279
+ print(result.stdout)
280
+ logger.info(f"Successfully pushed to {remote}/{branch}")
281
+ return 0
282
+ else:
283
+ print(f"\n❌ Push failed:\n{result.stderr}\n", file=sys.stderr)
284
+ logger.error(f"Git push failed: {result.stderr}")
285
+ return 1
286
+
287
+ except Exception as e:
288
+ print(f"\n❌ Error during push: {e}\n", file=sys.stderr)
289
+ logger.error(f"Error during push: {e}", exc_info=True)
290
+ return 1
realign/hooks.py CHANGED
@@ -356,6 +356,115 @@ def find_latest_session(history_path: Path, explicit_path: Optional[str] = None)
356
356
  return max(session_files, key=lambda p: p.stat().st_mtime)
357
357
 
358
358
 
359
+ def filter_session_content(content: str) -> Tuple[str, str, str]:
360
+ """
361
+ Filter session content to extract meaningful information for LLM summarization.
362
+
363
+ Filters out exploratory operations (Read, Grep, Glob) and technical details,
364
+ keeping only user requests, AI responses, and code changes.
365
+
366
+ Args:
367
+ content: Raw text content of new session additions
368
+
369
+ Returns:
370
+ Tuple of (user_messages, assistant_replies, code_changes)
371
+ """
372
+ if not content or not content.strip():
373
+ return "", "", ""
374
+
375
+ user_messages = []
376
+ assistant_replies = []
377
+ code_changes = []
378
+
379
+ lines = content.strip().split("\n")
380
+
381
+ for line in lines:
382
+ line = line.strip()
383
+ if not line:
384
+ continue
385
+
386
+ try:
387
+ obj = json.loads(line)
388
+
389
+ # Extract user messages and tool results
390
+ if obj.get("type") == "user":
391
+ msg = obj.get("message", {})
392
+ if isinstance(msg, dict):
393
+ content_data = msg.get("content", "")
394
+ if isinstance(content_data, str) and content_data.strip():
395
+ user_messages.append(content_data.strip())
396
+ elif isinstance(content_data, list):
397
+ # Extract text from content list
398
+ for item in content_data:
399
+ if isinstance(item, dict):
400
+ if item.get("type") == "text":
401
+ text = item.get("text", "").strip()
402
+ if text:
403
+ user_messages.append(text)
404
+ # Extract code changes from tool results
405
+ elif item.get("type") == "tool_result":
406
+ tool_use_result = obj.get("toolUseResult", {})
407
+ if "oldString" in tool_use_result and "newString" in tool_use_result:
408
+ # This is an Edit operation
409
+ new_string = tool_use_result.get("newString", "")
410
+ if new_string:
411
+ code_changes.append(f"Edit: {new_string[:300]}")
412
+ elif "content" in tool_use_result and "filePath" in tool_use_result:
413
+ # This is a Write operation
414
+ new_content = tool_use_result.get("content", "")
415
+ if new_content:
416
+ code_changes.append(f"Write: {new_content[:300]}")
417
+
418
+ # Extract assistant text replies (not tool use)
419
+ elif obj.get("type") == "assistant":
420
+ msg = obj.get("message", {})
421
+ if isinstance(msg, dict):
422
+ content_data = msg.get("content", [])
423
+ if isinstance(content_data, list):
424
+ for item in content_data:
425
+ if isinstance(item, dict):
426
+ # Only extract text blocks, skip tool_use blocks
427
+ if item.get("type") == "text":
428
+ text = item.get("text", "").strip()
429
+ if text:
430
+ assistant_replies.append(text)
431
+ # Extract code changes from Edit/Write tool uses
432
+ elif item.get("type") == "tool_use":
433
+ tool_name = item.get("name", "")
434
+ if tool_name in ("Edit", "Write"):
435
+ params = item.get("input", {})
436
+ if tool_name == "Edit":
437
+ new_string = params.get("new_string", "")
438
+ if new_string:
439
+ code_changes.append(f"Edit: {new_string[:200]}")
440
+ elif tool_name == "Write":
441
+ new_content = params.get("content", "")
442
+ if new_content:
443
+ code_changes.append(f"Write: {new_content[:200]}")
444
+
445
+ # Also handle simple role/content format (for compatibility)
446
+ elif obj.get("role") == "user":
447
+ content_text = obj.get("content", "")
448
+ if isinstance(content_text, str) and content_text.strip():
449
+ user_messages.append(content_text.strip())
450
+
451
+ elif obj.get("role") == "assistant":
452
+ content_text = obj.get("content", "")
453
+ if isinstance(content_text, str) and content_text.strip():
454
+ assistant_replies.append(content_text.strip())
455
+
456
+ except (json.JSONDecodeError, KeyError, TypeError):
457
+ # Not JSON or doesn't have expected structure, skip
458
+ continue
459
+
460
+ # Join with newlines for better readability
461
+ user_str = "\n".join(user_messages) if user_messages else ""
462
+ assistant_str = "\n".join(assistant_replies) if assistant_replies else ""
463
+ code_str = "\n".join(code_changes) if code_changes else ""
464
+
465
+ return user_str, assistant_str, code_str
466
+
467
+
359
468
  def simple_summarize(content: str, max_chars: int = 500) -> str:
360
469
  """
361
470
  Generate a simple summary from new session content.
@@ -445,34 +554,71 @@ def generate_summary_with_llm(
445
554
  content: str,
446
555
  max_chars: int = 500,
447
556
  provider: str = "auto"
448
- ) -> Tuple[Optional[str], Optional[str]]:
557
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
449
558
  """
450
559
  Generate summary using LLM (Anthropic Claude or OpenAI) for NEW content only.
451
- Returns (summary, model_name) tuple, or (None, None) if LLM is unavailable.
560
+ Returns (title, model_name, description) tuple, or (None, None, None) if LLM is unavailable.
452
561
 
453
562
  Args:
454
563
  content: Raw text content of new session additions
455
- max_chars: Maximum characters in summary
564
+ max_chars: Maximum characters in summary (not used, kept for compatibility)
456
565
  provider: LLM provider to use - "auto" (try Claude then OpenAI), "claude", or "openai"
566
+
567
+ Returns:
568
+ Tuple of (title, model_name, description) where:
569
+ - title: One-line summary (max 150 chars)
570
+ - model_name: Name of the model used
571
+ - description: Detailed description of what happened (200-400 chars)
457
572
  """
458
- logger.info(f"Attempting to generate LLM summary (provider: {provider}, max_chars: {max_chars})")
573
+ logger.info(f"Attempting to generate LLM summary (provider: {provider})")
459
574
 
460
575
  if not content or not content.strip():
461
576
  logger.debug("No content provided for summarization")
462
- return "No new content in this session", None
463
-
464
- # Truncate content for API (to avoid token limits)
465
- # Approximately 4000 chars = ~1000 tokens
466
- truncated_content = content[:4000]
467
- if len(content) > 4000:
468
- logger.debug(f"Content truncated from {len(content)} to 4000 chars for LLM API")
469
-
470
- # System prompt for summarization
471
- system_prompt = (
472
- "You are a helpful assistant that summarizes NEW content added to AI agent chat sessions. "
473
- "Provide a concise summary in English focusing on the main topics and actions in the NEW content."
474
- )
475
- user_prompt = f"Summarize this NEW content from an AI chat session in one or two sentences:\n\n{truncated_content}"
577
+ return "No new content in this session", None, ""
578
+
579
+ # Filter content to extract meaningful information
580
+ user_messages, assistant_replies, code_changes = filter_session_content(content)
581
+
582
+ # If no meaningful content after filtering, return early
583
+ if not user_messages and not assistant_replies and not code_changes:
584
+ logger.debug("No meaningful content after filtering")
585
+ return "Session update with no significant changes", None, "No significant changes detected in this session"
586
+
587
+ # System prompt for structured summarization
588
+ system_prompt = """You are a git commit message generator for AI chat sessions.
589
+ Analyze the conversation and code changes, then generate a summary in JSON format:
590
+ {
591
+ "title": "One-line summary (max 150 chars, imperative mood, like 'Add feature X' or 'Fix bug in Y'. NEVER truncate words - keep whole words only. Omit articles like 'the', 'a' when possible to save space)",
592
+ "description": "Detailed description of what happened in this session (200-400 chars). Focus on key actions, decisions, and outcomes. Don't worry about perfect grammar - clarity and completeness matter more. Include specific details like function names, features discussed, bugs fixed, etc."
593
+ }
594
+
595
+ IMPORTANT for description:
596
+ - Be specific and informative (200-400 characters)
597
+ - Focus on WHAT was accomplished and WHY, not HOW
598
+ - Include technical details: function names, module names, specific features
599
+ - Mention key decisions or discussions
600
+ - Don't worry about perfect grammar - focus on conveying the right meaning clearly
601
+ - Avoid mentioning tool names like 'Edit', 'Write', 'Read'
602
+ - For discussions without code: summarize the topics and conclusions
603
+ - For code changes: describe what was changed and the purpose
604
+
605
+ Return JSON only, no other text."""
606
+
607
+ # Build user prompt with filtered content
608
+ user_prompt_parts = ["Summarize this AI chat session:\n"]
609
+
610
+ if user_messages:
611
+ user_prompt_parts.append(f"User requests:\n{user_messages[:1500]}\n")
612
+
613
+ if assistant_replies:
614
+ user_prompt_parts.append(f"AI responses:\n{assistant_replies[:1500]}\n")
615
+
616
+ if code_changes:
617
+ user_prompt_parts.append(f"Code changes:\n{code_changes[:1500]}\n")
618
+
619
+ user_prompt_parts.append("\nReturn JSON only, no other text.")
620
+
621
+ user_prompt = "\n".join(user_prompt_parts)
476
622
 
477
623
  # Determine which providers to try based on the provider parameter
478
624
  try_claude = provider in ("auto", "claude")
@@ -493,7 +639,7 @@ def generate_summary_with_llm(
493
639
 
494
640
  response = client.messages.create(
495
641
  model="claude-3-5-haiku-20241022", # Fast and cost-effective
496
- max_tokens=150,
642
+ max_tokens=300, # Increased for keywords
497
643
  temperature=0.7,
498
644
  system=system_prompt,
499
645
  messages=[
@@ -505,17 +651,44 @@ def generate_summary_with_llm(
505
651
  )
506
652
 
507
653
  elapsed = time.time() - start_time
508
- summary = response.content[0].text.strip()
509
- logger.info(f"Claude API success: {len(summary)} chars in {elapsed:.2f}s")
510
- logger.debug(f"Claude response: {summary[:100]}...")
511
- print(" ✅ Anthropic (Claude) summary successful", file=sys.stderr)
512
- return summary[:max_chars], "claude-3-5-haiku-20241022"
654
+ response_text = response.content[0].text.strip()
655
+ logger.info(f"Claude API success: {len(response_text)} chars in {elapsed:.2f}s")
656
+ logger.debug(f"Claude response: {response_text[:200]}...")
657
+
658
+ # Parse JSON response
659
+ try:
660
+ # Try to extract JSON if wrapped in markdown code blocks
661
+ if "```json" in response_text:
662
+ json_start = response_text.find("```json") + 7
663
+ json_end = response_text.find("```", json_start)
664
+ json_str = response_text[json_start:json_end].strip()
665
+ elif "```" in response_text:
666
+ json_start = response_text.find("```") + 3
667
+ json_end = response_text.find("```", json_start)
668
+ json_str = response_text[json_start:json_end].strip()
669
+ else:
670
+ json_str = response_text
671
+
672
+ summary_data = json.loads(json_str)
673
+ title = summary_data.get("title", "")[:150] # Enforce 150 char limit
674
+ description = summary_data.get("description", "")[:400] # Enforce 400 char limit
675
+
676
+ print(" ✅ Anthropic (Claude) summary successful", file=sys.stderr)
677
+ return title, "claude-3-5-haiku-20241022", description
678
+
679
+ except json.JSONDecodeError as e:
680
+ logger.warning(f"Failed to parse JSON from Claude response: {e}")
681
+ logger.debug(f"Raw response: {response_text}")
682
+ # Fallback: use first line as title, empty description
683
+ first_line = response_text.split("\n")[0][:150]
684
+ print(" ⚠️ Claude response was not valid JSON, using fallback", file=sys.stderr)
685
+ return first_line, "claude-3-5-haiku-20241022", ""
513
686
 
514
687
  except ImportError:
515
688
  logger.warning("Anthropic package not installed")
516
689
  if provider == "claude":
517
690
  print(" ❌ Anthropic package not installed", file=sys.stderr)
518
- return None, None
691
+ return None, None, None
519
692
  else:
520
693
  print(" ❌ Anthropic package not installed, trying OpenAI...", file=sys.stderr)
521
694
  except Exception as e:
@@ -531,7 +704,7 @@ def generate_summary_with_llm(
531
704
  print(f" ❌ Anthropic quota/credit issue", file=sys.stderr)
532
705
  else:
533
706
  print(f" ❌ Anthropic API error: {e}", file=sys.stderr)
534
- return None, None
707
+ return None, None, None
535
708
  else:
536
709
  # Auto mode: try falling back to OpenAI
537
710
  if "authentication" in error_msg.lower() or "invalid" in error_msg.lower():
@@ -547,7 +720,7 @@ def generate_summary_with_llm(
547
720
  logger.debug("ANTHROPIC_API_KEY not set")
548
721
  if provider == "claude":
549
722
  print(" ❌ ANTHROPIC_API_KEY not set", file=sys.stderr)
550
- return None, None
723
+ return None, None, None
551
724
  else:
552
725
  print(" ⓘ ANTHROPIC_API_KEY not set, trying OpenAI...", file=sys.stderr)
553
726
 
@@ -576,21 +749,48 @@ def generate_summary_with_llm(
576
749
  "content": user_prompt
577
750
  }
578
751
  ],
579
- max_tokens=150,
752
+ max_tokens=300, # Increased for keywords
580
753
  temperature=0.7,
581
754
  )
582
755
 
583
756
  elapsed = time.time() - start_time
584
- summary = response.choices[0].message.content.strip()
585
- logger.info(f"OpenAI API success: {len(summary)} chars in {elapsed:.2f}s")
586
- logger.debug(f"OpenAI response: {summary[:100]}...")
587
- print(" ✅ OpenAI (GPT) summary successful", file=sys.stderr)
588
- return summary[:max_chars], "gpt-3.5-turbo"
757
+ response_text = response.choices[0].message.content.strip()
758
+ logger.info(f"OpenAI API success: {len(response_text)} chars in {elapsed:.2f}s")
759
+ logger.debug(f"OpenAI response: {response_text[:200]}...")
760
+
761
+ # Parse JSON response
762
+ try:
763
+ # Try to extract JSON if wrapped in markdown code blocks
764
+ if "```json" in response_text:
765
+ json_start = response_text.find("```json") + 7
766
+ json_end = response_text.find("```", json_start)
767
+ json_str = response_text[json_start:json_end].strip()
768
+ elif "```" in response_text:
769
+ json_start = response_text.find("```") + 3
770
+ json_end = response_text.find("```", json_start)
771
+ json_str = response_text[json_start:json_end].strip()
772
+ else:
773
+ json_str = response_text
774
+
775
+ summary_data = json.loads(json_str)
776
+ title = summary_data.get("title", "")[:150] # Enforce 150 char limit
777
+ description = summary_data.get("description", "")[:400] # Enforce 400 char limit
778
+
779
+ print(" ✅ OpenAI (GPT) summary successful", file=sys.stderr)
780
+ return title, "gpt-3.5-turbo", description
781
+
782
+ except json.JSONDecodeError as e:
783
+ logger.warning(f"Failed to parse JSON from OpenAI response: {e}")
784
+ logger.debug(f"Raw response: {response_text}")
785
+ # Fallback: use first line as title, empty description
786
+ first_line = response_text.split("\n")[0][:150]
787
+ print(" ⚠️ OpenAI response was not valid JSON, using fallback", file=sys.stderr)
788
+ return first_line, "gpt-3.5-turbo", ""
589
789
 
590
790
  except ImportError:
591
791
  logger.warning("OpenAI package not installed")
592
792
  print(" ❌ OpenAI package not installed", file=sys.stderr)
593
- return None, None
793
+ return None, None, None
594
794
  except Exception as e:
595
795
  error_msg = str(e)
596
796
  logger.error(f"OpenAI API error: {error_msg}", exc_info=True)
@@ -602,17 +802,17 @@ def generate_summary_with_llm(
602
802
  print(f" ❌ OpenAI quota/billing issue", file=sys.stderr)
603
803
  else:
604
804
  print(f" ❌ OpenAI API error: {e}", file=sys.stderr)
605
- return None, None
805
+ return None, None, None
606
806
  elif try_openai:
607
807
  logger.debug("OPENAI_API_KEY not set")
608
808
  print(" ❌ OPENAI_API_KEY not set", file=sys.stderr)
609
- return None, None
809
+ return None, None, None
610
810
 
611
811
  # No API keys available or provider not configured
612
812
  logger.warning(f"No LLM API keys available (provider: {provider})")
613
813
  if provider == "auto":
614
814
  print(" ❌ No LLM API keys configured", file=sys.stderr)
615
- return None, None
815
+ return None, None, None
616
816
 
617
817
 
618
818
  def generate_session_filename(user: str, agent: str = "claude") -> str:
@@ -694,6 +894,46 @@ def get_git_user() -> str:
694
894
  return os.getenv("USER", "unknown")
695
895
 
696
896
 
897
+ def get_username(session_relpath: str = "") -> str:
898
+ """
899
+ Get username for commit message.
900
+
901
+ Tries to get from git config first, then falls back to extracting
902
+ from session filename.
903
+
904
+ Args:
905
+ session_relpath: Relative path to session file (used for fallback)
906
+
907
+ Returns:
908
+ Username string
909
+ """
910
+ # Try git config first
911
+ try:
912
+ result = subprocess.run(
913
+ ["git", "config", "user.name"],
914
+ capture_output=True,
915
+ text=True,
916
+ check=True,
917
+ )
918
+ username = result.stdout.strip()
919
+ if username:
920
+ return username
921
+ except subprocess.CalledProcessError:
922
+ pass
923
+
924
+ # Fallback: extract from session filename
925
+ # Format: username_agent_hash.jsonl
926
+ if session_relpath:
927
+ filename = Path(session_relpath).name
928
+ parts = filename.split("_")
929
+ if len(parts) >= 3:
930
+ # First part is username
931
+ return parts[0]
932
+
933
+ # Final fallback
934
+ return os.getenv("USER", "unknown")
935
+
936
+
697
937
  def copy_session_to_repo(
698
938
  session_file: Path,
699
939
  repo_root: Path,
@@ -1106,18 +1346,19 @@ def process_sessions(
1106
1346
  continue
1107
1347
 
1108
1348
  # Generate summary for NEW content only
1109
- summary_text: Optional[str] = None
1349
+ summary_title: Optional[str] = None
1350
+ summary_description: str = ""
1110
1351
  is_llm_summary = False
1111
1352
  llm_model_name: Optional[str] = None
1112
1353
  if config.use_LLM:
1113
1354
  print(f"🤖 Attempting to generate LLM summary (provider: {config.llm_provider})...", file=sys.stderr)
1114
- summary_text, llm_model_name = generate_summary_with_llm(
1355
+ summary_title, llm_model_name, summary_description = generate_summary_with_llm(
1115
1356
  new_content,
1116
1357
  config.summary_max_chars,
1117
1358
  config.llm_provider
1118
1359
  )
1119
1360
 
1120
- if summary_text:
1361
+ if summary_title:
1121
1362
  print("✅ LLM summary generated successfully", file=sys.stderr)
1122
1363
  is_llm_summary = True
1123
1364
  if summary_model_label is None:
@@ -1126,23 +1367,26 @@ def process_sessions(
1126
1367
  print("⚠️ LLM summary failed - falling back to local summarization", file=sys.stderr)
1127
1368
  print(" Check your API keys: ANTHROPIC_API_KEY or OPENAI_API_KEY", file=sys.stderr)
1128
1369
 
1129
- if not summary_text:
1370
+ if not summary_title:
1130
1371
  # Fallback to simple summarize
1131
1372
  logger.info("Using local summarization (no LLM)")
1132
1373
  print("📝 Using local summarization (no LLM)", file=sys.stderr)
1133
- summary_text = simple_summarize(new_content, config.summary_max_chars)
1374
+ summary_title = simple_summarize(new_content, config.summary_max_chars)
1375
+ summary_description = ""
1134
1376
 
1135
1377
  # Identify agent type from filename
1136
1378
  agent_name = detect_agent_from_session_path(session_relpath)
1137
1379
 
1138
- summary_text = summary_text.strip()
1139
- logger.debug(f"Summary for {session_relpath} ({agent_name}): {summary_text[:100]}...")
1380
+ summary_title = summary_title.strip()
1381
+ logger.debug(f"Summary for {session_relpath} ({agent_name}): {summary_title[:100]}...")
1382
+ logger.debug(f"Description: {summary_description[:100]}...")
1140
1383
  summary_entries.append({
1141
1384
  "agent": agent_name,
1142
- "text": summary_text,
1385
+ "title": summary_title,
1386
+ "description": summary_description,
1143
1387
  "source": "llm" if is_llm_summary else "local",
1144
1388
  })
1145
- legacy_summary_chunks.append(f"[{agent_name}] {summary_text}")
1389
+ legacy_summary_chunks.append(f"[{agent_name}] {summary_title}")
1146
1390
 
1147
1391
  # Update metadata after successfully generating summary
1148
1392
  save_session_metadata(repo_root, session_relpath, current_size)
@@ -1230,23 +1474,59 @@ def prepare_commit_msg_hook():
1230
1474
 
1231
1475
  # Append summary to commit message
1232
1476
  summary_entries = result.get("summary_entries") or []
1477
+ session_relpaths = result.get("session_relpaths") or []
1478
+
1233
1479
  if summary_entries:
1234
1480
  try:
1481
+ # Read existing commit message to check if user provided one
1482
+ with open(msg_file, "r", encoding="utf-8") as f:
1483
+ existing_msg = f.read().strip()
1484
+
1485
+ # Check if this is an auto-commit (empty message or only comments)
1486
+ is_auto_commit = not existing_msg or all(
1487
+ line.startswith("#") for line in existing_msg.split("\n") if line.strip()
1488
+ )
1489
+
1490
+ # Get username from first session path
1491
+ username = get_username(session_relpaths[0] if session_relpaths else "")
1492
+
1493
+ # Get timestamp
1494
+ from datetime import datetime
1495
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1496
+
1235
1497
  with open(msg_file, "a", encoding="utf-8") as f:
1236
- summary_model = result.get("summary_model") or "Local summarizer"
1237
- f.write("\n\n")
1238
- f.write(f"--- LLM-Summary ({summary_model}) ---\n")
1239
- for entry in summary_entries:
1240
- agent_label = entry.get("agent", "Agent")
1241
- text = (entry.get("text") or "").strip()
1242
- if not text:
1243
- continue
1244
- f.write(f"* [{agent_label}] {text}\n")
1245
- f.write("\n")
1498
+ # Combine all summaries
1499
+ first_entry = summary_entries[0]
1500
+ title = first_entry.get("title", "Session update").strip()
1501
+ agent_label = first_entry.get("agent", "Unknown")
1502
+ description = first_entry.get("description", "").strip()
1503
+
1504
+ if is_auto_commit:
1505
+ # Auto-commit format: use aline summary as the main message
1506
+ f.write(f"aline: {title}\n\n")
1507
+ if description:
1508
+ f.write(f"{description}\n\n")
1509
+ f.write(f"Agent: {agent_label}\n")
1510
+ f.write(f"User: {username}\n")
1511
+ f.write(f"Timestamp: {timestamp}\n")
1512
+ else:
1513
+ # User-provided commit message format: append aline summary after separator
1514
+ f.write("\n\n---\n")
1515
+ f.write(f"aline: {title}\n\n")
1516
+ if description:
1517
+ f.write(f"{description}\n\n")
1518
+ f.write(f"Agent: {agent_label}\n")
1519
+ f.write(f"User: {username}\n")
1520
+ f.write(f"Timestamp: {timestamp}\n")
1521
+
1522
+ # Add redaction marker if applicable
1246
1523
  if result.get("redacted"):
1247
- f.write("Agent-Redacted: true\n")
1524
+ f.write("Redacted: true\n")
1525
+
1248
1526
  except Exception as e:
1249
1527
  print(f"Warning: Could not append to commit message: {e}", file=sys.stderr)
1528
+ import traceback
1529
+ logger.error(f"Commit message formatting error: {traceback.format_exc()}")
1250
1530
 
1251
1531
  sys.exit(0)
1252
1532
 
realign/mcp_server.py CHANGED
@@ -170,6 +170,63 @@ async def list_tools() -> list[Tool]:
170
170
  "properties": {},
171
171
  },
172
172
  ),
173
+ Tool(
174
+ name="aline_review",
175
+ description=(
176
+ "Review unpushed commits before pushing. "
177
+ "Shows commit messages, LLM summaries, session files modified, and line counts. "
178
+ "Helps identify what content will be made public when you push."
179
+ ),
180
+ inputSchema={
181
+ "type": "object",
182
+ "properties": {
183
+ "repo_path": {
184
+ "type": "string",
185
+ "description": "Path to repository root (default: current directory)",
186
+ },
187
+ "verbose": {
188
+ "type": "boolean",
189
+ "description": "Show detailed information",
190
+ "default": False,
191
+ },
192
+ "detect_secrets": {
193
+ "type": "boolean",
194
+ "description": "Run sensitive content detection",
195
+ "default": False,
196
+ },
197
+ },
198
+ },
199
+ ),
200
+ Tool(
201
+ name="aline_hide",
202
+ description=(
203
+ "Hide (redact) specific unpushed commits by rewriting git history. "
204
+ "This redacts commit messages and session content. "
205
+ "WARNING: This rewrites git history. Use with caution."
206
+ ),
207
+ inputSchema={
208
+ "type": "object",
209
+ "properties": {
210
+ "indices": {
211
+ "type": "string",
212
+ "description": (
213
+ "Commit indices to hide (e.g., '1', '1,3,5', '2-4', or '--all'). "
214
+ "Use aline_review first to see the commit indices."
215
+ ),
216
+ },
217
+ "repo_path": {
218
+ "type": "string",
219
+ "description": "Path to repository root (default: current directory)",
220
+ },
221
+ "force": {
222
+ "type": "boolean",
223
+ "description": "Skip confirmation prompt",
224
+ "default": False,
225
+ },
226
+ },
227
+ "required": ["indices"],
228
+ },
229
+ ),
173
230
  ]
174
231
 
175
232
 
@@ -189,6 +246,10 @@ async def call_tool(name: str, arguments: Any) -> list[TextContent]:
189
246
  result = await handle_get_latest_session(arguments)
190
247
  elif name == "aline_version":
191
248
  result = await handle_version(arguments)
249
+ elif name == "aline_review":
250
+ result = await handle_review(arguments)
251
+ elif name == "aline_hide":
252
+ result = await handle_hide(arguments)
192
253
  else:
193
254
  return [TextContent(type="text", text=f"Unknown tool: {name}")]
194
255
 
@@ -395,6 +456,78 @@ async def handle_version(args: dict) -> list[TextContent]:
395
456
  )]
396
457
 
397
458
 
459
+ async def handle_review(args: dict) -> list[TextContent]:
460
+ """Handle aline_review tool."""
461
+ from .commands.review import review_command
462
+ from io import StringIO
463
+ import sys
464
+
465
+ repo_path = args.get("repo_path")
466
+ verbose = args.get("verbose", False)
467
+ detect_secrets = args.get("detect_secrets", False)
468
+
469
+ # Convert repo_path to Path if provided
470
+ repo_root = Path(repo_path) if repo_path else None
471
+
472
+ # Capture stdout
473
+ old_stdout = sys.stdout
474
+ sys.stdout = captured_output = StringIO()
475
+
476
+ try:
477
+ # Call the review command
478
+ exit_code = review_command(
479
+ repo_root=repo_root,
480
+ verbose=verbose,
481
+ detect_secrets=detect_secrets
482
+ )
483
+
484
+ # Get the output
485
+ output = captured_output.getvalue()
486
+
487
+ if exit_code == 0:
488
+ return [TextContent(type="text", text=output or "No unpushed commits found.")]
489
+ else:
490
+ return [TextContent(type="text", text=f"Error: Review failed\n{output}")]
491
+ finally:
492
+ sys.stdout = old_stdout
493
+
494
+
495
+ async def handle_hide(args: dict) -> list[TextContent]:
496
+ """Handle aline_hide tool."""
497
+ from .commands.hide import hide_command
498
+ from io import StringIO
499
+ import sys
500
+
501
+ indices = args["indices"]
502
+ repo_path = args.get("repo_path")
503
+ force = args.get("force", False)
504
+
505
+ # Convert repo_path to Path if provided
506
+ repo_root = Path(repo_path) if repo_path else None
507
+
508
+ # Capture stdout
509
+ old_stdout = sys.stdout
510
+ sys.stdout = captured_output = StringIO()
511
+
512
+ try:
513
+ # Call the hide command
514
+ exit_code = hide_command(
515
+ indices=indices,
516
+ repo_root=repo_root,
517
+ force=force
518
+ )
519
+
520
+ # Get the output
521
+ output = captured_output.getvalue()
522
+
523
+ if exit_code == 0:
524
+ return [TextContent(type="text", text=output or "Hide operation completed.")]
525
+ else:
526
+ return [TextContent(type="text", text=f"Error: Hide failed\n{output}")]
527
+ finally:
528
+ sys.stdout = old_stdout
529
+
530
+
398
531
  def _server_log(msg: str):
399
532
  """Log MCP server messages to both stderr and file."""
400
533
  from datetime import datetime
realign/mcp_watcher.py CHANGED
@@ -442,9 +442,8 @@ class DialogueWatcher:
442
442
  session_file: Session file that triggered the commit
443
443
  """
444
444
  try:
445
- # Generate commit message
446
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
447
- message = f"chore: Auto-commit MCP session ({timestamp})"
445
+ # Use empty message to let prepare-commit-msg hook generate it with LLM summary
446
+ message = ""
448
447
 
449
448
  # Use realign commit command
450
449
  result = await asyncio.get_event_loop().run_in_executor(
@@ -455,8 +454,8 @@ class DialogueWatcher:
455
454
  )
456
455
 
457
456
  if result:
458
- logger.info(f"✓ Committed to {project_path.name}: {message}")
459
- print(f"[MCP Watcher] ✓ Committed to {project_path.name}: {message}", file=sys.stderr)
457
+ logger.info(f"✓ Committed to {project_path.name}")
458
+ print(f"[MCP Watcher] ✓ Auto-committed to {project_path.name}", file=sys.stderr)
460
459
  # Update last commit time for this project
461
460
  self.last_commit_times[str(project_path)] = time.time()
462
461
  # Baseline counts already updated in _check_if_turn_complete()