juno-code 1.0.36 → 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.
- package/README.md +170 -0
- package/dist/bin/cli.js +143 -5
- package/dist/bin/cli.js.map +1 -1
- package/dist/bin/cli.mjs +143 -5
- package/dist/bin/cli.mjs.map +1 -1
- package/dist/index.js +18 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +18 -1
- package/dist/index.mjs.map +1 -1
- package/dist/templates/scripts/install_requirements.sh +3 -0
- package/dist/templates/scripts/run_until_completion.sh +227 -11
- package/dist/templates/scripts/slack_fetch.py +717 -0
- package/dist/templates/scripts/slack_fetch.sh +269 -0
- package/dist/templates/scripts/slack_respond.py +691 -0
- package/dist/templates/scripts/slack_respond.sh +263 -0
- package/dist/templates/scripts/slack_state.py +383 -0
- package/package.json +8 -2
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# slack_respond.sh
|
|
4
|
+
#
|
|
5
|
+
# Purpose: Send kanban agent responses back to Slack
|
|
6
|
+
#
|
|
7
|
+
# This script activates the Python virtual environment and runs the
|
|
8
|
+
# slack_respond.py script to send completed task responses back to
|
|
9
|
+
# Slack as threaded replies.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# ./.juno_task/scripts/slack_respond.sh
|
|
13
|
+
# ./.juno_task/scripts/slack_respond.sh --tag slack-input
|
|
14
|
+
# ./.juno_task/scripts/slack_respond.sh --dry-run --verbose
|
|
15
|
+
# ./.juno_task/scripts/slack_respond.sh --reset-tracker
|
|
16
|
+
#
|
|
17
|
+
# Environment Variables:
|
|
18
|
+
# SLACK_BOT_TOKEN Slack bot token (required, starts with xoxb-)
|
|
19
|
+
# JUNO_DEBUG=true Show debug messages
|
|
20
|
+
# JUNO_VERBOSE=true Show informational messages
|
|
21
|
+
#
|
|
22
|
+
# Created by: juno-code init command
|
|
23
|
+
# Date: Auto-generated during project initialization
|
|
24
|
+
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
# Debug output
|
|
28
|
+
if [ "${JUNO_DEBUG:-false}" = "true" ]; then
|
|
29
|
+
echo "[DEBUG] slack_respond.sh is being executed from: $(pwd)" >&2
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Color output
|
|
33
|
+
RED='\033[0;31m'
|
|
34
|
+
GREEN='\033[0;32m'
|
|
35
|
+
YELLOW='\033[1;33m'
|
|
36
|
+
BLUE='\033[0;34m'
|
|
37
|
+
NC='\033[0m'
|
|
38
|
+
|
|
39
|
+
# Configuration
|
|
40
|
+
VENV_DIR=".venv_juno"
|
|
41
|
+
SCRIPTS_DIR=".juno_task/scripts"
|
|
42
|
+
INSTALL_SCRIPT="${SCRIPTS_DIR}/install_requirements.sh"
|
|
43
|
+
SLACK_RESPOND_SCRIPT="${SCRIPTS_DIR}/slack_respond.py"
|
|
44
|
+
|
|
45
|
+
# Logging functions
|
|
46
|
+
log_info() {
|
|
47
|
+
if [ "${JUNO_VERBOSE:-false}" = "true" ]; then
|
|
48
|
+
echo -e "${BLUE}[SLACK_RESPOND]${NC} $1"
|
|
49
|
+
fi
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
log_success() {
|
|
53
|
+
if [ "${JUNO_VERBOSE:-false}" = "true" ]; then
|
|
54
|
+
echo -e "${GREEN}[SLACK_RESPOND]${NC} $1"
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
log_warning() {
|
|
59
|
+
if [ "${JUNO_VERBOSE:-false}" = "true" ]; then
|
|
60
|
+
echo -e "${YELLOW}[SLACK_RESPOND]${NC} $1"
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
log_error() {
|
|
65
|
+
echo -e "${RED}[SLACK_RESPOND]${NC} $1" >&2
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Check if we're in .venv_juno
|
|
69
|
+
is_in_venv_juno() {
|
|
70
|
+
if [ -n "${VIRTUAL_ENV:-}" ]; then
|
|
71
|
+
if [[ "${VIRTUAL_ENV:-}" == *"/.venv_juno" ]] || [[ "${VIRTUAL_ENV:-}" == *".venv_juno"* ]]; then
|
|
72
|
+
return 0
|
|
73
|
+
fi
|
|
74
|
+
if [ "$(basename "${VIRTUAL_ENV:-}")" = ".venv_juno" ]; then
|
|
75
|
+
return 0
|
|
76
|
+
fi
|
|
77
|
+
fi
|
|
78
|
+
return 1
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Activate virtual environment
|
|
82
|
+
activate_venv() {
|
|
83
|
+
local venv_path="$1"
|
|
84
|
+
|
|
85
|
+
if [ ! -d "$venv_path" ]; then
|
|
86
|
+
log_error "Virtual environment not found: $venv_path"
|
|
87
|
+
return 1
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
if [ -f "$venv_path/bin/activate" ]; then
|
|
91
|
+
# shellcheck disable=SC1091
|
|
92
|
+
source "$venv_path/bin/activate"
|
|
93
|
+
log_success "Activated virtual environment: $venv_path"
|
|
94
|
+
return 0
|
|
95
|
+
else
|
|
96
|
+
log_error "Activation script not found: $venv_path/bin/activate"
|
|
97
|
+
return 1
|
|
98
|
+
fi
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Ensure Python environment is ready
|
|
102
|
+
ensure_python_environment() {
|
|
103
|
+
log_info "Checking Python environment..."
|
|
104
|
+
|
|
105
|
+
if is_in_venv_juno; then
|
|
106
|
+
log_success "Already inside .venv_juno virtual environment"
|
|
107
|
+
return 0
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
if [ -d "$VENV_DIR" ]; then
|
|
111
|
+
log_info "Found existing virtual environment: $VENV_DIR"
|
|
112
|
+
if activate_venv "$VENV_DIR"; then
|
|
113
|
+
return 0
|
|
114
|
+
else
|
|
115
|
+
log_error "Failed to activate virtual environment"
|
|
116
|
+
return 1
|
|
117
|
+
fi
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
log_warning "Virtual environment not found: $VENV_DIR"
|
|
121
|
+
log_info "Running install_requirements.sh to create virtual environment..."
|
|
122
|
+
|
|
123
|
+
if [ ! -f "$INSTALL_SCRIPT" ]; then
|
|
124
|
+
log_error "Install script not found: $INSTALL_SCRIPT"
|
|
125
|
+
log_error "Please run 'juno-code init' to initialize the project"
|
|
126
|
+
return 1
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
chmod +x "$INSTALL_SCRIPT"
|
|
130
|
+
|
|
131
|
+
if bash "$INSTALL_SCRIPT"; then
|
|
132
|
+
log_success "Python environment setup completed successfully"
|
|
133
|
+
if [ -d "$VENV_DIR" ]; then
|
|
134
|
+
activate_venv "$VENV_DIR"
|
|
135
|
+
fi
|
|
136
|
+
return 0
|
|
137
|
+
else
|
|
138
|
+
log_error "Failed to run install_requirements.sh"
|
|
139
|
+
return 1
|
|
140
|
+
fi
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# Check for required Slack dependencies
|
|
144
|
+
check_slack_deps() {
|
|
145
|
+
log_info "Checking Slack SDK dependencies..."
|
|
146
|
+
|
|
147
|
+
if python3 -c "import slack_sdk; import dotenv" 2>/dev/null; then
|
|
148
|
+
log_success "Slack SDK dependencies available"
|
|
149
|
+
return 0
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
log_warning "Slack SDK not installed. Installing..."
|
|
153
|
+
pip install slack_sdk python-dotenv >/dev/null 2>&1 || {
|
|
154
|
+
log_error "Failed to install Slack SDK dependencies"
|
|
155
|
+
log_error "Please run: pip install slack_sdk python-dotenv"
|
|
156
|
+
return 1
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
log_success "Slack SDK installed successfully"
|
|
160
|
+
return 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# Get script directory and project root
|
|
164
|
+
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
165
|
+
PROJECT_ROOT="$( cd "$SCRIPT_DIR/../.." && pwd )"
|
|
166
|
+
|
|
167
|
+
cd "$PROJECT_ROOT"
|
|
168
|
+
|
|
169
|
+
# Show help for Slack environment setup
|
|
170
|
+
show_env_help() {
|
|
171
|
+
echo ""
|
|
172
|
+
echo "========================================================================"
|
|
173
|
+
echo "Slack Integration - Environment Setup"
|
|
174
|
+
echo "========================================================================"
|
|
175
|
+
echo ""
|
|
176
|
+
echo "Required Environment Variables:"
|
|
177
|
+
echo " SLACK_BOT_TOKEN Your Slack bot token (starts with xoxb-)"
|
|
178
|
+
echo ""
|
|
179
|
+
echo "Optional Environment Variables:"
|
|
180
|
+
echo " LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)"
|
|
181
|
+
echo ""
|
|
182
|
+
echo "Configuration Methods:"
|
|
183
|
+
echo " 1. Environment variables:"
|
|
184
|
+
echo " export SLACK_BOT_TOKEN=xoxb-your-token-here"
|
|
185
|
+
echo ""
|
|
186
|
+
echo " 2. .env file (in project root):"
|
|
187
|
+
echo " SLACK_BOT_TOKEN=xoxb-your-token-here"
|
|
188
|
+
echo ""
|
|
189
|
+
echo " 3. .juno_task/.env file (project-specific):"
|
|
190
|
+
echo " SLACK_BOT_TOKEN=xoxb-your-token-here"
|
|
191
|
+
echo ""
|
|
192
|
+
echo "To generate a Slack bot token:"
|
|
193
|
+
echo " 1. Go to https://api.slack.com/apps and create a new app"
|
|
194
|
+
echo " 2. Under 'OAuth & Permissions', add these scopes:"
|
|
195
|
+
echo " - channels:history, channels:read (public channels)"
|
|
196
|
+
echo " - groups:history, groups:read (private channels)"
|
|
197
|
+
echo " - users:read (user info)"
|
|
198
|
+
echo " - chat:write (send messages)"
|
|
199
|
+
echo " 3. Install the app to your workspace"
|
|
200
|
+
echo " 4. Copy the 'Bot User OAuth Token' (starts with xoxb-)"
|
|
201
|
+
echo ""
|
|
202
|
+
echo " Full tutorial: https://api.slack.com/tutorials/tracks/getting-a-token"
|
|
203
|
+
echo ""
|
|
204
|
+
echo "========================================================================"
|
|
205
|
+
echo ""
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Main function
|
|
209
|
+
main() {
|
|
210
|
+
log_info "=== Slack Respond Wrapper ==="
|
|
211
|
+
|
|
212
|
+
# Ensure Python environment
|
|
213
|
+
if ! ensure_python_environment; then
|
|
214
|
+
log_error "Failed to setup Python environment"
|
|
215
|
+
exit 1
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
# Check Slack dependencies
|
|
219
|
+
if ! check_slack_deps; then
|
|
220
|
+
exit 1
|
|
221
|
+
fi
|
|
222
|
+
|
|
223
|
+
# Load .env file if it exists
|
|
224
|
+
if [ -f ".env" ]; then
|
|
225
|
+
# shellcheck disable=SC1091
|
|
226
|
+
set -a
|
|
227
|
+
source .env
|
|
228
|
+
set +a
|
|
229
|
+
log_success "Loaded environment from .env"
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
# Also check .juno_task/.env
|
|
233
|
+
if [ -f ".juno_task/.env" ]; then
|
|
234
|
+
# shellcheck disable=SC1091
|
|
235
|
+
set -a
|
|
236
|
+
source .juno_task/.env
|
|
237
|
+
set +a
|
|
238
|
+
log_success "Loaded environment from .juno_task/.env"
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
# Check for SLACK_BOT_TOKEN
|
|
242
|
+
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
|
243
|
+
log_error "SLACK_BOT_TOKEN not set!"
|
|
244
|
+
show_env_help
|
|
245
|
+
exit 1
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
# Validate token format
|
|
249
|
+
if [[ ! "${SLACK_BOT_TOKEN:-}" =~ ^xoxb- ]]; then
|
|
250
|
+
log_warning "SLACK_BOT_TOKEN does not start with 'xoxb-' - this may be an invalid bot token"
|
|
251
|
+
log_info "Bot tokens from Slack should start with 'xoxb-'"
|
|
252
|
+
log_info "To generate a valid token, visit: https://api.slack.com/tutorials/tracks/getting-a-token"
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
log_success "Python environment ready!"
|
|
256
|
+
log_success "Slack token configured"
|
|
257
|
+
|
|
258
|
+
# Execute slack_respond.py
|
|
259
|
+
log_info "Executing slack_respond.py: $*"
|
|
260
|
+
exec python3 "$SLACK_RESPOND_SCRIPT" "$@"
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
main "$@"
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Slack State Manager - Persistent state management for Slack-Kanban integration.
|
|
4
|
+
|
|
5
|
+
This module handles:
|
|
6
|
+
- Loading/saving message state from NDJSON file
|
|
7
|
+
- Tracking processed Slack messages with their kanban task IDs
|
|
8
|
+
- Tracking sent responses to avoid duplicates
|
|
9
|
+
- Deduplication of messages
|
|
10
|
+
|
|
11
|
+
NDJSON Format (Newline-Delimited JSON):
|
|
12
|
+
Each line is a complete JSON object for atomic append operations.
|
|
13
|
+
|
|
14
|
+
State Files:
|
|
15
|
+
- slack.ndjson: Processed Slack messages with task mappings
|
|
16
|
+
- responses_sent.ndjson: Responses already sent back to Slack
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
from slack_state import SlackStateManager, ResponseStateManager
|
|
20
|
+
|
|
21
|
+
# Track processed messages
|
|
22
|
+
state = SlackStateManager('.juno_task/slack/slack.ndjson')
|
|
23
|
+
if not state.is_processed(message_ts):
|
|
24
|
+
# Process message...
|
|
25
|
+
state.mark_processed(message_ts, task_id, message_data)
|
|
26
|
+
|
|
27
|
+
# Track sent responses
|
|
28
|
+
responses = ResponseStateManager('.juno_task/slack/responses_sent.ndjson')
|
|
29
|
+
if not responses.was_response_sent(task_id, message_ts):
|
|
30
|
+
# Send response...
|
|
31
|
+
responses.record_sent(task_id, message_ts, channel_id)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Dict, List, Optional, Any
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SlackStateManager:
|
|
44
|
+
"""
|
|
45
|
+
Manages persistent state for Slack message processing.
|
|
46
|
+
|
|
47
|
+
Tracks which Slack messages have been processed and their associated
|
|
48
|
+
kanban task IDs for later response matching.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, state_file_path: str):
|
|
52
|
+
"""
|
|
53
|
+
Initialize SlackStateManager.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
state_file_path: Path to NDJSON state file (e.g., .juno_task/slack/slack.ndjson)
|
|
57
|
+
"""
|
|
58
|
+
self.state_file = Path(state_file_path)
|
|
59
|
+
self.messages: List[Dict[str, Any]] = []
|
|
60
|
+
self.message_ts_set: set = set()
|
|
61
|
+
self.last_ts: Optional[str] = None
|
|
62
|
+
self._load_state()
|
|
63
|
+
|
|
64
|
+
def _load_state(self) -> None:
|
|
65
|
+
"""Load existing state from NDJSON file."""
|
|
66
|
+
if not self.state_file.exists():
|
|
67
|
+
logger.info(f"State file does not exist, will create: {self.state_file}")
|
|
68
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self.messages = []
|
|
70
|
+
self.message_ts_set = set()
|
|
71
|
+
self.last_ts = None
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
self.messages = []
|
|
76
|
+
self.message_ts_set = set()
|
|
77
|
+
with open(self.state_file, 'r', encoding='utf-8') as f:
|
|
78
|
+
for line in f:
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if line:
|
|
81
|
+
msg = json.loads(line)
|
|
82
|
+
self.messages.append(msg)
|
|
83
|
+
ts = msg.get('ts')
|
|
84
|
+
if ts:
|
|
85
|
+
self.message_ts_set.add(ts)
|
|
86
|
+
|
|
87
|
+
# Get last timestamp
|
|
88
|
+
if self.messages:
|
|
89
|
+
self.last_ts = max(msg.get('ts', '0') for msg in self.messages)
|
|
90
|
+
logger.info(
|
|
91
|
+
f"Loaded {len(self.messages)} messages from {self.state_file}, "
|
|
92
|
+
f"last_ts={self.last_ts}"
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
self.last_ts = None
|
|
96
|
+
logger.info(f"State file empty: {self.state_file}")
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"Error loading state from {self.state_file}: {e}")
|
|
100
|
+
self.messages = []
|
|
101
|
+
self.message_ts_set = set()
|
|
102
|
+
self.last_ts = None
|
|
103
|
+
|
|
104
|
+
def get_last_timestamp(self) -> Optional[str]:
|
|
105
|
+
"""
|
|
106
|
+
Get the timestamp of the last processed message.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Last message timestamp or None if no messages processed
|
|
110
|
+
"""
|
|
111
|
+
return self.last_ts
|
|
112
|
+
|
|
113
|
+
def is_processed(self, message_ts: str) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Check if a message has already been processed.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
message_ts: Slack message timestamp
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True if already processed, False otherwise
|
|
122
|
+
"""
|
|
123
|
+
return message_ts in self.message_ts_set
|
|
124
|
+
|
|
125
|
+
def mark_processed(
|
|
126
|
+
self,
|
|
127
|
+
message_ts: str,
|
|
128
|
+
task_id: str,
|
|
129
|
+
message_data: Dict[str, Any]
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Mark a message as processed and store task mapping.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
message_ts: Slack message timestamp (unique identifier)
|
|
136
|
+
task_id: Kanban task ID created for this message
|
|
137
|
+
message_data: Additional message data (text, author, channel, etc.)
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if message was new and recorded, False if duplicate
|
|
141
|
+
"""
|
|
142
|
+
if message_ts in self.message_ts_set:
|
|
143
|
+
logger.debug(f"Duplicate message ts={message_ts}, skipping")
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
# Build state entry
|
|
147
|
+
entry = {
|
|
148
|
+
'ts': message_ts,
|
|
149
|
+
'task_id': task_id,
|
|
150
|
+
'processed_at': datetime.now(timezone.utc).isoformat(),
|
|
151
|
+
**message_data
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
# Append to file (atomic)
|
|
156
|
+
with open(self.state_file, 'a', encoding='utf-8') as f:
|
|
157
|
+
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
158
|
+
|
|
159
|
+
# Update in-memory state
|
|
160
|
+
self.messages.append(entry)
|
|
161
|
+
self.message_ts_set.add(message_ts)
|
|
162
|
+
if not self.last_ts or message_ts > self.last_ts:
|
|
163
|
+
self.last_ts = message_ts
|
|
164
|
+
|
|
165
|
+
logger.debug(f"Recorded message ts={message_ts} -> task_id={task_id}")
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"Error appending to {self.state_file}: {e}")
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
def get_task_id_for_message(self, message_ts: str) -> Optional[str]:
|
|
173
|
+
"""
|
|
174
|
+
Get the kanban task ID for a given message.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
message_ts: Slack message timestamp
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Task ID or None if not found
|
|
181
|
+
"""
|
|
182
|
+
for msg in self.messages:
|
|
183
|
+
if msg.get('ts') == message_ts:
|
|
184
|
+
return msg.get('task_id')
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def get_message_for_task(self, task_id: str) -> Optional[Dict[str, Any]]:
|
|
188
|
+
"""
|
|
189
|
+
Get the message data for a given kanban task ID.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
task_id: Kanban task ID
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Message data dict or None if not found
|
|
196
|
+
"""
|
|
197
|
+
for msg in self.messages:
|
|
198
|
+
if msg.get('task_id') == task_id:
|
|
199
|
+
return msg
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def get_message_count(self) -> int:
|
|
203
|
+
"""Get total number of processed messages."""
|
|
204
|
+
return len(self.messages)
|
|
205
|
+
|
|
206
|
+
def get_messages_since(self, since_ts: str) -> List[Dict[str, Any]]:
|
|
207
|
+
"""
|
|
208
|
+
Get all messages since a given timestamp.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
since_ts: Timestamp to filter from
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of messages after since_ts
|
|
215
|
+
"""
|
|
216
|
+
return [msg for msg in self.messages if msg.get('ts', '0') > since_ts]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ResponseStateManager:
|
|
220
|
+
"""
|
|
221
|
+
Manages state for tracking sent responses.
|
|
222
|
+
|
|
223
|
+
Prevents duplicate responses by tracking which task/message combinations
|
|
224
|
+
have already received a response.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def __init__(self, state_file_path: str):
|
|
228
|
+
"""
|
|
229
|
+
Initialize ResponseStateManager.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
state_file_path: Path to NDJSON state file
|
|
233
|
+
"""
|
|
234
|
+
self.state_file = Path(state_file_path)
|
|
235
|
+
self.sent_responses: List[Dict[str, Any]] = []
|
|
236
|
+
self.sent_keys: set = set() # (task_id, message_ts) tuples
|
|
237
|
+
self._load_state()
|
|
238
|
+
|
|
239
|
+
def _load_state(self) -> None:
|
|
240
|
+
"""Load existing state from NDJSON file."""
|
|
241
|
+
if not self.state_file.exists():
|
|
242
|
+
logger.info(f"Response state file does not exist, will create: {self.state_file}")
|
|
243
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
self.sent_responses = []
|
|
245
|
+
self.sent_keys = set()
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
self.sent_responses = []
|
|
250
|
+
self.sent_keys = set()
|
|
251
|
+
with open(self.state_file, 'r', encoding='utf-8') as f:
|
|
252
|
+
for line in f:
|
|
253
|
+
line = line.strip()
|
|
254
|
+
if line:
|
|
255
|
+
entry = json.loads(line)
|
|
256
|
+
self.sent_responses.append(entry)
|
|
257
|
+
task_id = entry.get('task_id')
|
|
258
|
+
message_ts = entry.get('message_ts')
|
|
259
|
+
if task_id and message_ts:
|
|
260
|
+
self.sent_keys.add((task_id, message_ts))
|
|
261
|
+
|
|
262
|
+
logger.info(f"Loaded {len(self.sent_responses)} sent responses from {self.state_file}")
|
|
263
|
+
|
|
264
|
+
except Exception as e:
|
|
265
|
+
logger.error(f"Error loading response state from {self.state_file}: {e}")
|
|
266
|
+
self.sent_responses = []
|
|
267
|
+
self.sent_keys = set()
|
|
268
|
+
|
|
269
|
+
def was_response_sent(self, task_id: str, message_ts: str) -> bool:
|
|
270
|
+
"""
|
|
271
|
+
Check if a response was already sent for this task/message.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
task_id: Kanban task ID
|
|
275
|
+
message_ts: Slack message timestamp
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
True if already sent, False otherwise
|
|
279
|
+
"""
|
|
280
|
+
return (task_id, message_ts) in self.sent_keys
|
|
281
|
+
|
|
282
|
+
def record_sent(
|
|
283
|
+
self,
|
|
284
|
+
task_id: str,
|
|
285
|
+
message_ts: str,
|
|
286
|
+
channel_id: str,
|
|
287
|
+
response_ts: Optional[str] = None
|
|
288
|
+
) -> bool:
|
|
289
|
+
"""
|
|
290
|
+
Record that a response was sent.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
task_id: Kanban task ID
|
|
294
|
+
message_ts: Original Slack message timestamp
|
|
295
|
+
channel_id: Slack channel ID
|
|
296
|
+
response_ts: Timestamp of the response message (optional)
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
True if recorded, False if duplicate or error
|
|
300
|
+
"""
|
|
301
|
+
key = (task_id, message_ts)
|
|
302
|
+
if key in self.sent_keys:
|
|
303
|
+
logger.debug(f"Response already recorded for task={task_id}, ts={message_ts}")
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
entry = {
|
|
307
|
+
'task_id': task_id,
|
|
308
|
+
'message_ts': message_ts,
|
|
309
|
+
'channel_id': channel_id,
|
|
310
|
+
'sent_at': datetime.now(timezone.utc).isoformat(),
|
|
311
|
+
}
|
|
312
|
+
if response_ts:
|
|
313
|
+
entry['response_ts'] = response_ts
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
# Append to file (atomic)
|
|
317
|
+
with open(self.state_file, 'a', encoding='utf-8') as f:
|
|
318
|
+
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
319
|
+
|
|
320
|
+
# Update in-memory state
|
|
321
|
+
self.sent_responses.append(entry)
|
|
322
|
+
self.sent_keys.add(key)
|
|
323
|
+
|
|
324
|
+
logger.debug(f"Recorded sent response for task={task_id}, ts={message_ts}")
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.error(f"Error recording response to {self.state_file}: {e}")
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
def get_sent_count(self) -> int:
|
|
332
|
+
"""Get total number of sent responses."""
|
|
333
|
+
return len(self.sent_responses)
|
|
334
|
+
|
|
335
|
+
def reset_state(self) -> None:
|
|
336
|
+
"""
|
|
337
|
+
Clear all state (WARNING: will cause re-sending).
|
|
338
|
+
|
|
339
|
+
Use with caution - should only be called after user confirmation.
|
|
340
|
+
"""
|
|
341
|
+
if self.state_file.exists():
|
|
342
|
+
self.state_file.unlink()
|
|
343
|
+
logger.warning(f"Deleted response state file: {self.state_file}")
|
|
344
|
+
|
|
345
|
+
self.sent_responses = []
|
|
346
|
+
self.sent_keys = set()
|
|
347
|
+
logger.warning("Response state reset - all responses may be re-sent")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == '__main__':
|
|
351
|
+
# Simple test/demo
|
|
352
|
+
import sys
|
|
353
|
+
|
|
354
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
355
|
+
|
|
356
|
+
# Test SlackStateManager
|
|
357
|
+
print("Testing SlackStateManager...")
|
|
358
|
+
state = SlackStateManager('/tmp/test_slack_state.ndjson')
|
|
359
|
+
print(f" Message count: {state.get_message_count()}")
|
|
360
|
+
print(f" Last timestamp: {state.get_last_timestamp()}")
|
|
361
|
+
|
|
362
|
+
if not state.is_processed('1234567890.123456'):
|
|
363
|
+
state.mark_processed(
|
|
364
|
+
'1234567890.123456',
|
|
365
|
+
'task_abc123',
|
|
366
|
+
{'text': 'Test message', 'author': 'test_user', 'channel': 'general'}
|
|
367
|
+
)
|
|
368
|
+
print(" Marked test message as processed")
|
|
369
|
+
|
|
370
|
+
print(f" Task ID for test: {state.get_task_id_for_message('1234567890.123456')}")
|
|
371
|
+
|
|
372
|
+
# Test ResponseStateManager
|
|
373
|
+
print("\nTesting ResponseStateManager...")
|
|
374
|
+
responses = ResponseStateManager('/tmp/test_responses_sent.ndjson')
|
|
375
|
+
print(f" Sent count: {responses.get_sent_count()}")
|
|
376
|
+
|
|
377
|
+
if not responses.was_response_sent('task_abc123', '1234567890.123456'):
|
|
378
|
+
responses.record_sent('task_abc123', '1234567890.123456', 'C1234567890')
|
|
379
|
+
print(" Recorded test response")
|
|
380
|
+
|
|
381
|
+
print(f" Was sent: {responses.was_response_sent('task_abc123', '1234567890.123456')}")
|
|
382
|
+
|
|
383
|
+
print("\nAll tests passed!")
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "juno-code",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.37",
|
|
4
|
+
"description": "Ralph Wiggum meet Kanban! Ralph style execution for [Claude Code, Codex, Gemini, Cursor]. One task per iteration, automatic progress tracking, and git commits. Set it and let it run.",
|
|
5
5
|
"keywords": [
|
|
6
|
+
"Ralph",
|
|
7
|
+
"Ralph Wiggum",
|
|
8
|
+
"Claude code",
|
|
9
|
+
"backlog ralph",
|
|
10
|
+
"claude lopp",
|
|
6
11
|
"ai",
|
|
7
12
|
"cli",
|
|
8
13
|
"typescript",
|
|
@@ -82,6 +87,7 @@
|
|
|
82
87
|
"handlebars": "^4.7.8",
|
|
83
88
|
"ink": "^4.4.1",
|
|
84
89
|
"js-yaml": "^4.1.0",
|
|
90
|
+
"juno-code": "file:develop_package",
|
|
85
91
|
"react": "^18.2.0",
|
|
86
92
|
"semver": "^7.5.4",
|
|
87
93
|
"supports-color": "^9.4.0",
|