juno-code 1.0.36 → 1.0.38

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.
@@ -0,0 +1,693 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Slack Respond - Send kanban agent responses back to Slack.
4
+
5
+ This script reads completed kanban tasks and sends their agent responses
6
+ back to Slack as threaded replies to the original messages.
7
+
8
+ Features:
9
+ - Matches kanban tasks with original Slack messages
10
+ - Sends responses as threaded replies
11
+ - Tracks sent responses to avoid duplicates
12
+ - Supports filtering by tag (e.g., slack-input)
13
+
14
+ Usage:
15
+ python slack_respond.py # Process all tasks with responses
16
+ python slack_respond.py --tag slack-input # Only slack-input tagged tasks
17
+ python slack_respond.py --dry-run --verbose # Test mode
18
+
19
+ Environment Variables:
20
+ SLACK_BOT_TOKEN Slack bot token (required, starts with xoxb-)
21
+ LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import logging
27
+ import os
28
+ import re
29
+ import subprocess
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Dict, List, Optional, Any
33
+
34
+ try:
35
+ from dotenv import load_dotenv
36
+ from slack_sdk import WebClient
37
+ from slack_sdk.errors import SlackApiError
38
+ except ImportError as e:
39
+ print(f"Error: Missing required dependencies: {e}")
40
+ print("Please run: pip install slack_sdk python-dotenv")
41
+ sys.exit(1)
42
+
43
+ # Import local state managers
44
+ try:
45
+ from slack_state import SlackStateManager, ResponseStateManager
46
+ except ImportError:
47
+ script_dir = Path(__file__).parent
48
+ sys.path.insert(0, str(script_dir))
49
+ from slack_state import SlackStateManager, ResponseStateManager
50
+
51
+
52
+ # Configure logging
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ def setup_logging(verbose: bool = False) -> None:
57
+ """Configure logging for the application."""
58
+ log_level = logging.DEBUG if verbose else logging.INFO
59
+
60
+ env_log_level = os.getenv('LOG_LEVEL', '').upper()
61
+ if env_log_level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
62
+ log_level = getattr(logging, env_log_level)
63
+
64
+ logging.basicConfig(
65
+ level=log_level,
66
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
67
+ datefmt='%Y-%m-%d %H:%M:%S'
68
+ )
69
+
70
+
71
+ def get_kanban_tasks(
72
+ kanban_script: str,
73
+ tag: Optional[str] = None,
74
+ status: Optional[str] = None,
75
+ limit: int = 10000
76
+ ) -> List[Dict[str, Any]]:
77
+ """
78
+ Get kanban tasks from the kanban.sh script.
79
+
80
+ Args:
81
+ kanban_script: Path to kanban.sh script
82
+ tag: Optional tag to filter by
83
+ status: Optional status to filter by
84
+ limit: Maximum number of tasks to retrieve (default: 10000 to ensure all tasks)
85
+
86
+ Returns:
87
+ List of task dicts
88
+ """
89
+ cmd = [kanban_script, 'list', '--limit', str(limit)]
90
+
91
+ if tag:
92
+ cmd.extend(['--tag', tag])
93
+ if status:
94
+ cmd.extend(['--status', status])
95
+
96
+ logger.debug(f"Running: {' '.join(cmd)}")
97
+
98
+ try:
99
+ result = subprocess.run(
100
+ cmd,
101
+ capture_output=True,
102
+ text=True,
103
+ timeout=30
104
+ )
105
+
106
+ if result.returncode != 0:
107
+ logger.error(f"Kanban command failed: {result.stderr}")
108
+ return []
109
+
110
+ try:
111
+ tasks = json.loads(result.stdout)
112
+ if isinstance(tasks, list):
113
+ return tasks
114
+ logger.warning(f"Unexpected kanban output format: {type(tasks)}")
115
+ return []
116
+ except json.JSONDecodeError as e:
117
+ logger.error(f"Failed to parse kanban output: {e}")
118
+ return []
119
+
120
+ except subprocess.TimeoutExpired:
121
+ logger.error("Kanban command timed out")
122
+ return []
123
+ except Exception as e:
124
+ logger.error(f"Error running kanban command: {e}")
125
+ return []
126
+
127
+
128
+ def normalize_text(text: str) -> str:
129
+ """
130
+ Normalize text for comparison by removing Slack-specific formatting.
131
+
132
+ Handles:
133
+ - Slack link formatting: <url|label> -> label, <url> -> url
134
+ - Slack user/channel mentions: <@U123> -> @user, <#C123|channel> -> #channel
135
+ - Markdown formatting: *bold*, _italic_, ~strike~, `code`
136
+ - Multiple whitespace: collapses to single space
137
+ - JSON-like content: normalizes quotes and escapes
138
+ - Trailing/leading whitespace
139
+
140
+ Args:
141
+ text: The text to normalize
142
+
143
+ Returns:
144
+ Normalized text suitable for comparison
145
+ """
146
+ if not text:
147
+ return ''
148
+
149
+ # Remove Slack link formatting: <url|label> -> label
150
+ text = re.sub(r'<([^|>]+)\|([^>]+)>', r'\2', text)
151
+ # Remove bare Slack links: <url> -> url (remove angle brackets)
152
+ text = re.sub(r'<(https?://[^>]+)>', r'\1', text)
153
+ # Remove mailto links: <mailto:email|email> -> email
154
+ text = re.sub(r'<mailto:([^|>]+)\|([^>]+)>', r'\2', text)
155
+ text = re.sub(r'<mailto:([^>]+)>', r'\1', text)
156
+
157
+ # Remove Slack user mentions: <@U123ABC> -> @user
158
+ text = re.sub(r'<@[A-Z0-9]+>', '@user', text)
159
+ # Remove Slack channel mentions: <#C123|channel> -> #channel
160
+ text = re.sub(r'<#[A-Z0-9]+\|([^>]+)>', r'#\1', text)
161
+ text = re.sub(r'<#[A-Z0-9]+>', '#channel', text)
162
+
163
+ # Remove Slack markdown: *bold* -> bold, _italic_ -> italic
164
+ # Note: We don't remove backticks from code as they affect meaning
165
+ text = re.sub(r'\*([^*]+)\*', r'\1', text)
166
+ text = re.sub(r'_([^_]+)_', r'\1', text)
167
+ text = re.sub(r'~([^~]+)~', r'\1', text)
168
+
169
+ # Normalize JSON-like content: handle escaped quotes
170
+ # This helps match when JSON is stored differently
171
+ text = text.replace('\\"', '"')
172
+ text = text.replace("\\'", "'")
173
+
174
+ # Normalize whitespace: collapse multiple spaces/newlines to single space
175
+ text = re.sub(r'\s+', ' ', text)
176
+
177
+ # Strip leading/trailing whitespace
178
+ text = text.strip()
179
+
180
+ return text
181
+
182
+
183
+ def compute_text_similarity(text1: str, text2: str) -> float:
184
+ """
185
+ Compute similarity ratio between two texts.
186
+
187
+ Uses a simple character-based comparison that works well for
188
+ detecting if two texts are essentially the same with minor
189
+ formatting differences.
190
+
191
+ Args:
192
+ text1: First text
193
+ text2: Second text
194
+
195
+ Returns:
196
+ Similarity ratio between 0.0 and 1.0
197
+ """
198
+ if not text1 or not text2:
199
+ return 0.0
200
+
201
+ if text1 == text2:
202
+ return 1.0
203
+
204
+ # Use difflib for sequence matching
205
+ from difflib import SequenceMatcher
206
+ return SequenceMatcher(None, text1, text2).ratio()
207
+
208
+
209
+ def find_matching_message(
210
+ task: Dict[str, Any],
211
+ slack_state: SlackStateManager,
212
+ similarity_threshold: float = 0.85
213
+ ) -> Optional[Dict[str, Any]]:
214
+ """
215
+ Find the Slack message that corresponds to a kanban task.
216
+
217
+ Uses multiple strategies with fallback:
218
+ 1. Look up by task_id in state (preferred, exact match)
219
+ 2. Exact text match (fast path)
220
+ 3. Normalized text match (handles formatting differences)
221
+ 4. Prefix match with normalization
222
+ 5. Fuzzy match with similarity threshold (catches minor edits)
223
+
224
+ Args:
225
+ task: Kanban task dict
226
+ slack_state: SlackStateManager with processed messages
227
+ similarity_threshold: Minimum similarity ratio for fuzzy matching (0.0-1.0)
228
+
229
+ Returns:
230
+ Message data dict or None if not found
231
+ """
232
+ task_id = task.get('id')
233
+ task_body = task.get('body', '')
234
+
235
+ if not task_body:
236
+ logger.debug(f"Task {task_id} has empty body, skipping text match")
237
+ # Only try task_id lookup for empty body tasks
238
+ message = slack_state.get_message_for_task(task_id)
239
+ if message:
240
+ logger.debug(f"Found message for task {task_id} by task_id lookup (empty body)")
241
+ return message
242
+ return None
243
+
244
+ # Strategy 1: Look up by task_id (most reliable)
245
+ message = slack_state.get_message_for_task(task_id)
246
+ if message:
247
+ logger.debug(f"Found message for task {task_id} by task_id lookup")
248
+ return message
249
+
250
+ # Pre-compute normalized task body for comparison strategies
251
+ normalized_task_body = normalize_text(task_body)
252
+ normalized_task_body_short = normalized_task_body[:500] if len(normalized_task_body) > 500 else normalized_task_body
253
+
254
+ # Track best fuzzy match for Strategy 5
255
+ best_match: Optional[Dict[str, Any]] = None
256
+ best_similarity: float = 0.0
257
+
258
+ for msg in slack_state.messages:
259
+ msg_text = msg.get('text', '')
260
+ if not msg_text:
261
+ continue
262
+
263
+ # Strategy 2: Exact text match (fast path)
264
+ if msg_text == task_body:
265
+ logger.debug(f"Found message for task {task_id} by exact text match")
266
+ return msg
267
+
268
+ # Strategy 3: Normalized text match
269
+ normalized_msg_text = normalize_text(msg_text)
270
+
271
+ if normalized_msg_text == normalized_task_body:
272
+ logger.debug(f"Found message for task {task_id} by normalized text match")
273
+ return msg
274
+
275
+ # Strategy 4: Prefix match with normalization
276
+ # Check if task body starts with message text (agent may have appended)
277
+ if normalized_task_body.startswith(normalized_msg_text):
278
+ logger.debug(f"Found message for task {task_id} by normalized prefix match (task starts with msg)")
279
+ return msg
280
+
281
+ # Check if message text starts with task body (message may be longer)
282
+ normalized_msg_short = normalized_msg_text[:500] if len(normalized_msg_text) > 500 else normalized_msg_text
283
+ if normalized_msg_text.startswith(normalized_task_body_short):
284
+ logger.debug(f"Found message for task {task_id} by normalized prefix match (msg starts with task)")
285
+ return msg
286
+
287
+ # Strategy 5: Fuzzy match - compute similarity for later
288
+ # Only compute if texts are reasonably close in length (within 2x)
289
+ len_ratio = len(normalized_msg_text) / max(len(normalized_task_body), 1)
290
+ if 0.5 <= len_ratio <= 2.0:
291
+ similarity = compute_text_similarity(normalized_msg_text, normalized_task_body)
292
+ if similarity > best_similarity:
293
+ best_similarity = similarity
294
+ best_match = msg
295
+
296
+ # Strategy 5: Return best fuzzy match if above threshold
297
+ if best_match and best_similarity >= similarity_threshold:
298
+ logger.debug(
299
+ f"Found message for task {task_id} by fuzzy match "
300
+ f"(similarity={best_similarity:.2f}, threshold={similarity_threshold})"
301
+ )
302
+ return best_match
303
+
304
+ logger.debug(f"No matching message found for task {task_id}")
305
+ if best_match:
306
+ logger.debug(f" Best fuzzy match had similarity={best_similarity:.2f}, below threshold={similarity_threshold}")
307
+ return None
308
+
309
+
310
+ def send_slack_response(
311
+ client: WebClient,
312
+ channel_id: str,
313
+ thread_ts: str,
314
+ task_id: str,
315
+ response_text: str,
316
+ dry_run: bool = False
317
+ ) -> Optional[str]:
318
+ """
319
+ Send a response to Slack as a threaded reply.
320
+
321
+ Args:
322
+ client: Slack WebClient instance
323
+ channel_id: Slack channel ID
324
+ thread_ts: Thread timestamp to reply to
325
+ task_id: Kanban task ID (for prefixing)
326
+ response_text: The response text to send
327
+ dry_run: If True, don't actually send
328
+
329
+ Returns:
330
+ Response message timestamp if sent, None if failed
331
+ """
332
+ # Format the response with task ID
333
+ formatted_response = f"**Task ID: {task_id}**\n\n{response_text}"
334
+
335
+ if dry_run:
336
+ logger.info(f"[DRY RUN] Would send to channel {channel_id}, thread {thread_ts}:")
337
+ logger.info(f"[DRY RUN] {formatted_response[:200]}...")
338
+ return "dry-run-ts"
339
+
340
+ try:
341
+ result = client.chat_postMessage(
342
+ channel=channel_id,
343
+ thread_ts=thread_ts,
344
+ text=formatted_response
345
+ )
346
+
347
+ if result.get('ok'):
348
+ response_ts = result.get('ts')
349
+ logger.info(f"Sent response for task {task_id} (ts: {response_ts})")
350
+ return response_ts
351
+ else:
352
+ logger.error(f"Slack API returned ok=False: {result}")
353
+ return None
354
+
355
+ except SlackApiError as e:
356
+ error = e.response.get('error', 'unknown_error')
357
+ logger.error(f"Slack API error: {error}")
358
+
359
+ if error == 'ratelimited':
360
+ retry_after = int(e.response.headers.get('Retry-After', 60))
361
+ logger.warning(f"Rate limited, should retry after {retry_after} seconds")
362
+ elif error == 'channel_not_found':
363
+ logger.error("Channel not found - check channel configuration")
364
+ elif error == 'invalid_auth':
365
+ logger.error("Invalid auth token - check SLACK_BOT_TOKEN")
366
+
367
+ return None
368
+
369
+
370
+ def find_kanban_script(project_dir: Path) -> Optional[str]:
371
+ """Find the kanban.sh script in the project."""
372
+ candidates = [
373
+ project_dir / '.juno_task' / 'scripts' / 'kanban.sh',
374
+ project_dir / 'scripts' / 'kanban.sh',
375
+ ]
376
+
377
+ for path in candidates:
378
+ if path.exists():
379
+ return str(path)
380
+
381
+ logger.error("Could not find kanban.sh script")
382
+ return None
383
+
384
+
385
+ SLACK_TOKEN_DOCS_URL = "https://api.slack.com/tutorials/tracks/getting-a-token"
386
+
387
+
388
+ def validate_slack_environment() -> tuple[Optional[str], list[str]]:
389
+ """
390
+ Validate Slack environment variables are properly configured.
391
+
392
+ Checks for SLACK_BOT_TOKEN in environment or .env file.
393
+ The token should start with 'xoxb-' for bot tokens.
394
+
395
+ Returns:
396
+ Tuple of (bot_token, errors)
397
+ - bot_token: The Slack bot token if found, None otherwise
398
+ - errors: List of error messages if validation failed
399
+ """
400
+ errors = []
401
+
402
+ # Check for bot token
403
+ bot_token = os.getenv('SLACK_BOT_TOKEN')
404
+ if not bot_token:
405
+ errors.append(
406
+ "SLACK_BOT_TOKEN not found.\n"
407
+ " Set it via environment variable or in a .env file:\n"
408
+ " export SLACK_BOT_TOKEN=xoxb-your-token-here\n"
409
+ " Or add to .env file:\n"
410
+ " SLACK_BOT_TOKEN=xoxb-your-token-here\n"
411
+ f"\n To generate a Slack bot token, visit:\n"
412
+ f" {SLACK_TOKEN_DOCS_URL}\n"
413
+ "\n Required OAuth scopes for bot token:\n"
414
+ " - channels:history (read messages from public channels)\n"
415
+ " - channels:read (list public channels)\n"
416
+ " - groups:history (read messages from private channels)\n"
417
+ " - groups:read (list private channels)\n"
418
+ " - users:read (get user info for message authors)\n"
419
+ " - chat:write (send messages as the bot)"
420
+ )
421
+ elif not bot_token.startswith('xoxb-'):
422
+ errors.append(
423
+ f"SLACK_BOT_TOKEN appears invalid (should start with 'xoxb-').\n"
424
+ f" Current value starts with: {bot_token[:10]}...\n"
425
+ f" Bot tokens from Slack start with 'xoxb-'.\n"
426
+ f" To generate a valid bot token, visit:\n"
427
+ f" {SLACK_TOKEN_DOCS_URL}"
428
+ )
429
+
430
+ return bot_token, errors
431
+
432
+
433
+ def print_env_help() -> None:
434
+ """Print help message about configuring Slack environment variables."""
435
+ print("\n" + "=" * 70)
436
+ print("Slack Integration - Environment Configuration")
437
+ print("=" * 70)
438
+ print("""
439
+ Required Environment Variables:
440
+ SLACK_BOT_TOKEN Your Slack bot token (starts with xoxb-)
441
+
442
+ Optional Environment Variables:
443
+ LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
444
+
445
+ Configuration Methods:
446
+ 1. Environment variables:
447
+ export SLACK_BOT_TOKEN=xoxb-your-token-here
448
+
449
+ 2. .env file (in project root):
450
+ SLACK_BOT_TOKEN=xoxb-your-token-here
451
+
452
+ Generating a Slack Bot Token:
453
+ 1. Go to https://api.slack.com/apps and create a new app
454
+ 2. Under "OAuth & Permissions", add the required scopes:
455
+ - channels:history, channels:read (public channels)
456
+ - groups:history, groups:read (private channels)
457
+ - users:read (user info)
458
+ - chat:write (send messages)
459
+ 3. Install the app to your workspace
460
+ 4. Copy the "Bot User OAuth Token" (starts with xoxb-)
461
+
462
+ Full tutorial: """ + SLACK_TOKEN_DOCS_URL + """
463
+
464
+ Example .env file:
465
+ SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN-HERE
466
+ LOG_LEVEL=INFO
467
+ """)
468
+ print("=" * 70 + "\n")
469
+
470
+
471
+ def main_loop(args: argparse.Namespace) -> int:
472
+ """Main processing loop."""
473
+ # Load environment variables from .env file
474
+ # load_dotenv() looks for .env in current directory and parent directories
475
+ load_dotenv()
476
+
477
+ # Also try loading from project root .env if running from a subdirectory
478
+ project_root = Path.cwd()
479
+ env_file = project_root / '.env'
480
+ if env_file.exists():
481
+ load_dotenv(env_file)
482
+
483
+ # Also check .juno_task/.env for project-specific config
484
+ juno_env_file = project_root / '.juno_task' / '.env'
485
+ if juno_env_file.exists():
486
+ load_dotenv(juno_env_file)
487
+
488
+ # Setup logging
489
+ setup_logging(verbose=args.verbose)
490
+
491
+ logger.info("=" * 70)
492
+ logger.info("Slack Respond - Sending agent responses to Slack")
493
+ logger.info("=" * 70)
494
+
495
+ # Validate environment
496
+ bot_token, errors = validate_slack_environment()
497
+
498
+ if errors:
499
+ for error in errors:
500
+ logger.error(error)
501
+ print_env_help()
502
+ return 1
503
+
504
+ # Find project root and kanban script
505
+ project_dir = Path.cwd()
506
+ kanban_script = find_kanban_script(project_dir)
507
+ if not kanban_script:
508
+ logger.error("Cannot find kanban.sh script. Is the project initialized?")
509
+ return 1
510
+
511
+ # Initialize Slack client
512
+ logger.info("Initializing Slack client...")
513
+ client = WebClient(token=bot_token)
514
+
515
+ # Test connection
516
+ try:
517
+ auth_response = client.auth_test()
518
+ team_name = auth_response.get('team')
519
+ logger.info(f"Connected to Slack workspace: {team_name}")
520
+ except SlackApiError as e:
521
+ logger.error(f"Failed to connect to Slack: {e.response['error']}")
522
+ return 1
523
+
524
+ # Initialize state managers
525
+ state_dir = project_dir / '.juno_task' / 'slack'
526
+ slack_state_file = state_dir / 'slack.ndjson'
527
+ response_state_file = state_dir / 'responses_sent.ndjson'
528
+
529
+ logger.info(f"Loading Slack message state: {slack_state_file}")
530
+ slack_state = SlackStateManager(str(slack_state_file))
531
+
532
+ logger.info(f"Loading response state: {response_state_file}")
533
+ response_state = ResponseStateManager(str(response_state_file))
534
+
535
+ if args.reset_tracker:
536
+ confirm = input("WARNING: This will reset the response tracker. Type 'yes' to confirm: ")
537
+ if confirm.lower() == 'yes':
538
+ response_state.reset_state()
539
+ logger.info("Response tracker reset")
540
+ else:
541
+ logger.info("Reset cancelled")
542
+ return 0
543
+
544
+ if args.dry_run:
545
+ logger.info("Running in DRY RUN mode - no responses will be sent")
546
+
547
+ logger.info(f"Slack messages loaded: {slack_state.get_message_count()}")
548
+ logger.info(f"Responses already sent: {response_state.get_sent_count()}")
549
+ logger.info("-" * 70)
550
+
551
+ # Get kanban tasks
552
+ tasks = get_kanban_tasks(kanban_script, tag=args.tag)
553
+ logger.info(f"Found {len(tasks)} kanban tasks")
554
+
555
+ # Process tasks
556
+ total_tasks = 0
557
+ matched_tasks = 0
558
+ sent_responses = 0
559
+ already_sent = 0
560
+ errors = 0
561
+
562
+ for task in tasks:
563
+ task_id = task.get('id')
564
+ task_body = task.get('body', '')
565
+ agent_response = task.get('agent_response', '')
566
+
567
+ total_tasks += 1
568
+
569
+ # Skip if no response
570
+ if not agent_response or agent_response == 'null':
571
+ logger.debug(f"Task {task_id}: No agent response, skipping")
572
+ continue
573
+
574
+ # Find matching Slack message
575
+ message = find_matching_message(task, slack_state)
576
+ if not message:
577
+ logger.debug(f"Task {task_id}: No matching Slack message found")
578
+ continue
579
+
580
+ matched_tasks += 1
581
+
582
+ message_ts = message.get('ts')
583
+ thread_ts = message.get('thread_ts', message_ts)
584
+ channel_id = message.get('channel_id')
585
+ author = message.get('author_name', 'unknown')
586
+
587
+ logger.debug(f"Task {task_id}: Found match - ts={message_ts}, author={author}")
588
+
589
+ # Check if already sent
590
+ if response_state.was_response_sent(task_id, message_ts):
591
+ logger.info(f"Task {task_id}: Already sent response to {author} (skipping)")
592
+ already_sent += 1
593
+ continue
594
+
595
+ # Send response
596
+ logger.info(f"Task {task_id}: Sending response to {author}")
597
+
598
+ response_ts = send_slack_response(
599
+ client,
600
+ channel_id,
601
+ thread_ts,
602
+ task_id,
603
+ agent_response,
604
+ dry_run=args.dry_run
605
+ )
606
+
607
+ if response_ts:
608
+ sent_responses += 1
609
+
610
+ # Record sent
611
+ if not args.dry_run:
612
+ response_state.record_sent(task_id, message_ts, channel_id, response_ts)
613
+
614
+ logger.info(f" ✓ Response sent successfully")
615
+ else:
616
+ errors += 1
617
+ logger.error(f" ✗ Failed to send response")
618
+
619
+ # Summary
620
+ logger.info("")
621
+ logger.info("=" * 70)
622
+ logger.info("Summary:")
623
+ logger.info(f" Total tasks processed: {total_tasks}")
624
+ logger.info(f" Tasks matched with Slack messages: {matched_tasks}")
625
+ logger.info(f" Responses sent: {sent_responses}")
626
+ logger.info(f" Already sent (skipped): {already_sent}")
627
+ if errors > 0:
628
+ logger.error(f" Errors: {errors}")
629
+
630
+ if args.dry_run:
631
+ logger.info("(Dry run mode - no messages were actually sent)")
632
+
633
+ return 0 if errors == 0 else 1
634
+
635
+
636
+ def main() -> int:
637
+ """Main entry point."""
638
+ parser = argparse.ArgumentParser(
639
+ description='Send kanban agent responses back to Slack',
640
+ formatter_class=argparse.RawDescriptionHelpFormatter,
641
+ epilog="""
642
+ Examples:
643
+ %(prog)s # Process all tasks
644
+ %(prog)s --tag slack-input # Only slack-input tagged tasks
645
+ %(prog)s --dry-run --verbose # Test mode
646
+ %(prog)s --reset-tracker # Reset sent tracker (WARNING: re-sends all)
647
+
648
+ Environment Variables:
649
+ SLACK_BOT_TOKEN Slack bot token (required)
650
+ LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
651
+
652
+ Notes:
653
+ - Only sends responses for tasks with non-empty agent_response
654
+ - Matches tasks to Slack messages by task_id or body text
655
+ - Responses are prefixed with "**Task ID: {id}**"
656
+ - Tracks sent responses in .juno_task/slack/responses_sent.ndjson
657
+ """
658
+ )
659
+
660
+ parser.add_argument(
661
+ '--tag',
662
+ help='Filter tasks by tag (e.g., slack-input)'
663
+ )
664
+
665
+ parser.add_argument(
666
+ '--dry-run',
667
+ action='store_true',
668
+ help='Show what would be sent without actually sending'
669
+ )
670
+
671
+ parser.add_argument(
672
+ '--verbose', '-v',
673
+ action='store_true',
674
+ help='Enable DEBUG level logging'
675
+ )
676
+
677
+ parser.add_argument(
678
+ '--reset-tracker',
679
+ action='store_true',
680
+ help='Reset response tracker (WARNING: will re-send all responses)'
681
+ )
682
+
683
+ args = parser.parse_args()
684
+
685
+ try:
686
+ return main_loop(args)
687
+ except Exception as e:
688
+ logger.error(f"Fatal error: {e}", exc_info=True)
689
+ return 1
690
+
691
+
692
+ if __name__ == '__main__':
693
+ sys.exit(main())