juno-code 1.0.35 → 1.0.37

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